Files
EVEngine/test/action_timeline_widget.cpp

442 lines
22 KiB
C++

#include "action/editor/ActionTimelineWidget.h"
#include "action/ActionAudioWaveform.h"
#include "action/ActionParameterCurve.h"
#include "common/Capability.h"
#include "zeroerr/assert.h"
#include "zeroerr/unittest.h"
#include <map>
#include <string_view>
namespace {
eve::LogicalId id(std::string_view value) {
auto parsed = eve::LogicalId::parse(value);
REQUIRE(parsed.has_value());
return std::move(*parsed);
}
eve::action::ActionTimeline timelineFixture() {
eve::action::ActionTimeline timeline;
timeline.actionId = id("combat:widget-test");
timeline.duration = eve::Duration::fromNanoseconds(100);
eve::action::ActionTrack track;
track.id = id("combat-track:gameplay");
track.label = "Gameplay";
track.kind = eve::action::ActionTrackKind::Gameplay;
track.notifies.push_back({id("combat-notify:damage"),
id("combat:damage"),
eve::Duration::fromNanoseconds(20),
{{"damageType", eve::Value("Damage.Physical")}, {"amount", eve::Value(10)}}});
track.states.push_back({id("combat-state:hitbox"),
id("combat:hitbox-window"),
eve::Duration::fromNanoseconds(10),
eve::Duration::fromNanoseconds(30),
{{"hitbox", eve::Value("weapon.main")}}});
timeline.tracks.push_back(std::move(track));
return timeline;
}
class RecordingOverlay final : public eve::editor::IEditorOverlay {
public:
void line(const eve::editor::OverlayPoint& from, const eve::editor::OverlayPoint& to,
const eve::editor::OverlayStyle&) override {
++lines;
(void)from;
(void)to;
}
void circle(const eve::editor::OverlayPoint&, float, const eve::editor::OverlayStyle&) override { ++circles; }
void rectangle(const eve::editor::OverlayPoint&, const eve::editor::OverlayPoint&,
const eve::editor::OverlayStyle&) override {
++rectangles;
}
void text(const eve::editor::OverlayPoint&, const std::string&, const eve::editor::OverlayStyle&) override {
++texts;
}
int lines = 0;
int circles = 0;
int rectangles = 0;
int texts = 0;
};
class TestWaveformProvider final : public eve::action::IActionAudioWaveformProvider {
public:
eve::Result<eve::action::ActionAudioWaveform> waveform(
const eve::action::ActionAudioWaveformRequest& request) override {
++calls;
lastRequest = request;
eve::action::ActionAudioWaveform result;
result.clipDurationSeconds = 0.5;
result.buckets.assign(request.bucketCount, {-0.5f, 0.75f});
return eve::Result<eve::action::ActionAudioWaveform>::success(std::move(result));
}
int calls = 0;
eve::action::ActionAudioWaveformRequest lastRequest;
};
class EditingInspector final : public eve::editor::IEditorInspector {
public:
void beginGroup(const std::string&, const std::string&) override {}
void endGroup() override {}
bool boolean(const std::string&, const std::string&, bool&) override { return false; }
bool integer(const std::string&, const std::string&, int&, int, int) override { return false; }
bool scalar(const std::string& field, const std::string&, float& value, float, float) override {
auto found = scalarReplacements.find(field);
if (found == scalarReplacements.end()) return false;
value = found->second;
return true;
}
bool string(const std::string& field, const std::string&, std::string& value) override {
auto found = replacements.find(field);
if (found == replacements.end()) return false;
value = found->second;
return true;
}
std::map<std::string, std::string> replacements;
std::map<std::string, float> scalarReplacements;
};
class CurveEditingInspector final : public eve::editor::IEditorInspector {
public:
void beginGroup(const std::string& id, const std::string&) override { groups.push_back(id); }
void endGroup() override { groups.pop_back(); }
bool boolean(const std::string&, const std::string&, bool&) override { return false; }
bool integer(const std::string&, const std::string&, int&, int, int) override { return false; }
bool scalar(const std::string& id, const std::string&, float& value, float, float) override {
if (!groups.empty() && groups.back() == "action.timeline.parameter-key.1" && id == "value") {
value = 0.25f;
return true;
}
return false;
}
bool string(const std::string&, const std::string&, std::string&) override { return false; }
std::vector<std::string> groups;
};
eve::editor::ActionTimelineWidget widget(eve::editor::ActionTimelineEditor& editor,
eve::action::ActionNotifyRegistry& registry) {
eve::editor::ActionTimelineWidget result(editor, registry);
REQUIRE(result.setViewport(1120.0f, 24.0f, 120.0f).ok());
return result;
}
} // namespace
TEST_CASE("actionTimelineWidget.projectsDrawsAndHitTestsSemanticItems") {
eve::editor::ActionTimelineEditor editor("asset.combat.widget", timelineFixture());
auto registry = eve::action::ActionNotifyRegistry::withBuiltins();
REQUIRE(registry.ok());
auto view = widget(editor, registry.value());
auto layout = view.layout();
REQUIRE_EQ(layout.items.size(), 2u);
CHECK_EQ(layout.width, 1120.0f);
CHECK_EQ(layout.height, 24.0f);
CHECK_EQ(layout.items[0].displayName, std::string("Apply Damage"));
CHECK_EQ(layout.items[0].detail, std::string("Damage.Physical 10"));
CHECK(layout.items[0].visual == eve::editor::TimelineItemVisual::Damage);
CHECK_EQ(layout.items[1].displayName, std::string("Hitbox Window"));
CHECK_EQ(layout.items[1].detail, std::string("weapon.main"));
CHECK(layout.items[1].visual == eve::editor::TimelineItemVisual::Hitbox);
auto notify = view.hitTest(320.0f, 12.0f);
REQUIRE(notify.has_value());
CHECK_EQ(notify->itemId, id("combat-notify:damage"));
auto stateStart = view.hitTest(220.0f, 12.0f);
REQUIRE(stateStart.has_value());
CHECK(static_cast<int>(stateStart->part) == static_cast<int>(eve::editor::TimelineHitPart::StartHandle));
RecordingOverlay overlay;
view.draw(overlay);
CHECK(overlay.rectangles >= 3);
CHECK(overlay.lines >= 4);
CHECK_EQ(overlay.texts, 2);
}
TEST_CASE("actionTimelineWidget.projectsSemanticResourceThumbnailsWithoutPersistingThem") {
auto timeline = timelineFixture();
timeline.tracks[0].notifies.clear();
timeline.tracks[0].states.clear();
timeline.tracks[0].states.push_back(
{id("vfx-state:slash"), id("presentation:vfx-state"), eve::Duration::fromNanoseconds(10),
eve::Duration::fromNanoseconds(90), {{"uri", "effects/combat/slash.effect"}}});
eve::editor::ActionTimelineEditor editor("asset.combat.widget-thumbnail", std::move(timeline));
auto registry = eve::action::ActionNotifyRegistry::withBuiltins();
REQUIRE(registry.ok());
const auto layout = widget(editor, registry.value()).layout();
REQUIRE_EQ(layout.items.size(), 1u);
CHECK_EQ(layout.items[0].displayName, std::string("VFX State"));
CHECK_EQ(layout.items[0].detail, std::string("slash.effect"));
CHECK(layout.items[0].visual == eve::editor::TimelineItemVisual::Vfx);
auto encoded = editor.target().timeline().toValue();
REQUIRE(encoded.ok());
const auto json = encoded.value().toJson();
REQUIRE(json.ok());
CHECK(json.value().find("displayName") == std::string::npos);
CHECK(json.value().find("visual") == std::string::npos);
}
TEST_CASE("actionTimelineWidget.usesRegistryLabelAndExplicitCustomVisualFallback") {
auto timeline = timelineFixture();
timeline.tracks[0].states.clear();
timeline.tracks[0].notifies[0].type = id("project:charge");
timeline.tracks[0].notifies[0].payload = {{"power", 3}};
eve::editor::ActionTimelineEditor editor("asset.combat.widget-custom", std::move(timeline));
auto registry = eve::action::ActionNotifyRegistry::withBuiltins();
REQUIRE(registry.ok());
REQUIRE(registry.value()
.registerDescriptor({"project:charge", "Charge Meter", "Project",
eve::action::ActionNotifyShape::Instant, {"power"}})
.ok());
const auto layout = widget(editor, registry.value()).layout();
REQUIRE_EQ(layout.items.size(), 1u);
CHECK_EQ(layout.items[0].displayName, std::string("Charge Meter"));
CHECK(layout.items[0].detail.empty());
CHECK(layout.items[0].visual == eve::editor::TimelineItemVisual::Custom);
CHECK_EQ(eve::editor::timelineItemVisualName(layout.items[0].visual), std::string_view("custom"));
}
TEST_CASE("actionTimelineWidget.projectsBoundedAudioWaveformWithoutOwningAudioState") {
auto timeline = timelineFixture();
timeline.tracks[0].kind = eve::action::ActionTrackKind::Audio;
timeline.tracks[0].notifies.clear();
timeline.tracks[0].states.clear();
timeline.tracks[0].states.push_back(
{id("audio-state:loop"), id("presentation:audio-state"), eve::Duration::fromNanoseconds(10),
eve::Duration::fromNanoseconds(90),
{{"uri", "audio/hit.wav"}, {"pitch", 1.5}, {"looping", true}}});
eve::editor::ActionTimelineEditor editor("asset.combat.widget-waveform", std::move(timeline));
auto registry = eve::action::ActionNotifyRegistry::withBuiltins();
REQUIRE(registry.ok());
auto view = widget(editor, registry.value());
CHECK(!view.layout().audioWaveformsAvailable);
TestWaveformProvider provider;
eve::cap::provide<eve::action::IActionAudioWaveformProvider>(&provider);
CHECK(view.layout().audioWaveformsAvailable);
RecordingOverlay overlay;
view.draw(overlay);
eve::cap::revoke<eve::action::IActionAudioWaveformProvider>(&provider);
REQUIRE_EQ(provider.calls, 1);
CHECK_EQ(provider.lastRequest.binding.uri, std::string("audio/hit.wav"));
CHECK_EQ(provider.lastRequest.binding.pitch, 1.5);
CHECK(provider.lastRequest.binding.looping);
CHECK_EQ(provider.lastRequest.blockDuration, eve::Duration::fromNanoseconds(80));
CHECK(provider.lastRequest.bucketCount > 0);
CHECK(provider.lastRequest.bucketCount <= 512);
CHECK(overlay.lines >= static_cast<int>(provider.lastRequest.bucketCount));
}
TEST_CASE("actionTimelineWidget.drawsAndTransactionallyInspectsParameterCurveKeys") {
auto timeline = timelineFixture();
timeline.tracks[0].states.clear();
timeline.tracks[0].notifies.clear();
timeline.tracks[0].states.push_back(
{id("parameter-curve:fade"), id("presentation:parameter-curve"),
eve::Duration::fromNanoseconds(10), eve::Duration::fromNanoseconds(90),
{{"target", "audio:master-volume"}, {"operation", "multiply"},
{"keys", eve::Value::Array{
eve::Value::Object{{"time", 0.0}, {"value", 1.0}, {"interpolation", "linear"}},
eve::Value::Object{{"time", 0.5}, {"value", 0.5}, {"interpolation", "cubic"}},
eve::Value::Object{{"time", 1.0}, {"value", 0.0}, {"interpolation", "linear"}}}}}});
eve::editor::ActionTimelineEditor editor("asset.combat.widget-curve", std::move(timeline));
auto registry = eve::action::ActionNotifyRegistry::withBuiltins();
REQUIRE(registry.ok());
auto view = widget(editor, registry.value());
RecordingOverlay overlay;
view.draw(overlay);
CHECK(overlay.lines >= 128);
CHECK(overlay.circles >= 3);
REQUIRE(editor.selectItem(id("parameter-curve:fade")).ok());
CurveEditingInspector inspector;
REQUIRE(view.inspectSelection(inspector).ok());
auto binding = eve::action::ActionParameterCurveBinding::fromPayload(
editor.target().timeline().tracks[0].states[0].payload);
REQUIRE(binding.ok());
CHECK_EQ(binding.value().keys[1].value, 0.25);
REQUIRE(editor.undo().ok());
binding = eve::action::ActionParameterCurveBinding::fromPayload(
editor.target().timeline().tracks[0].states[0].payload);
REQUIRE(binding.ok());
CHECK_EQ(binding.value().keys[1].value, 0.5);
REQUIRE(editor.addParameterKey(id("parameter-curve:fade"),
{0.25, 0.75, 0.0, 0.0,
eve::action::ActionParameterInterpolation::Linear})
.ok());
binding = eve::action::ActionParameterCurveBinding::fromPayload(
editor.target().timeline().tracks[0].states[0].payload);
REQUIRE(binding.ok());
REQUIRE_EQ(binding.value().keys.size(), 4u);
CHECK_EQ(binding.value().keys[1].time, 0.25);
CHECK(!editor.addParameterKey(id("parameter-curve:fade"), binding.value().keys[1]).ok());
REQUIRE(editor.editParameterKey(id("parameter-curve:fade"), 1,
{0.2, 0.8, 0.0, 0.0,
eve::action::ActionParameterInterpolation::Step})
.ok());
REQUIRE(editor.removeParameterKey(id("parameter-curve:fade"), 1).ok());
CHECK(!editor.removeParameterKey(id("parameter-curve:fade"), 0).ok());
REQUIRE(editor.undo().ok());
REQUIRE(editor.redo().ok());
REQUIRE(editor.setTrackLocked(id("combat-track:gameplay"), true).ok());
CHECK(!editor.addParameterKey(id("parameter-curve:fade"),
{0.25, 0.75, 0.0, 0.0,
eve::action::ActionParameterInterpolation::Linear})
.ok());
}
TEST_CASE("actionTimelineWidget.dragPreviewCommitsOnceAndUndoRestores") {
eve::editor::ActionTimelineEditor editor("asset.combat.widget", timelineFixture());
auto registry = eve::action::ActionNotifyRegistry::withBuiltins();
REQUIRE(registry.ok());
auto view = widget(editor, registry.value());
REQUIRE(view.pointerDown(320.0f, 12.0f).ok());
REQUIRE(view.pointerMove(520.0f).ok());
CHECK_EQ(editor.target().timeline().tracks[0].notifies[0].time, eve::Duration::fromNanoseconds(20));
REQUIRE(view.pointerUp(520.0f).ok());
CHECK_EQ(editor.target().timeline().tracks[0].notifies[0].time, eve::Duration::fromNanoseconds(40));
REQUIRE(editor.undo().ok());
CHECK_EQ(editor.target().timeline().tracks[0].notifies[0].time, eve::Duration::fromNanoseconds(20));
REQUIRE(view.pointerDown(220.0f, 12.0f).ok());
REQUIRE(view.pointerUp(270.0f).ok());
CHECK_EQ(editor.target().timeline().tracks[0].states[0].start, eve::Duration::fromNanoseconds(15));
CHECK_EQ(editor.target().timeline().tracks[0].states[0].end, eve::Duration::fromNanoseconds(30));
}
TEST_CASE("actionTimelineWidget.magneticSnapUsesCrossTrackBlockEdges") {
eve::editor::ActionTimelineEditor editor("asset.combat.widget-magnetic-snap", timelineFixture());
auto registry = eve::action::ActionNotifyRegistry::withBuiltins();
REQUIRE(registry.ok());
eve::editor::ActionTimelineWidget view(editor, registry.value());
REQUIRE(view.setViewport(220.0f, 24.0f, 120.0f).ok());
REQUIRE(view.pointerDown(140.0f, 12.0f).ok());
REQUIRE(view.pointerUp(145.0f).ok());
CHECK_EQ(editor.target().timeline().tracks[0].notifies[0].time, eve::Duration::fromNanoseconds(30));
REQUIRE(editor.undo().ok());
CHECK_EQ(editor.target().timeline().tracks[0].notifies[0].time, eve::Duration::fromNanoseconds(20));
}
TEST_CASE("actionTimelineWidget.draggingSelectedBodyMovesWholeSelectionOnce") {
eve::editor::ActionTimelineEditor editor("asset.combat.widget-selection", timelineFixture());
auto registry = eve::action::ActionNotifyRegistry::withBuiltins();
REQUIRE(registry.ok());
auto view = widget(editor, registry.value());
REQUIRE(editor.selectItem(id("combat-notify:damage")).ok());
REQUIRE(editor.selectItem(id("combat-state:hitbox"), true).ok());
REQUIRE(view.pointerDown(320.0f, 12.0f).ok());
REQUIRE(view.pointerUp(420.0f).ok());
CHECK_EQ(editor.target().timeline().tracks[0].notifies[0].time, eve::Duration::fromNanoseconds(30));
CHECK_EQ(editor.target().timeline().tracks[0].states[0].start, eve::Duration::fromNanoseconds(20));
CHECK_EQ(editor.target().timeline().tracks[0].states[0].end, eve::Duration::fromNanoseconds(40));
REQUIRE(editor.undo().ok());
CHECK_EQ(editor.target().timeline().tracks[0].notifies[0].time, eve::Duration::fromNanoseconds(20));
CHECK_EQ(editor.target().timeline().tracks[0].states[0].start, eve::Duration::fromNanoseconds(10));
}
TEST_CASE("actionTimelineWidget.multiSelectionInspectorScalesCanonicalItems") {
eve::editor::ActionTimelineEditor editor("asset.combat.widget-selection-inspector", timelineFixture());
auto registry = eve::action::ActionNotifyRegistry::withBuiltins();
REQUIRE(registry.ok());
auto view = widget(editor, registry.value());
REQUIRE(editor.selectItem(id("combat-notify:damage")).ok());
REQUIRE(editor.selectItem(id("combat-state:hitbox"), true).ok());
EditingInspector inspector;
inspector.scalarReplacements["start"] = 0.00000002f;
inspector.scalarReplacements["end"] = 0.00000006f;
REQUIRE(view.inspectSelection(inspector).ok());
CHECK_EQ(editor.target().timeline().tracks[0].notifies[0].time, eve::Duration::fromNanoseconds(40));
CHECK_EQ(editor.target().timeline().tracks[0].states[0].start, eve::Duration::fromNanoseconds(20));
CHECK_EQ(editor.target().timeline().tracks[0].states[0].end, eve::Duration::fromNanoseconds(60));
}
TEST_CASE("actionTimelineWidget.exposesUndoableSelectionAlignmentCommands") {
eve::editor::ActionTimelineEditor editor("asset.combat.widget-selection-align", timelineFixture());
auto registry = eve::action::ActionNotifyRegistry::withBuiltins();
REQUIRE(registry.ok());
auto view = widget(editor, registry.value());
REQUIRE(editor.selectItem(id("combat-notify:damage")).ok());
REQUIRE(editor.selectItem(id("combat-state:hitbox"), true).ok());
const auto commands = view.commands();
REQUIRE_EQ(commands.size(), 8u);
CHECK(commands[3].enabled);
CHECK(commands[4].enabled);
REQUIRE(view.handleShortcut("Shift+[").ok());
CHECK_EQ(editor.target().timeline().tracks[0].notifies[0].time, eve::Duration::fromNanoseconds(10));
REQUIRE(editor.undo().ok());
REQUIRE(view.handleShortcut("Shift+]").ok());
CHECK_EQ(editor.target().timeline().tracks[0].notifies[0].time, eve::Duration::fromNanoseconds(30));
}
TEST_CASE("actionTimelineWidget.inspectorUsesRegistryAndCanonicalPayload") {
eve::editor::ActionTimelineEditor editor("asset.combat.widget", timelineFixture());
auto registry = eve::action::ActionNotifyRegistry::withBuiltins();
REQUIRE(registry.ok());
auto view = widget(editor, registry.value());
REQUIRE(editor.selectItem(id("combat-notify:damage")).ok());
EditingInspector inspector;
inspector.replacements["type"] = "gameplay:event";
inspector.replacements["payload"] = R"({"tag":"Combat.Action.Hit"})";
REQUIRE(view.inspectSelection(inspector).ok());
const auto& notify = editor.target().timeline().tracks[0].notifies[0];
CHECK_EQ(notify.type, id("gameplay:event"));
CHECK(notify.payload.contains("tag"));
inspector.replacements["type"] = "combat:hitbox-window";
inspector.scalarReplacements["start"] = 0.00000005f;
CHECK(!view.inspectSelection(inspector).ok());
CHECK_EQ(editor.target().timeline().tracks[0].notifies[0].type, id("gameplay:event"));
CHECK_EQ(editor.target().timeline().tracks[0].notifies[0].time, eve::Duration::fromNanoseconds(20));
}
TEST_CASE("actionTimelineWidget.commandsAndShortcutsAreUndoable") {
eve::editor::ActionTimelineEditor editor("asset.combat.widget", timelineFixture());
auto registry = eve::action::ActionNotifyRegistry::withBuiltins();
REQUIRE(registry.ok());
auto view = widget(editor, registry.value());
REQUIRE(editor.selectItem(id("combat-notify:damage")).ok());
REQUIRE(view.handleShortcut("Ctrl+C").ok());
REQUIRE(view.seek(720.0f).ok());
REQUIRE(view.handleShortcut("Ctrl+V").ok());
CHECK_EQ(editor.target().timeline().tracks[0].notifies.size(), 2u);
REQUIRE(view.handleShortcut("Delete").ok());
CHECK_EQ(editor.target().timeline().tracks[0].notifies.size(), 1u);
REQUIRE(view.handleShortcut("Ctrl+Z").ok());
CHECK_EQ(editor.target().timeline().tracks[0].notifies.size(), 2u);
CHECK(!view.handleShortcut("Ctrl+Unknown").ok());
}
TEST_CASE("actionTimelineWidget.insertsRegisteredNotifyShapesAtCursor") {
eve::editor::ActionTimelineEditor editor("asset.combat.widget", timelineFixture());
auto registry = eve::action::ActionNotifyRegistry::withBuiltins();
REQUIRE(registry.ok());
auto view = widget(editor, registry.value());
REQUIRE(view.seek(620.0f).ok());
const auto track = id("combat-track:gameplay");
REQUIRE(view.addNotifyAtCursor(track, "presentation:vfx",
{{"uri", eve::Value("asset://vfx/slash")},
{"lifetimeSeconds", eve::Value(0.5)}})
.ok());
REQUIRE(view.addStateAtCursor(track, "input:combo-window", eve::Duration::fromNanoseconds(10),
{{"input", eve::Value("Attack.Heavy")}})
.ok());
CHECK_EQ(editor.target().timeline().tracks[0].notifies.size(), 2u);
CHECK_EQ(editor.target().timeline().tracks[0].states.size(), 2u);
CHECK_EQ(view.insertableTypes(eve::action::ActionNotifyShape::Instant).size(), 5u);
CHECK_EQ(view.insertableTypes(eve::action::ActionNotifyShape::State).size(), 7u);
CHECK(!view.addNotifyAtCursor(track, "combat:hitbox-window", {{"hitbox", eve::Value("weapon")}}).ok());
}