yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
overworld_ui_tests.cc
Go to the documentation of this file.
2
3#if defined(YAZE_ENABLE_IMGUI_TEST_ENGINE) && YAZE_ENABLE_IMGUI_TEST_ENGINE
4
5#include <array>
6#include <chrono>
7#include <cmath>
8#include <cstdio>
9#include <cstdlib>
10#include <filesystem>
11#include <fstream>
12#include <iterator>
13#include <memory>
14#include <optional>
15#include <string>
16
17#include "absl/status/status.h"
18#include "app/application.h"
26#include "imgui.h"
27#include "nlohmann/json.hpp"
28#include "zelda3/game_data.h"
30#if __has_include("imgui_test_engine/imgui_te_context.h")
31#include "imgui_test_engine/imgui_te_context.h"
32#elif __has_include("imgui_te_context.h")
33#include "imgui_te_context.h"
34#else
35#error "ImGui Test Engine context header not found"
36#endif
37
38#if __has_include("imgui_test_engine/imgui_te_engine.h")
39#include "imgui_test_engine/imgui_te_engine.h"
40#elif __has_include("imgui_te_engine.h")
41#include "imgui_te_engine.h"
42#else
43#error "ImGui Test Engine engine header not found"
44#endif
45
46namespace yaze::test {
47
48namespace {
49
50void EnsureTile16PanelOpenAndFocused(ImGuiTestContext* ctx) {
51 ctx->SetRef("Tile16 Editor");
52 if (ctx->GetWindowByRef("") == nullptr) {
53 ctx->KeyPress(ImGuiMod_Ctrl | ImGuiKey_T);
54 ctx->Yield(4);
55 }
56
57 ctx->SetRef("Tile16 Editor");
58 IM_CHECK(ctx->GetWindowByRef("") != nullptr);
59 ctx->WindowFocus("");
60 ctx->Yield(2);
61}
62
63void ExpectActiveQuadrant(ImGuiTestContext* ctx, const char* label) {
64 const std::string pattern = std::string("**/Active ") + label + ":*";
65 IM_CHECK(ctx->ItemExists(pattern.c_str()));
66}
67
68void ExpectBrushPalette(ImGuiTestContext* ctx, int palette) {
69 const std::string pattern =
70 std::string("**/*Brush Palette: ") + std::to_string(palette) + "*";
71 IM_CHECK(ctx->ItemExists(pattern.c_str()));
72}
73
74} // namespace
75
76// ============================================================================
77// Test: Keyboard shortcut mode switching
78//
79// The overworld editor uses number keys (1, 2) and letter keys (B, F)
80// to switch between Mouse, Draw Tile, and Fill Tile modes. These shortcuts
81// are handled in OverworldEditor::HandleKeyboardShortcuts() and delegate
82// to TilePaintingManager for B/F.
83//
84// These tests verify that pressing each shortcut key doesn't crash the
85// application. Actual mode state verification would require accessing
86// the OverworldEditor's internal state, which we defer to unit tests.
87// ============================================================================
88
89static void RegisterKeyboardShortcutTests(ImGuiTestEngine* engine) {
90 // Test: Key '1' (Mouse mode) doesn't crash
91 {
92 ImGuiTest* test =
93 IM_REGISTER_TEST(engine, "overworld_keys", "mouse_mode_key_1");
94 test->TestFunc = [](ImGuiTestContext* ctx) {
95 ctx->KeyPress(ImGuiKey_1);
96 ctx->Yield(3);
97 };
98 }
99
100 // Test: Key '2' (Draw Tile mode) doesn't crash
101 {
102 ImGuiTest* test =
103 IM_REGISTER_TEST(engine, "overworld_keys", "draw_mode_key_2");
104 test->TestFunc = [](ImGuiTestContext* ctx) {
105 ctx->KeyPress(ImGuiKey_2);
106 ctx->Yield(3);
107 // Restore mouse mode
108 ctx->KeyPress(ImGuiKey_1);
109 ctx->Yield(1);
110 };
111 }
112
113 // Test: Key 'B' (Brush toggle) doesn't crash
114 {
115 ImGuiTest* test =
116 IM_REGISTER_TEST(engine, "overworld_keys", "brush_toggle_key_b");
117 test->TestFunc = [](ImGuiTestContext* ctx) {
118 ctx->KeyPress(ImGuiKey_B);
119 ctx->Yield(3);
120 // Toggle back
121 ctx->KeyPress(ImGuiKey_B);
122 ctx->Yield(1);
123 };
124 }
125
126 // Test: Key 'F' (Fill tool) doesn't crash
127 {
128 ImGuiTest* test =
129 IM_REGISTER_TEST(engine, "overworld_keys", "fill_tool_key_f");
130 test->TestFunc = [](ImGuiTestContext* ctx) {
131 ctx->KeyPress(ImGuiKey_F);
132 ctx->Yield(3);
133 // Toggle back
134 ctx->KeyPress(ImGuiKey_F);
135 ctx->Yield(1);
136 };
137 }
138
139 // Test: Key 'I' (Eyedropper / pick tile16) doesn't crash
140 {
141 ImGuiTest* test =
142 IM_REGISTER_TEST(engine, "overworld_keys", "eyedropper_key_i");
143 test->TestFunc = [](ImGuiTestContext* ctx) {
144 ctx->KeyPress(ImGuiKey_I);
145 ctx->Yield(3);
146 };
147 }
148
149 // Test: Ctrl+L (Lock map toggle) doesn't crash
150 {
151 ImGuiTest* test =
152 IM_REGISTER_TEST(engine, "overworld_keys", "lock_toggle_ctrl_l");
153 test->TestFunc = [](ImGuiTestContext* ctx) {
154 ctx->KeyPress(ImGuiMod_Ctrl | ImGuiKey_L);
155 ctx->Yield(3);
156 // Toggle back
157 ctx->KeyPress(ImGuiMod_Ctrl | ImGuiKey_L);
158 ctx->Yield(1);
159 };
160 }
161
162 // Test: F11 (Fullscreen toggle) doesn't crash
163 {
164 ImGuiTest* test =
165 IM_REGISTER_TEST(engine, "overworld_keys", "fullscreen_toggle_f11");
166 test->TestFunc = [](ImGuiTestContext* ctx) {
167 ctx->KeyPress(ImGuiKey_F11);
168 ctx->Yield(3);
169 // Toggle back
170 ctx->KeyPress(ImGuiKey_F11);
171 ctx->Yield(1);
172 };
173 }
174
175 // Test: Ctrl+Z (Undo) doesn't crash when no undo history
176 {
177 ImGuiTest* test =
178 IM_REGISTER_TEST(engine, "overworld_keys", "undo_no_crash");
179 test->TestFunc = [](ImGuiTestContext* ctx) {
180 ctx->KeyPress(ImGuiMod_Ctrl | ImGuiKey_Z);
181 ctx->Yield(3);
182 };
183 }
184
185 // Test: Ctrl+Y (Redo) doesn't crash when no redo history
186 {
187 ImGuiTest* test =
188 IM_REGISTER_TEST(engine, "overworld_keys", "redo_no_crash");
189 test->TestFunc = [](ImGuiTestContext* ctx) {
190 ctx->KeyPress(ImGuiMod_Ctrl | ImGuiKey_Y);
191 ctx->Yield(3);
192 };
193 }
194
195 // Test: Ctrl+T (Toggle Tile16 Editor panel) doesn't crash
196 {
197 ImGuiTest* test =
198 IM_REGISTER_TEST(engine, "overworld_keys", "tile16_editor_ctrl_t");
199 test->TestFunc = [](ImGuiTestContext* ctx) {
200 ctx->KeyPress(ImGuiMod_Ctrl | ImGuiKey_T);
201 ctx->Yield(3);
202 // Toggle back
203 ctx->KeyPress(ImGuiMod_Ctrl | ImGuiKey_T);
204 ctx->Yield(1);
205 };
206 }
207
208 // Test: Keys 1..4 update tile16 quadrant focus in panel state text.
209 {
210 ImGuiTest* test = IM_REGISTER_TEST(engine, "overworld_keys",
211 "tile16_quadrant_hotkeys_apply_focus");
212 test->TestFunc = [](ImGuiTestContext* ctx) {
213 EnsureTile16PanelOpenAndFocused(ctx);
214
215 struct QuadrantStep {
216 ImGuiKey key;
217 const char* label;
218 };
219 const QuadrantStep steps[] = {
220 {ImGuiKey_1, "TL"},
221 {ImGuiKey_2, "TR"},
222 {ImGuiKey_3, "BL"},
223 {ImGuiKey_4, "BR"},
224 };
225
226 for (const auto& step : steps) {
227 ctx->KeyPress(step.key);
228 ctx->Yield(2);
229 ExpectActiveQuadrant(ctx, step.label);
230 }
231
232 ctx->KeyPress(ImGuiMod_Ctrl | ImGuiKey_T);
233 ctx->Yield(2);
234 };
235 }
236
237 // Test: Ctrl+1..8 update brush palette while preserving active quadrant.
238 {
239 ImGuiTest* test = IM_REGISTER_TEST(
240 engine, "overworld_keys", "tile16_ctrl_numeric_hotkeys_palette_rows");
241 test->TestFunc = [](ImGuiTestContext* ctx) {
242 EnsureTile16PanelOpenAndFocused(ctx);
243
244 ctx->KeyPress(ImGuiKey_4);
245 ctx->Yield(2);
246 ExpectActiveQuadrant(ctx, "BR");
247
248 for (int palette = 0; palette < 8; ++palette) {
249 const ImGuiKey number_key = static_cast<ImGuiKey>(ImGuiKey_1 + palette);
250 ctx->KeyPress(ImGuiMod_Ctrl | number_key);
251 ctx->Yield(2);
252 ExpectBrushPalette(ctx, palette);
253 ExpectActiveQuadrant(ctx, "BR");
254 }
255
256 ctx->KeyPress(ImGuiMod_Ctrl | ImGuiKey_T);
257 ctx->Yield(2);
258 };
259 }
260}
261
262// ============================================================================
263// Test: Entity editing mode shortcuts (3-8 keys)
264//
265// Keys 3-8 activate different entity editing modes:
266// 3 = Entrances, 4 = Exits, 5 = Items,
267// 6 = Sprites, 7 = Transports, 8 = Music
268// ============================================================================
269
270static void RegisterEntityModeTests(ImGuiTestEngine* engine) {
271 // Test: Entity mode keys (3-8) don't crash
272 {
273 ImGuiTest* test =
274 IM_REGISTER_TEST(engine, "overworld_entity", "mode_keys_3_to_8");
275 test->TestFunc = [](ImGuiTestContext* ctx) {
276 // Cycle through all entity modes
277 ctx->KeyPress(ImGuiKey_3); // Entrances
278 ctx->Yield(2);
279 ctx->KeyPress(ImGuiKey_4); // Exits
280 ctx->Yield(2);
281 ctx->KeyPress(ImGuiKey_5); // Items
282 ctx->Yield(2);
283 ctx->KeyPress(ImGuiKey_6); // Sprites
284 ctx->Yield(2);
285 ctx->KeyPress(ImGuiKey_7); // Transports
286 ctx->Yield(2);
287 ctx->KeyPress(ImGuiKey_8); // Music
288 ctx->Yield(2);
289 // Return to mouse mode
290 ctx->KeyPress(ImGuiKey_1);
291 ctx->Yield(1);
292 };
293 }
294}
295
296// ============================================================================
297// Test: Mode switching round-trips
298//
299// Verifies that cycling through all editing modes and returning to Mouse
300// mode doesn't leave the editor in a broken state.
301// ============================================================================
302
303static void RegisterModeRoundTripTests(ImGuiTestEngine* engine) {
304 // Test: Full mode cycle (Mouse -> Draw -> Fill -> Mouse) is stable
305 {
306 ImGuiTest* test =
307 IM_REGISTER_TEST(engine, "overworld_modes", "full_mode_cycle");
308 test->TestFunc = [](ImGuiTestContext* ctx) {
309 // Start in mouse mode
310 ctx->KeyPress(ImGuiKey_1);
311 ctx->Yield(2);
312
313 // Switch to draw tile
314 ctx->KeyPress(ImGuiKey_2);
315 ctx->Yield(2);
316
317 // Switch to fill via F
318 ctx->KeyPress(ImGuiKey_F);
319 ctx->Yield(2);
320
321 // Toggle brush via B (should go to draw tile)
322 ctx->KeyPress(ImGuiKey_B);
323 ctx->Yield(2);
324
325 // Toggle brush again (should go back to mouse)
326 ctx->KeyPress(ImGuiKey_B);
327 ctx->Yield(2);
328
329 // Back to mouse mode explicitly
330 ctx->KeyPress(ImGuiKey_1);
331 ctx->Yield(3);
332 };
333 }
334
335 // Test: Rapid mode switching doesn't crash
336 {
337 ImGuiTest* test =
338 IM_REGISTER_TEST(engine, "overworld_modes", "rapid_mode_switch");
339 test->TestFunc = [](ImGuiTestContext* ctx) {
340 for (int i = 0; i < 10; ++i) {
341 ctx->KeyPress(ImGuiKey_B);
342 ctx->Yield(1);
343 }
344 // Settle back to mouse
345 ctx->KeyPress(ImGuiKey_1);
346 ctx->Yield(3);
347 };
348 }
349}
350
351// ============================================================================
352// Test: Canvas navigation smoke tests
353//
354// These exercise CanvasNavigationManager methods indirectly through the
355// keyboard shortcuts and UI interactions. The actual methods tested:
356// - HandleOverworldPan (via middle-click drag simulation)
357// - HandleOverworldZoom (via the zoom step mechanism)
358// - ResetOverworldView (not directly bound to a key, but used internally)
359//
360// Note: Mouse-based pan/zoom requires pixel-precise ImGui interaction that
361// the test engine handles through its coroutine system.
362// ============================================================================
363
364static void RegisterCanvasNavigationTests(ImGuiTestEngine* engine) {
365 // Test: Multiple frames of rendering without interaction is stable
366 {
367 ImGuiTest* test =
368 IM_REGISTER_TEST(engine, "overworld_canvas", "idle_frames_stable");
369 test->TestFunc = [](ImGuiTestContext* ctx) {
370 // Just yield several frames to verify the overworld editor
371 // renders stably without any interaction
372 ctx->Yield(10);
373 };
374 }
375}
376
377// ============================================================================
378// Test: World combo selector smoke tests
379//
380// The toolbar has a world selector combo (Light World, Dark World, Special).
381// These tests verify the combo exists but don't change world since that
382// triggers heavy map reloading that depends on ROM state.
383// ============================================================================
384
385static void RegisterWorldSelectorTests(ImGuiTestEngine* engine) {
386 // Test: Overworld editor remains stable after multiple frame yields
387 {
388 ImGuiTest* test =
389 IM_REGISTER_TEST(engine, "overworld_toolbar", "toolbar_renders_stable");
390 test->TestFunc = [](ImGuiTestContext* ctx) {
391 // Render several frames to verify toolbar doesn't crash
392 ctx->Yield(5);
393 // The toolbar "CanvasToolbar" table renders every frame when
394 // ROM is loaded and overworld is initialized
395 };
396 }
397}
398
399// This test writes only an explicitly named disposable project and its terrain
400// sidecar. It is absent from ordinary UI runs unless the exact project path is
401// supplied at process startup, and still refuses an unmarked/wrong project.
402static void RegisterLyraTerrainAcceptanceTest(ImGuiTestEngine* engine) {
403 const char* requested = std::getenv("YAZE_LYRA_TERRAIN_ACCEPTANCE_PROJECT");
404 if (!requested || !*requested ||
405 std::getenv("YAZE_LYRA_TERRAIN_REOPEN_PROJECT"))
406 return;
407 ImGuiTestEngine_GetIO(engine).ConfigLogToTTY = true;
408 ImGuiTest* test = IM_REGISTER_TEST(engine, "lyra_terrain",
409 "cross_map_stroke_undo_redo_project_save");
410 test->TestFunc = [](ImGuiTestContext* ctx) {
411 namespace fs = std::filesystem;
412 const char* requested = std::getenv("YAZE_LYRA_TERRAIN_ACCEPTANCE_PROJECT");
413 IM_CHECK(requested && *requested);
414 IM_CHECK(fs::path(requested).is_absolute());
415 std::error_code ec;
416 const auto expected_project = fs::canonical(requested, ec);
417 IM_CHECK(!ec);
418
419 editor::EditorManager* manager = nullptr;
420 project::YazeProject* current_project = nullptr;
421 for (int frame = 0; frame < 200; ++frame) {
422 auto* controller = Application::Instance().GetController();
423 manager = controller ? controller->editor_manager() : nullptr;
424 current_project = manager ? manager->GetCurrentProject() : nullptr;
425 if (current_project && current_project->project_opened() &&
426 fs::weakly_canonical(current_project->filepath, ec) ==
427 expected_project &&
428 !ec)
429 break;
430 ctx->Yield(1);
431 }
432 IM_CHECK(manager && current_project && current_project->project_opened());
433 IM_CHECK(fs::canonical(current_project->filepath, ec) == expected_project &&
434 !ec);
435 IM_CHECK(current_project->name.find("[Lyra canvas acceptance]") !=
436 std::string::npos);
437 const auto project_directory = expected_project.parent_path();
438 const auto rom_path = fs::canonical(
440 IM_CHECK(!ec && rom_path.parent_path() == project_directory);
441 IM_CHECK(rom_path.filename() == "main-with-d3.sfc");
442 auto* rom = manager->GetCurrentRom();
443 IM_CHECK(rom && rom->is_loaded() && !rom->dirty());
444 IM_CHECK(fs::canonical(rom->filename(), ec) == rom_path && !ec);
445 const auto rom_before = rom->vector();
446 const auto read_file =
447 [](const fs::path& path) -> std::optional<std::string> {
448 std::ifstream file(path, std::ios::binary);
449 if (!file)
450 return std::nullopt;
451 std::string bytes{std::istreambuf_iterator<char>(file), {}};
452 return file.bad() ? std::nullopt
453 : std::optional<std::string>(std::move(bytes));
454 };
455 const auto rom_disk_before = read_file(rom_path);
456 IM_CHECK(rom_disk_before.has_value());
457 if (!current_project->lyra_terrain_file.empty()) {
458 const auto source = fs::canonical(
460 ec);
461 IM_CHECK(!ec && source.parent_path() == project_directory);
462 }
463
464 manager->OpenEditorAndPanelsFromFlags(
465 "Overworld", "overworld.canvas,overworld.tile16_selector");
466 auto& widgets = gui::WidgetIdRegistry::Instance();
467 editor::OverworldEditor* overworld = nullptr;
468 for (int frame = 0; frame < 200; ++frame) {
469 auto* editors = manager->GetCurrentEditorSet();
470 overworld = editors ? editors->GetEditorAs<editor::OverworldEditor>(
472 : nullptr;
473 if (overworld && overworld->overworld().is_loaded() &&
474 overworld->lyra_canvas_.loaded() &&
475 widgets.GetWidgetId("OverworldToolbar/world:2") != 0 &&
476 widgets.GetWidgetId("OwBlocksetSelector/button:Lookup") != 0 &&
477 ctx->ItemExists("**/OwMap"))
478 break;
479 ctx->Yield(1);
480 }
481 IM_CHECK(overworld && overworld->overworld().is_loaded());
482 if (!overworld->lyra_canvas_status_.ok()) {
483 IM_ERRORF("Native Loom binding failed: %s",
484 overworld->lyra_canvas_status_.ToString().c_str());
485 return;
486 }
487 IM_CHECK(overworld->lyra_canvas_.loaded());
488 // Project autoload must land on Lyra inside the normal Special World.
489 // Check before the test changes the world, selection, or viewport.
490 IM_CHECK_EQ(overworld->current_world_id(), 2);
491 IM_CHECK_EQ(overworld->current_map_id(), 0xA0);
492 IM_CHECK_EQ(overworld->DisplayedMapCount(), 192);
493 IM_CHECK(!overworld->IsProjectTerrainMap(0x80));
494 IM_CHECK(overworld->IsProjectTerrainMap(0xA0));
495 IM_CHECK(overworld->IsProjectTerrainMap(0xBF));
496 const auto* document = overworld->lyra_canvas_.document();
497 IM_CHECK(document != nullptr && !document->dirty());
498 const auto source_path = fs::weakly_canonical(
500 ec);
501 IM_CHECK(!ec && source_path.parent_path() == project_directory);
502 IM_CHECK(source_path.extension() == ".json");
503
504 const auto world =
505 ctx->ItemInfo(widgets.GetWidgetId("OverworldToolbar/world:2"));
506 IM_CHECK(world.ID != 0 && world.Window != nullptr);
507 ctx->SetRef(world.Window);
508 ctx->WindowFocus("");
509 ctx->ItemClick(world.ID);
510 ctx->Yield(2);
511 IM_CHECK_EQ(overworld->current_world_id(), 2);
512
513 // Only the viewport setup is programmatic. Brush selection, the stroke,
514 // and undo/redo use the ordinary editor controls. Lyra's first document
515 // row is world Tile16 row 128 (map A0), below the existing SW maps.
516 constexpr int kX = 4;
517 constexpr int kStartY = 30;
518 constexpr int kLyraWorldY = 128;
519 auto& canvas = overworld->ow_map_canvas();
520 canvas.set_global_scale(0.25f);
521 overworld->LocateOverworldTile(0xA0, kX, kLyraWorldY + 48);
522 ctx->Yield(3);
523 auto terrain = ctx->ItemInfo("**/OwMap");
524 IM_CHECK(terrain.ID != 0 && terrain.Window != nullptr);
525 const auto cell_position = [&](int y) {
526 const auto origin = canvas.zero_point();
527 const auto scrolling = canvas.scrolling();
528 const float cell_pixels = 16.0f * canvas.global_scale();
529 return ImVec2(
530 origin.x + scrolling.x + (kX + 0.5f) * cell_pixels,
531 origin.y + scrolling.y + (kLyraWorldY + y + 0.5f) * cell_pixels);
532 };
533 const bool reaches_b0 = terrain.RectClipped.Contains(cell_position(65));
534 const int end_y = reaches_b0 ? 65 : 33;
535 IM_CHECK(terrain.RectClipped.Contains(cell_position(kStartY)));
536 IM_CHECK(terrain.RectClipped.Contains(cell_position(end_y)));
537
538 // The normal Tile16 selector's lookup popup chooses the brush through
539 // RequestTile16Selection; it must use the Loom atlas for map A0.
540 std::array<bool, 8> used{};
541 for (const int y : {31, 32, end_y}) {
542 const auto tile = document->tile(kX, y);
543 IM_CHECK(tile.ok());
544 if (*tile < used.size())
545 used[*tile] = true;
546 }
547 const int old_brush = overworld->current_tile16_id();
548 if (old_brush >= 0 && old_brush < static_cast<int>(used.size()))
549 used[old_brush] = true;
550 int brush = 0;
551 while (brush < static_cast<int>(used.size()) && used[brush])
552 ++brush;
553 IM_CHECK(brush < 8 &&
554 brush < static_cast<int>(document->profile().tile_count));
555 // Startup toasts overlap the selector's top-right lookup control. Their
556 // TTL uses steady_clock and pauses on hover, so simulated SleepNoSkip
557 // time cannot expire them. Let the real UI age them without dismissing
558 // notifications or bypassing the ordinary selector interaction.
559 auto* toasts = manager->toast_manager();
560 IM_CHECK(toasts != nullptr);
561 if (toasts->pending_count() != 0) {
562 // The dockspace fills the viewport, so there may be no window-free
563 // point. This validated canvas cell is away from the toast stack.
564 ctx->MouseSetViewport(terrain.Window);
565 ctx->MouseMoveToPos(cell_position(kStartY));
566 const auto deadline =
567 std::chrono::steady_clock::now() + std::chrono::seconds(15);
568 while (toasts->pending_count() != 0 &&
569 std::chrono::steady_clock::now() < deadline && !ctx->IsError())
570 ctx->Yield(1);
571 IM_CHECK_EQ(toasts->pending_count(), 0);
572 ctx->Yield(2);
573 }
574
575 // Registry entries outlive their widgets by 600 frames; only an item
576 // ImGui submitted recently counts as present.
577 const auto visible_widget = [&](const std::string& suffix) -> ImGuiID {
578 const std::string tail = "/" + suffix;
579 for (const auto& path : widgets.FindWidgets("*" + suffix)) {
580 const bool match =
581 path == suffix ||
582 (path.size() > tail.size() &&
583 path.compare(path.size() - tail.size(), tail.size(), tail) == 0);
584 const ImGuiID id = match ? widgets.GetWidgetId(path) : 0;
585 if (id != 0 && ctx->ItemExists(id))
586 return id;
587 }
588 return 0;
589 };
590
591 // A legacy Current area + Hide unused filter must not hide project
592 // terrain tiles, and must return unchanged on the next legacy map. Only
593 // native grid clicks prove which tile a visible cell holds.
594 constexpr int kLegacyMap = 0x3A;
595 const bool selector_was_pinned = overworld->map_pinned();
596 if (!selector_was_pinned)
597 overworld->ToggleMapLock();
598 manager->OpenEditorAndPanelsFromFlags("Overworld",
599 "overworld.tile16_selector");
600 ctx->Yield(3);
601 const auto selector_window =
602 manager->window_manager().GetWorkspaceWindowName(
603 manager->GetCurrentSessionId(), "overworld.tile16_selector");
604 ctx->SetRef(selector_window.c_str());
605 ImGuiWindow* selector = ctx->GetWindowByRef("");
606 IM_CHECK(selector != nullptr);
607 ctx->WindowFocus("");
608 ctx->Yield(2);
609 // The grid child has no padding: cell i is drawn after the selector's
610 // x offset, at column i % 8 and row i / 8 of square cells.
611 const std::string grid_prefix = std::string(selector->Name) + "/";
612 const auto tile16_grid = [&]() -> ImGuiWindow* {
613 for (ImGuiWindow* window : ctx->UiContext->Windows) {
614 const std::string name = window->Name;
615 if (window->WasActive && name.rfind(grid_prefix, 0) == 0 &&
616 name.find("/##Tile16Grid") != std::string::npos)
617 return window;
618 }
619 return nullptr;
620 };
621 constexpr int kColumns = editor::kTile16SelectorColumns;
622 float cell = 0.0f;
623 // Returns the native selection after clicking a visible cell, else -1.
624 const auto click_cell = [&](ImGuiWindow* grid, int index) -> int {
625 const ImVec2 pos(
626 grid->Pos.x - grid->Scroll.x + editor::kTile16SelectorDrawOffsetX +
627 (index % kColumns + 0.5f) * cell,
628 grid->Pos.y - grid->Scroll.y + (index / kColumns + 0.5f) * cell);
629 if (!grid->InnerClipRect.Contains(pos))
630 return -1;
631 const auto name = [](const ImGuiWindow* window) {
632 return window != nullptr ? window->Name : "(null)";
633 };
634 ctx->SetRef(selector);
635 const auto canvas_item =
636 ctx->ItemInfo(grid->GetID("OwBlockset"), ImGuiTestOpFlags_NoError);
637 const ImRect& full = canvas_item.RectFull;
638 const ImRect& clipped = canvas_item.RectClipped;
639 ctx->LogInfo(
640 "Tile16 click map=0x%02X selected=0x%03X requested=0x%03X "
641 "cell=%.2f grid=%s pos=(%.1f,%.1f) scroll=(%.1f,%.1f) "
642 "target=(%.1f,%.1f) canvas_id=0x%08X canvas_window=%s "
643 "full=(%.1f,%.1f)-(%.1f,%.1f) clipped=(%.1f,%.1f)-(%.1f,%.1f) "
644 "in_canvas=%d hovered=%s",
645 overworld->current_map_id(), overworld->current_tile16_id(), index,
646 cell, name(grid), grid->Pos.x, grid->Pos.y, grid->Scroll.x,
647 grid->Scroll.y, pos.x, pos.y, canvas_item.ID,
648 name(canvas_item.Window), full.Min.x, full.Min.y, full.Max.x,
649 full.Max.y, clipped.Min.x, clipped.Min.y, clipped.Max.x,
650 clipped.Max.y, canvas_item.ID != 0 && clipped.Contains(pos),
651 name(ctx->UiContext->HoveredWindow));
652 ctx->MouseSetViewport(grid);
653 ctx->MouseMoveToPos(pos);
654 ctx->LogInfo("Tile16 click moved hovered=%s",
655 name(ctx->UiContext->HoveredWindow));
656 ctx->MouseClick(ImGuiMouseButton_Left);
657 // Successive probes can share a screen point; never double-click.
658 ctx->SleepNoSkip(0.5f, 1.0f / 60.0f);
659 ctx->LogInfo("Tile16 click requested=0x%03X selected=0x%03X hovered=%s",
660 index, overworld->current_tile16_id(),
661 name(ctx->UiContext->HoveredWindow));
662 return overworld->current_tile16_id();
663 };
664 const auto lookup_tile = [&](int tile) -> bool {
665 const ImGuiID button =
666 widgets.GetWidgetId("OwBlocksetSelector/button:Lookup");
667 if (button == 0)
668 return false;
669 ctx->ItemClick(button);
670 ctx->Yield(2);
671 const ImGuiID input =
672 widgets.GetWidgetId("OwBlocksetSelector/input:TileID");
673 if (input == 0)
674 return false;
675 char hex[8];
676 std::snprintf(hex, sizeof(hex), "%X", tile);
677 ctx->ItemInputValue(input, hex);
678 ctx->Yield(3);
679 return overworld->current_tile16_id() == tile;
680 };
681 // Project terrain grids use full ID order. Lookup centres its tile, so
682 // two lookups 16 rows apart measure the cell size from the scroll delta.
683 const auto check_lyra_grid = [&](int map_id) -> bool {
684 overworld->JumpToMap(map_id);
685 ctx->Yield(3);
686 ImGuiWindow* grid = tile16_grid();
687 if (overworld->current_map_id() != map_id || grid == nullptr)
688 return false;
689 if (cell <= 0.0f) {
690 if (!lookup_tile(0x800))
691 return false;
692 const float top = grid->Scroll.y;
693 if (!lookup_tile(0x880))
694 return false;
695 cell = std::round((grid->Scroll.y - top) / 16.0f);
696 if (cell < 1.0f)
697 return false;
698 }
699 // Lookup alone can select a hidden tile. Click a different ID first,
700 // then the final palette cell, to prove both native hit targets exist.
701 return lookup_tile(0xFFF) && click_cell(grid, 0xFF8) == 0xFF8 &&
702 click_cell(grid, 0xFFF) == 0xFFF;
703 };
704 overworld->JumpToMap(kLegacyMap);
705 ImGuiID scope_button = 0;
706 for (int frame = 0; frame < 120 && scope_button == 0 && !ctx->IsError();
707 ++frame) {
708 ctx->Yield(1);
709 if (overworld->current_map_id() == kLegacyMap)
710 scope_button = visible_widget("button:tile16_usage_scope");
711 }
712 IM_CHECK_EQ(overworld->current_map_id(), kLegacyMap);
713 IM_CHECK(scope_button != 0);
714 ctx->ItemClick(scope_button);
715 ctx->Yield(2);
716 ImGuiWindow* scope_popup = ctx->GetWindowByRef("//$FOCUSED");
717 IM_CHECK(scope_popup != nullptr &&
718 (scope_popup->Flags & ImGuiWindowFlags_Popup) != 0);
719 ctx->SetRef(scope_popup);
720 ctx->ItemClick("Current area");
721 ctx->ItemCheck("Hide unused tiles");
722 IM_CHECK(ctx->ItemIsChecked("Hide unused tiles"));
723 ctx->PopupCloseAll();
724 ctx->Yield(3);
725 const std::string legacy_scope = ctx->ItemInfo(scope_button).DebugLabel;
726 IM_CHECK(legacy_scope.find('*') != std::string::npos);
727
728 for (const int map_id : {0xA0, 0xA1})
729 IM_CHECK(check_lyra_grid(map_id));
730
731 overworld->JumpToMap(kLegacyMap);
732 scope_button = 0;
733 for (int frame = 0; frame < 120 && scope_button == 0 && !ctx->IsError();
734 ++frame) {
735 ctx->Yield(1);
736 if (overworld->current_map_id() == kLegacyMap)
737 scope_button = visible_widget("button:tile16_usage_scope");
738 }
739 IM_CHECK_EQ(overworld->current_map_id(), kLegacyMap);
740 IM_CHECK(scope_button != 0);
741 ctx->Yield(2);
742 IM_CHECK(ctx->ItemInfo(scope_button).DebugLabel == legacy_scope);
743 ctx->ItemClick(scope_button);
744 ctx->Yield(2);
745 scope_popup = ctx->GetWindowByRef("//$FOCUSED");
746 IM_CHECK(scope_popup != nullptr &&
747 (scope_popup->Flags & ImGuiWindowFlags_Popup) != 0);
748 ctx->SetRef(scope_popup);
749 IM_CHECK(ctx->ItemIsChecked("Current area"));
750 IM_CHECK(ctx->ItemIsChecked("Hide unused tiles"));
751 ctx->PopupCloseAll();
752 ctx->Yield(2);
753
754 canvas.set_global_scale(0.25f);
755 overworld->LocateOverworldTile(0xA0, kX, kLyraWorldY + 48);
756 ctx->Yield(3);
757 if (!selector_was_pinned)
758 overworld->ToggleMapLock();
759 IM_CHECK_EQ(overworld->current_world_id(), 2);
760 IM_CHECK_EQ(overworld->current_map_id(), 0xA0);
761
762 const auto lookup =
763 ctx->ItemInfo(widgets.GetWidgetId("OwBlocksetSelector/button:Lookup"));
764 IM_CHECK(lookup.ID != 0 && lookup.Window != nullptr);
765 ctx->SetRef(lookup.Window);
766 ctx->WindowFocus("");
767 ctx->ItemClick(lookup.ID);
768 ctx->Yield(2);
769 const ImGuiID tile_input =
770 widgets.GetWidgetId("OwBlocksetSelector/input:TileID");
771 IM_CHECK(tile_input != 0);
772 // The first eight tile IDs have the same decimal and hexadecimal text.
773 ctx->ItemInputValue(tile_input, std::to_string(brush).c_str());
774 ctx->Yield(2);
775 IM_CHECK_EQ(overworld->current_tile16_id(), brush);
776 ctx->SetRef(world.Window);
777 ctx->WindowFocus("");
778 ImGuiID paint = widgets.GetWidgetId("OverworldToolbar/button:Paint");
779 const bool tools_overflow = paint == 0 || !ctx->ItemExists(paint);
780 if (tools_overflow) {
781 const ImGuiID tools =
782 widgets.GetWidgetId("OverworldToolbar/button:Tools");
783 IM_CHECK(tools != 0);
784 ctx->ItemClick(tools);
785 ctx->Yield(2);
786 paint = widgets.GetWidgetId("OverworldToolbar/button:Paint");
787 }
788 IM_CHECK(paint != 0);
789 ctx->ItemClick(paint);
790 if (tools_overflow)
791 ctx->KeyPress(ImGuiKey_Escape);
792 ctx->Yield(2);
793 const auto before =
794 std::make_unique<core::LyraTerrainDocument::Maps>(document->maps());
795 const size_t undo_before = overworld->undo_manager().UndoStackSize();
796 IM_CHECK(undo_before < overworld->undo_manager().GetMaxStackSize());
797
798 terrain = ctx->ItemInfo("**/OwMap");
799 IM_CHECK(terrain.RectClipped.Contains(cell_position(kStartY)));
800 IM_CHECK(terrain.RectClipped.Contains(cell_position(end_y)));
801 ctx->MouseSetViewport(terrain.Window);
802 ctx->MouseMoveToPos(cell_position(kStartY));
803 ctx->MouseDown(ImGuiMouseButton_Left);
804 ctx->Yield(2);
805 ctx->MouseMoveToPos(cell_position(32));
806 ctx->Yield(2);
807 ctx->MouseMoveToPos(cell_position(end_y));
808 ctx->Yield(2);
809 ctx->MouseUp(ImGuiMouseButton_Left);
810 ctx->Yield(3);
811
812 IM_CHECK_EQ(overworld->undo_manager().UndoStackSize(), undo_before + 1);
813 IM_CHECK(document->dirty());
814 for (int y = kStartY; y <= end_y; ++y)
815 IM_CHECK(document->tile(kX, y).value() == brush);
816 const auto painted =
817 std::make_unique<core::LyraTerrainDocument::Maps>(document->maps());
818 IM_CHECK(manager->GetCurrentEditor() == overworld);
819 ctx->KeyPress(ImGuiMod_Ctrl | ImGuiKey_Z);
820 ctx->Yield(2);
821 IM_CHECK(overworld->lyra_canvas_status_.ok());
822 IM_CHECK(document->maps() == *before);
823 IM_CHECK(!document->dirty());
824 IM_CHECK_EQ(overworld->undo_manager().UndoStackSize(), undo_before);
825 ctx->KeyPress(ImGuiMod_Ctrl | ImGuiMod_Shift | ImGuiKey_Z);
826 ctx->Yield(2);
827 IM_CHECK(overworld->lyra_canvas_status_.ok());
828 IM_CHECK(document->maps() == *painted);
829 IM_CHECK_EQ(overworld->undo_manager().UndoStackSize(), undo_before + 1);
830
831 // Capture a real rectangle across A0/A8, copy it through the editor's
832 // clipboard shortcut, and paste it across the next available map seam.
833 // Four columns at 0.25x exceed ImGui's drag threshold without changing
834 // the viewport; the original painted column makes the pattern nonuniform.
835 const auto xy_position = [&](int x, int y) {
836 const auto origin = canvas.zero_point();
837 const auto scrolling = canvas.scrolling();
838 const float size = 16.0f * canvas.global_scale();
839 return ImVec2(origin.x + scrolling.x + (x + 0.5f) * size,
840 origin.y + scrolling.y + (kLyraWorldY + y + 0.5f) * size);
841 };
842 editor::TileBrush rectangle{4, 3, {}};
843 for (int y = 31; y <= 33; ++y)
844 for (int x = kX; x < kX + rectangle.width; ++x)
845 rectangle.tile_ids.push_back(document->tile(x, y).value());
846 ctx->MouseMoveToPos(xy_position(kX, 31));
847 ctx->MouseDown(ImGuiMouseButton_Right);
848 ctx->Yield(2);
849 ctx->MouseMoveToPos(xy_position(kX + 3, 33));
850 ctx->Yield(2);
851 ctx->MouseUp(ImGuiMouseButton_Right);
852 ctx->Yield(3);
853 IM_CHECK(overworld->tile_painting()->selection_brush() != nullptr);
854 IM_CHECK_EQ(overworld->tile_painting()->selection_brush()->width, 4);
855 IM_CHECK_EQ(overworld->tile_painting()->selection_brush()->height, 3);
856 IM_CHECK(overworld->tile_painting()->selection_brush()->tile_ids ==
857 rectangle.tile_ids);
858 ctx->KeyPress(ImGuiMod_Ctrl | ImGuiKey_C);
859 ctx->Yield(2);
860 const int paste_y = reaches_b0 ? 63 : 31;
861 IM_CHECK(terrain.RectClipped.Contains(xy_position(11, paste_y + 2)));
862 ctx->MouseMoveToPos(xy_position(8, paste_y));
863 ctx->Yield(2);
864 ctx->KeyPress(ImGuiMod_Ctrl | ImGuiKey_V);
865 ctx->Yield(3);
866 IM_CHECK_EQ(overworld->undo_manager().UndoStackSize(), undo_before + 2);
867 for (int y = 0; y < rectangle.height; ++y)
868 for (int x = 0; x < rectangle.width; ++x)
869 IM_CHECK(document->tile(8 + x, paste_y + y).value() ==
870 rectangle.at(x, y));
871 const auto pasted =
872 std::make_unique<core::LyraTerrainDocument::Maps>(document->maps());
873 ctx->KeyPress(ImGuiMod_Ctrl | ImGuiKey_Z);
874 ctx->Yield(2);
875 IM_CHECK(document->maps() == *painted);
876 ctx->KeyPress(ImGuiMod_Ctrl | ImGuiMod_Shift | ImGuiKey_Z);
877 ctx->Yield(2);
878 IM_CHECK(document->maps() == *pasted);
879
880 // The same captured rectangle is a brush and a fill pattern. Draw a
881 // stamp, undo it exactly, then fill A9 through the normal toolbar.
882 ctx->MouseMoveToPos(xy_position(16, paste_y));
883 ctx->MouseClick(ImGuiMouseButton_Left);
884 ctx->Yield(3);
885 IM_CHECK_EQ(overworld->undo_manager().UndoStackSize(), undo_before + 3);
886 for (int y = 0; y < rectangle.height; ++y)
887 for (int x = 0; x < rectangle.width; ++x)
888 IM_CHECK(document->tile(16 + x, paste_y + y).value() ==
889 rectangle.at(x, y));
890 ctx->KeyPress(ImGuiMod_Ctrl | ImGuiKey_Z);
891 ctx->Yield(2);
892 IM_CHECK(document->maps() == *pasted);
893 ImGuiID fill = widgets.GetWidgetId("OverworldToolbar/button:Fill");
894 const bool fill_overflow = fill == 0 || !ctx->ItemExists(fill);
895 if (fill_overflow) {
896 const auto tools = widgets.GetWidgetId("OverworldToolbar/button:Tools");
897 IM_CHECK(tools != 0);
898 ctx->ItemClick(tools);
899 ctx->Yield(2);
900 fill = widgets.GetWidgetId("OverworldToolbar/button:Fill");
901 }
902 IM_CHECK(fill != 0);
903 ctx->ItemClick(fill);
904 if (fill_overflow)
905 ctx->KeyPress(ImGuiKey_Escape);
906 overworld->LocateOverworldTile(0xA9, 48, kLyraWorldY + 48);
907 ctx->Yield(3);
908 terrain = ctx->ItemInfo("**/OwMap");
909 IM_CHECK(terrain.RectClipped.Contains(xy_position(48, 48)));
910 ctx->MouseSetViewport(terrain.Window);
911 ctx->MouseMoveToPos(xy_position(48, 48));
912 ctx->MouseClick(ImGuiMouseButton_Left);
913 ctx->Yield(3);
914 IM_CHECK_EQ(overworld->undo_manager().UndoStackSize(), undo_before + 3);
915 for (int y = 0; y < 32; ++y)
916 for (int x = 0; x < 32; ++x)
917 IM_CHECK(document->tile(32 + x, 32 + y).value() ==
918 rectangle.at(x % rectangle.width, y % rectangle.height));
919 const auto filled =
920 std::make_unique<core::LyraTerrainDocument::Maps>(document->maps());
921 ctx->KeyPress(ImGuiMod_Ctrl | ImGuiKey_Z);
922 ctx->Yield(2);
923 IM_CHECK(document->maps() == *pasted);
924 ctx->KeyPress(ImGuiMod_Ctrl | ImGuiMod_Shift | ImGuiKey_Z);
925 ctx->Yield(2);
926 IM_CHECK(document->maps() == *filled);
927 editor::UndoManager rejected_history;
928 const auto rejected = overworld->lyra_canvas_.PaintPattern(
929 {2, 1, {brush, static_cast<int>(document->profile().tile_count)}}, 0, 0,
930 2, 1, true, rejected_history);
931 IM_CHECK(!rejected.ok());
932 IM_CHECK_EQ(rejected_history.UndoStackSize(), 0);
933 IM_CHECK(document->maps() == *filled);
934 IM_CHECK_EQ(overworld->undo_manager().UndoStackSize(), undo_before + 3);
935
936 // Native property controls use the same project history as terrain.
937 // The fill click left the cursor over A9; pin A0 so hover-follow cannot
938 // retarget the properties panel before the native input reaches it.
939 const bool was_pinned = overworld->map_pinned();
940 if (!was_pinned)
941 overworld->ToggleMapLock();
942 overworld->set_current_map(0xA0);
943 manager->OpenEditorAndPanelsFromFlags("Overworld", "overworld.properties");
944 ctx->Yield(5);
945 const auto properties_window =
946 manager->window_manager().GetWorkspaceWindowName(
947 manager->GetCurrentSessionId(), "overworld.properties");
948 ctx->SetRef(properties_window.c_str());
949 IM_CHECK(ctx->GetWindowByRef("") != nullptr);
950 ctx->WindowFocus("");
951 ctx->Yield(2);
952 const auto property_input =
953 widgets.GetWidgetId("LyraMapProperties/input:MainPalette");
954 IM_CHECK(property_input != 0);
955 const auto property_item = ctx->ItemInfo(property_input);
956 IM_CHECK(property_item.Window != nullptr);
957 ctx->SetRef(property_item.Window);
958 ctx->WindowFocus("");
959 IM_CHECK_EQ(overworld->current_map_id(), 0xA0);
960 const auto authoring_before =
961 std::make_unique<core::LyraAuthoringState>(document->authoring());
962 const int next_palette =
963 document->authoring().maps[0].properties.main_palette == 0 ? 1 : 0;
964 ctx->ItemInputValue(property_input, std::to_string(next_palette).c_str());
965 ctx->ItemClick(widgets.GetWidgetId("LyraMapProperties/button:Apply"));
966 ctx->Yield(3);
967 IM_CHECK_EQ(document->authoring().maps[0].properties.main_palette,
968 next_palette);
969 IM_CHECK(overworld->Undo().ok());
970 IM_CHECK(document->authoring() == *authoring_before);
971 IM_CHECK(overworld->Redo().ok());
972 IM_CHECK_EQ(document->authoring().maps[0].properties.main_palette,
973 next_palette);
974 ctx->Yield(3);
975 const auto sprite_count = document->authoring().maps[0].sprites.size();
976 ctx->ItemClick(widgets.GetWidgetId("LyraMapProperties/button:AddSprite"));
977 ctx->Yield(2);
978 IM_CHECK_EQ(document->authoring().maps[0].sprites.size(), sprite_count + 1);
979 IM_CHECK_EQ(overworld->current_map_id(), 0xA0);
980 if (!was_pinned)
981 overworld->ToggleMapLock();
982 auto authored =
983 std::make_unique<core::LyraAuthoringState>(document->authoring());
984 authored->maps[1].properties.graphics_sheets[0] = 0;
985 authored->maps[1].properties.graphics_sheets[1] = 0xFF;
986 authored->maps[0].doors = {{900001, 9, 10, std::nullopt}};
987 authored->maps[0].exits = {{900002, 0x105, 11, 12, std::nullopt}};
988 authored->flight_points = {
989 {900003, 0xA0, 13, 14, "Lyra test", std::nullopt},
990 {900004, 0x20, 15, 16, "Legacy test", std::nullopt}};
991 const auto edit = overworld->EditProjectMapAuthoring(*authored);
992 IM_CHECK(edit.ok());
993 const auto expected_authoring =
994 std::make_unique<core::LyraAuthoringState>(document->authoring());
995 IM_CHECK(overworld->Undo().ok());
996 IM_CHECK(overworld->Redo().ok());
997 IM_CHECK(document->authoring() == *expected_authoring);
998 for (int state = 0; state < 3; ++state)
999 IM_CHECK(overworld->lyra_canvas_
1000 .PrepareMapAssets(overworld->overworld(), state)
1001 .ok());
1002 IM_CHECK(overworld->lyra_canvas_.PrepareMapAssets(overworld->overworld(), 0)
1003 .ok());
1004 const auto* source_map = overworld->overworld().overworld_map(0x3A);
1005 auto normal = *source_map;
1006 normal.ClearInheritedAreaProperties();
1007 normal.set_main_palette(next_palette);
1008 IM_CHECK(normal.LoadPalette().ok());
1009 IM_CHECK_EQ(overworld->lyra_canvas_.MapAtlas(0xA0).palette()[33].snes(),
1010 normal.current_palette()[33].snes());
1011 IM_CHECK_EQ(overworld->lyra_canvas_.MapGraphics(0xA1)->vector()[0],
1012 static_cast<uint8_t>(
1013 source_map->game_data()->graphics_buffer[0] + 0x88));
1014 auto invalid_authoring =
1015 std::make_unique<core::LyraAuthoringState>(document->authoring());
1016 invalid_authoring->maps[0].properties.main_palette = 0xFE;
1017 IM_CHECK(!overworld->EditProjectMapAuthoring(*invalid_authoring).ok());
1018 IM_CHECK(document->authoring() == *expected_authoring);
1019 IM_CHECK(document->maps() == *filled);
1020
1021 // The workbench rename popup edits the same project this save writes.
1022 // The registry must be present so the reopen below re-injects defaults.
1023 namespace room_labels = editor::dungeon_project_labels;
1024 constexpr int kRenamedRoom = 0x25;
1025 constexpr int kHoverRoom = 0x12;
1026 const std::string room_rename = "Lyra Native Rename";
1027 const std::string room_key = std::to_string(kRenamedRoom);
1028 const auto default_room_label = [&](const project::YazeProject& project) {
1029 const auto type = project.resource_labels.find("room");
1030 if (type == project.resource_labels.end())
1031 return std::string();
1032 const auto label = type->second.find(room_key);
1033 return label == type->second.end() ? std::string() : label->second;
1034 };
1036 const std::string room_default = default_room_label(*current_project);
1037 IM_CHECK(!room_default.empty());
1038 manager->OpenEditorAndPanelsFromFlags("Dungeon", "dungeon.workbench");
1039 const auto workbench_window =
1040 manager->window_manager().GetWorkspaceWindowName(
1041 manager->GetCurrentSessionId(), "dungeon.workbench");
1042 ctx->SetRef(workbench_window.c_str());
1043 for (int frame = 0;
1044 frame < 120 && ctx->GetWindowByRef("") == nullptr && !ctx->IsError();
1045 ++frame)
1046 ctx->Yield(1);
1047 IM_CHECK(ctx->GetWindowByRef("") != nullptr);
1048 ctx->WindowFocus("");
1049 ctx->Yield(2);
1050 editor::DungeonEditorV2* dungeon = nullptr;
1051 ImGuiID hover_cell = 0;
1052 for (int frame = 0; frame < 600 && !ctx->IsError(); ++frame) {
1053 auto* editors = manager->GetCurrentEditorSet();
1054 dungeon = editors ? editors->GetEditorAs<editor::DungeonEditorV2>(
1056 : nullptr;
1057 if (dungeon && dungeon->IsWorkbenchWorkflowEnabled() &&
1058 (hover_cell = visible_widget("button:Room 0x012")) != 0)
1059 break;
1060 ctx->Yield(1);
1061 }
1062 IM_CHECK(dungeon != nullptr && hover_cell != 0);
1063 dungeon->add_room(kRenamedRoom);
1064 ImGuiID rename_button = 0;
1065 for (int frame = 0; frame < 200 && rename_button == 0 && !ctx->IsError();
1066 ++frame) {
1067 ctx->Yield(1);
1068 if (dungeon->current_room_id() == kRenamedRoom)
1069 rename_button = visible_widget("button:rename_room_button");
1070 }
1071 IM_CHECK_EQ(dungeon->current_room_id(), kRenamedRoom);
1072 IM_CHECK(rename_button != 0);
1073 ctx->Yield(3);
1074 // Toasts stack over the inspector's top edge; park on the open room's
1075 // matrix cell so they can expire.
1076 if (toasts->pending_count() != 0) {
1077 const ImGuiID park = visible_widget("button:Room 0x025");
1078 IM_CHECK(park != 0);
1079 ctx->MouseMove(park, ImGuiTestOpFlags_NoCheckHoveredId);
1080 const auto deadline =
1081 std::chrono::steady_clock::now() + std::chrono::seconds(15);
1082 while (toasts->pending_count() != 0 &&
1083 std::chrono::steady_clock::now() < deadline && !ctx->IsError())
1084 ctx->Yield(1);
1085 IM_CHECK_EQ(toasts->pending_count(), 0);
1086 ctx->Yield(2);
1087 }
1088 const auto visible_popup_widget = [&](const char* suffix) {
1089 ImGuiID id = 0;
1090 for (int frame = 0; frame < 60 && id == 0 && !ctx->IsError(); ++frame) {
1091 ctx->Yield(1);
1092 id = visible_widget(suffix);
1093 }
1094 return id;
1095 };
1096 // Enter submits through EnterReturnsTrue and closes the popup, so Save is
1097 // never clicked afterwards.
1098 const auto submit_room_name = [&](const std::string& name) {
1099 ctx->ItemClick(rename_button);
1100 const ImGuiID input =
1101 visible_popup_widget("input_text:rename_room_input");
1102 if (input == 0)
1103 return false;
1104 ctx->ItemInputValue(input, name.c_str());
1105 ctx->Yield(2);
1106 return !ctx->IsError();
1107 };
1108 IM_CHECK(submit_room_name(room_rename));
1109 IM_CHECK(room_labels::FindRoomOverride(current_project, kRenamedRoom) ==
1110 room_rename);
1111 IM_CHECK(room_labels::GetRoomLabel(current_project, kRenamedRoom) ==
1112 room_rename);
1113 IM_CHECK(zelda3::GetRoomLabel(kRenamedRoom) == room_rename);
1114 IM_CHECK(manager->IsCurrentProjectDirty());
1115 IM_CHECK(default_room_label(*current_project) == room_default);
1116 ctx->ItemClick(rename_button);
1117 const ImGuiID rename_reset =
1118 visible_popup_widget("button:rename_room_reset");
1119 IM_CHECK(rename_reset != 0);
1120 ctx->ItemClick(rename_reset);
1121 ctx->Yield(2);
1122 IM_CHECK(!room_labels::HasCustomRoomLabel(current_project, kRenamedRoom));
1123 IM_CHECK(room_labels::GetRoomLabel(current_project, kRenamedRoom) ==
1124 room_default);
1125 IM_CHECK(zelda3::GetRoomLabel(kRenamedRoom) == room_default);
1126 IM_CHECK(default_room_label(*current_project) == room_default);
1127 IM_CHECK(submit_room_name(room_rename));
1128 IM_CHECK(room_labels::HasCustomRoomLabel(current_project, kRenamedRoom));
1129 IM_CHECK(room_labels::FindRoomOverride(current_project, kRenamedRoom) ==
1130 room_rename);
1131 IM_CHECK(room_labels::GetRoomLabel(current_project, kRenamedRoom) ==
1132 room_rename);
1133 IM_CHECK(zelda3::GetRoomLabel(kRenamedRoom) == room_rename);
1134 IM_CHECK(manager->IsCurrentProjectDirty());
1135 IM_CHECK(default_room_label(*current_project) == room_default);
1136
1137 // Hovering an unopened room may materialize an empty Room for the render
1138 // context, but must not load it or give it pending changes.
1139 IM_CHECK(dungeon->rooms().GetIfLoaded(kHoverRoom) == nullptr);
1140 hover_cell = visible_widget("button:Room 0x012");
1141 IM_CHECK(hover_cell != 0);
1142 ctx->MouseMove(hover_cell);
1143 ctx->Yield(4);
1144 IM_CHECK(dungeon->rooms().GetIfLoaded(kHoverRoom) == nullptr);
1145 if (const auto* hovered = dungeon->rooms().GetIfMaterialized(kHoverRoom))
1146 IM_CHECK(!hovered->custom_collision_dirty() &&
1147 !hovered->HasUnsavedChanges());
1148 const auto* renamed_room = dungeon->rooms().GetIfLoaded(kRenamedRoom);
1149 IM_CHECK(renamed_room != nullptr && !renamed_room->HasUnsavedChanges());
1150
1151 const auto save_status = manager->SaveProject();
1152 ctx->Yield(3);
1153 if (!save_status.ok()) {
1154 IM_ERRORF("SaveProject failed: %s", save_status.ToString().c_str());
1155 return;
1156 }
1157 IM_CHECK(!overworld->HasPendingProjectDraftChanges());
1158 const auto saved = read_file(source_path);
1159 IM_CHECK(saved.has_value() && *saved == document->Serialize());
1160 // Parsing is verified by the separate fresh-process autoload test below.
1161 // Keep full-map snapshots on the heap: the test-engine coroutine has a
1162 // smaller stack than the main UI thread that normally loads terrain.
1163 auto reopened_project = std::make_unique<project::YazeProject>();
1164 IM_CHECK(reopened_project->Open(expected_project.string()).ok());
1165 IM_CHECK(fs::canonical(reopened_project->lyra_terrain_file, ec) ==
1166 source_path &&
1167 !ec);
1168 IM_CHECK(reopened_project->hack_manifest.HasProjectRegistry());
1169 IM_CHECK(
1170 room_labels::HasCustomRoomLabel(reopened_project.get(), kRenamedRoom));
1171 IM_CHECK(room_labels::GetRoomLabel(reopened_project.get(), kRenamedRoom) ==
1172 room_rename);
1173 IM_CHECK(default_room_label(*reopened_project) == room_default);
1174 IM_CHECK(rom->vector() == rom_before && !rom->dirty());
1175 IM_CHECK(read_file(rom_path) == rom_disk_before);
1176
1177 // Directly queued static tests do not receive an RPC execution ID. Emit a
1178 // unique receipt only after every native interaction/persistence check.
1179 const auto stamp = std::chrono::duration_cast<std::chrono::milliseconds>(
1180 std::chrono::system_clock::now().time_since_epoch())
1181 .count();
1182 const auto receipt_path =
1183 project_directory /
1184 ("lyra-canvas-native-pass-" + std::to_string(stamp) + ".json");
1185
1186 // Inactive session controls are hidden. Exercise their public edit guard
1187 // with a retained editor, then restore the saved project for the driver.
1188 IM_CHECK(!manager->IsCurrentProjectDirty());
1189 const size_t project_session_id = manager->GetCurrentSessionId();
1190 const size_t project_session_index = manager->GetCurrentSessionIndex();
1191 const auto project_labels = current_project->resource_labels;
1192 const auto project_saved_bytes = read_file(expected_project);
1193 IM_CHECK(project_saved_bytes.has_value());
1194 auto second_rom_path = receipt_path;
1195 second_rom_path.replace_extension(".session-b.sfc");
1196 // The startup guard confines rom_path and the receipt to the explicitly
1197 // named disposable directory. copy_file refuses any existing destination.
1198 IM_CHECK(
1199 fs::copy_file(rom_path, second_rom_path, fs::copy_options::none, ec));
1200 IM_CHECK(!ec && read_file(second_rom_path) == rom_disk_before);
1201 IM_CHECK(manager
1202 ->OpenRomOrProject(
1203 second_rom_path.string(),
1205 .ok());
1206 ctx->Yield(3);
1207 IM_CHECK(manager->GetCurrentSessionId() != project_session_id);
1208 auto* second_project = manager->GetCurrentProject();
1209 auto* second_rom = manager->GetCurrentRom();
1210 IM_CHECK(second_project && second_rom && !second_rom->dirty());
1211 IM_CHECK(!manager->IsCurrentProjectDirty());
1212 const auto second_labels = second_project->resource_labels;
1213 const auto second_global_label = zelda3::GetRoomLabel(kRenamedRoom);
1214 const auto refused =
1215 dungeon->ApplyRoomLabelEdit(kRenamedRoom, "Wrong Session");
1216 IM_CHECK(absl::IsFailedPrecondition(refused));
1217 IM_CHECK(refused.message() ==
1218 "Activate this dungeon project before renaming rooms");
1219 IM_CHECK(second_project->resource_labels == second_labels);
1220 IM_CHECK(zelda3::GetRoomLabel(kRenamedRoom) == second_global_label);
1221 IM_CHECK(!manager->IsCurrentProjectDirty() && !second_rom->dirty());
1222 manager->SwitchToSession(project_session_index);
1223 ctx->Yield(3);
1224 IM_CHECK_EQ(manager->GetCurrentSessionId(), project_session_id);
1225 current_project = manager->GetCurrentProject();
1226 IM_CHECK(current_project &&
1227 current_project->resource_labels == project_labels);
1228 IM_CHECK(!manager->IsCurrentProjectDirty());
1229 IM_CHECK(zelda3::GetRoomLabel(kRenamedRoom) == room_rename);
1230 IM_CHECK(manager->GetCurrentRom() == rom && !rom->dirty());
1231 IM_CHECK(rom->vector() == rom_before);
1232 IM_CHECK(read_file(expected_project) == project_saved_bytes);
1233 IM_CHECK(read_file(source_path) == saved);
1234 IM_CHECK(read_file(rom_path) == rom_disk_before);
1235 IM_CHECK(read_file(second_rom_path) == rom_disk_before);
1236
1237 const nlohmann::json receipt = {
1238 {"test", "lyra_terrain/cross_map_stroke_undo_redo_project_save"},
1239 {"result", "PASS"},
1240 {"project", expected_project.string()},
1241 {"terrain", source_path.string()},
1242 {"rom", rom_path.string()},
1243 {"paint_x", kX},
1244 {"paint_y_start", kStartY},
1245 {"paint_y_end", end_y},
1246 {"tile16", brush},
1247 {"undo_actions_added", 3},
1248 {"rectangle_selection_copy_paste", true},
1249 {"rectangle_stamp", true},
1250 {"pattern_fill", true},
1251 {"invalid_brush_refused_atomically", true},
1252 {"map_authoring_native", true},
1253 {"main_palette", next_palette},
1254 {"pattern_width", rectangle.width},
1255 {"pattern_height", rectangle.height},
1256 {"pattern_tile_ids", rectangle.tile_ids},
1257 {"pattern_paste_x", 8},
1258 {"pattern_paste_y", paste_y},
1259 {"surface", "normal_special_overworld"},
1260 {"normal_tile16_selector", true},
1261 {"displayed_map_count", overworld->DisplayedMapCount()},
1262 {"a0_a8_crossed", true},
1263 {"a8_b0_crossed", reaches_b0},
1264 {"tile16_selector_filter_preserved", true},
1265 {"tile16_legacy_map", kLegacyMap},
1266 {"tile16_project_terrain_deep_tile", 0xFFF},
1267 {"room_rename_native", true},
1268 {"room_label_room", kRenamedRoom},
1269 {"room_override_label", room_rename},
1270 {"room_default_label", room_default},
1271 {"matrix_hover_room", kHoverRoom},
1272 {"wrong_session_rename_refused", true},
1273 {"terrain_sha256", core::ComputeSourceArtifactSha256(*saved)},
1274 {"rom_sha256", core::ComputeSourceArtifactSha256(*rom_disk_before)}};
1275 std::ofstream output(receipt_path, std::ios::binary);
1276 IM_CHECK(output.is_open());
1277 output << receipt.dump(2) << '\n';
1278 output.close();
1279 IM_CHECK(!output.fail());
1280 ctx->LogInfo("Lyra native canvas PASS; receipt: %s",
1281 receipt_path.string().c_str());
1282 };
1283 ImGuiTestEngine_QueueTest(engine, test, ImGuiTestRunFlags_RunFromCommandLine);
1284}
1285
1286// A separate process opts into this read-only test using the successful native
1287// paint receipt. It must observe automatic loading before any canvas UI action.
1288static void RegisterLyraTerrainReopenTest(ImGuiTestEngine* engine) {
1289 const char* requested = std::getenv("YAZE_LYRA_TERRAIN_REOPEN_PROJECT");
1290 if (!requested || !*requested ||
1291 std::getenv("YAZE_LYRA_TERRAIN_ACCEPTANCE_PROJECT"))
1292 return;
1293 ImGuiTestEngine_GetIO(engine).ConfigLogToTTY = true;
1294 ImGuiTest* test = IM_REGISTER_TEST(engine, "lyra_terrain",
1295 "fresh_process_autoload_saved_terrain");
1296 test->TestFunc = [](ImGuiTestContext* ctx) {
1297 namespace fs = std::filesystem;
1298 const char* requested = std::getenv("YAZE_LYRA_TERRAIN_REOPEN_PROJECT");
1299 const char* prior_receipt = std::getenv("YAZE_LYRA_TERRAIN_REOPEN_RECEIPT");
1300 IM_CHECK(requested && *requested && prior_receipt && *prior_receipt);
1301 IM_CHECK(fs::path(requested).is_absolute());
1302 IM_CHECK(fs::path(prior_receipt).is_absolute());
1303 std::error_code ec;
1304 const auto expected_project = fs::canonical(requested, ec);
1305 IM_CHECK(!ec);
1306 const auto project_directory = expected_project.parent_path();
1307 const auto receipt_path = fs::canonical(prior_receipt, ec);
1308 IM_CHECK(!ec && receipt_path.parent_path() == project_directory);
1309 const auto read_file =
1310 [](const fs::path& path) -> std::optional<std::string> {
1311 std::ifstream file(path, std::ios::binary);
1312 if (!file)
1313 return std::nullopt;
1314 std::string bytes{std::istreambuf_iterator<char>(file), {}};
1315 return file.bad() ? std::nullopt
1316 : std::optional<std::string>(std::move(bytes));
1317 };
1318 const auto prior_bytes = read_file(receipt_path);
1319 IM_CHECK(prior_bytes.has_value());
1320 const auto prior = nlohmann::json::parse(*prior_bytes, nullptr, false);
1321 IM_CHECK(prior.is_object());
1322 IM_CHECK(prior.value("result", "") == "PASS");
1323 IM_CHECK(prior.value("test", "") ==
1324 "lyra_terrain/cross_map_stroke_undo_redo_project_save");
1325 IM_CHECK(prior.value("project", "") == expected_project.string());
1326 const auto project_before = read_file(expected_project);
1327 IM_CHECK(project_before.has_value());
1328
1329 editor::EditorManager* manager = nullptr;
1330 project::YazeProject* current_project = nullptr;
1331 for (int frame = 0; frame < 200; ++frame) {
1332 auto* controller = Application::Instance().GetController();
1333 manager = controller ? controller->editor_manager() : nullptr;
1334 current_project = manager ? manager->GetCurrentProject() : nullptr;
1335 if (current_project && current_project->project_opened() &&
1336 fs::weakly_canonical(current_project->filepath, ec) ==
1337 expected_project &&
1338 !ec)
1339 break;
1340 ctx->Yield(1);
1341 }
1342 IM_CHECK(manager && current_project && current_project->project_opened());
1343 IM_CHECK(fs::canonical(current_project->filepath, ec) == expected_project &&
1344 !ec);
1345 IM_CHECK(current_project->name.find("[Lyra canvas acceptance]") !=
1346 std::string::npos);
1347 IM_CHECK(!current_project->lyra_terrain_file.empty());
1348 const auto source_path = fs::canonical(
1350 ec);
1351 IM_CHECK(!ec && source_path.parent_path() == project_directory);
1352 IM_CHECK(source_path.string() == prior.value("terrain", ""));
1353 const auto source_before = read_file(source_path);
1354 IM_CHECK(source_before.has_value());
1355 IM_CHECK(core::ComputeSourceArtifactSha256(*source_before) ==
1356 prior.value("terrain_sha256", ""));
1357 const auto rom_path = fs::canonical(
1359 IM_CHECK(!ec && rom_path.parent_path() == project_directory);
1360 IM_CHECK(rom_path.filename() == "main-with-d3.sfc");
1361 IM_CHECK(rom_path.string() == prior.value("rom", ""));
1362 auto* rom = manager->GetCurrentRom();
1363 IM_CHECK(rom && rom->is_loaded() && !rom->dirty());
1364 IM_CHECK(fs::canonical(rom->filename(), ec) == rom_path && !ec);
1365 const auto rom_disk = read_file(rom_path);
1366 IM_CHECK(rom_disk.has_value());
1367 IM_CHECK(core::ComputeSourceArtifactSha256(*rom_disk) ==
1368 prior.value("rom_sha256", ""));
1369 const auto rom_bytes = [&]() {
1370 return std::string_view(reinterpret_cast<const char*>(rom->data()),
1371 rom->size());
1372 };
1373 IM_CHECK(core::ComputeSourceArtifactSha256(rom_bytes()) ==
1374 prior.value("rom_sha256", ""));
1375
1376 manager->OpenEditorAndPanelsFromFlags("Overworld", "overworld.canvas");
1377 editor::OverworldEditor* overworld = nullptr;
1378 for (int frame = 0; frame < 200; ++frame) {
1379 auto* editors = manager->GetCurrentEditorSet();
1380 overworld = editors ? editors->GetEditorAs<editor::OverworldEditor>(
1382 : nullptr;
1383 if (overworld && overworld->overworld().is_loaded() &&
1384 overworld->lyra_canvas_.loaded() && ctx->ItemExists("**/OwMap"))
1385 break;
1386 ctx->Yield(1);
1387 }
1388 IM_CHECK(overworld && overworld->overworld().is_loaded());
1389 if (!overworld->lyra_canvas_status_.ok()) {
1390 IM_ERRORF("Native Lyra autoload failed: %s",
1391 overworld->lyra_canvas_status_.ToString().c_str());
1392 return;
1393 }
1394 IM_CHECK(overworld->lyra_canvas_.loaded());
1395 IM_CHECK_EQ(overworld->current_world_id(), 2);
1396 IM_CHECK_EQ(overworld->current_map_id(), 0xA0);
1397 IM_CHECK_EQ(overworld->DisplayedMapCount(), 192);
1398 IM_CHECK(!overworld->IsProjectTerrainMap(0x80));
1399 IM_CHECK(overworld->IsProjectTerrainMap(0xA0));
1400 IM_CHECK(overworld->IsProjectTerrainMap(0xBF));
1401 IM_CHECK(prior.value("surface", "") == "normal_special_overworld");
1402 IM_CHECK(prior.value("normal_tile16_selector", false));
1403 const auto* document = overworld->lyra_canvas_.document();
1404 IM_CHECK(document && !document->dirty());
1405 IM_CHECK(!overworld->HasPendingProjectDraftChanges());
1406 IM_CHECK(document->Serialize() == *source_before);
1407 IM_CHECK(core::ComputeSourceArtifactSha256(document->Serialize()) ==
1408 prior.value("terrain_sha256", ""));
1409 const int x = prior.value("paint_x", -1);
1410 const int first_y = prior.value("paint_y_start", -1);
1411 const int last_y = prior.value("paint_y_end", -1);
1412 const int tile = prior.value("tile16", -1);
1413 IM_CHECK(x >= 0 && x < core::LyraTerrainDocument::kWidth);
1414 IM_CHECK(first_y >= 0 && first_y <= 31 && last_y >= 32 &&
1416 IM_CHECK(tile >= 0 && tile < document->profile().tile_count);
1417 for (int y = first_y; y <= last_y; ++y)
1418 IM_CHECK(document->tile(x, y).value() == tile);
1419 if (prior.value("rectangle_selection_copy_paste", false)) {
1420 const editor::TileBrush rectangle{
1421 prior.at("pattern_width").get<int>(),
1422 prior.at("pattern_height").get<int>(),
1423 prior.at("pattern_tile_ids").get<std::vector<int>>()};
1424 IM_CHECK(rectangle.valid());
1425 const int px = prior.at("pattern_paste_x").get<int>();
1426 const int py = prior.at("pattern_paste_y").get<int>();
1427 for (int y = 0; y < rectangle.height; ++y)
1428 for (int x = 0; x < rectangle.width; ++x)
1429 IM_CHECK(document->tile(px + x, py + y).value() ==
1430 rectangle.at(x, y));
1431 for (int y = 0; y < 32; ++y)
1432 for (int x = 0; x < 32; ++x)
1433 IM_CHECK(document->tile(32 + x, 32 + y).value() ==
1434 rectangle.at(x % rectangle.width, y % rectangle.height));
1435 }
1436 if (prior.value("map_authoring_native", false)) {
1437 IM_CHECK_EQ(document->authoring().maps[0].properties.main_palette,
1438 prior.at("main_palette").get<int>());
1439 IM_CHECK_EQ(document->authoring().maps[1].properties.graphics_sheets[0],
1440 0);
1441 IM_CHECK_EQ(document->authoring().maps[1].properties.graphics_sheets[1],
1442 255);
1443 IM_CHECK(!document->authoring().maps[0].sprites.empty());
1444 IM_CHECK_EQ(document->authoring().maps[0].doors.at(0).id, 900001);
1445 IM_CHECK(
1446 !document->authoring().maps[0].doors.at(0).entrance_id.has_value());
1447 IM_CHECK_EQ(document->authoring().maps[0].exits.at(0).room_id, 0x105);
1448 IM_CHECK_EQ(document->authoring().flight_points.size(), 2);
1449 IM_CHECK_EQ(document->authoring().flight_points.at(0).map_id, 0xA0);
1450 IM_CHECK_EQ(document->authoring().flight_points.at(1).map_id, 0x20);
1451 }
1452 const bool room_rename_native = prior.value("room_rename_native", false);
1453 if (room_rename_native) {
1454 namespace room_labels = editor::dungeon_project_labels;
1455 const int room = prior.at("room_label_room").get<int>();
1456 const auto override_label =
1457 prior.at("room_override_label").get<std::string>();
1458 const auto default_label =
1459 prior.at("room_default_label").get<std::string>();
1460 IM_CHECK_EQ(room, 0x25);
1461 IM_CHECK_EQ(prior.value("matrix_hover_room", -1), 0x12);
1462 IM_CHECK(!override_label.empty() && !default_label.empty());
1464 IM_CHECK(room_labels::HasCustomRoomLabel(current_project, room));
1465 IM_CHECK(room_labels::GetRoomLabel(current_project, room) ==
1466 override_label);
1467 IM_CHECK(zelda3::GetRoomLabel(room) == override_label);
1468 const auto labels = current_project->resource_labels.find("room");
1469 IM_CHECK(labels != current_project->resource_labels.end());
1470 const auto default_it = labels->second.find(std::to_string(room));
1471 IM_CHECK(default_it != labels->second.end() &&
1472 default_it->second == default_label);
1473 }
1474 IM_CHECK_EQ(overworld->undo_manager().UndoStackSize(), 0);
1475
1476 // Fit the one normal SW canvas for the driver's screenshot. Both the
1477 // existing map 80 and the project terrain at A0 must be in the same view.
1478 const auto terrain = ctx->ItemInfo("**/OwMap");
1479 IM_CHECK(terrain.ID != 0 && terrain.Window != nullptr);
1480 ctx->SetRef(terrain.Window);
1481 ctx->WindowFocus("");
1482 overworld->ZoomToFit();
1483 ctx->Yield(3);
1484 const auto fitted = ctx->ItemInfo("**/OwMap");
1485 const auto& canvas = overworld->ow_map_canvas();
1486 const auto origin = canvas.zero_point();
1487 const auto scroll = canvas.scrolling();
1488 const float cell_pixels = 16.0f * canvas.global_scale();
1489 const auto map_center = [&](int map_row) {
1490 return ImVec2(
1491 origin.x + scroll.x + 16.0f * cell_pixels,
1492 origin.y + scroll.y + (map_row * 32.0f + 16.0f) * cell_pixels);
1493 };
1494 IM_CHECK(fitted.RectClipped.Contains(map_center(0)));
1495 IM_CHECK(fitted.RectClipped.Contains(map_center(4)));
1496 IM_CHECK(!overworld->HasPendingProjectDraftChanges());
1497 IM_CHECK(read_file(expected_project) == project_before);
1498 IM_CHECK(read_file(source_path) == source_before);
1499 IM_CHECK(read_file(rom_path) == rom_disk && !rom->dirty());
1500 IM_CHECK(core::ComputeSourceArtifactSha256(rom_bytes()) ==
1501 prior.value("rom_sha256", ""));
1502
1503 const auto stamp = std::chrono::duration_cast<std::chrono::milliseconds>(
1504 std::chrono::system_clock::now().time_since_epoch())
1505 .count();
1506 const auto output_path =
1507 project_directory /
1508 ("lyra-canvas-reopen-pass-" + std::to_string(stamp) + ".json");
1509 const nlohmann::json receipt = {
1510 {"test", "lyra_terrain/fresh_process_autoload_saved_terrain"},
1511 {"result", "PASS"},
1512 {"project", expected_project.string()},
1513 {"terrain", source_path.string()},
1514 {"rom", rom_path.string()},
1515 {"prior_receipt", receipt_path.string()},
1516 {"autoloaded", true},
1517 {"surface", "normal_special_overworld"},
1518 {"map_80_and_a0_same_canvas", true},
1519 {"displayed_map_count", overworld->DisplayedMapCount()},
1520 {"document_clean", true},
1521 {"saved_cells_exact", true},
1522 {"project_unchanged", true},
1523 {"room_override_persisted", room_rename_native},
1524 {"terrain_sha256", prior.at("terrain_sha256")},
1525 {"rom_sha256", prior.at("rom_sha256")}};
1526 std::ofstream output(output_path, std::ios::binary);
1527 IM_CHECK(output.is_open());
1528 output << receipt.dump(2) << '\n';
1529 output.close();
1530 IM_CHECK(!output.fail());
1531 ctx->LogInfo("Lyra native reopen PASS; receipt: %s",
1532 output_path.string().c_str());
1533 };
1534 ImGuiTestEngine_QueueTest(engine, test, ImGuiTestRunFlags_RunFromCommandLine);
1535}
1536
1537// ============================================================================
1538// Public registration entry point
1539// ============================================================================
1540
1541// Read-only GUI coverage for an explicitly selected disposable boat fixture.
1542// Only input widgets and Preview are exercised; Import/Publish/Save are absent.
1543static void RegisterProjectGraphicsImportPreviewTest(ImGuiTestEngine* engine) {
1544#ifdef YAZE_WITH_GRPC
1545 const char* requested = std::getenv("YAZE_GUI_BOAT_PROJECT");
1546 const char* pack = std::getenv("YAZE_GUI_BOAT_PACK");
1547 if (!requested || !*requested || !pack || !*pack)
1548 return;
1549 auto& io = ImGuiTestEngine_GetIO(engine);
1550 io.ConfigLogToTTY = true;
1551 ImGuiTest* test =
1552 IM_REGISTER_TEST(engine, "project_graphics", "boat_pack_preview_fields");
1553 test->TestFunc = [](ImGuiTestContext* ctx) {
1554 namespace fs = std::filesystem;
1555 const char* requested = std::getenv("YAZE_GUI_BOAT_PROJECT");
1556 const char* pack = std::getenv("YAZE_GUI_BOAT_PACK");
1557 const char* capture = std::getenv("YAZE_GUI_BOAT_CAPTURE");
1558 IM_CHECK(requested && *requested && pack && *pack && capture && *capture);
1559 IM_CHECK(fs::path(requested).is_absolute() && fs::path(pack).is_absolute());
1560 IM_CHECK(fs::path(capture).is_absolute());
1561 std::error_code ec;
1562 const auto project_path = fs::canonical(requested, ec);
1563 IM_CHECK(!ec && project_path.filename() == "boat-import-reopen.yaze");
1564 const auto pack_path = fs::canonical(pack, ec);
1565 IM_CHECK(!ec);
1566 const fs::path capture_path(capture);
1567 const auto fields_path = capture_path.parent_path() /
1568 (capture_path.stem().string() + "-fields.png");
1569 IM_CHECK(fs::is_directory(capture_path.parent_path()));
1570 IM_CHECK(!fs::exists(capture_path) && !fs::exists(fields_path));
1571 const auto read = [](const fs::path& path) -> std::optional<std::string> {
1572 std::ifstream file(path, std::ios::binary);
1573 if (!file)
1574 return std::nullopt;
1575 std::string bytes{std::istreambuf_iterator<char>(file), {}};
1576 return file.bad() ? std::nullopt : std::optional<std::string>(bytes);
1577 };
1578
1579 editor::EditorManager* manager = nullptr;
1580 for (int frame = 0; frame < 200 && !manager; ++frame) {
1581 auto* controller = Application::Instance().GetController();
1582 manager = controller ? controller->editor_manager() : nullptr;
1583 if (!manager)
1584 ctx->Yield(1);
1585 }
1586 IM_CHECK(manager != nullptr);
1587 // Project and Lyra palette loading belong on the normal main frame, not
1588 // the test coroutine's smaller platform thread stack.
1589 auto opened = std::make_shared<std::optional<absl::Status>>();
1590 manager->QueueDeferredAction([manager, project_path, opened] {
1591 const auto status = manager->OpenRomOrProject(project_path.string());
1592 if (status.ok()) {
1593 manager->OpenEditorAndPanelsFromFlags(
1594 "Overworld", "overworld.canvas,overworld.area_graphics");
1595 }
1596 *opened = status;
1597 });
1598 for (int frame = 0; frame < 200 && !opened->has_value(); ++frame)
1599 ctx->Yield(1);
1600 IM_CHECK(opened->has_value() && opened->value().ok());
1601 editor::OverworldEditor* overworld = nullptr;
1602 for (int frame = 0; frame < 200; ++frame) {
1603 auto* editors = manager->GetCurrentEditorSet();
1604 overworld = editors ? editors->GetEditorAs<editor::OverworldEditor>(
1606 : nullptr;
1607 if (overworld && overworld->overworld().is_loaded())
1608 break;
1609 ctx->Yield(1);
1610 }
1611 IM_CHECK(overworld && overworld->overworld().is_loaded());
1612 auto* project = manager->GetCurrentProject();
1613 IM_CHECK(project && fs::canonical(project->filepath, ec) == project_path &&
1614 !ec);
1615 IM_CHECK(project->name.find("disposable") != std::string::npos);
1616 auto* rom = manager->GetCurrentRom();
1617 IM_CHECK(rom && rom->is_loaded() && !rom->dirty());
1618 const auto rom_path = fs::canonical(rom->filename(), ec);
1619 IM_CHECK(!ec && rom_path.parent_path() == project_path.parent_path());
1620 IM_CHECK(rom_path.filename() == "boat-import-save-as.sfc");
1621 const auto rom_before = rom->vector();
1622 const auto rom_disk_before = read(rom_path);
1623 const auto project_before = read(project_path);
1624 IM_CHECK(rom_disk_before && project_before);
1625 // The area button contains '/', so use its public action to open the
1626 // modal, then exercise the actual ImGui inputs and Preview button below.
1627 auto source_opened = std::make_shared<std::optional<absl::Status>>();
1628 manager->QueueDeferredAction([overworld, source_opened] {
1629 overworld->SetCurrentMap(0x31);
1630 auto status = overworld->overworld().EnsureMapBuilt(0x31);
1631 if (status.ok())
1632 status = overworld->OpenProjectGraphicsSource();
1633 *source_opened = status;
1634 });
1635 for (int frame = 0; frame < 200 && !source_opened->has_value(); ++frame)
1636 ctx->Yield(1);
1637 IM_CHECK(source_opened->has_value() && source_opened->value().ok());
1638 ctx->Yield(3);
1639 ctx->SetRef("Edit Project Graphics");
1640 IM_CHECK(ctx->GetWindowByRef("") != nullptr);
1641 const auto input = ctx->ItemInfo("**/Logical pack JSON");
1642 IM_CHECK(input.ID != 0 && input.Window != nullptr);
1643 ctx->SetRef(input.Window);
1644 ctx->ItemInputValue(input.ID, pack_path.string().c_str());
1645 ctx->ItemClick("Load pack");
1646 ctx->Yield(3);
1647 struct PaletteInputs {
1648 const char* id;
1649 int row;
1650 const char* first;
1651 int count;
1652 };
1653 const PaletteInputs palettes[] = {
1654 {"P0", 3, "100", 64}, {"P1", 4, "180", 32}, {"P2", 2, "140", 64}};
1655 for (const auto& palette : palettes) {
1656 const std::string scope = std::string(palette.id) + "/";
1657 IM_CHECK(ctx->ItemExists((scope + "BG palette row").c_str()));
1658 ctx->ItemInputValue((scope + "BG palette row").c_str(), palette.row);
1659 ctx->ItemInputValue((scope + "First BG character (hex)").c_str(),
1660 palette.first);
1661 ctx->ItemInputValue((scope + "Owned character count").c_str(),
1662 palette.count);
1663 }
1664 ctx->ItemInputValue("First Tile16 (hex)", "EEC");
1665 ctx->ItemInputValue("Owned Tile16 count", 96);
1666 ctx->ItemInputValue("Area anchor X (Tile16)", 33);
1667 ctx->ItemInputValue("Area anchor Y (Tile16)", 17);
1668 ctx->ItemClick("Preview import");
1669 ctx->Yield(3);
1670 IM_CHECK(ctx->ItemExists(
1671 "I reviewed the destination ranges and shared palette records"));
1672
1673 // The application's test engine has no ScreenCaptureFunc binding. Reuse
1674 // its existing renderer queue rather than an OS capture or a second backend.
1675 const auto capture_renderer = [&](const fs::path& path) -> absl::Status {
1676 auto result =
1677 std::make_shared<std::optional<absl::StatusOr<ScreenshotArtifact>>>();
1678 auto* controller = Application::Instance().GetController();
1679 if (!controller)
1680 return absl::FailedPreconditionError("No GUI controller");
1681 controller->RequestScreenshot(
1682 {.preferred_path = path.string(),
1683 .format = ScreenshotFormat::kPng,
1684 .reveal_to_user = false,
1685 .callback = [result](absl::StatusOr<ScreenshotArtifact> image) {
1686 *result = std::move(image);
1687 }});
1688 for (int frame = 0; frame < 200 && !result->has_value(); ++frame)
1689 ctx->Yield(1);
1690 if (!result->has_value())
1691 return absl::DeadlineExceededError("GUI capture timed out");
1692 if (!result->value().ok())
1693 return result->value().status();
1694 if (result->value()->file_path != path.string() ||
1695 result->value()->width <= 0 || result->value()->height <= 0)
1696 return absl::DataLossError(
1697 "GUI capture did not return the requested image");
1698 return absl::OkStatus();
1699 };
1700 ctx->ScrollToTop(input.Window->ID);
1701 ctx->Yield(2);
1702 IM_CHECK(capture_renderer(fields_path).ok());
1703 ctx->ScrollToItemY(
1704 "I reviewed the destination ranges and shared palette records");
1705 ctx->Yield(2);
1706 IM_CHECK(capture_renderer(capture_path).ok());
1707 IM_CHECK(fs::is_regular_file(capture_path) &&
1708 fs::is_regular_file(fields_path));
1709 ctx->SetRef("Edit Project Graphics");
1710 ctx->ItemClick("Close (keep draft)");
1711 ctx->Yield(3);
1712 const auto* closed_modal = ctx->GetWindowByRef("");
1713 IM_CHECK(!closed_modal || !closed_modal->Active);
1714 IM_CHECK(!overworld->HasPendingProjectDraftChanges());
1715 IM_CHECK(!rom->dirty() && rom->vector() == rom_before);
1716 IM_CHECK(read(rom_path) == rom_disk_before &&
1717 read(project_path) == project_before);
1718 ctx->LogInfo(
1719 "Boat GUI preview PASS: P0/P1/P2, map31, no import/publish/save; "
1720 "captures %s and %s",
1721 capture_path.string().c_str(), fields_path.string().c_str());
1722 };
1723 ImGuiTestEngine_QueueTest(engine, test, ImGuiTestRunFlags_RunFromCommandLine);
1724#else
1725 (void)engine; // The maintained renderer screenshot queue requires gRPC.
1726#endif
1727}
1728
1729void RegisterOverworldUITests(ImGuiTestEngine* engine) {
1730 if (engine == nullptr)
1731 return;
1732 RegisterKeyboardShortcutTests(engine);
1733 RegisterEntityModeTests(engine);
1734 RegisterModeRoundTripTests(engine);
1735 RegisterCanvasNavigationTests(engine);
1736 RegisterWorldSelectorTests(engine);
1737 RegisterLyraTerrainAcceptanceTest(engine);
1738 RegisterLyraTerrainReopenTest(engine);
1739 RegisterProjectGraphicsImportPreviewTest(engine);
1740}
1741
1742} // namespace yaze::test
1743
1744#else // !YAZE_ENABLE_IMGUI_TEST_ENGINE
1745
1746namespace yaze::test {
1747void RegisterOverworldUITests(ImGuiTestEngine* /*engine*/) {}
1748} // namespace yaze::test
1749
1750#endif // YAZE_ENABLE_IMGUI_TEST_ENGINE
Controller * GetController()
Definition application.h:83
static Application & Instance()
editor::EditorManager * editor_manager()
Definition controller.h:85
auto filename() const
Definition rom.h:219
const auto & vector() const
Definition rom.h:217
auto data() const
Definition rom.h:213
auto size() const
Definition rom.h:212
bool dirty() const
Definition rom.h:200
bool is_loaded() const
Definition rom.h:199
bool HasProjectRegistry() const
static WidgetIdRegistry & Instance()
std::string ComputeSourceArtifactSha256(std::string_view content)
Rom * rom()
Get the current ROM instance.
::yaze::project::YazeProject * current_project()
Get the current project instance.
constexpr int kTile16SelectorColumns
constexpr float kTile16SelectorDrawOffsetX
void RegisterOverworldUITests(ImGuiTestEngine *)
std::string GetRoomLabel(int id)
Convenience function to get a room label.
bool project_opened() const
Definition project.h:538
core::HackManifest hack_manifest
Definition project.h:376
std::string lyra_terrain_file
Definition project.h:342
std::unordered_map< std::string, std::unordered_map< std::string, std::string > > resource_labels
Definition project.h:369
std::string GetAbsolutePath(const std::string &relative_path) const
Definition project.cc:2675