721 lines
38 KiB
GDScript
721 lines
38 KiB
GDScript
extends SceneTree
|
|
|
|
const SANDBOX_STAGE: PackedScene = preload("res://scenes/stage/sandbox_stage.tscn")
|
|
const STAGE_SELECTION := preload("res://scripts/stage/stage_selection.gd")
|
|
const STAGE_GIZMOS := preload("res://scripts/stage/stage_gizmos.gd")
|
|
const CAPTURE_DIR: String = "res://tests/phase20a_captures"
|
|
|
|
var _failures: int = 0
|
|
|
|
|
|
func _initialize() -> void:
|
|
call_deferred("_run_capture_scenarios")
|
|
|
|
|
|
func _run_capture_scenarios() -> void:
|
|
root.size = Vector2i(1280, 720)
|
|
root.position = Vector2i(40, 40)
|
|
root.gui_embed_subwindows = true
|
|
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(CAPTURE_DIR))
|
|
var stage: SandboxStage = SANDBOX_STAGE.instantiate() as SandboxStage
|
|
root.add_child(stage)
|
|
await _wait_frames(8)
|
|
stage.call("_on_grid_toggled", false)
|
|
var spawner: StageSpawner = stage.get("_spawner") as StageSpawner
|
|
spawner.selected_prop_id = "crate"
|
|
stage.set_placement_mode("prop")
|
|
var center_screen: Vector2 = _screen_at(stage, Vector2.ZERO)
|
|
await _send_mouse_motion(center_screen)
|
|
await _wait_frames(3)
|
|
_check(_placement_prompt(stage).visible, "placement prompt is visible")
|
|
_check(_placement_prompt(stage).text.contains("Click to place Crate"), "placement prompt shows the next automatic name")
|
|
await _save_capture("placement_name_prompt")
|
|
|
|
await _send_click(center_screen)
|
|
await _wait_frames(4)
|
|
var placed_props: Array[PropBlock] = _placed_props(stage)
|
|
var chooser: PopupPanel = null
|
|
_check(placed_props.size() == 1, "first prop is placed through the viewport input path")
|
|
if placed_props.size() >= 1:
|
|
_check(placed_props[0].stage_name == "Crate", "first prop receives its asset-based stage name")
|
|
await _send_click(center_screen)
|
|
await _wait_frames(4)
|
|
placed_props = _placed_props(stage)
|
|
_check(placed_props.size() == 2, "second prop is placed through the viewport input path")
|
|
if placed_props.size() < 2:
|
|
stage.queue_free()
|
|
await _wait_frames(2)
|
|
quit(1)
|
|
return
|
|
_check(placed_props[0].stage_name == "Crate", "first prop receives its asset-based stage name")
|
|
_check(placed_props[1].stage_name == "Crate 2", "repeat placement gets the next unique name")
|
|
await _make_prop_rectangle(placed_props[0], Color("#C46B48"))
|
|
await _make_prop_rectangle(placed_props[1], Color("#4D86C6"))
|
|
stage.call("_show_placement_feedback", placed_props[1], placed_props[1].stage_name)
|
|
stage.set_placement_mode("")
|
|
await _wait_frames(3)
|
|
await _save_capture("placement_feedback")
|
|
await create_timer(1.8).timeout
|
|
|
|
# A third prop shares the broad-phase bounds, but its visible outline leaves
|
|
# the click point empty. The production chooser should exclude it.
|
|
var outline_decoy: PropBlock = spawner.spawn_prop_asset("crate", Vector2.ZERO)
|
|
if outline_decoy != null:
|
|
stage.call("_finalize_placed_node", outline_decoy)
|
|
await _make_prop_outline(outline_decoy)
|
|
var identity: StageObjectIdentity = stage.get("_object_identity") as StageObjectIdentity
|
|
var rename_result: Dictionary = identity.rename(outline_decoy, "Outline Decoy", stage.get_node("World") as Node2D)
|
|
_check(bool(rename_result.get("ok", false)), "outline-only decoy gets a unique label")
|
|
else:
|
|
_check(false, "outline-only decoy was spawned")
|
|
await _wait_frames(4)
|
|
|
|
var world: Node2D = stage.get_node("World") as Node2D
|
|
var character: StickmanRig = spawner.spawn_stickman_asset("res://content/stickmen/break.stk", Vector2(-560.0, 0.0))
|
|
if character != null:
|
|
stage.call("_finalize_placed_node", character)
|
|
var selection: StageSelection = stage.get("_selection") as StageSelection
|
|
selection.select_only(character)
|
|
await _wait_frames(4)
|
|
await _save_capture("character_inspector")
|
|
var character_view: StageCharacterSettingsView = stage.get("_character_settings_view") as StageCharacterSettingsView
|
|
var rename_button: Button = character_view.find_child("RenameStageObject", true, false) as Button
|
|
var character_card: StageInspectorPanel = character_view.get_card() as StageInspectorPanel
|
|
var character_title: Label = character_card.find_child("InspectorTitle", true, false) as Label
|
|
var character_body: ScrollContainer = character_card.find_child("InspectorBodyScroll", true, false) as ScrollContainer
|
|
_check(rename_button != null and rename_button.visible and rename_button.text == "Rename", "character inspector has a visible Rename action in its header")
|
|
_check(character_title != null and character_title.text == character.stage_name, "character name appears once as the inspector heading")
|
|
_check(character_body != null and not character_body.visible, "character inspector stays a compact name row until editing")
|
|
if rename_button != null:
|
|
stage.call("_set_scene_dirty", false)
|
|
_check(not bool(stage.get("_dirty")), "name editor starts from a clean document")
|
|
await _click_control(rename_button)
|
|
await _wait_frames(3)
|
|
var character_edit: LineEdit = character_view.find_child("StageNameEdit", true, false) as LineEdit
|
|
_check(character_edit != null and character_edit.visible, "Rename opens the inline editor")
|
|
await _save_capture("character_name_edit")
|
|
if character_edit != null:
|
|
_check(character_edit.has_focus(), "inline name editor receives keyboard focus")
|
|
await _send_key(KEY_A, false, true)
|
|
await _type_text("Milo")
|
|
_check(character_edit.text == "Milo", "injected keyboard input replaces the character name")
|
|
var done_button: Button = character_view.find_child("DoneStageName", true, false) as Button
|
|
if done_button != null:
|
|
await _click_control(done_button)
|
|
await _wait_frames(3)
|
|
_check(character.stage_name == "Milo", "valid name commits from the inline editor")
|
|
_check(character_title != null and character_title.text == "Milo", "renamed character heading updates immediately")
|
|
_check(bool(stage.get("_dirty")), "valid rename marks the document dirty")
|
|
await _save_capture("character_renamed")
|
|
if rename_button != null:
|
|
stage.call("_set_scene_dirty", false)
|
|
_check(not bool(stage.get("_dirty")), "duplicate-name check starts from a clean document")
|
|
await _click_control(rename_button)
|
|
await _wait_frames(3)
|
|
character_edit = character_view.find_child("StageNameEdit", true, false) as LineEdit
|
|
if character_edit != null:
|
|
_check(character_edit.has_focus(), "duplicate-name editor receives keyboard focus")
|
|
await _send_key(KEY_A, false, true)
|
|
await _type_text(placed_props[0].stage_name)
|
|
_check(character_edit.text == placed_props[0].stage_name, "injected keyboard input enters the duplicate name")
|
|
await _send_key(KEY_ENTER)
|
|
await _wait_frames(3)
|
|
var feedback: Label = character_view.find_child("StageNameFeedback", true, false) as Label
|
|
_check(feedback != null and feedback.visible and feedback.text.contains("Crate"), "character duplicate name remains in the editor with feedback")
|
|
_check(not bool(stage.get("_dirty")) and character.stage_name == "Milo", "invalid duplicate rename leaves the name and dirty state unchanged")
|
|
await _save_capture("character_duplicate_name_feedback")
|
|
var cancel_name_button: Button = character_view.find_child("CancelStageName", true, false) as Button
|
|
if cancel_name_button != null:
|
|
await _click_control(cancel_name_button)
|
|
await _wait_frames(3)
|
|
_check(not bool(stage.get("_dirty")) and character.stage_name == "Milo", "Cancel discards the draft without marking the scene dirty")
|
|
else:
|
|
_check(false, "character asset is spawned for inspector capture")
|
|
|
|
placed_props = _placed_props(stage)
|
|
var first_prop: PropBlock = placed_props[0] if not placed_props.is_empty() else null
|
|
var second_prop: PropBlock = placed_props[1] if placed_props.size() > 1 else null
|
|
var selection_after_setup: StageSelection = stage.get("_selection") as StageSelection
|
|
if first_prop != null:
|
|
selection_after_setup.select_only(first_prop)
|
|
await _wait_frames(3)
|
|
await _save_capture("prop_inspector")
|
|
var prop_view: StagePropSettingsView = stage.get("_prop_settings_view") as StagePropSettingsView
|
|
_check(prop_view != null and prop_view.visible, "selected prop inspector is visible")
|
|
var prop_card: StageInspectorPanel = prop_view.get_card() as StageInspectorPanel
|
|
var prop_title: Label = prop_card.find_child("InspectorTitle", true, false) as Label
|
|
var prop_rename_button: Button = prop_card.find_child("RenameStageObject", true, false) as Button
|
|
var collapse_button: Button = prop_card.find_child("CollapseStageInspector", true, false) as Button
|
|
_check(prop_title != null and prop_title.text == first_prop.stage_name, "prop name appears once in the inspector heading")
|
|
_check(_find_label_with_text(prop_view, "Name on stage") == null, "prop inspector does not repeat its name in the body")
|
|
_check(prop_rename_button != null and prop_rename_button.visible and prop_rename_button.text == "Rename", "prop inspector shares the header Rename action")
|
|
_check(collapse_button != null and collapse_button.visible and not prop_card.is_collapsed(), "expanded prop inspector has a disclosure control")
|
|
if collapse_button != null:
|
|
await _click_control(collapse_button)
|
|
await _wait_frames(3)
|
|
_check(prop_card.visible and prop_card.is_collapsed() and collapse_button.text == "▾", "Close collapses to the named prop header")
|
|
_check(_find_button_with_text(prop_view, "Settings") == null, "collapsed inspector does not replace the name with a Settings button")
|
|
await _save_capture("prop_inspector_collapsed")
|
|
if prop_rename_button != null:
|
|
await _click_control(prop_rename_button)
|
|
await _wait_frames(3)
|
|
var prop_name_edit: LineEdit = prop_view.find_child("StageNameEdit", true, false) as LineEdit
|
|
_check(not prop_card.is_collapsed() and prop_name_edit != null and prop_name_edit.visible, "Rename expands the inspector and opens its name editor")
|
|
var prop_cancel_name: Button = prop_view.find_child("CancelStageName", true, false) as Button
|
|
if prop_cancel_name != null:
|
|
await _click_control(prop_cancel_name)
|
|
await _wait_frames(3)
|
|
_check(prop_card.is_collapsed(), "Cancel returns to the prior collapsed state")
|
|
await _click_control(collapse_button)
|
|
await _wait_frames(3)
|
|
_check(not prop_card.is_collapsed() and prop_view.get_fields().visible, "chevron expands the full prop settings body")
|
|
|
|
# Click at a true overlap through the stage's normal unhandled input route.
|
|
# Existing selection remains unchanged while the chooser is open.
|
|
if first_prop != null and second_prop != null:
|
|
selection_after_setup.select_only(first_prop)
|
|
await _wait_frames(2)
|
|
await _send_click(center_screen)
|
|
await _wait_frames(5)
|
|
chooser = stage.get("_objects_here_popup") as PopupPanel
|
|
_check(chooser != null and chooser.visible, "overlap click opens Objects here")
|
|
_check(selection_after_setup.get_selected().size() == 1 and selection_after_setup.get_selected()[0] == first_prop, "opening chooser leaves the previous selection unchanged")
|
|
var candidates: Array[Node2D] = selection_after_setup.hit_test_all(Vector2.ZERO)
|
|
_check(candidates.size() == 2 and candidates.has(first_prop) and candidates.has(second_prop), "chooser hit test excludes the empty center of the outline-only decoy")
|
|
if chooser != null:
|
|
var chooser_shell: StyleBoxFlat = chooser.get_theme_stylebox("panel") as StyleBoxFlat
|
|
_check(chooser_shell != null and chooser_shell.corner_radius_top_left >= 12, "overlap chooser shell has the rounded popup treatment")
|
|
_check(chooser_shell != null and chooser_shell.border_width_left >= 2 and chooser_shell.shadow_size > 0, "overlap chooser shell has a clear border and local shadow")
|
|
var selected_candidate: Button = chooser.find_child("ObjectCandidate_0", true, false) as Button
|
|
var selected_candidate_style: StyleBoxFlat = selected_candidate.get_theme_stylebox("normal") as StyleBoxFlat if selected_candidate != null else null
|
|
_check(selected_candidate_style != null and selected_candidate_style.corner_radius_top_left >= 10, "overlap candidate rows use rounded corners")
|
|
var cancel_button: Button = chooser.find_child("CancelObjectsHere", true, false) as Button
|
|
var cancel_style: StyleBoxFlat = cancel_button.get_theme_stylebox("normal") as StyleBoxFlat if cancel_button != null else null
|
|
_check(cancel_button != null and cancel_button.custom_minimum_size.y >= 48, "overlap chooser Cancel has a large target")
|
|
_check(cancel_style != null and cancel_style.corner_radius_top_left >= 8, "overlap chooser Cancel uses the rounded secondary treatment")
|
|
await _save_capture("overlap_chooser")
|
|
if chooser != null:
|
|
var candidate_button: Button = chooser.find_child("ObjectCandidate_0", true, false) as Button
|
|
if candidate_button != null:
|
|
var gizmos: StageGizmos = stage.get("_gizmos") as StageGizmos
|
|
await _send_mouse_motion(candidate_button.get_screen_position() + candidate_button.size * 0.5, candidate_button.get_window().get_window_id())
|
|
await _wait_frames(3)
|
|
_check(gizmos != null and gizmos.get("_candidate_preview") == second_prop, "pointer hover previews the exact overlap candidate")
|
|
await _save_capture("overlap_hover_preview")
|
|
await _send_mouse_motion(Vector2(860.0, 360.0), chooser.get_window().get_window_id())
|
|
await _wait_frames(3)
|
|
_check(gizmos != null and gizmos.get("_candidate_preview") == null, "leaving a candidate row clears hover preview")
|
|
await _send_key(KEY_TAB, true)
|
|
await _send_key(KEY_TAB, true)
|
|
await _wait_frames(3)
|
|
_check(candidate_button.has_focus(), "Shift+Tab keyboard navigation focuses the first overlap candidate")
|
|
_check(gizmos != null and gizmos.get("_candidate_preview") == second_prop, "keyboard focus previews the exact candidate")
|
|
await _save_capture("overlap_candidate_preview")
|
|
var cancel_button: Button = chooser.find_child("CancelObjectsHere", true, false) as Button
|
|
if cancel_button != null:
|
|
await _click_control(cancel_button)
|
|
await _wait_frames(4)
|
|
_check(stage.get("_objects_here_popup") == null, "Cancel closes the chooser")
|
|
_check(selection_after_setup.get_selected().size() == 1 and selection_after_setup.get_selected()[0] == first_prop, "Cancel preserves the previous selection")
|
|
await _save_capture("overlap_cancelled")
|
|
|
|
await _send_click(center_screen)
|
|
await _wait_frames(4)
|
|
await _send_key(KEY_ESCAPE)
|
|
await _wait_frames(4)
|
|
_check(stage.get("_objects_here_popup") == null, "Escape closes the chooser")
|
|
_check(selection_after_setup.get_selected().size() == 1 and selection_after_setup.get_selected()[0] == first_prop, "Escape preserves the previous selection")
|
|
var escape_gizmos: StageGizmos = stage.get("_gizmos") as StageGizmos
|
|
_check(escape_gizmos == null or escape_gizmos.get("_candidate_preview") == null, "Escape clears the transient candidate preview")
|
|
await _save_capture("overlap_escape_dismissal")
|
|
|
|
await _send_click(center_screen)
|
|
await _wait_frames(4)
|
|
await _send_click(_screen_at(stage, Vector2(-60.0, -40.0)))
|
|
await _wait_frames(4)
|
|
_check(stage.get("_objects_here_popup") == null, "outside dismissal click closes the chooser")
|
|
_check(selection_after_setup.get_selected().size() == 1 and selection_after_setup.get_selected()[0] == first_prop, "outside dismissal consumes the click and preserves selection")
|
|
await _save_capture("overlap_outside_dismissal")
|
|
|
|
await _send_click(center_screen)
|
|
await _wait_frames(4)
|
|
chooser = stage.get("_objects_here_popup") as PopupPanel
|
|
if chooser != null:
|
|
var choose_button: Button = chooser.find_child("ObjectCandidate_0", true, false) as Button
|
|
if choose_button != null:
|
|
await _click_control(choose_button)
|
|
await _wait_frames(5)
|
|
_check(selection_after_setup.get_selected().size() == 1 and selection_after_setup.get_selected()[0] == second_prop, "choosing a row commits that object once")
|
|
await _save_capture("overlap_committed")
|
|
|
|
# DIRECT uses the same geometry-filtered chooser with a prop-only list and
|
|
# target selection does not take over EDIT selection.
|
|
var direct_use_spot_id: String = second_prop.add_use_spot(Vector2.ZERO) if second_prop != null else ""
|
|
_check(not direct_use_spot_id.is_empty(), "DIRECT target fixture has a valid Use spot")
|
|
stage.set_mode(SandboxStage.StageMode.DIRECT)
|
|
selection_after_setup.select_only(first_prop)
|
|
stage.call("_begin_use_target_capture", "direct")
|
|
await _wait_frames(4)
|
|
await _send_click(center_screen)
|
|
await _wait_frames(4)
|
|
chooser = stage.get("_objects_here_popup") as PopupPanel
|
|
_check(chooser != null and chooser.visible, "DIRECT Use opens the shared prop chooser")
|
|
if chooser != null:
|
|
var direct_rows: VBoxContainer = chooser.find_child("ObjectsHereRows", true, false) as VBoxContainer
|
|
_check(direct_rows != null and direct_rows.get_child_count() == 2, "DIRECT Use chooser contains only the two props that actually cover the point")
|
|
await _save_capture("direct_use_chooser")
|
|
var direct_candidate: Button = chooser.find_child("ObjectCandidate_0", true, false) as Button
|
|
if direct_candidate != null:
|
|
await _send_key(KEY_TAB, true)
|
|
await _send_key(KEY_TAB, true)
|
|
await _wait_frames(3)
|
|
_check(direct_candidate.has_focus(), "DIRECT picker candidate can receive keyboard focus")
|
|
await _save_capture("direct_use_preview")
|
|
await _click_control(direct_candidate)
|
|
await _wait_frames(4)
|
|
var target_capture: StageTargetCaptureSession = stage.get("_target_capture") as StageTargetCaptureSession
|
|
_check(target_capture != null and target_capture.use_target_prop == second_prop and target_capture.use_target_step == 1, "DIRECT chooser advances to Use spot selection for the chosen prop")
|
|
var direct_capture_prompt: Label = stage.find_child("Prompt", true, false) as Label
|
|
_check(direct_capture_prompt != null and direct_capture_prompt.text == "Choose a spot on the prop", "valid DIRECT prop selection shows the next-step prompt")
|
|
_check(selection_after_setup.get_selected().size() == 1 and selection_after_setup.get_selected()[0] == first_prop, "DIRECT target selection leaves object selection untouched")
|
|
await _save_capture("direct_use_target_selected")
|
|
else:
|
|
_check(false, "DIRECT target chooser is available for interaction")
|
|
|
|
# A director selects between overlapping named characters. A nearby Blue Door
|
|
# remains visible in the scene but its actual art does not cover the click.
|
|
stage.set_mode(SandboxStage.StageMode.EDIT)
|
|
var props_clear_of_character_point: Array[PropBlock] = _placed_props(stage)
|
|
for prop_index: int in props_clear_of_character_point.size():
|
|
props_clear_of_character_point[prop_index].position = Vector2(1000.0 + float(prop_index) * 220.0, 700.0)
|
|
if character != null:
|
|
character.position = Vector2.ZERO
|
|
var ava: StickmanRig = spawner.spawn_stickman_asset("res://content/stickmen/break.stk", character.position)
|
|
var character_identity: StageObjectIdentity = stage.get("_object_identity") as StageObjectIdentity
|
|
if ava != null:
|
|
stage.call("_finalize_placed_node", ava)
|
|
var ava_result: Dictionary = character_identity.rename(ava, "Ava", stage.get_node("World") as Node2D)
|
|
_check(bool(ava_result.get("ok", false)), "overlapping second character receives the distinct name Ava")
|
|
else:
|
|
_check(false, "overlapping second character is spawned")
|
|
var blue_door: PropBlock = spawner.spawn_prop_asset("crate", character.position + Vector2(220.0, 0.0))
|
|
if blue_door != null:
|
|
stage.call("_finalize_placed_node", blue_door)
|
|
await _make_blue_door(blue_door)
|
|
var door_result: Dictionary = character_identity.rename(blue_door, "Blue Door", stage.get_node("World") as Node2D)
|
|
_check(bool(door_result.get("ok", false)), "nearby prop receives the descriptive name Blue Door")
|
|
else:
|
|
_check(false, "nearby Blue Door prop is spawned")
|
|
if ava != null and blue_door != null:
|
|
var shared_point: Vector2 = _find_shared_hit_point(stage.get("_selection") as StageSelection, [character, ava])
|
|
_check(shared_point.is_finite(), "overlapping Milo and Ava share a visible hit point")
|
|
if shared_point.is_finite():
|
|
var shared_hits: Array[Node2D] = (stage.get("_selection") as StageSelection).hit_test_all(shared_point)
|
|
_check(shared_hits.size() == 2 and shared_hits.has(character) and shared_hits.has(ava) and not shared_hits.has(blue_door), "character overlap hit test omits the nearby Blue Door outside visible geometry")
|
|
selection_after_setup.select_only(first_prop)
|
|
await _send_click(_screen_at(stage, shared_point))
|
|
await _wait_frames(5)
|
|
chooser = stage.get("_objects_here_popup") as PopupPanel
|
|
_check(chooser != null and chooser.visible, "overlapping Milo and Ava open the production Objects here chooser")
|
|
if chooser != null:
|
|
var character_rows: VBoxContainer = chooser.find_child("ObjectsHereRows", true, false) as VBoxContainer
|
|
_check(character_rows != null and character_rows.get_child_count() == 2, "character overlap chooser contains Milo and Ava only")
|
|
var row_names: Array[String] = []
|
|
if character_rows != null:
|
|
for row_index: int in character_rows.get_child_count():
|
|
var row_button: Button = character_rows.get_child(row_index) as Button
|
|
if row_button == null:
|
|
continue
|
|
for label_node: Node in row_button.find_children("*", "Label", true, false):
|
|
var row_label: Label = label_node as Label
|
|
if row_label != null:
|
|
for expected_name: String in ["Milo", "Ava"]:
|
|
if row_label.text.contains(expected_name) and not row_names.has(expected_name):
|
|
row_names.append(expected_name)
|
|
_check(row_names.size() == 2 and row_names.has("Milo") and row_names.has("Ava"), "chooser rows identify the two characters by their stage names")
|
|
await _save_capture("character_overlap_chooser")
|
|
var ava_button: Button = _find_candidate_button_for_name(chooser, "Ava")
|
|
if ava_button != null:
|
|
await _send_mouse_motion(ava_button.get_screen_position() + ava_button.size * 0.5, ava_button.get_window().get_window_id())
|
|
await _wait_frames(3)
|
|
await _save_capture("character_overlap_preview")
|
|
await _click_control(ava_button)
|
|
await _wait_frames(4)
|
|
_check(selection_after_setup.get_selected().size() == 1 and selection_after_setup.get_selected()[0] == ava, "choosing Ava commits the character selection")
|
|
else:
|
|
_check(false, "Ava row is available for mouse selection")
|
|
await _save_capture("character_overlap_committed")
|
|
else:
|
|
_check(false, "character overlap chooser is available for candidate inspection")
|
|
for prop_index: int in props_clear_of_character_point.size():
|
|
props_clear_of_character_point[prop_index].position = Vector2.ZERO
|
|
else:
|
|
_check(false, "Milo is available for the character overlap scenario")
|
|
|
|
# The larger overlap exercises the chooser's bounded scroll area at a small
|
|
# viewport while retaining the normal stage click and popup input routes.
|
|
stage.set_mode(SandboxStage.StageMode.EDIT)
|
|
for extra_index: int in range(5):
|
|
var extra_prop: PropBlock = spawner.spawn_prop_asset("crate", Vector2.ZERO)
|
|
if extra_prop == null:
|
|
_check(false, "crowded chooser prop %d is spawned" % extra_index)
|
|
continue
|
|
stage.call("_finalize_placed_node", extra_prop)
|
|
await _make_prop_rectangle(extra_prop, Color.from_hsv(float(extra_index) / 5.0, 0.55, 0.82))
|
|
root.size = Vector2i(920, 560)
|
|
await _wait_frames(8)
|
|
var stage_characters: Array[StickmanRig] = []
|
|
for child: Node in (stage.get_node("World") as Node2D).get_children():
|
|
if child is StickmanRig:
|
|
stage_characters.append(child as StickmanRig)
|
|
(child as StickmanRig).visible = false
|
|
if first_prop != null:
|
|
selection_after_setup.select_only(first_prop)
|
|
stage.call("_refresh_prop_settings_card")
|
|
await _wait_frames(4)
|
|
var prop_identity: StageObjectIdentity = stage.get("_object_identity") as StageObjectIdentity
|
|
var original_prop_name: String = first_prop.stage_name
|
|
var long_prop_name: String = "Crate with a Very Long Descriptive Stage Name"
|
|
var long_prop_result: Dictionary = prop_identity.rename(first_prop, long_prop_name, stage.get_node("World") as Node2D)
|
|
_check(bool(long_prop_result.get("ok", false)), "long prop name is valid for compact-header capture")
|
|
stage.call("_refresh_prop_settings_card")
|
|
await _wait_frames(5)
|
|
var long_prop_view: StagePropSettingsView = stage.get("_prop_settings_view") as StagePropSettingsView
|
|
var long_prop_card: StageInspectorPanel = long_prop_view.get_card() as StageInspectorPanel
|
|
var long_prop_title: Label = long_prop_card.find_child("InspectorTitle", true, false) as Label
|
|
long_prop_card.set_collapsed(true)
|
|
await _wait_frames(4)
|
|
_check(long_prop_title != null and long_prop_title.max_lines_visible == 1 and long_prop_title.autowrap_mode == TextServer.AUTOWRAP_OFF, "collapsed prop title stays on one line")
|
|
_check(long_prop_title != null and long_prop_title.tooltip_text == long_prop_name and long_prop_title.text_overrun_behavior == TextServer.OVERRUN_TRIM_ELLIPSIS, "collapsed prop keeps its full-name tooltip and ellipsis")
|
|
await _save_capture("prop_inspector_collapsed_long_name_small_viewport")
|
|
prop_identity.rename(first_prop, original_prop_name, stage.get_node("World") as Node2D)
|
|
stage.call("_refresh_prop_settings_card")
|
|
long_prop_card.set_collapsed(false)
|
|
if character != null:
|
|
var character_identity_small: StageObjectIdentity = stage.get("_object_identity") as StageObjectIdentity
|
|
var original_position: Vector2 = character.global_position
|
|
var original_name: String = character.stage_name
|
|
var original_scale: Vector2 = character.scale
|
|
character.scale = Vector2(0.6, 0.6)
|
|
var canvas_to_world: Transform2D = stage.get_viewport().get_canvas_transform().affine_inverse()
|
|
character.visible = true
|
|
character.global_position = canvas_to_world * Vector2(float(root.size.x) * 0.52, 260.0)
|
|
var long_character_name: String = "Milo with a Very Long Character Name"
|
|
character_identity_small.rename(character, long_character_name, stage.get_node("World") as Node2D)
|
|
selection_after_setup.select_only(character)
|
|
stage.call("_refresh_character_settings_card")
|
|
await _wait_frames(7)
|
|
var compact_character_view: StageCharacterSettingsView = stage.get("_character_settings_view") as StageCharacterSettingsView
|
|
var compact_character_card: StageInspectorPanel = compact_character_view.get_card() as StageInspectorPanel
|
|
var compact_character_title: Label = compact_character_card.find_child("InspectorTitle", true, false) as Label
|
|
var gizmos: StageGizmos = stage.get("_gizmos") as StageGizmos
|
|
var character_bounds: Rect2 = STAGE_SELECTION.get_world_aabb(character)
|
|
var rotation_handle: Vector2 = gizmos._rotate_handle_position(character_bounds)
|
|
_check(character.source_stk_path.ends_with("break.stk"), "colorful Break character is present in the visual review")
|
|
_check(compact_character_title != null and compact_character_title.max_lines_visible == 1 and compact_character_title.tooltip_text == long_character_name, "compact character header stays one line and retains the full-name tooltip")
|
|
_check(rotation_handle.y > character_bounds.get_center().y, "rotate handle moves below a character near the top chrome")
|
|
_check(gizmos.hit_test(rotation_handle) == STAGE_GIZMOS.Handle.ROTATE, "lower rotate handle remains inside its hit target")
|
|
await _save_capture("break_character_long_name_top_handle_small_viewport")
|
|
var original_rotation: float = character.global_rotation
|
|
var rotate_press: Vector2 = _screen_at(stage, rotation_handle)
|
|
await _send_mouse_motion(rotate_press)
|
|
await _send_mouse_button(rotate_press, true)
|
|
await _wait_frames(2)
|
|
_check(gizmos.is_dragging(), "clicking the lower handle starts rotation through the stage input path")
|
|
var rotation_target: Vector2 = character_bounds.get_center() + (rotation_handle - character_bounds.get_center()).rotated(deg_to_rad(20.0))
|
|
await _send_mouse_motion(_screen_at(stage, rotation_target))
|
|
await _send_mouse_button(_screen_at(stage, rotation_target), false)
|
|
await _wait_frames(3)
|
|
_check(absf(angle_difference(character.global_rotation, original_rotation + deg_to_rad(20.0))) < 0.03, "dragging the lower handle rotates the selected character")
|
|
character.global_rotation = original_rotation
|
|
character.global_position = original_position
|
|
character.scale = original_scale
|
|
character_identity_small.rename(character, original_name, stage.get_node("World") as Node2D)
|
|
stage.call("_refresh_character_settings_card")
|
|
for stage_character: StickmanRig in stage_characters:
|
|
stage_character.visible = true
|
|
selection_after_setup.select_only(first_prop)
|
|
stage.call("_refresh_prop_settings_card")
|
|
center_screen = _screen_at(stage, Vector2.ZERO)
|
|
selection_after_setup.select_only(first_prop)
|
|
await _send_click(center_screen)
|
|
await _wait_frames(5)
|
|
chooser = stage.get("_objects_here_popup") as PopupPanel
|
|
_check(chooser != null and chooser.visible, "crowded overlap opens the chooser at a small viewport")
|
|
if chooser != null:
|
|
var crowded_rows: VBoxContainer = chooser.find_child("ObjectsHereRows", true, false) as VBoxContainer
|
|
_check(crowded_rows != null and crowded_rows.get_child_count() == 9, "crowded chooser lists seven props and both visible characters")
|
|
var popup_rect: Rect2 = Rect2(Vector2(chooser.position), Vector2(chooser.size))
|
|
_check(popup_rect.position.x >= 0.0 and popup_rect.position.y >= 64.0 and popup_rect.end.x <= float(root.size.x) and popup_rect.end.y <= float(root.size.y) - 38.0, "small viewport popup remains inside safe screen bounds")
|
|
var crowded_scroll: ScrollContainer = chooser.find_child("ObjectsHereScroll", true, false) as ScrollContainer
|
|
_check(crowded_scroll != null and crowded_scroll.size.y <= 320.0, "crowded chooser bounds its scroll viewport")
|
|
await _save_capture("crowded_chooser_small_viewport")
|
|
if crowded_scroll != null:
|
|
var scroll_pointer: Vector2 = crowded_scroll.get_screen_position() + crowded_scroll.size * 0.5
|
|
await _send_mouse_wheel_down(scroll_pointer, chooser.get_window().get_window_id())
|
|
await _wait_frames(4)
|
|
_check(crowded_scroll.scroll_vertical > 0, "crowded chooser scrolls through production mouse-wheel input")
|
|
await _save_capture("crowded_chooser_scrolled_small_viewport")
|
|
await _send_key(KEY_ESCAPE)
|
|
await _wait_frames(4)
|
|
_check(stage.get("_objects_here_popup") == null, "Escape closes the crowded small-viewport chooser")
|
|
|
|
if _failures == 0:
|
|
print("PHASE20A_GUI_CAPTURE PASS: production input/render scenarios; captures saved under %s" % CAPTURE_DIR)
|
|
else:
|
|
push_error("PHASE20A_GUI_CAPTURE FAIL: %d assertion(s) failed" % _failures)
|
|
stage.queue_free()
|
|
await _wait_frames(2)
|
|
quit(1 if _failures > 0 else 0)
|
|
|
|
|
|
func _placed_props(stage: SandboxStage) -> Array[PropBlock]:
|
|
var result: Array[PropBlock] = []
|
|
var world: Node2D = stage.get_node("World") as Node2D
|
|
for child: Node in world.get_children():
|
|
if child is PropBlock:
|
|
result.append(child as PropBlock)
|
|
return result
|
|
|
|
|
|
func _placement_prompt(stage: SandboxStage) -> Label:
|
|
return stage.find_child("PlacementNamePrompt", true, false) as Label
|
|
|
|
|
|
func _make_prop_rectangle(prop: PropBlock, color: Color) -> void:
|
|
var points: PackedVector2Array = PackedVector2Array([
|
|
Vector2(-80.0, -60.0),
|
|
Vector2(80.0, -60.0),
|
|
Vector2(80.0, 60.0),
|
|
Vector2(-80.0, 60.0),
|
|
])
|
|
var shapes: Array[Dictionary] = [{
|
|
"shape_type": "polygon",
|
|
"points": points,
|
|
"closed": true,
|
|
"fill_color": color.to_html(false),
|
|
"outline_color": "#2D2532",
|
|
"outline_width": 3.0,
|
|
}]
|
|
prop.set_custom_shapes(shapes, points)
|
|
|
|
|
|
func _make_prop_outline(prop: PropBlock) -> void:
|
|
var bars: Array[PackedVector2Array] = [
|
|
PackedVector2Array([Vector2(-80.0, -60.0), Vector2(80.0, -60.0), Vector2(80.0, -50.0), Vector2(-80.0, -50.0)]),
|
|
PackedVector2Array([Vector2(70.0, -60.0), Vector2(80.0, -60.0), Vector2(80.0, 60.0), Vector2(70.0, 60.0)]),
|
|
PackedVector2Array([Vector2(-80.0, 50.0), Vector2(80.0, 50.0), Vector2(80.0, 60.0), Vector2(-80.0, 60.0)]),
|
|
PackedVector2Array([Vector2(-80.0, -60.0), Vector2(-70.0, -60.0), Vector2(-70.0, 60.0), Vector2(-80.0, 60.0)]),
|
|
]
|
|
var shapes: Array[Dictionary] = []
|
|
for points: PackedVector2Array in bars:
|
|
shapes.append({
|
|
"shape_type": "polygon",
|
|
"points": points,
|
|
"closed": true,
|
|
"fill_color": "#9B83C5",
|
|
"outline_color": "#392C4C",
|
|
"outline_width": 2.0,
|
|
})
|
|
var hull_points: PackedVector2Array = PackedVector2Array([
|
|
Vector2(-80.0, -60.0), Vector2(80.0, -60.0), Vector2(80.0, 60.0), Vector2(-80.0, 60.0),
|
|
])
|
|
prop.set_custom_shapes(shapes, Geometry2D.convex_hull(hull_points))
|
|
|
|
|
|
func _make_blue_door(prop: PropBlock) -> void:
|
|
var door_points: PackedVector2Array = PackedVector2Array([
|
|
Vector2(-45.0, -85.0),
|
|
Vector2(45.0, -85.0),
|
|
Vector2(45.0, 85.0),
|
|
Vector2(-45.0, 85.0),
|
|
])
|
|
var shapes: Array[Dictionary] = [{
|
|
"shape_type": "polygon",
|
|
"points": door_points,
|
|
"closed": true,
|
|
"fill_color": "#2878D5",
|
|
"outline_color": "#173C6B",
|
|
"outline_width": 5.0,
|
|
}]
|
|
var knob_points: PackedVector2Array = PackedVector2Array([
|
|
Vector2(28.0, -8.0), Vector2(34.0, -14.0), Vector2(40.0, -8.0), Vector2(34.0, -2.0),
|
|
])
|
|
shapes.append({
|
|
"shape_type": "polygon",
|
|
"points": knob_points,
|
|
"closed": true,
|
|
"fill_color": "#F7D45C",
|
|
"outline_color": "#684F1A",
|
|
"outline_width": 2.0,
|
|
})
|
|
prop.set_custom_shapes(shapes, door_points)
|
|
|
|
|
|
func _find_shared_hit_point(selection: StageSelection, nodes: Array[Node2D]) -> Vector2:
|
|
if selection == null or nodes.size() < 2:
|
|
return Vector2.INF
|
|
var shared_bounds: Rect2 = STAGE_SELECTION.get_world_aabb(nodes[0])
|
|
for node_index: int in range(1, nodes.size()):
|
|
shared_bounds = shared_bounds.intersection(STAGE_SELECTION.get_world_aabb(nodes[node_index]))
|
|
if shared_bounds.size.x <= 0.0 or shared_bounds.size.y <= 0.0:
|
|
return Vector2.INF
|
|
for y_index: int in range(1, 10):
|
|
for x_index: int in range(1, 10):
|
|
var point: Vector2 = shared_bounds.position + Vector2(shared_bounds.size.x * float(x_index) / 10.0, shared_bounds.size.y * float(y_index) / 10.0)
|
|
var hits: Array[Node2D] = selection.hit_test_all(point)
|
|
var all_nodes_hit: bool = true
|
|
for node: Node2D in nodes:
|
|
if not hits.has(node):
|
|
all_nodes_hit = false
|
|
break
|
|
if all_nodes_hit:
|
|
return point
|
|
return Vector2.INF
|
|
|
|
|
|
func _find_candidate_button_for_name(popup: PopupPanel, candidate_name: String) -> Button:
|
|
var rows: VBoxContainer = popup.find_child("ObjectsHereRows", true, false) as VBoxContainer
|
|
if rows == null:
|
|
return null
|
|
for row_index: int in rows.get_child_count():
|
|
var button: Button = rows.get_child(row_index) as Button
|
|
if button == null:
|
|
continue
|
|
for label_node: Node in button.find_children("*", "Label", true, false):
|
|
var label: Label = label_node as Label
|
|
if label != null and label.text.contains(candidate_name):
|
|
return button
|
|
return null
|
|
|
|
|
|
func _find_button_with_text(parent: Node, text_value: String) -> Button:
|
|
for child: Node in parent.find_children("*", "Button", true, false):
|
|
var button: Button = child as Button
|
|
if button != null and button.text == text_value:
|
|
return button
|
|
return null
|
|
|
|
|
|
func _find_label_with_text(parent: Node, text_value: String) -> Label:
|
|
for child: Node in parent.find_children("*", "Label", true, false):
|
|
var label: Label = child as Label
|
|
if label != null and label.text == text_value:
|
|
return label
|
|
return null
|
|
|
|
|
|
func _screen_at(stage: SandboxStage, world_position: Vector2) -> Vector2:
|
|
return stage.get_viewport().get_canvas_transform() * world_position
|
|
|
|
|
|
func _click_control(control: Control) -> void:
|
|
var click_position: Vector2 = control.get_screen_position() + control.size * 0.5
|
|
var owner_window: Window = control.get_window()
|
|
print("PHASE20A_CONTROL_CLICK name=%s rect=%s screen=%s window_id=%d window_pos=%s root_window_id=%d" % [
|
|
control.name,
|
|
control.get_global_rect(),
|
|
control.get_screen_position(),
|
|
owner_window.get_window_id(),
|
|
owner_window.position,
|
|
root.get_window_id(),
|
|
])
|
|
await _send_click(click_position, owner_window.get_window_id())
|
|
|
|
|
|
func _send_click(position: Vector2, window_id: int = 0) -> void:
|
|
await _send_mouse_button(position, true, window_id)
|
|
await _wait_frames(1)
|
|
await _send_mouse_button(position, false, window_id)
|
|
await _wait_frames(2)
|
|
|
|
|
|
func _send_mouse_button(position: Vector2, pressed: bool, window_id: int = 0) -> void:
|
|
var event: InputEventMouseButton = InputEventMouseButton.new()
|
|
event.button_index = MOUSE_BUTTON_LEFT
|
|
event.position = position
|
|
event.global_position = position
|
|
event.window_id = window_id
|
|
event.pressed = pressed
|
|
Input.parse_input_event(event)
|
|
await process_frame
|
|
|
|
|
|
func _send_mouse_wheel_down(position: Vector2, window_id: int = 0) -> void:
|
|
var event: InputEventMouseButton = InputEventMouseButton.new()
|
|
event.button_index = MOUSE_BUTTON_WHEEL_DOWN
|
|
event.position = position
|
|
event.global_position = position
|
|
event.window_id = window_id
|
|
event.pressed = true
|
|
Input.parse_input_event(event)
|
|
await process_frame
|
|
|
|
|
|
func _send_mouse_motion(position: Vector2, window_id: int = 0) -> void:
|
|
var event: InputEventMouseMotion = InputEventMouseMotion.new()
|
|
event.position = position
|
|
event.global_position = position
|
|
event.window_id = window_id
|
|
Input.parse_input_event(event)
|
|
await process_frame
|
|
|
|
|
|
func _send_key(keycode: Key, with_shift: bool = false, with_ctrl: bool = false) -> void:
|
|
var event: InputEventKey = InputEventKey.new()
|
|
event.keycode = keycode
|
|
event.shift_pressed = with_shift
|
|
event.ctrl_pressed = with_ctrl
|
|
event.pressed = true
|
|
Input.parse_input_event(event)
|
|
await process_frame
|
|
event = InputEventKey.new()
|
|
event.keycode = keycode
|
|
event.shift_pressed = with_shift
|
|
event.ctrl_pressed = with_ctrl
|
|
event.pressed = false
|
|
Input.parse_input_event(event)
|
|
await process_frame
|
|
|
|
|
|
func _type_text(text_value: String) -> void:
|
|
for character_index: int in range(text_value.length()):
|
|
var character: String = text_value.substr(character_index, 1)
|
|
var event: InputEventKey = InputEventKey.new()
|
|
event.keycode = OS.find_keycode_from_string(character.to_upper())
|
|
event.unicode = text_value.unicode_at(character_index)
|
|
event.shift_pressed = character != character.to_lower()
|
|
event.pressed = true
|
|
Input.parse_input_event(event)
|
|
await process_frame
|
|
event = InputEventKey.new()
|
|
event.keycode = OS.find_keycode_from_string(character.to_upper())
|
|
event.unicode = text_value.unicode_at(character_index)
|
|
event.shift_pressed = character != character.to_lower()
|
|
event.pressed = false
|
|
Input.parse_input_event(event)
|
|
await process_frame
|
|
|
|
|
|
func _wait_frames(count: int) -> void:
|
|
for _index: int in count:
|
|
await process_frame
|
|
await RenderingServer.frame_post_draw
|
|
|
|
|
|
func _save_capture(name: String) -> void:
|
|
await RenderingServer.frame_post_draw
|
|
var image: Image = root.get_texture().get_image()
|
|
var path: String = CAPTURE_DIR.path_join("%s.png" % name)
|
|
var error: Error = image.save_png(ProjectSettings.globalize_path(path))
|
|
print("PHASE20A_CAPTURE path=%s size=%s error=%d" % [path, image.get_size(), error])
|
|
_check(error == OK and image.get_size() == root.size, "capture %s is saved at the expected viewport size" % name)
|
|
|
|
|
|
func _check(condition: bool, label: String) -> void:
|
|
if condition:
|
|
print("PHASE20A_CHECK PASS: %s" % label)
|
|
else:
|
|
_failures += 1
|
|
push_error("PHASE20A_CHECK FAIL: %s" % label)
|