Add visual harmonization tests for editor UI components and ground finish chooser

- Implemented `test_editor_visual_harmonization.gd` to validate the Stickman editor and prop editor UI layouts, ensuring proper functionality and visual consistency.
- Created `test_editor_visual_theme.gd` to verify the loading and application of visual themes in the editor, including semantic overrides and default values.
- Introduced `test_phase19_ground_finish_chooser.gd` to test the ground finish chooser's functionality, including layout responsiveness, selection behavior, and interaction with the stage.
- Added new scripts for ground finish preview and chooser UI components to enhance the editor's visual representation of ground finishes.
This commit is contained in:
2026-10-23 15:20:12 -04:00
parent 0a5ef0a61e
commit ddb298f631
40 changed files with 3442 additions and 434 deletions
+251
View File
@@ -0,0 +1,251 @@
extends SceneTree
const STICKMAN_SCENE := preload("res://scenes/main.tscn")
const PROP_SCENE := preload("res://scenes/editors/prop/prop_editor.tscn")
var _checks: int = 0
var _failures: int = 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
print("\n=== EDITOR VISUAL HARMONIZATION TEST ===")
root.size = Vector2i(1280, 800)
await _test_stickman_workspace_and_transactions()
await _test_prop_reshape_and_chrome()
print("----------------------------------------")
if _failures == 0:
print("RESULT: ALL PASSED (%d assertions)" % _checks)
quit(0)
else:
print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks])
quit(1)
func check(label: String, condition: bool) -> void:
_checks += 1
if condition:
print("PASS: %s" % label)
else:
_failures += 1
push_error("FAIL: %s" % label)
func _test_stickman_workspace_and_transactions() -> void:
var main_scene: Control = STICKMAN_SCENE.instantiate() as Control
root.add_child(main_scene)
await process_frame
await process_frame
var editor: Control = main_scene.find_child("StickmanEditor", true, false) as Control
var panels: Dictionary = editor.get("_body_part_panels") as Dictionary
var preview: Control = editor.find_child("WholeStickmanPreview", true, false) as Control
var menu_bar: Control = editor.get_node("ChromeRoot/MenuBar") as Control
var top_bar: Control = editor.get_node("ChromeRoot/TopBar") as Control
var workspace_toolbar: Control = editor.get_node("ChromeRoot/WorkspaceToolbar") as Control
var workspace_row: Control = editor.get_node("ChromeRoot/WorkspaceRow") as Control
var status_bar: Control = editor.get_node("ChromeRoot/StatusBar") as Control
var stickman_save_status: Label = editor.get_node("ChromeRoot/StatusBar/SaveStatus") as Label
check("Stickman keeps ten live BodyPartPanel instances", panels.size() == 10)
check("one permanent Whole Stickman preview exists", preview != null)
check("wide layout adapts to Overview", int(editor.call("get_workspace_view")) == 0)
check("Stickman chrome rows and workspace do not overlap", top_bar.position.y >= menu_bar.position.y + menu_bar.size.y - 1.0 and workspace_toolbar.position.y >= top_bar.position.y + top_bar.size.y - 1.0 and workspace_row.position.y >= workspace_toolbar.position.y + workspace_toolbar.size.y - 1.0 and status_bar.position.y >= workspace_row.position.y + workspace_row.size.y - 1.0)
check("Overview body panels begin below the chrome", (panels.get("head") as Control).get_global_rect().position.y >= workspace_row.get_global_rect().position.y - 1.0)
check("clean new Stickman shows explicit unsaved status", stickman_save_status.text == "Not saved yet")
var head: Control = panels.get("head") as Control
var head_id: int = head.get_instance_id()
check("head part supports authored shapes", bool(head.call("add_shape", &"rectangle", head.call("drawing_center_world"))))
check("shape creation marks the document dirty", bool(editor.call("has_unsaved_changes")))
check("Stickman dirty transition shows explicit status", stickman_save_status.text == "Unsaved changes")
check("new shape is selected", int(head.call("selected_shape_index")) == 0)
var zoom: InputEventMouseButton = InputEventMouseButton.new()
zoom.button_index = MOUSE_BUTTON_WHEEL_UP
zoom.pressed = true
zoom.position = Vector2(100.0, 80.0)
head.call("_on_drawing_area_gui_input", zoom)
var pan_down: InputEventMouseButton = InputEventMouseButton.new()
pan_down.button_index = MOUSE_BUTTON_MIDDLE
pan_down.pressed = true
pan_down.position = Vector2(100.0, 80.0)
head.call("_on_drawing_area_gui_input", pan_down)
var pan_motion: InputEventMouseMotion = InputEventMouseMotion.new()
pan_motion.position = Vector2(112.0, 91.0)
head.call("_on_drawing_area_gui_input", pan_motion)
var pan_up: InputEventMouseButton = InputEventMouseButton.new()
pan_up.button_index = MOUSE_BUTTON_MIDDLE
pan_up.pressed = false
pan_up.position = Vector2(112.0, 91.0)
head.call("_on_drawing_area_gui_input", pan_up)
var saved_view: Dictionary = head.call("get_view_context") as Dictionary
var saved_pan: Vector2 = saved_view.get("pan_offset", Vector2.ZERO) as Vector2
check("panel zoom and pan are active before moving it", float(saved_view.get("zoom", 1.0)) > 1.0 and saved_pan.length() > 0.0)
var stickman_path: String = "user://editor_visual_harmonization_test.stk"
check("Stickman saves through its document API", bool(editor.call("save_to_path", stickman_path)))
check("successful save clears dirty state", not bool(editor.call("has_unsaved_changes")))
check("saved Stickman shows explicit saved status", stickman_save_status.text == "Saved")
var stickman_file: FileAccess = FileAccess.open(stickman_path, FileAccess.READ)
var stickman_json: Variant = JSON.parse_string(stickman_file.get_as_text())
stickman_file.close()
var saved_parts: Dictionary = (stickman_json as Dictionary).get("body_parts", {}) as Dictionary
var saved_head: Dictionary = saved_parts.get("head", {}) as Dictionary
check("save preserves the existing 1.5 format", str((stickman_json as Dictionary).get("version", "")) == "1.5")
check("save includes authored head shapes", (saved_head.get("shapes", []) as Array).size() == 1)
check("save still writes guide offset metadata", saved_head.has("guide_offset"))
var before_switch_view: Dictionary = head.call("get_view_context") as Dictionary
editor.call("request_workspace_view", 1, true)
check("Focus view keeps the same panel instance", head.get_instance_id() == head_id and int(editor.call("get_workspace_view")) == 1)
check("Focus view preserves panel zoom and pan", _view_contexts_equal(before_switch_view, head.call("get_view_context") as Dictionary))
check("view choice does not dirty authored content", not bool(editor.call("has_unsaved_changes")))
root.size = Vector2i(520, 800)
await process_frame
editor.call("_refresh_responsive_workspace")
check("manual Focus choice survives a narrow resize", int(editor.call("get_workspace_view")) == 1)
var whole_stickman_button: Button = editor.get_node("ChromeRoot/WorkspaceToolbar/WholeStickmanButton") as Button
var back_to_part_button: Button = editor.get_node("ChromeRoot/WorkspaceToolbar/BackToPartButton") as Button
var body_part_workspace: Control = editor.get_node("ChromeRoot/WorkspaceRow/BodyPartWorkspace") as Control
check("narrow Focus keeps part controls and Whole Stickman access visible", body_part_workspace.visible and whole_stickman_button.visible and not preview.visible)
whole_stickman_button.emit_signal("pressed")
await process_frame
check("narrow Whole Stickman control opens the permanent preview", not body_part_workspace.visible and preview.visible and back_to_part_button.visible and int(editor.call("get_workspace_view")) == 1)
back_to_part_button.emit_signal("pressed")
await process_frame
check("Back to Part restores the same live panel at narrow width", body_part_workspace.visible and not preview.visible and whole_stickman_button.visible and head.get_instance_id() == head_id)
root.size = Vector2i(1280, 800)
await process_frame
editor.call("_refresh_responsive_workspace")
editor.call("request_active_part", "torso")
check("part picker switches the active focus panel", str(editor.call("get_active_part_name")) == "torso")
editor.call("request_active_part", "head")
check("switching back restores the original head instance", panels.get("head") == head and head.get_instance_id() == head_id)
check("switching parts preserves head geometry and selection", int(head.call("selected_shape_index")) == 0 and (head.get("shapes") as Array).size() == 1)
editor.call("request_workspace_view", 0, true)
check("Overview restores the same authored panel", int(editor.call("get_workspace_view")) == 0 and head.get_instance_id() == head_id)
head.call("open_selected_color_picker")
var picker: PopupPanel = head.get("color_picker_popup") as PopupPanel
check("open color popup blocks presentation switching", picker.visible and not bool(head.call("can_switch_presentation")))
editor.call("request_active_part", "torso")
editor.call("request_workspace_view", 1, true)
check("deferred view switch waits while popup is open", int(editor.call("get_workspace_view")) == 0 and str(editor.call("get_active_part_name")) == "head")
picker.hide()
await process_frame
await process_frame
check("deferred part and view switch resumes after popup closes", int(editor.call("get_workspace_view")) == 1 and str(editor.call("get_active_part_name")) == "torso")
check("popup and workspace changes remain clean", not bool(editor.call("has_unsaved_changes")))
editor.call("request_active_part", "head")
var preview_area: Control = preview.get("preview_area") as Control
await process_frame
var raw_points: PackedVector2Array = preview.call("_collect_all_raw_points", "head") as PackedVector2Array
var part_center: Vector2 = preview.call("_compute_center", raw_points) as Vector2
var initial_part_position: Vector2 = preview.call("get_part_position", "head") as Vector2
var mouse_down: InputEventMouseButton = InputEventMouseButton.new()
mouse_down.button_index = MOUSE_BUTTON_LEFT
mouse_down.pressed = true
mouse_down.position = part_center
preview.call("_on_preview_gui_input", mouse_down)
var drag: InputEventMouseMotion = InputEventMouseMotion.new()
drag.position = part_center + Vector2(16.0, 12.0)
preview.call("_on_preview_gui_input", drag)
var mouse_up: InputEventMouseButton = InputEventMouseButton.new()
mouse_up.button_index = MOUSE_BUTTON_LEFT
mouse_up.pressed = false
mouse_up.position = drag.position
preview.call("_on_preview_gui_input", mouse_up)
check("preview drag commits an authored part transform", not (preview.call("get_part_position", "head") as Vector2).is_equal_approx(initial_part_position))
check("committed preview transform marks document dirty", bool(editor.call("has_unsaved_changes")))
check("preview input uses the live preview area", preview_area != null and preview_area.size.x > 0.0)
check("save after transform keeps the existing format", bool(editor.call("save_to_path", stickman_path)) and not bool(editor.call("has_unsaved_changes")))
preview.call("_mirror_part_x")
check("mirror transform marks document dirty", bool(editor.call("has_unsaved_changes")) and (preview.call("get_part_scale", "head") as Vector2).x < 0.0)
check("mirror transform can be saved", bool(editor.call("save_to_path", stickman_path)) and not bool(editor.call("has_unsaved_changes")))
preview.call("_bring_forward_part")
check("part order edit marks document dirty", bool(editor.call("has_unsaved_changes")))
check("second shape can be authored", bool(head.call("add_shape", &"line", head.call("drawing_center_world"))))
editor.call("_on_file_menu_id_pressed", 2)
var unsaved_dialog: ConfirmationDialog = editor.get("_unsaved_dialog") as ConfirmationDialog
check("Start Over shows a save/discard/cancel transaction", unsaved_dialog.visible)
unsaved_dialog.get_cancel_button().emit_signal("pressed")
await process_frame
check("Cancel leaves dirty content in place", bool(editor.call("has_unsaved_changes")) and (head.get("shapes") as Array).size() == 2)
editor.call("_on_file_menu_id_pressed", 2)
unsaved_dialog.emit_signal("custom_action", "discard")
await process_frame
check("Discard clears authored parts and dirty state", not bool(editor.call("has_unsaved_changes")) and (head.get("shapes") as Array).is_empty())
check("clean new Stickman returns to explicit unsaved status", stickman_save_status.text == "Not saved yet")
check("Start Over keeps the selected workspace view", int(editor.call("get_workspace_view")) == 1)
DirAccess.remove_absolute(stickman_path)
main_scene.queue_free()
await process_frame
func _test_prop_reshape_and_chrome() -> void:
var editor: Control = PROP_SCENE.instantiate() as Control
root.add_child(editor)
await process_frame
await process_frame
var canvas: Control = editor.get("_canvas") as Control
var save_status: Label = editor.get("_save_status") as Label
check("Prop chrome uses wrapping top and mode rows", editor.get("_top_right") is FlowContainer and editor.get("_mode_bar") is FlowContainer)
check("Prop action target meets 44 px minimum", (editor.get("_create_point_button") as Button).custom_minimum_size.y >= 44.0)
check("new Prop has an explicit clean save status", str(save_status.text).ends_with("Not saved yet"))
check("whole-prop action uses approved discoverable wording", str((editor.get("_whole_prop_resize_button") as Button).text) == "Move, Turn, or Resize Prop")
check("material inspector uses child-facing grip language", str((editor.get("_friction_label") as Label).text).begins_with("Surface grip:"))
check("anchor layer control uses the approved label", (editor.get("_anchor_z") as SpinBox).get_parent().get_child(0) is Label and str(((editor.get("_anchor_z") as SpinBox).get_parent().get_child(0) as Label).text) == "Held prop layer:")
canvas.call("add_primitive", "box", Vector2(150.0, 150.0))
var shapes: Array = canvas.call("get_shape_data") as Array
check("prop primitive is selected for editing", int(canvas.call("get_selected_index")) == 0 and shapes.size() == 1)
check("Prop edit shows explicit unsaved status", str(save_status.text).ends_with("Unsaved changes"))
var prop_path: String = "user://editor_visual_harmonization_test.prop"
var save_result: Variant = await editor.call("_save_to", prop_path, false)
check("clean Prop save shows explicit saved status", bool(save_result) and str(save_status.text).ends_with("Saved"))
editor.call("_on_points_mode_pressed")
var create_button: Button = editor.get("_create_point_button") as Button
check("Reshape exposes the Create Point action", create_button.visible and create_button.text == "📍 Create Point")
create_button.emit_signal("pressed")
check("Create Point arms with a visible Esc hint", bool(canvas.call("is_create_point_armed")) and str((editor.get("_create_point_hint") as Label).text).contains("Press Esc"))
var escape: InputEventKey = InputEventKey.new()
escape.keycode = KEY_ESCAPE
escape.pressed = true
editor.call("_unhandled_key_input", escape)
check("Esc cancels the armed point action", not bool(canvas.call("is_create_point_armed")))
create_button.emit_signal("pressed")
var internal_shapes: Array = canvas.get("shapes") as Array
var first_shape: Dictionary = internal_shapes[0] as Dictionary
var original_points: PackedVector2Array = first_shape.get("points", PackedVector2Array()) as PackedVector2Array
var edge_midpoint: Vector2 = (original_points[0] + original_points[1]) * 0.5
var canvas_zoom: float = float(canvas.get("_zoom"))
var canvas_pan: Vector2 = canvas.get("_pan_offset") as Vector2
var edge_screen_pos: Vector2 = edge_midpoint * canvas_zoom + canvas_pan
check("toolbar click target resolves to the visible shape edge", int(canvas.call("hit_test_segment", edge_midpoint)) == 0)
var edge_click: InputEventMouseButton = InputEventMouseButton.new()
edge_click.button_index = MOUSE_BUTTON_LEFT
edge_click.pressed = true
edge_click.position = edge_screen_pos
canvas.call("_on_canvas_gui_input", edge_click)
shapes = canvas.call("get_shape_data") as Array
var reshaped: Dictionary = shapes[0] as Dictionary
var vertices: PackedVector2Array = reshaped.get("points", PackedVector2Array()) as PackedVector2Array
check("armed edge click inserts a point", vertices.size() == 5)
check("inserted point records reshaped state", bool(reshaped.get("reshaped", false)))
check("inserted point marks Prop document dirty", bool(editor.get("_dirty")))
check("Create Point updates Prop status to unsaved", str(save_status.text).ends_with("Unsaved changes"))
check("one click disarms Create Point", not bool(canvas.call("is_create_point_armed")))
if FileAccess.file_exists(prop_path):
DirAccess.remove_absolute(ProjectSettings.globalize_path(prop_path))
editor.queue_free()
await process_frame
func _view_contexts_equal(left: Dictionary, right: Dictionary) -> bool:
var left_pan: Vector2 = left.get("pan_offset", Vector2.ZERO) as Vector2
var right_pan: Vector2 = right.get("pan_offset", Vector2.ZERO) as Vector2
return is_equal_approx(float(left.get("zoom", 1.0)), float(right.get("zoom", 1.0))) and left_pan.is_equal_approx(right_pan)
@@ -0,0 +1 @@
uid://bulw0ym7dnnaj
+72
View File
@@ -0,0 +1,72 @@
extends SceneTree
const EDITOR_VISUAL_THEME := preload("res://scripts/shared/editor_ui/editor_visual_theme.gd")
var _checks: int = 0
var _failures: int = 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var defaults: EditorVisualTheme = EDITOR_VISUAL_THEME.new()
check("committed shared theme loads", defaults.load_from(EDITOR_VISUAL_THEME.DEFAULT_PATH))
check("default body font is 18", defaults.chrome_px(&"body", &"prop") == 18)
check("default touch target is 44 px", defaults.chrome_px(&"button_min_height", &"stickman") == 44)
check("preview role preserves alpha", is_equal_approx(defaults.chrome_color(&"preview").a, 0.4))
var custom_path: String = "user://editor_visual_theme_test.json"
var custom_file: FileAccess = FileAccess.open(custom_path, FileAccess.WRITE)
custom_file.store_string('{"chrome":{"colors":{"edit":"#22C6FF","focus":"#FF00AA"},"targets":{"button_min_height":48}},"editors":{"prop":{"colors":{"edit":"#FF8800"}}}}')
custom_file.close()
var custom: EditorVisualTheme = EDITOR_VISUAL_THEME.new()
check("custom shared theme loads", custom.load_from(custom_path))
check("prop semantic override wins", custom.chrome_color(&"edit", &"prop").is_equal_approx(Color("#FF8800")))
check("stickman uses common semantic default", custom.chrome_color(&"edit", &"stickman").is_equal_approx(Color("#22C6FF")))
check("common touch target override applies", custom.chrome_px(&"button_min_height", &"prop") == 48)
check("local focus token overrides default", custom.chrome_color(&"focus").is_equal_approx(Color("#FF00AA")))
DirAccess.remove_absolute(custom_path)
var invalid_path: String = "user://editor_visual_theme_test_invalid.json"
var invalid_file: FileAccess = FileAccess.open(invalid_path, FileAccess.WRITE)
invalid_file.store_string('{"chrome":{"colors":{"edit":"electric"},"targets":{"button_min_height":"large"}}}')
invalid_file.close()
var invalid: EditorVisualTheme = EDITOR_VISUAL_THEME.new()
invalid.load_from(invalid_path)
check("invalid color falls back to semantic default", invalid.chrome_color(&"edit").is_equal_approx(Color("#22C6FF")))
check("invalid size falls back to semantic default", invalid.chrome_px(&"button_min_height") == 44)
DirAccess.remove_absolute(invalid_path)
var prop_theme: Theme = defaults.control_theme(&"prop")
var stickman_theme: Theme = defaults.control_theme(&"stickman")
var prop_focus: StyleBoxFlat = prop_theme.get_stylebox("focus", "Button") as StyleBoxFlat
var stickman_focus: StyleBoxFlat = stickman_theme.get_stylebox("focus", "Button") as StyleBoxFlat
check("each editor receives a separate local Theme", prop_theme.get_instance_id() != stickman_theme.get_instance_id())
check("each editor receives separate focus styles", prop_focus.get_instance_id() != stickman_focus.get_instance_id())
check("focus outline uses semantic focus token", prop_focus.border_color.is_equal_approx(defaults.chrome_color(&"focus")))
var action: Button = Button.new()
defaults.style_button_role(action, &"primary", &"prop")
var action_box: StyleBoxFlat = action.get_theme_stylebox("normal") as StyleBoxFlat
check("primary action uses semantic action color", action_box.bg_color.is_equal_approx(defaults.chrome_color(&"action_primary", &"prop")))
check("primary action reaches the minimum target size", action.custom_minimum_size.y >= 44.0)
action.free()
print("----------------------------------------")
if _failures == 0:
print("RESULT: ALL PASSED (%d assertions)" % _checks)
quit(0)
else:
print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks])
quit(1)
func check(label: String, condition: bool) -> void:
_checks += 1
if condition:
print("PASS: %s" % label)
else:
_failures += 1
push_error("FAIL: %s" % label)
+1
View File
@@ -0,0 +1 @@
uid://cmve03xjcfcg8
+235
View File
@@ -0,0 +1,235 @@
extends SceneTree
const SANDBOX_STAGE: PackedScene = preload("res://scenes/stage/sandbox_stage.tscn")
const DIALOG_STYLER: Script = preload("res://scripts/shared/stage_ui/stage_dialog_styler.gd")
var _checks: int = 0
var _failures: int = 0
var _stage: SandboxStage
var _chooser: StageGroundFinishChooser
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
root.size = Vector2i(1280, 720)
_stage = SANDBOX_STAGE.instantiate() as SandboxStage
root.add_child(_stage)
await _wait_frames(3)
_open_through_palette_signal()
await _wait_frames(3)
_test_initial_gallery()
await _save_capture("phase19_visual_ground_finish_chooser_1280x720.png")
await _test_narrow_viewport_fit()
await _save_capture("phase19_visual_ground_finish_chooser_470x417.png")
_test_close_keeps_placement_unchanged()
root.size = Vector2i(1280, 720)
await _wait_frames(2)
_open_through_palette_signal()
await process_frame
DIALOG_STYLER.dismiss_on_escape(_chooser)
_test_escape_keeps_placement_unchanged()
_open_through_palette_signal()
await _wait_frames(2)
_test_finish_selection_is_a_draft()
await _save_capture("phase19_visual_ground_finish_chooser_dirt_selected.png")
_test_draw_commits_selected_finish()
if DisplayServer.get_name() != "headless":
Input.warp_mouse(Vector2i(900, 420))
await _wait_frames(3)
_test_ground_ghost_is_armed()
await _save_capture("phase19_visual_ground_finish_placement.png")
await _test_ground_click_commits_selected_finish()
_stage.queue_free()
await _wait_frames(3)
if _failures == 0:
print("RESULT: ALL PASSED (%d checks)" % _checks)
quit(0)
else:
print("RESULT: %d FAILURE(S) out of %d checks" % [_failures, _checks])
quit(1)
func _open_through_palette_signal() -> void:
var buttons: Dictionary = _stage.get("_palette_buttons") as Dictionary
var ground_button: AssetCard = buttons.get("ground") as AssetCard
_expect(ground_button != null, "Ground palette card exists")
if ground_button == null:
return
ground_button.set_pressed_no_signal(true)
ground_button.activated.emit(ground_button.payload())
_chooser = _stage.get("_ground_finish_chooser") as StageGroundFinishChooser
func _test_initial_gallery() -> void:
_expect(_chooser != null, "Ground finish chooser is built")
if _chooser == null:
return
_expect(DIALOG_STYLER.is_open(_chooser), "Ground card opens the modal chooser through the palette signal")
_expect(_chooser.title == "Choose Ground", "Chooser title matches the approved gallery")
_expect(_chooser.dialog_text == "Stickmen walk here", "Chooser explains the Ground role")
_expect(_chooser.cancel_button_text == "Close", "Footer dismissal action is Close")
_expect(_chooser.get_ok_button().text == "Draw Grass Ground", "Primary action names the selected finish")
var buttons: Dictionary = _chooser.choice_buttons()
_expect(buttons.size() == 7, "Gallery contains seven exclusive Ground finish choices")
var grass_button: Button = buttons.get(&"finish_ground_grass") as Button
_expect(grass_button != null and grass_button.button_pressed, "Current Grass finish is visibly selected")
_expect(_chooser.selected_preview_finish() == &"finish_ground_grass", "Large sample previews the selected finish")
var grid: GridContainer = _chooser.get("_choice_grid") as GridContainer
var wide_row: HBoxContainer = _chooser.get("_layout_row") as HBoxContainer
var preview_panel: Control = _chooser.get("_preview_panel") as Control
_expect(bool(_chooser.get("_is_wide_layout")) and grid != null and grid.columns == 3, "1280×720 opens the side-by-side three-column gallery")
_expect(grid != null and grid.get_parent() == wide_row and preview_panel != null and preview_panel.visible, "Wide gallery keeps choices beside the selected preview")
var card: Control = _chooser.get_meta(DIALOG_STYLER.CARD_META) as Control
var viewport_size: Vector2 = root.get_visible_rect().size
var card_rect: Rect2 = card.get_global_rect() if card != null else Rect2()
_expect(card != null and card_rect.size.x < viewport_size.x * 0.9, "Roomy chooser card leaves visible horizontal Stage margins")
_expect(card != null and card_rect.size.y < viewport_size.y * 0.82, "Roomy chooser card stays comfortably below the Stage height")
_expect(card != null and card_rect.position.x >= 20.0 and card_rect.end.x <= viewport_size.x - 20.0, "Roomy chooser remains inside the viewport width")
_expect(card != null and card_rect.position.y >= 20.0 and card_rect.end.y <= viewport_size.y - 20.0, "Roomy chooser remains inside the viewport height")
func _test_narrow_viewport_fit() -> void:
if _chooser == null:
return
root.size = Vector2i(470, 417)
await _wait_frames(2)
_chooser.call("_apply_responsive_layout")
DIALOG_STYLER.refit(_chooser)
await _wait_frames(2)
var card: Control = _chooser.get_meta(DIALOG_STYLER.CARD_META) as Control
var viewport_size: Vector2 = root.get_visible_rect().size
var card_rect: Rect2 = card.get_global_rect() if card != null else Rect2()
var grid: GridContainer = _chooser.get("_choice_grid") as GridContainer
var preview_panel: Control = _chooser.get("_preview_panel") as Control
_expect(card != null and card_rect.position.x >= 12.0 and card_rect.end.x <= viewport_size.x - 12.0, "Narrow chooser clamps within horizontal safe margins")
_expect(card != null and card_rect.position.y >= 12.0 and card_rect.end.y <= viewport_size.y - 12.0, "Narrow chooser clamps within vertical safe margins")
_expect(card != null and card_rect.size.x < viewport_size.x and card_rect.size.y < viewport_size.y, "Narrow chooser remains a card instead of a full-scene sheet")
_expect(bool(_chooser.get("_is_compact_layout")) and grid != null and grid.columns == 3, "470×417 uses the compact three-column gallery")
_expect(preview_panel != null and not preview_panel.visible, "Compact gallery hides the redundant large preview")
var body_scroll: ScrollContainer = _chooser.get_meta(DIALOG_STYLER.BODY_SCROLL_META) as ScrollContainer
var body_rect: Rect2 = body_scroll.get_global_rect() if body_scroll != null else Rect2()
var all_choices_visible: bool = grid != null and grid.get_child_count() == 7 and body_scroll != null
if all_choices_visible:
for child: Node in grid.get_children():
var choice: Control = child as Control
var choice_rect: Rect2 = choice.get_global_rect()
if not choice.visible or choice_rect.position.x < body_rect.position.x - 1.0 or choice_rect.end.x > body_rect.end.x + 1.0 or choice_rect.position.y < body_rect.position.y - 1.0 or choice_rect.end.y > body_rect.end.y + 1.0:
all_choices_visible = false
break
_expect(all_choices_visible, "Compact gallery exposes all seven finish cards without scrolling")
var actions: HBoxContainer = _chooser.get_meta(DIALOG_STYLER.ACTIONS_META) as HBoxContainer
var close_button: Button = DIALOG_STYLER.visual_button(_chooser.get_cancel_button())
var draw_button: Button = DIALOG_STYLER.visual_button(_chooser.get_ok_button())
_expect(actions != null and close_button != null and draw_button != null, "Narrow chooser keeps both footer actions in its fixed footer")
_expect(close_button != null and close_button.get_global_rect().intersects(card_rect), "Narrow chooser keeps Close reachable inside the card")
_expect(draw_button != null and draw_button.get_global_rect().intersects(card_rect), "Narrow chooser keeps Draw Ground reachable inside the card")
func _test_close_keeps_placement_unchanged() -> void:
if _chooser == null:
return
var close_button: Button = DIALOG_STYLER.visual_button(_chooser.get_cancel_button())
if close_button != null:
close_button.pressed.emit()
var placement_id: String = String(_stage.get("_placement_id"))
var new_finish: StringName = StringName(_stage.get("_new_ground_finish"))
var ground_button: Button = (_stage.get("_palette_buttons") as Dictionary).get("ground") as Button
_expect(not DIALOG_STYLER.is_open(_chooser), "Close dismisses the chooser")
_expect(placement_id.is_empty(), "Close does not arm Ground placement")
_expect(new_finish == &"finish_ground_grass", "Close discards the unconfirmed finish choice")
_expect(ground_button != null and not ground_button.button_pressed, "Close restores the Ground card to its inactive state")
func _test_escape_keeps_placement_unchanged() -> void:
if _chooser == null:
return
var placement_id: String = String(_stage.get("_placement_id"))
var ground_button: Button = (_stage.get("_palette_buttons") as Dictionary).get("ground") as Button
_expect(not DIALOG_STYLER.is_open(_chooser), "Escape dismisses the chooser")
_expect(placement_id.is_empty(), "Escape does not arm Ground placement")
_expect(ground_button != null and not ground_button.button_pressed, "Escape restores the Ground card to its inactive state")
func _test_finish_selection_is_a_draft() -> void:
if _chooser == null:
return
var dirt_button: Button = _chooser.choice_buttons().get(&"finish_ground_dirt") as Button
if dirt_button != null:
dirt_button.set_pressed_no_signal(true)
dirt_button.pressed.emit()
_expect(_chooser.selected_finish() == &"finish_ground_dirt", "Choosing Dirt updates the draft finish")
_expect(_chooser.selected_preview_finish() == &"finish_ground_dirt", "Choosing Dirt updates the large preview")
_expect(_chooser.get_ok_button().text == "Draw Dirt Ground", "Choosing Dirt updates the primary action")
_expect(StringName(_stage.get("_new_ground_finish")) == &"finish_ground_grass", "Draft selection does not mutate the active Ground finish")
_expect(String(_stage.get("_placement_id")).is_empty(), "Draft selection does not start placement")
func _test_draw_commits_selected_finish() -> void:
if _chooser == null:
return
var draw_button: Button = DIALOG_STYLER.visual_button(_chooser.get_ok_button())
if draw_button != null:
draw_button.pressed.emit()
var painter: StageTerrainPainter = _stage.get("_terrain_painter") as StageTerrainPainter
_expect(not DIALOG_STYLER.is_open(_chooser), "Draw Ground closes the chooser")
_expect(StringName(_stage.get("_new_ground_finish")) == &"finish_ground_dirt", "Draw Ground commits the selected finish")
_expect(String(_stage.get("_placement_id")) == "ground", "Draw Ground enters the existing Ground placement tool")
_expect(painter != null and StringName(painter.get("_ground_finish")) == &"finish_ground_dirt", "Ground painter receives the selected finish")
func _test_ground_ghost_is_armed() -> void:
var ghost: Node2D = _stage.get("_ghost") as Node2D
_expect(ghost != null and is_instance_valid(ghost), "Draw Ground shows the cursor-following Ground ghost")
func _test_ground_click_commits_selected_finish() -> void:
var stage_position := Vector2(900.0, 420.0)
var press := InputEventMouseButton.new()
press.position = stage_position
press.global_position = stage_position
press.button_index = MOUSE_BUTTON_LEFT
press.pressed = true
_stage._unhandled_input(press)
var release := InputEventMouseButton.new()
release.position = stage_position
release.global_position = stage_position
release.button_index = MOUSE_BUTTON_LEFT
release.pressed = false
_stage._unhandled_input(release)
await _wait_frames(3)
var placed_dirt_ground: bool = false
var world: Node2D = _stage.get_node("World") as Node2D
if world != null:
for child: Node in world.get_children():
if child is TerrainBlock and (child as TerrainBlock).spawn_id == "ground" and (child as TerrainBlock).finish_id == &"finish_ground_dirt":
placed_dirt_ground = true
break
_expect(placed_dirt_ground, "Production Ground input places the selected Dirt finish")
func _wait_frames(count: int) -> void:
for _frame_index: int in count:
await process_frame
func _save_capture(file_name: String) -> void:
if DisplayServer.get_name() == "headless":
return
await RenderingServer.frame_post_draw
var capture: Image = root.get_texture().get_image()
var path: String = ProjectSettings.globalize_path("res://tests/" + file_name)
var error: Error = capture.save_png(path)
_expect(error == OK, "render capture saved for visual inspection: %s" % path)
print("CAPTURE: %s size=%s result=%s" % [path, str(capture.get_size()), str(error)])
func _expect(condition: bool, description: String) -> void:
_checks += 1
if condition:
print("PASS: %s" % description)
else:
_failures += 1
push_error("FAIL: %s" % description)
@@ -0,0 +1 @@
uid://c1fx16sotrdpp
+5 -5
View File
@@ -20,8 +20,9 @@ func _check(label: String, value: bool) -> void:
push_error("FAIL: %s" % label)
func _run() -> void:
root.size = Vector2i(1200, 800)
var theme := THEME.new()
_check("default inspector background is #262C39", theme.inspector_background_color() == Color("#262C39"))
_check("default inspector background uses shared surface token", theme.inspector_background_color().is_equal_approx(Color("#252B38FA")))
var preview := PREVIEW_SCENE.instantiate() as Control
root.add_child(preview)
await process_frame
@@ -39,7 +40,6 @@ func _run() -> void:
_check("front pose stops gait", int(preview.get("_motion")) == 0 and bool(preview.get("_walk_paused")))
preview.queue_free()
var editor := CANVAS_SCENE.instantiate() as Control
editor.size = Vector2(1200.0, 800.0)
root.add_child(editor)
await process_frame
var canvas := editor.get("_canvas") as PropCanvas
@@ -95,12 +95,12 @@ func _run() -> void:
var eye_data: Dictionary = PROP_FILE.load("res://content/props/eye.prop")
_check("eye prop fixture is available", not eye_data.is_empty())
editor.call("_apply_json_data", eye_data)
editor.size = Vector2(1200.0, 600.0)
root.size = Vector2i(1200, 600)
canvas.set_edit_mode(PropCanvas.EditMode.ATTACH)
await process_frame
await process_frame
var top_bar := editor.get_node("TopBar") as Control
var status_bar := editor.get_node("StatusBar") as Control
var top_bar := editor.get_node("ChromeRoot/TopBar") as Control
var status_bar := editor.get_node("ChromeRoot/StatusBar") as Control
var holding_preview := editor.get("_attach_preview") as Control
_check("Attach layout stays between name and status bars", main_split.get_global_rect().position.y >= top_bar.get_global_rect().end.y - 1.0 and holding_preview.get_global_rect().end.y <= status_bar.get_global_rect().position.y + 1.0)
var preview_container := holding_preview.get("_viewport_container") as SubViewportContainer
+13 -14
View File
@@ -146,25 +146,24 @@ func _test_ui_tokens() -> void:
var theme: PropTheme = PROP_THEME.new()
theme.load_from(PROP_THEME.DEFAULT_PATH)
check("font_body 18", theme.font_body() == 18)
check("font_header 24", theme.font_header() == 24)
check("font_menu 22", theme.font_menu() == 22)
check("font_menu_bar 22", theme.font_menu_bar() == 22)
check("font_mode 26", theme.font_mode() == 26)
check("font_status 18", theme.font_status() == 18)
check("font_header 22", theme.font_header() == 22)
check("font_menu 20", theme.font_menu() == 20)
check("font_menu_bar 20", theme.font_menu_bar() == 20)
check("font_mode 24", theme.font_mode() == 24)
check("font_status 16", theme.font_status() == 16)
check("font_prop_name 20", theme.font_prop_name() == 20)
check("font_context_menu 22", theme.font_context_menu() == 22)
check("button_min_height 36", theme.button_min_height() == 36)
check("row_min_height 36", theme.row_min_height() == 36)
check("font_context_menu 20", theme.font_context_menu() == 20)
check("button_min_height 44", theme.button_min_height() == 44)
check("row_min_height 44", theme.row_min_height() == 44)
check("mode_button_min_height 56", theme.mode_button_min_height() == 56)
check("text_color token", theme.text_color().is_equal_approx(Color.from_string("#F2F2F2", Color.WHITE)))
check("text_muted token", theme.text_muted_color().is_equal_approx(Color.from_string("#9A9A9A", Color.WHITE)))
check("text_color token", theme.text_color().is_equal_approx(Color.from_string("#F5F7FA", Color.WHITE)))
check("text_muted token", theme.text_muted_color().is_equal_approx(Color.from_string("#AAB2C0", Color.WHITE)))
check("border token", theme.border_color().is_equal_approx(Color.from_string("#FFFFFF33", Color.WHITE)))
check("accent token", theme.accent_color().is_equal_approx(Color.from_string("#4FA3FF", Color.WHITE)))
check("accent token", theme.accent_color().is_equal_approx(Color.from_string("#22C6FF", Color.WHITE)))
var path := "user://prop_theme_test_ui.json"
_write_user_theme('{"version":"1.0","ui":{"fonts":{"body":24},"colors":{"accent":"#FF8800"}}}', path)
var custom: PropTheme = PROP_THEME.new()
custom.load_from(path)
check("ui font override", custom.font_body() == 24)
check("unoverridden ui token falls back", custom.font_header() == 24)
check("ui accent override", custom.accent_color().is_equal_approx(Color.from_string("#FF8800", Color.WHITE)))
check("legacy PropTheme ui font does not override shared tokens", custom.font_body() == 18)
check("legacy PropTheme ui accent does not override shared tokens", custom.accent_color().is_equal_approx(Color.from_string("#22C6FF", Color.WHITE)))
DirAccess.remove_absolute(path)