Files
EVEngine/test/action_timeline_widget.cpp
T
西风逍遥游andCursor 24502babef Nest satellite packages under their host modules.
Keep CMake module names and trim switches, but stop flattening editing/editor and runtime bridges as top-level src/modules directories. Parent scans skip nested facets so runtime-3d can drop authoring by directory leaf.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 17:56:12 +08:00

191 lines
8.6 KiB
C++

#include "action/editor/ActionTimelineWidget.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&, const eve::editor::OverlayPoint&,
const eve::editor::OverlayStyle&) override {
++lines;
}
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 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;
};
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);
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, 1);
}
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.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")}}).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(), 5u);
CHECK(!view.addNotifyAtCursor(track, "combat:hitbox-window", {{"hitbox", eve::Value("weapon")}}).ok());
}