diff options
| author | radiohotline <radoihotline@disroot.rg> | 2026-08-25 16:33:56 +0300 |
|---|---|---|
| committer | radiohotline <radoihotline@disroot.rg> | 2026-08-25 16:33:56 +0300 |
| commit | a89e744ed0e8c759caf041a580112365bbfe4303 (patch) | |
| tree | dd61e320904176995727e86a857d00c90d4933ba /addons/escoria-core/testing/ashes/interpreter/esc_interpreter_test.gd | |
| parent | 6d53d8cac78250dc888aa49d5f9d4175c6652feb (diff) | |
Diffstat (limited to 'addons/escoria-core/testing/ashes/interpreter/esc_interpreter_test.gd')
| -rw-r--r-- | addons/escoria-core/testing/ashes/interpreter/esc_interpreter_test.gd | 1879 |
1 files changed, 1879 insertions, 0 deletions
diff --git a/addons/escoria-core/testing/ashes/interpreter/esc_interpreter_test.gd b/addons/escoria-core/testing/ashes/interpreter/esc_interpreter_test.gd new file mode 100644 index 0000000..e03d122 --- /dev/null +++ b/addons/escoria-core/testing/ashes/interpreter/esc_interpreter_test.gd @@ -0,0 +1,1879 @@ +# GdUnit generated TestSuite +class_name ESCInterpreterTest +extends GdUnitTestSuite + +@warning_ignore("unused_parameter") +@warning_ignore("return_value_discarded") + +const MARK_SCENE := preload("res://game/characters/mark/mark.tscn") +const SIMPLE_CHOOSER_SCENE := preload("res://addons/escoria-dialog-simple/chooser/simple.tscn") + +var _terminate_on_errors_before: bool +var _command_directories_before: PackedStringArray +var _dialog_player_before + + +class DialogPlayerDouble extends ESCDialogPlayer: + var _choice_indexes: Array = [] + var _choices_consumed: int = 0 + var _dialogs_started: Array = [] + + + func _init(choice_indexes: Array = []): + _choice_indexes = choice_indexes.duplicate() + + + func start_dialog_choices(dialog: ESCDialog, _type: String = "simple"): + var option_keys: Array = [] + var option_texts: Array = [] + var option_source_indexes: Array = [] + for option in dialog.options: + option_keys.append(option.translation_key) + option_texts.append(option.option) + option_source_indexes.append(option.source_option_index) + + var timeout_option := dialog.get_timeout_option() + + _dialogs_started.append({ + "avatar": dialog.avatar, + "timeout": dialog.timeout, + "timeout_option": dialog.timeout_option, + "authored_options_count": dialog.authored_options_count, + "visible_timeout_option": timeout_option.option if timeout_option else null, + "options_size": dialog.options.size(), + "option_keys": option_keys, + "option_texts": option_texts, + "option_source_indexes": option_source_indexes + }) + + # Mirror the real dialog player closely enough for interpreter tests: + # only valid options are choosable, and scripted indexes deterministically + # drive nested dialog flows without needing UI interaction. + var valid_options: Array = [] + for option in dialog.options: + if option.is_valid(): + valid_options.append(option) + + var choice_index := 0 + if not _choice_indexes.is_empty(): + choice_index = int(_choice_indexes.pop_front()) + _choices_consumed += 1 + + call_deferred("_emit_choice", valid_options[choice_index] if choice_index < valid_options.size() else null) + + + func _emit_choice(option) -> void: + option_chosen.emit(option) + + + func get_choices_consumed() -> int: + return _choices_consumed + + + func get_remaining_choices() -> int: + return _choice_indexes.size() + + + func get_dialogs_started() -> Array: + return _dialogs_started.duplicate(true) + + +func before() -> void: + # Interpreter tests intentionally trigger runtime errors in a few cases, so + # they disable terminate-on-error and install a test-only command directory. + # The dialog player is also replaced on demand by a deterministic test + # double so dialog execution can run headlessly. + ESCInterpreterFactory.reset_interpreter() + escoria.event_manager.interrupt([], false) + escoria.event_manager.clear_event_queue() + _terminate_on_errors_before = ESCProjectSettingsManager.get_setting( + ESCProjectSettingsManager.TERMINATE_ON_ERRORS + ) + _command_directories_before = PackedStringArray( + ESCProjectSettingsManager.get_setting( + ESCProjectSettingsManager.COMMAND_DIRECTORIES + ) + ) + _dialog_player_before = escoria.get("dialog_player") + + ESCProjectSettingsManager.set_setting( + ESCProjectSettingsManager.TERMINATE_ON_ERRORS, + false + ) + ESCProjectSettingsManager.set_setting( + ESCProjectSettingsManager.COMMAND_DIRECTORIES, + PackedStringArray([ + "res://addons/escoria-core/testing/ashes/interpreter/support/commands" + ] + Array(_command_directories_before)) + ) + escoria.command_registry.registry.clear() + escoria.set("dialog_player", null) + + +func after() -> void: + # Restore shared Escoria settings and globals so each test starts from the + # same baseline and does not leak command registry or dialog-player state. + ESCInterpreterFactory.reset_interpreter() + escoria.event_manager.interrupt([], false) + escoria.event_manager.clear_event_queue() + ESCProjectSettingsManager.set_setting( + ESCProjectSettingsManager.TERMINATE_ON_ERRORS, + _terminate_on_errors_before + ) + ESCProjectSettingsManager.set_setting( + ESCProjectSettingsManager.COMMAND_DIRECTORIES, + _command_directories_before + ) + escoria.command_registry.registry.clear() + escoria.set("dialog_player", _dialog_player_before) + + +func test_execute_block_restores_scope_after_break() -> void: + # This fixture creates a nested block-local `x`, exits that block with + # `break`, and then reads `x` afterward into a global. If `_execute_block()` + # fails to restore `_environment` on the early return, the later lookup will + # incorrectly resolve the inner `x` instead of the outer one. + var source := _load_fixture("block_scope_restores_after_break.esc") + var compiler := ESCCompiler.new() + var scanner := ESCScanner.new() + scanner.set_source(source) + scanner.set_filename(_fixture_path("block_scope_restores_after_break.esc")) + + var tokens: Array = scanner.scan_tokens() + var parser := ESCParser.new() + parser.init(compiler, tokens, "") + + var statements := parser.parse() + assert_bool(compiler.had_error).is_false() + assert_int(statements.size()).is_equal(1) + + var interpreter := ESCInterpreter.new([], {}) + var resolver := ESCResolver.new(interpreter) + resolver.resolve(statements) + + await interpreter.interpret(statements) + + var globals := interpreter.get_global_values() + assert_bool(globals.has("result")).is_true() + assert_float(float(globals["result"])).is_equal(1.0) + + interpreter.cleanup() + + +func test_invalid_numeric_comparison_does_not_succeed_silently() -> void: + # This fixture performs an invalid numeric comparison. Invalid numeric + # operations should log an interpreter error and evaluate to `null` rather + # than continuing into a native GDScript operator error. + var source := _load_fixture("invalid_numeric_comparison.esc") + var compiler := ESCCompiler.new() + var scanner := ESCScanner.new() + scanner.set_source(source) + scanner.set_filename(_fixture_path("invalid_numeric_comparison.esc")) + + var tokens: Array = scanner.scan_tokens() + var parser := ESCParser.new() + parser.init(compiler, tokens, "") + + var statements := parser.parse() + assert_bool(compiler.had_error).is_false() + assert_int(statements.size()).is_equal(1) + + var interpreter := ESCInterpreter.new([], {}) + var resolver := ESCResolver.new(interpreter) + resolver.resolve(statements) + + await interpreter.interpret(statements) + + var globals := interpreter.get_global_values() + assert_bool(globals.has("result")).is_true() + assert_bool(globals["result"] == null).is_true() + + interpreter.cleanup() + + +func test_print_with_zero_arguments_does_not_native_crash() -> void: + # This fixture calls the builtin `print()` with zero arguments. The current + # implementation only rejects calls with more than one argument, so this + # test captures the actual runtime behavior before we decide on a fix. + var source := _load_fixture("print_zero_args.esc") + var compiler := ESCCompiler.new() + var scanner := ESCScanner.new() + scanner.set_source(source) + scanner.set_filename(_fixture_path("print_zero_args.esc")) + + var tokens: Array = scanner.scan_tokens() + var parser := ESCParser.new() + parser.init(compiler, tokens, "") + + var statements := parser.parse() + assert_bool(compiler.had_error).is_false() + assert_int(statements.size()).is_equal(1) + + var interpreter := ESCInterpreter.new([], {}) + var resolver := ESCResolver.new(interpreter) + resolver.resolve(statements) + + await interpreter.interpret(statements) + + interpreter.cleanup() + + +func test_print_with_two_arguments_does_not_native_crash() -> void: + # This fixture calls the builtin `print()` with too many arguments. The + # builtin should reject the call cleanly and avoid any native runtime error. + var source := _load_fixture("print_two_args.esc") + var compiler := ESCCompiler.new() + var scanner := ESCScanner.new() + scanner.set_source(source) + scanner.set_filename(_fixture_path("print_two_args.esc")) + + var tokens: Array = scanner.scan_tokens() + var parser := ESCParser.new() + parser.init(compiler, tokens, "") + + var statements := parser.parse() + assert_bool(compiler.had_error).is_false() + assert_int(statements.size()).is_equal(1) + + var interpreter := ESCInterpreter.new([], {}) + var resolver := ESCResolver.new(interpreter) + resolver.resolve(statements) + + await interpreter.interpret(statements) + + interpreter.cleanup() + + +func test_stop_in_nested_block_prevents_later_statements() -> void: + # `stop` should unwind the current event immediately, even when it is issued + # from inside a nested block. The later assignment must therefore never run. + var outcome := await _interpret_fixture("stop_nested_block.esc") + var globals: Dictionary = outcome.globals + assert_bool(globals.has("result")).is_true() + assert_float(float(globals["result"])).is_equal(0.0) + + +func test_done_in_nested_dialog_block_prevents_later_statements() -> void: + # `done` should conclude the current dialog from inside a nested block, but + # it must not abort the rest of the event. The assignment after the dialog + # should still execute, proving the dialog exits cleanly without leaking a + # control-flow sentinel to the enclosing event body. + var outcome := await _interpret_fixture( + "done_nested_block_in_dialog.esc", + [0] + ) + var globals: Dictionary = outcome.globals + assert_bool(globals.has("result")).is_true() + assert_float(float(globals["result"])).is_equal(1.0) + + +func test_break_one_returns_to_parent_dialog_level() -> void: + # `break 1` should move dialog control back up one level, not continue the + # remainder of the current outer option body. The scripted choice sequence + # must therefore consume a third outer-dialog choice after the nested break; + # if the dialog ends early instead, the final string alone is ambiguous. + var outcome := await _interpret_fixture( + "nested_dialog_break_one.esc", + [0, 0, 1] + ) + assert_bool(outcome.globals.has("result")).is_true() + assert_str(String(outcome.globals["result"])).is_equal("start-done") + assert_int(int(outcome.dialog_choices_consumed)).is_equal(3) + assert_int(int(outcome.remaining_dialog_choices)).is_equal(0) + + +func test_done_exits_entire_nested_dialog_tree() -> void: + # `done` should conclude the entire dialog from any nesting depth. After the + # nested choice issues `done`, the outer option body must not continue and + # the outer dialog must not be shown again. Execution should resume only + # after the dialog statement, so the final value should reflect the initial + # value plus the post-dialog append. + var outcome := await _interpret_fixture( + "done_exits_entire_nested_dialog.esc", + [0, 0] + ) + var globals: Dictionary = outcome.globals + assert_bool(globals.has("result")).is_true() + assert_str(String(globals["result"])).is_equal("start-done") + + +func test_dialog_followup_runs_after_all_options_are_exhausted() -> void: + # When each option invalidates itself, the dialog should conclude once no + # valid options remain. Execution must then continue with the statement after + # the dialog block. + var outcome := await _interpret_fixture( + "dialog_exhausted_options_runs_followup.esc", + [0, 0] + ) + var globals: Dictionary = outcome.globals + assert_bool(globals.has("result")).is_true() + assert_str(String(globals["result"])).is_equal("start-say1-say2-finished") + assert_bool(bool(globals["option1_is_valid"])).is_false() + assert_bool(bool(globals["option2_is_valid"])).is_false() + assert_int(int(outcome.dialog_choices_consumed)).is_equal(2) + assert_int(int(outcome.remaining_dialog_choices)).is_equal(0) + + +func test_break_two_exits_two_dialog_levels() -> void: + # `break 2` from the innermost dialog should move back up two dialog levels + # to the outermost dialog in this fixture shape. As with `break 1`, the test + # also verifies that the final scripted outer choice was actually consumed, + # which proves the outer dialog stayed alive after the nested unwind. + var outcome := await _interpret_fixture( + "break_two_exits_two_dialog_levels.esc", + [0, 0, 0, 1] + ) + assert_bool(outcome.globals.has("result")).is_true() + assert_str(String(outcome.globals["result"])).is_equal("start-done") + assert_int(int(outcome.dialog_choices_consumed)).is_equal(4) + assert_int(int(outcome.remaining_dialog_choices)).is_equal(0) + + +func test_stop_in_dialog_option_prevents_later_event_statements() -> void: + # `stop` issued from a dialog option should abort the entire event, not just + # the current dialog. The assignment after the dialog must therefore never run. + var outcome := await _interpret_fixture( + "stop_in_dialog_option.esc", + [0] + ) + assert_bool(outcome.globals.has("result")).is_true() + assert_float(float(outcome.globals["result"])).is_equal(0.0) + + +func test_delayed_command_in_dialog_option_preserves_ordering() -> void: + # A blocking command inside a dialog option must complete before the option + # body resumes and before execution continues after the dialog. If it does + # not block correctly, the final append would observe `unset` instead of the + # command-written value. + var outcome := await _interpret_fixture( + "delayed_command_in_dialog_option.esc", + [0] + ) + assert_bool(outcome.globals.has("result")).is_true() + assert_str(String(outcome.globals["result"])).is_equal("cmd-after") + + +func test_delayed_command_in_loop_preserves_ordering() -> void: + # A blocking command inside a loop body must finish before the loop can + # continue to the next statement. The later append therefore proves the + # delayed command completed before the event resumed after the loop. + var outcome := await _interpret_fixture("delayed_command_in_loop.esc") + assert_bool(outcome.globals.has("result")).is_true() + assert_str(String(outcome.globals["result"])).is_equal("cmd-after") + assert_bool(outcome.globals.has("count")).is_true() + assert_float(float(outcome.globals["count"])).is_equal(1.0) + + +func test_interrupting_delayed_command_stops_event_progress() -> void: + # Interrupting an in-flight command should stop the surrounding event from + # resuming normal execution. The command records that it observed the + # interrupt, and the later append must never run. + var source := _load_fixture("delayed_command_interrupt.esc") + var compiler := ESCCompiler.new() + var scanner := ESCScanner.new() + scanner.set_source(source) + scanner.set_filename(_fixture_path("delayed_command_interrupt.esc")) + + var tokens: Array = scanner.scan_tokens() + var parser := ESCParser.new() + parser.init(compiler, tokens, "") + + var statements := parser.parse() + assert_bool(compiler.had_error).is_false() + assert_int(statements.size()).is_equal(1) + + var interpreter := ESCInterpreter.new(ESCCompiler.load_commands(), {}) + var resolver := ESCResolver.new(interpreter) + resolver.resolve(statements) + + assert_object(statements[0]).is_instanceof(ESCGrammarStmts.Event) + var event_stmt: ESCGrammarStmts.Event = statements[0] + interpreter.call_deferred("interpret", statements) + await escoria.get_tree().create_timer(0.05).timeout + interpreter.interrupt() + await event_stmt.finished + + var globals := interpreter.get_global_values() + assert_bool(globals.has("result")).is_true() + assert_str(String(globals["result"])).is_equal("start") + assert_bool(globals.has("was_interrupted")).is_true() + assert_bool(bool(globals["was_interrupted"])).is_true() + assert_bool(event_stmt.is_interrupted()).is_true() + + interpreter.cleanup() + + +func test_interrupting_delayed_command_in_dialog_stops_event_progress() -> void: + # Interrupting an in-flight command inside a dialog option should abort the + # surrounding event just as cleanly as interruption in a plain block. The + # dialog must not resume and the post-dialog append must never run. + var source := _load_fixture("delayed_command_interrupt_in_dialog.esc") + var compiler := ESCCompiler.new() + var scanner := ESCScanner.new() + scanner.set_source(source) + scanner.set_filename(_fixture_path("delayed_command_interrupt_in_dialog.esc")) + + var tokens: Array = scanner.scan_tokens() + var parser := ESCParser.new() + parser.init(compiler, tokens, "") + + var statements := parser.parse() + assert_bool(compiler.had_error).is_false() + assert_int(statements.size()).is_equal(1) + + var interpreter := ESCInterpreter.new(ESCCompiler.load_commands(), {}) + var resolver := ESCResolver.new(interpreter) + resolver.resolve(statements) + var dialog_player := DialogPlayerDouble.new([0]) + escoria.set("dialog_player", dialog_player) + + assert_object(statements[0]).is_instanceof(ESCGrammarStmts.Event) + var event_stmt: ESCGrammarStmts.Event = statements[0] + interpreter.call_deferred("interpret", statements) + await escoria.get_tree().create_timer(0.05).timeout + interpreter.interrupt() + await event_stmt.finished + + var globals := interpreter.get_global_values() + assert_bool(globals.has("result")).is_true() + assert_str(String(globals["result"])).is_equal("start") + assert_bool(globals.has("was_interrupted")).is_true() + assert_bool(bool(globals["was_interrupted"])).is_true() + assert_bool(event_stmt.is_interrupted()).is_true() + + _cleanup_dialog_test(interpreter, dialog_player) + + +func test_interrupting_delayed_command_stops_multi_iteration_loop() -> void: + # Interrupting a delayed command in the first loop iteration should stop the + # loop itself, not just the current statement. The counter must therefore + # stay at zero and the post-loop append must never run. + var source := _load_fixture("delayed_command_interrupt_in_multi_loop.esc") + var compiler := ESCCompiler.new() + var scanner := ESCScanner.new() + scanner.set_source(source) + scanner.set_filename(_fixture_path("delayed_command_interrupt_in_multi_loop.esc")) + + var tokens: Array = scanner.scan_tokens() + var parser := ESCParser.new() + parser.init(compiler, tokens, "") + + var statements := parser.parse() + assert_bool(compiler.had_error).is_false() + assert_int(statements.size()).is_equal(1) + + var interpreter := ESCInterpreter.new(ESCCompiler.load_commands(), {}) + var resolver := ESCResolver.new(interpreter) + resolver.resolve(statements) + + assert_object(statements[0]).is_instanceof(ESCGrammarStmts.Event) + var event_stmt: ESCGrammarStmts.Event = statements[0] + interpreter.call_deferred("interpret", statements) + await escoria.get_tree().create_timer(0.05).timeout + interpreter.interrupt() + await event_stmt.finished + + var globals := interpreter.get_global_values() + assert_bool(globals.has("result")).is_true() + assert_str(String(globals["result"])).is_equal("start") + assert_bool(globals.has("was_interrupted")).is_true() + assert_bool(bool(globals["was_interrupted"])).is_true() + assert_bool(globals.has("count")).is_true() + assert_float(float(globals["count"])).is_equal(0.0) + assert_bool(event_stmt.is_interrupted()).is_true() + + interpreter.cleanup() + + +func test_invalid_command_does_not_continue_event() -> void: + # A missing command should fail the current event cleanly instead of letting + # later statements continue as if the command had succeeded. + var outcome := await _interpret_fixture("invalid_command_does_not_continue.esc") + assert_bool(outcome.globals.has("result")).is_true() + assert_str(String(outcome.globals["result"])).is_equal("start") + + +func test_invalid_command_in_dialog_does_not_continue_event() -> void: + # A missing command inside a dialog option should still terminate the event + # cleanly. The assignment after the dialog must not run. + var outcome := await _interpret_fixture( + "invalid_command_in_dialog_stops_event.esc", + [0] + ) + assert_bool(outcome.globals.has("result")).is_true() + assert_str(String(outcome.globals["result"])).is_equal("start") + + +func test_invalid_command_in_loop_does_not_continue_event() -> void: + # A missing command inside a loop body should escape the loop as a terminal + # runtime error instead of allowing later loop or post-loop statements to run. + var outcome := await _interpret_fixture("invalid_command_in_loop_stops_event.esc") + assert_bool(outcome.globals.has("result")).is_true() + assert_str(String(outcome.globals["result"])).is_equal("start") + assert_bool(outcome.globals.has("count")).is_true() + assert_float(float(outcome.globals["count"])).is_equal(0.0) + + +func test_script_preserves_multi_target_use_events_without_creating_untargeted_use() -> void: + # Room05-style scripts can define the same action multiple times with + # different targets. Both targeted events must survive compilation, while an + # untargeted lookup for the same action must not accidentally match either. + var script_object := _compile_fixture_script("multi_target_use_events.esc") + + assert_bool(script_object.has_event_with_target("use", "r5_wrench")).is_true() + assert_bool(script_object.has_event_with_target("use", "r5_pen")).is_true() + assert_bool(script_object.has_event_with_target("use")).is_false() + + var wrench_event = script_object.get_event_with_target("use", "r5_wrench") + var pen_event = script_object.get_event_with_target("use", "r5_pen") + + assert_object(wrench_event).is_not_null() + assert_object(pen_event).is_not_null() + assert_object(wrench_event).is_not_same(pen_event) + assert_str(wrench_event.get_target_name()).is_equal("r5_wrench") + assert_str(pen_event.get_target_name()).is_equal("r5_pen") + assert_int(wrench_event.get_num_statements_in_block()).is_equal(1) + assert_int(pen_event.get_num_statements_in_block()).is_equal(1) + + +func test_dialog_option_scope_shadowing_restores_outer_local() -> void: + # A dialog option body introduces nested block scope. Assignments inside the + # option should see the inner shadowing variable, while execution after the + # dialog must still resolve the original outer local. + var outcome := await _interpret_fixture( + "dialog_scope_shadowing.esc", + [0] + ) + assert_bool(outcome.globals.has("result")).is_true() + assert_str(String(outcome.globals["result"])).is_equal("inner:outer") + + +func test_interrupting_background_channel_does_not_interrupt_front_channel() -> void: + # Background-channel interruption should only affect commands running on + # that channel. The front event must still complete normally while the + # background event should finish as interrupted once its in-flight command + # observes the interrupt. + var events = _load_fixture_events("channel_interrupt_isolation.esc") + assert_bool(events.has_event_with_target("front_event")).is_true() + assert_bool(events.has_event_with_target("background_event")).is_true() + escoria.globals_manager.set_global("channel_front_result", "start") + escoria.globals_manager.set_global("channel_background_result", "start") + escoria.globals_manager.set_global("channel_front_was_interrupted", false) + escoria.globals_manager.set_global("channel_background_was_interrupted", false) + + var signal_results := { + "front": null, + "background": null, + } + var on_front_finished := func(return_code, event_name) -> void: + if event_name == "front_event": + signal_results["front"] = [return_code, event_name] + var on_background_finished := func(return_code, event_name, finished_channel_name) -> void: + if event_name == "background_event" and finished_channel_name == "bg_test": + signal_results["background"] = [return_code, event_name, finished_channel_name] + + escoria.event_manager.event_finished.connect(on_front_finished) + escoria.event_manager.background_event_finished.connect(on_background_finished) + + escoria.event_manager.queue_event(events.get_event_with_target("front_event")) + escoria.event_manager.queue_background_event( + "bg_test", + events.get_event_with_target("background_event") + ) + + await escoria.get_tree().create_timer(0.05).timeout + escoria.event_manager.interrupt_channel("bg_test") + + while signal_results["front"] == null or signal_results["background"] == null: + await escoria.get_tree().process_frame + + var front_finished = signal_results["front"] + var background_finished = signal_results["background"] + + assert_int(int(front_finished[0])).is_equal(ESCExecution.RC_OK) + assert_int(int(background_finished[0])).is_equal(ESCExecution.RC_INTERRUPTED) + assert_str(String(escoria.globals_manager.get_global("channel_front_result"))).is_equal("front-cmd-after") + assert_bool(escoria.globals_manager.get_global("channel_front_was_interrupted") == true).is_false() + assert_str(String(escoria.globals_manager.get_global("channel_background_result"))).is_equal("start") + assert_bool(escoria.globals_manager.get_global("channel_background_was_interrupted") == true).is_true() + + escoria.event_manager.event_finished.disconnect(on_front_finished) + escoria.event_manager.background_event_finished.disconnect(on_background_finished) + + +func test_concurrent_channels_complete_without_interrupting_each_other() -> void: + # Two events on different channels should be able to run at the same time + # and still complete normally when no interrupt is issued. + var events = _load_fixture_events("channel_interrupt_isolation.esc") + assert_bool(events.has_event_with_target("front_event")).is_true() + assert_bool(events.has_event_with_target("background_event")).is_true() + escoria.globals_manager.set_global("channel_front_result", "start") + escoria.globals_manager.set_global("channel_background_result", "start") + escoria.globals_manager.set_global("channel_front_was_interrupted", false) + escoria.globals_manager.set_global("channel_background_was_interrupted", false) + + var signal_results := { + "front": null, + "background": null, + } + var on_front_finished := func(return_code, event_name) -> void: + if event_name == "front_event": + signal_results["front"] = [return_code, event_name] + var on_background_finished := func(return_code, event_name, finished_channel_name) -> void: + if event_name == "background_event" and finished_channel_name == "bg_test": + signal_results["background"] = [return_code, event_name, finished_channel_name] + + escoria.event_manager.event_finished.connect(on_front_finished) + escoria.event_manager.background_event_finished.connect(on_background_finished) + + escoria.event_manager.queue_event(events.get_event_with_target("front_event")) + escoria.event_manager.queue_background_event( + "bg_test", + events.get_event_with_target("background_event") + ) + + while signal_results["front"] == null or signal_results["background"] == null: + await escoria.get_tree().process_frame + + var front_finished = signal_results["front"] + var background_finished = signal_results["background"] + + assert_int(int(front_finished[0])).is_equal(ESCExecution.RC_OK) + assert_int(int(background_finished[0])).is_equal(ESCExecution.RC_OK) + assert_str(String(escoria.globals_manager.get_global("channel_front_result"))).is_equal("front-cmd-after") + assert_bool(escoria.globals_manager.get_global("channel_front_was_interrupted") == true).is_false() + assert_str(String(escoria.globals_manager.get_global("channel_background_result"))).is_equal("bg-cmd-after") + assert_bool(escoria.globals_manager.get_global("channel_background_was_interrupted") == true).is_false() + + escoria.event_manager.event_finished.disconnect(on_front_finished) + escoria.event_manager.background_event_finished.disconnect(on_background_finished) + + +func test_interrupting_channel_clears_queued_successor_events() -> void: + # Interrupting a channel should stop the currently running event and purge + # later queued events on that same channel before they ever start running. + var events = _load_fixture_events("channel_interrupt_clears_queue.esc") + assert_bool(events.has_event_with_target("first")).is_true() + assert_bool(events.has_event_with_target("second")).is_true() + escoria.globals_manager.set_global("bg_queue_interrupted", false) + + var background_finishes: Array = [] + var on_background_finished := func(return_code, event_name, finished_channel_name) -> void: + if finished_channel_name == "bg_test": + background_finishes.append([return_code, event_name, finished_channel_name]) + + escoria.event_manager.background_event_finished.connect(on_background_finished) + + escoria.event_manager.queue_background_event("bg_test", events.get_event_with_target("first")) + escoria.event_manager.queue_background_event("bg_test", events.get_event_with_target("second")) + + await escoria.get_tree().create_timer(0.05).timeout + escoria.event_manager.interrupt_channel("bg_test") + + while background_finishes.is_empty(): + await escoria.get_tree().process_frame + + assert_int(background_finishes.size()).is_equal(1) + assert_int(int(background_finishes[0][0])).is_equal(ESCExecution.RC_INTERRUPTED) + assert_str(String(background_finishes[0][1])).is_equal("first") + assert_str(String(background_finishes[0][2])).is_equal("bg_test") + assert_str(String(escoria.globals_manager.get_global("bg_queue_result"))).is_equal("first") + assert_bool(escoria.globals_manager.get_global("bg_queue_interrupted") == true).is_true() + + escoria.event_manager.background_event_finished.disconnect(on_background_finished) + + +func test_concurrent_channels_do_not_block_other_channel_queues() -> void: + # Each channel should serialize only its own queue. A front-channel event + # must complete independently while a background channel works through two + # queued events in order. + var events = _load_fixture_events("channel_queue_progress_isolated.esc") + assert_bool(events.has_event_with_target("front_event")).is_true() + assert_bool(events.has_event_with_target("background_first")).is_true() + assert_bool(events.has_event_with_target("background_second")).is_true() + escoria.globals_manager.set_global("isolated_front_result", "start") + escoria.globals_manager.set_global("isolated_background_result", "start") + escoria.globals_manager.set_global("isolated_front_interrupted", false) + escoria.globals_manager.set_global("isolated_background_interrupted", false) + + var signal_results := { + "front": null, + "background_finishes": [], + } + var on_front_finished := func(return_code, event_name) -> void: + if event_name == "front_event": + signal_results["front"] = [return_code, event_name] + var on_background_finished := func(return_code, event_name, finished_channel_name) -> void: + if finished_channel_name == "bg_test": + signal_results["background_finishes"].append([return_code, event_name, finished_channel_name]) + + escoria.event_manager.event_finished.connect(on_front_finished) + escoria.event_manager.background_event_finished.connect(on_background_finished) + + escoria.event_manager.queue_event(events.get_event_with_target("front_event")) + escoria.event_manager.queue_background_event("bg_test", events.get_event_with_target("background_first")) + escoria.event_manager.queue_background_event("bg_test", events.get_event_with_target("background_second")) + + while signal_results["front"] == null or signal_results["background_finishes"].size() < 2: + await escoria.get_tree().process_frame + + var front_finished = signal_results["front"] + var background_finishes: Array = signal_results["background_finishes"] + + assert_int(int(front_finished[0])).is_equal(ESCExecution.RC_OK) + assert_int(int(background_finishes[0][0])).is_equal(ESCExecution.RC_OK) + assert_str(String(background_finishes[0][1])).is_equal("background_first") + assert_int(int(background_finishes[1][0])).is_equal(ESCExecution.RC_OK) + assert_str(String(background_finishes[1][1])).is_equal("background_second") + assert_str(String(escoria.globals_manager.get_global("isolated_front_result"))).is_equal("front-cmd-after") + assert_bool(escoria.globals_manager.get_global("isolated_front_interrupted") == true).is_false() + assert_str(String(escoria.globals_manager.get_global("isolated_background_result"))).is_equal("first-cmd-after-first-second") + assert_bool(escoria.globals_manager.get_global("isolated_background_interrupted") == true).is_false() + + escoria.event_manager.event_finished.disconnect(on_front_finished) + escoria.event_manager.background_event_finished.disconnect(on_background_finished) + + +func test_global_interrupt_stops_running_channels_and_clears_queues() -> void: + # A global interrupt should stop every currently running channel and purge + # any queued successor events before they start. This fixture queues one + # running and one queued event on both `_front` and `bg_test`. + var events = _load_fixture_events("global_interrupt_clears_all_channels.esc") + assert_bool(events.has_event_with_target("front_running")).is_true() + assert_bool(events.has_event_with_target("front_queued")).is_true() + assert_bool(events.has_event_with_target("background_running")).is_true() + assert_bool(events.has_event_with_target("background_queued")).is_true() + escoria.globals_manager.set_global("global_interrupt_front_result", "start") + escoria.globals_manager.set_global("global_interrupt_background_result", "start") + escoria.globals_manager.set_global("global_interrupt_front_was_interrupted", false) + escoria.globals_manager.set_global("global_interrupt_background_was_interrupted", false) + + var signal_results := { + "front_finishes": [], + "background_finishes": [], + } + var on_front_finished := func(return_code, event_name) -> void: + signal_results["front_finishes"].append([return_code, event_name]) + var on_background_finished := func(return_code, event_name, finished_channel_name) -> void: + if finished_channel_name == "bg_test": + signal_results["background_finishes"].append([return_code, event_name, finished_channel_name]) + + escoria.event_manager.event_finished.connect(on_front_finished) + escoria.event_manager.background_event_finished.connect(on_background_finished) + + escoria.event_manager.queue_event(events.get_event_with_target("front_running")) + escoria.event_manager.queue_event(events.get_event_with_target("front_queued")) + escoria.event_manager.queue_background_event("bg_test", events.get_event_with_target("background_running")) + escoria.event_manager.queue_background_event("bg_test", events.get_event_with_target("background_queued")) + + await escoria.get_tree().create_timer(0.05).timeout + escoria.event_manager.interrupt() + + while signal_results["front_finishes"].is_empty() or signal_results["background_finishes"].is_empty(): + await escoria.get_tree().process_frame + + await escoria.get_tree().create_timer(0.05).timeout + + assert_int(signal_results["front_finishes"].size()).is_equal(1) + assert_int(int(signal_results["front_finishes"][0][0])).is_equal(ESCExecution.RC_INTERRUPTED) + assert_str(String(signal_results["front_finishes"][0][1])).is_equal("front_running") + assert_int(signal_results["background_finishes"].size()).is_equal(1) + assert_int(int(signal_results["background_finishes"][0][0])).is_equal(ESCExecution.RC_INTERRUPTED) + assert_str(String(signal_results["background_finishes"][0][1])).is_equal("background_running") + assert_str(String(signal_results["background_finishes"][0][2])).is_equal("bg_test") + assert_str(String(escoria.globals_manager.get_global("global_interrupt_front_result"))).is_equal("start") + assert_bool(escoria.globals_manager.get_global("global_interrupt_front_was_interrupted") == true).is_true() + assert_str(String(escoria.globals_manager.get_global("global_interrupt_background_result"))).is_equal("start") + assert_bool(escoria.globals_manager.get_global("global_interrupt_background_was_interrupted") == true).is_true() + + escoria.event_manager.event_finished.disconnect(on_front_finished) + escoria.event_manager.background_event_finished.disconnect(on_background_finished) + + +func test_queue_event_block_waits_for_correct_background_event_under_concurrency() -> void: + # Blocking queue_event waits must ignore unrelated background completions, + # including earlier events on the same channel, and return only when the + # specifically requested background event finishes. + var script_object := _compile_fixture_script("queue_event_block_waits_for_correct_background_event.esc") + assert_bool(script_object.has_event_with_target("background_first")).is_true() + assert_bool(script_object.has_event_with_target("background_target")).is_true() + assert_bool(script_object.has_event_with_target("front_event")).is_true() + escoria.globals_manager.set_global("block_wait_result", "start") + escoria.globals_manager.set_global("block_wait_front_result", "start") + + escoria.event_manager.queue_background_event( + "bg_test", + script_object.get_event_with_target("background_first") + ) + escoria.event_manager.queue_event(script_object.get_event_with_target("front_event")) + + var block_rc: int = await escoria.event_manager.queue_event_from_esc( + script_object, + "background_target", + "bg_test", + true + ) + + assert_int(block_rc).is_equal(ESCExecution.RC_OK) + assert_str(String(escoria.globals_manager.get_global("block_wait_result"))).is_equal("first-target") + assert_str(String(escoria.globals_manager.get_global("block_wait_front_result"))).is_equal("front") + + +func test_queue_event_block_waits_for_correct_front_event_under_concurrency() -> void: + # Blocking front queue_event waits must ignore both background completions + # and earlier foreground events, returning only when the specifically + # requested front event finishes. + var script_object := _compile_fixture_script("queue_event_block_waits_for_correct_front_event.esc") + assert_bool(script_object.has_event_with_target("front_first")).is_true() + assert_bool(script_object.has_event_with_target("front_target")).is_true() + assert_bool(script_object.has_event_with_target("background_event")).is_true() + escoria.globals_manager.set_global("front_block_wait_result", "start") + escoria.globals_manager.set_global("front_block_wait_background_result", "start") + + escoria.event_manager.queue_event(script_object.get_event_with_target("front_first")) + escoria.event_manager.queue_background_event( + "bg_test", + script_object.get_event_with_target("background_event") + ) + + var block_rc: int = await escoria.event_manager.queue_event_from_esc( + script_object, + "front_target", + "_front", + true + ) + + assert_int(block_rc).is_equal(ESCExecution.RC_OK) + assert_str(String(escoria.globals_manager.get_global("front_block_wait_result"))).is_equal("first-target") + assert_str(String(escoria.globals_manager.get_global("front_block_wait_background_result"))).is_equal("background") + + +func test_sequential_front_events_preserve_globals_between_runtime_interpreters() -> void: + # Each event-manager run currently observes globals written by earlier runs. + # The ownership refactor must preserve that user-visible behavior even though + # the underlying interpreter instances will stop sharing a singleton slot. + var events = _load_fixture_events("interpreter_runtime_state_persists_between_events.esc") + assert_bool(events.has_event_with_target("first")).is_true() + assert_bool(events.has_event_with_target("second")).is_true() + + var front_finishes: Array = [] + var on_front_finished := func(return_code, event_name) -> void: + front_finishes.append([return_code, event_name]) + + escoria.event_manager.event_finished.connect(on_front_finished) + + escoria.event_manager.queue_event(events.get_event_with_target("first")) + while front_finishes.size() < 1: + await escoria.get_tree().process_frame + + escoria.event_manager.queue_event(events.get_event_with_target("second")) + while front_finishes.size() < 2: + await escoria.get_tree().process_frame + + assert_int(front_finishes.size()).is_equal(2) + assert_int(int(front_finishes[0][0])).is_equal(ESCExecution.RC_OK) + assert_str(String(front_finishes[0][1])).is_equal("first") + assert_int(int(front_finishes[1][0])).is_equal(ESCExecution.RC_OK) + assert_str(String(front_finishes[1][1])).is_equal("second") + assert_str(String(escoria.globals_manager.get_global("interpreter_runtime_result"))).is_equal("first-second") + + escoria.event_manager.event_finished.disconnect(on_front_finished) + + +func test_background_event_sees_globals_written_by_previous_front_event() -> void: + # Front and background channels each create their own runtime interpreter. + # A background event queued after a front event must still observe globals + # written by that earlier front-channel execution. + var events = _load_fixture_events("interpreter_runtime_state_persists_between_events.esc") + assert_bool(events.has_event_with_target("background_seed")).is_true() + assert_bool(events.has_event_with_target("background_after")).is_true() + + var front_finishes: Array = [] + var background_finishes: Array = [] + var on_front_finished := func(return_code, event_name) -> void: + front_finishes.append([return_code, event_name]) + var on_background_finished := func(return_code, event_name, finished_channel_name) -> void: + if finished_channel_name == "bg_test": + background_finishes.append([return_code, event_name, finished_channel_name]) + + escoria.event_manager.event_finished.connect(on_front_finished) + escoria.event_manager.background_event_finished.connect(on_background_finished) + + escoria.event_manager.queue_event(events.get_event_with_target("background_seed")) + while front_finishes.size() < 1: + await escoria.get_tree().process_frame + + escoria.event_manager.queue_background_event("bg_test", events.get_event_with_target("background_after")) + while background_finishes.size() < 1: + await escoria.get_tree().process_frame + + assert_int(front_finishes.size()).is_equal(1) + assert_int(int(front_finishes[0][0])).is_equal(ESCExecution.RC_OK) + assert_str(String(front_finishes[0][1])).is_equal("background_seed") + assert_int(background_finishes.size()).is_equal(1) + assert_int(int(background_finishes[0][0])).is_equal(ESCExecution.RC_OK) + assert_str(String(background_finishes[0][1])).is_equal("background_after") + assert_str(String(background_finishes[0][2])).is_equal("bg_test") + assert_str(String(escoria.globals_manager.get_global("interpreter_runtime_background_result"))).is_equal("first-background") + + escoria.event_manager.event_finished.disconnect(on_front_finished) + escoria.event_manager.background_event_finished.disconnect(on_background_finished) + + +func test_running_interpreter_sees_new_global_defined_by_other_channel() -> void: + # A runtime interpreter that is already running must observe globals declared + # by a concurrent channel after it has started. Otherwise overlapping events + # can regress from shared-global behavior to "Undefined variable" failures. + var events = _load_fixture_events("concurrent_new_global_visibility.esc") + assert_bool(events.has_event_with_target("front_waits_for_new_global")).is_true() + assert_bool(events.has_event_with_target("background_declares_new_global")).is_true() + + var front_finishes: Array = [] + var background_finishes: Array = [] + var on_front_finished := func(return_code, event_name) -> void: + front_finishes.append([return_code, event_name]) + var on_background_finished := func(return_code, event_name, finished_channel_name) -> void: + if finished_channel_name == "bg_test": + background_finishes.append([return_code, event_name, finished_channel_name]) + + escoria.event_manager.event_finished.connect(on_front_finished) + escoria.event_manager.background_event_finished.connect(on_background_finished) + + escoria.event_manager.queue_event(events.get_event_with_target("front_waits_for_new_global")) + await escoria.get_tree().create_timer(0.05).timeout + escoria.event_manager.queue_background_event("bg_test", events.get_event_with_target("background_declares_new_global")) + + while front_finishes.is_empty() or background_finishes.is_empty(): + await escoria.get_tree().process_frame + + assert_int(int(front_finishes[0][0])).is_equal(ESCExecution.RC_OK) + assert_str(str(front_finishes[0][1])).is_equal("front_waits_for_new_global") + assert_int(int(background_finishes[0][0])).is_equal(ESCExecution.RC_OK) + assert_str(str(background_finishes[0][1])).is_equal("background_declares_new_global") + assert_str(str(escoria.globals_manager.get_global("concurrent_visibility_result"))).is_equal("from-background") + + escoria.event_manager.event_finished.disconnect(on_front_finished) + escoria.event_manager.background_event_finished.disconnect(on_background_finished) + + +func test_queue_background_event_allows_consecutive_duplicate_queue_entries() -> void: + # Queueing the same background event twice in a row should be allowed on a + # background channel. In practice these queues are populated via ASHES + # commands, so the second request should remain queued behind the first run. + var events = _load_fixture_events("background_duplicate_suppression.esc") + assert_bool(events.has_event_with_target("repeat_event")).is_true() + escoria.globals_manager.set_global("background_duplicate_count", 0) + escoria.globals_manager.set_global("background_duplicate_last", "start") + + var background_finishes: Array = [] + var on_background_finished := func(return_code, event_name, finished_channel_name) -> void: + if finished_channel_name == "bg_test": + background_finishes.append([return_code, event_name, finished_channel_name]) + + escoria.event_manager.background_event_finished.connect(on_background_finished) + + escoria.event_manager.queue_background_event("bg_test", events.get_event_with_target("repeat_event")) + escoria.event_manager.queue_background_event("bg_test", events.get_event_with_target("repeat_event")) + + while background_finishes.size() < 2: + await escoria.get_tree().process_frame + + assert_int(background_finishes.size()).is_equal(2) + assert_int(int(background_finishes[0][0])).is_equal(ESCExecution.RC_OK) + assert_str(String(background_finishes[0][1])).is_equal("repeat_event") + assert_str(String(background_finishes[0][2])).is_equal("bg_test") + assert_int(int(background_finishes[1][0])).is_equal(ESCExecution.RC_OK) + assert_str(String(background_finishes[1][1])).is_equal("repeat_event") + assert_str(String(background_finishes[1][2])).is_equal("bg_test") + assert_int(int(escoria.globals_manager.get_global("background_duplicate_count"))).is_equal(2) + assert_str(String(escoria.globals_manager.get_global("background_duplicate_last"))).is_equal("tick") + + escoria.event_manager.background_event_finished.disconnect(on_background_finished) + + +func test_queue_background_event_allows_same_event_after_current_run_starts() -> void: + # Once the first background event is already running, queueing the same event + # again on that channel should queue a second run behind it instead of being + # suppressed as a duplicate. + var events = _load_fixture_events("background_duplicate_suppression.esc") + assert_bool(events.has_event_with_target("repeat_event")).is_true() + escoria.globals_manager.set_global("background_duplicate_count", 0) + escoria.globals_manager.set_global("background_duplicate_last", "start") + + var background_finishes: Array = [] + var on_background_finished := func(return_code, event_name, finished_channel_name) -> void: + if finished_channel_name == "bg_test": + background_finishes.append([return_code, event_name, finished_channel_name]) + + escoria.event_manager.background_event_finished.connect(on_background_finished) + + escoria.event_manager.queue_background_event("bg_test", events.get_event_with_target("repeat_event")) + await escoria.get_tree().create_timer(0.01).timeout + escoria.event_manager.queue_background_event("bg_test", events.get_event_with_target("repeat_event")) + + while background_finishes.size() < 2: + await escoria.get_tree().process_frame + + assert_int(background_finishes.size()).is_equal(2) + assert_int(int(background_finishes[0][0])).is_equal(ESCExecution.RC_OK) + assert_str(String(background_finishes[0][1])).is_equal("repeat_event") + assert_int(int(background_finishes[1][0])).is_equal(ESCExecution.RC_OK) + assert_str(String(background_finishes[1][1])).is_equal("repeat_event") + assert_int(int(escoria.globals_manager.get_global("background_duplicate_count"))).is_equal(2) + assert_str(String(escoria.globals_manager.get_global("background_duplicate_last"))).is_equal("tick") + + escoria.event_manager.background_event_finished.disconnect(on_background_finished) + + +func test_global_interrupt_preserves_exception_events() -> void: + # `interrupt(exceptions=...)` should leave only the named running and queued + # events alive. Here the front running event and one queued background event + # are excepted, while the other front/background events must be cleared. + var events = _load_fixture_events("global_interrupt_preserves_exceptions.esc") + assert_bool(events.has_event_with_target("front_keep")).is_true() + assert_bool(events.has_event_with_target("front_drop")).is_true() + assert_bool(events.has_event_with_target("background_drop")).is_true() + assert_bool(events.has_event_with_target("background_keep")).is_true() + escoria.globals_manager.set_global("interrupt_except_front_result", "start") + escoria.globals_manager.set_global("interrupt_except_front_interrupted", false) + escoria.globals_manager.set_global("interrupt_except_background_result", "start") + escoria.globals_manager.set_global("interrupt_except_background_interrupted", false) + escoria.globals_manager.set_global("interrupt_except_background_keep_result", "start") + + var signal_results := { + "front_finishes": [], + "background_finishes": [], + } + var on_front_finished := func(return_code, event_name) -> void: + signal_results["front_finishes"].append([return_code, event_name]) + var on_background_finished := func(return_code, event_name, finished_channel_name) -> void: + if finished_channel_name == "bg_test": + signal_results["background_finishes"].append([return_code, event_name, finished_channel_name]) + + escoria.event_manager.event_finished.connect(on_front_finished) + escoria.event_manager.background_event_finished.connect(on_background_finished) + + escoria.event_manager.queue_event(events.get_event_with_target("front_keep")) + escoria.event_manager.queue_event(events.get_event_with_target("front_drop")) + escoria.event_manager.queue_background_event("bg_test", events.get_event_with_target("background_drop")) + escoria.event_manager.queue_background_event("bg_test", events.get_event_with_target("background_keep")) + + await escoria.get_tree().create_timer(0.05).timeout + escoria.event_manager.interrupt(PackedStringArray(["front_keep", "background_keep"])) + + while signal_results["front_finishes"].is_empty() or signal_results["background_finishes"].size() < 2: + await escoria.get_tree().process_frame + + assert_int(signal_results["front_finishes"].size()).is_equal(1) + assert_int(int(signal_results["front_finishes"][0][0])).is_equal(ESCExecution.RC_OK) + assert_str(String(signal_results["front_finishes"][0][1])).is_equal("front_keep") + assert_int(signal_results["background_finishes"].size()).is_equal(2) + assert_int(int(signal_results["background_finishes"][0][0])).is_equal(ESCExecution.RC_INTERRUPTED) + assert_str(String(signal_results["background_finishes"][0][1])).is_equal("background_drop") + assert_int(int(signal_results["background_finishes"][1][0])).is_equal(ESCExecution.RC_OK) + assert_str(String(signal_results["background_finishes"][1][1])).is_equal("background_keep") + assert_str(String(escoria.globals_manager.get_global("interrupt_except_front_result"))).is_equal("front-cmd-after") + assert_bool(escoria.globals_manager.get_global("interrupt_except_front_interrupted") == true).is_false() + assert_str(String(escoria.globals_manager.get_global("interrupt_except_background_result"))).is_equal("start") + assert_bool(escoria.globals_manager.get_global("interrupt_except_background_interrupted") == true).is_true() + assert_str(String(escoria.globals_manager.get_global("interrupt_except_background_keep_result"))).is_equal("bg-keep-after") + + escoria.event_manager.event_finished.disconnect(on_front_finished) + escoria.event_manager.background_event_finished.disconnect(on_background_finished) + + +func test_scheduled_front_event_runs_after_current_front_event_while_background_is_busy() -> void: + # Scheduled events always feed the `_front` queue. They should still dispatch + # while a background channel is busy, but must wait for the current front + # event to finish before the scheduled front event runs. + var events = _load_fixture_events("scheduled_front_event_with_busy_background.esc") + assert_bool(events.has_event_with_target("front_running")).is_true() + assert_bool(events.has_event_with_target("scheduled_front")).is_true() + assert_bool(events.has_event_with_target("background_running")).is_true() + escoria.globals_manager.set_global("scheduled_front_result", "start") + escoria.globals_manager.set_global("scheduled_front_interrupted", false) + escoria.globals_manager.set_global("scheduled_background_result", "start") + escoria.globals_manager.set_global("scheduled_background_interrupted", false) + + var signal_results := { + "front_finishes": [], + "background_finishes": [], + } + var on_front_finished := func(return_code, event_name) -> void: + signal_results["front_finishes"].append([return_code, event_name]) + var on_background_finished := func(return_code, event_name, finished_channel_name) -> void: + if finished_channel_name == "bg_test": + signal_results["background_finishes"].append([return_code, event_name, finished_channel_name]) + + escoria.event_manager.event_finished.connect(on_front_finished) + escoria.event_manager.background_event_finished.connect(on_background_finished) + + escoria.event_manager.queue_event(events.get_event_with_target("front_running")) + escoria.event_manager.queue_background_event("bg_test", events.get_event_with_target("background_running")) + escoria.event_manager.schedule_event(events.get_event_with_target("scheduled_front"), 0.05, "") + + while signal_results["front_finishes"].size() < 2 or signal_results["background_finishes"].is_empty(): + await escoria.get_tree().process_frame + + assert_int(signal_results["front_finishes"].size()).is_equal(2) + assert_int(int(signal_results["front_finishes"][0][0])).is_equal(ESCExecution.RC_OK) + assert_str(String(signal_results["front_finishes"][0][1])).is_equal("front_running") + assert_int(int(signal_results["front_finishes"][1][0])).is_equal(ESCExecution.RC_OK) + assert_str(String(signal_results["front_finishes"][1][1])).is_equal("scheduled_front") + assert_int(signal_results["background_finishes"].size()).is_equal(1) + assert_int(int(signal_results["background_finishes"][0][0])).is_equal(ESCExecution.RC_OK) + assert_str(String(signal_results["background_finishes"][0][1])).is_equal("background_running") + assert_str(String(escoria.globals_manager.get_global("scheduled_front_result"))).is_equal("front-running-after-scheduled") + assert_bool(escoria.globals_manager.get_global("scheduled_front_interrupted") == true).is_false() + assert_str(String(escoria.globals_manager.get_global("scheduled_background_result"))).is_equal("background-running-after") + assert_bool(escoria.globals_manager.get_global("scheduled_background_interrupted") == true).is_false() + + escoria.event_manager.event_finished.disconnect(on_front_finished) + escoria.event_manager.background_event_finished.disconnect(on_background_finished) + + +func test_global_interrupt_preserves_scheduled_events_before_dispatch() -> void: + # Global interrupt should stop the currently running event, but it should + # not clear scheduled events that have not yet been promoted into the front + # queue. The scheduled front event should therefore still dispatch and + # complete normally after the interrupted event finishes. + var events = _load_fixture_events("scheduled_event_interrupted_before_dispatch.esc") + assert_bool(events.has_event_with_target("front_running")).is_true() + assert_bool(events.has_event_with_target("scheduled_front")).is_true() + escoria.globals_manager.set_global("scheduled_interrupt_result", "start") + escoria.globals_manager.set_global("scheduled_interrupt_was_interrupted", false) + + var front_finishes: Array = [] + var on_front_finished := func(return_code, event_name) -> void: + front_finishes.append([return_code, event_name]) + + escoria.event_manager.event_finished.connect(on_front_finished) + + escoria.event_manager.queue_event(events.get_event_with_target("front_running")) + escoria.event_manager.schedule_event(events.get_event_with_target("scheduled_front"), 0.2, "") + + await escoria.get_tree().create_timer(0.05).timeout + escoria.event_manager.interrupt() + + while front_finishes.is_empty(): + await escoria.get_tree().process_frame + + await escoria.get_tree().create_timer(0.25).timeout + + assert_int(front_finishes.size()).is_equal(2) + assert_int(int(front_finishes[0][0])).is_equal(ESCExecution.RC_INTERRUPTED) + assert_str(String(front_finishes[0][1])).is_equal("front_running") + assert_int(int(front_finishes[1][0])).is_equal(ESCExecution.RC_OK) + assert_str(String(front_finishes[1][1])).is_equal("scheduled_front") + assert_str(String(escoria.globals_manager.get_global("scheduled_interrupt_result"))).is_equal("start-scheduled") + assert_bool(escoria.globals_manager.get_global("scheduled_interrupt_was_interrupted") == true).is_true() + + escoria.event_manager.event_finished.disconnect(on_front_finished) + + +func test_clear_event_queue_preserves_scheduled_events() -> void: + # Clearing the event queue should only remove events that are already queued + # for execution. Scheduled events live in a separate list until `_process()` + # promotes them into the front queue, so they should still dispatch after + # `clear_event_queue()` is called. + var events = _load_fixture_events("scheduled_event_interrupted_before_dispatch.esc") + assert_bool(events.has_event_with_target("scheduled_front")).is_true() + escoria.globals_manager.set_global("scheduled_interrupt_result", "start") + + var front_finishes: Array = [] + var on_front_finished := func(return_code, event_name) -> void: + front_finishes.append([return_code, event_name]) + + escoria.event_manager.event_finished.connect(on_front_finished) + + escoria.event_manager.schedule_event(events.get_event_with_target("scheduled_front"), 0.05, "") + escoria.event_manager.clear_event_queue() + + while front_finishes.is_empty(): + await escoria.get_tree().process_frame + + assert_int(front_finishes.size()).is_equal(1) + assert_int(int(front_finishes[0][0])).is_equal(ESCExecution.RC_OK) + assert_str(String(front_finishes[0][1])).is_equal("scheduled_front") + assert_str(String(escoria.globals_manager.get_global("scheduled_interrupt_result"))).is_equal("start-scheduled") + + escoria.event_manager.event_finished.disconnect(on_front_finished) + + +func test_interrupt_channel_does_not_affect_still_scheduled_front_events() -> void: + # A scheduled front event should be unaffected by `interrupt_channel()` for a + # background channel while it is still waiting in `scheduled_events`. Once it + # is later promoted into `_front`, it should run like any other front-queue + # event. + var events = _load_fixture_events("scheduled_front_event_with_busy_background.esc") + assert_bool(events.has_event_with_target("scheduled_front")).is_true() + assert_bool(events.has_event_with_target("background_running")).is_true() + escoria.globals_manager.set_global("scheduled_front_result", "start") + escoria.globals_manager.set_global("scheduled_front_interrupted", false) + escoria.globals_manager.set_global("scheduled_background_result", "start") + escoria.globals_manager.set_global("scheduled_background_interrupted", false) + + var signal_results := { + "front_finishes": [], + "background_finishes": [], + } + var on_front_finished := func(return_code, event_name) -> void: + signal_results["front_finishes"].append([return_code, event_name]) + var on_background_finished := func(return_code, event_name, finished_channel_name) -> void: + if finished_channel_name == "bg_test": + signal_results["background_finishes"].append([return_code, event_name, finished_channel_name]) + + escoria.event_manager.event_finished.connect(on_front_finished) + escoria.event_manager.background_event_finished.connect(on_background_finished) + + escoria.event_manager.queue_background_event("bg_test", events.get_event_with_target("background_running")) + escoria.event_manager.schedule_event(events.get_event_with_target("scheduled_front"), 0.2, "") + + await escoria.get_tree().create_timer(0.05).timeout + escoria.event_manager.interrupt_channel("bg_test") + + while signal_results["front_finishes"].is_empty() or signal_results["background_finishes"].is_empty(): + await escoria.get_tree().process_frame + + assert_int(signal_results["background_finishes"].size()).is_equal(1) + assert_int(int(signal_results["background_finishes"][0][0])).is_equal(ESCExecution.RC_INTERRUPTED) + assert_str(String(signal_results["background_finishes"][0][1])).is_equal("background_running") + assert_int(signal_results["front_finishes"].size()).is_equal(1) + assert_int(int(signal_results["front_finishes"][0][0])).is_equal(ESCExecution.RC_OK) + assert_str(String(signal_results["front_finishes"][0][1])).is_equal("scheduled_front") + assert_str(String(escoria.globals_manager.get_global("scheduled_front_result"))).is_equal("start-scheduled") + assert_bool(escoria.globals_manager.get_global("scheduled_front_interrupted") == true).is_false() + assert_str(String(escoria.globals_manager.get_global("scheduled_background_result"))).is_equal("start") + assert_bool(escoria.globals_manager.get_global("scheduled_background_interrupted") == true).is_true() + + escoria.event_manager.event_finished.disconnect(on_front_finished) + escoria.event_manager.background_event_finished.disconnect(on_background_finished) + + +func test_immediate_command_preserves_statement_ordering() -> void: + # A synchronous command should complete before the next statement runs. The + # trailing assignment observes the command's mutation and appends to it. + var outcome := await _interpret_fixture("immediate_command_ordering.esc") + var globals: Dictionary = outcome.globals + assert_bool(globals.has("result")).is_true() + assert_str(String(globals["result"])).is_equal("cmd-after") + + +func test_delayed_command_preserves_statement_ordering() -> void: + # An asynchronous command must also block statement sequencing. If the + # interpreter failed to await it, the trailing assignment would append to the + # pre-command value instead of the command-written one. + var outcome := await _interpret_fixture("delayed_command_ordering.esc") + var globals: Dictionary = outcome.globals + assert_bool(globals.has("result")).is_true() + assert_str(String(globals["result"])).is_equal("cmd-after") + + +func test_set_animations_updates_persisted_player_resource_on_repeat_calls() -> void: + # Room changes restore player animations from the reserved + # `ANIMATION_RESOURCES` global. Repeated `set_animations()` calls must update + # the stored path each time, otherwise the next room load re-applies a stale + # costume even if the live player was changed back already. + var previous_animations = escoria.globals_manager.get_global( + escoria.room_manager.GLOBAL_ANIMATION_RESOURCES + ) + var room := ESCRoom.new() + room.global_id = "test_room_issue_1226" + escoria.object_manager.set_current_room(room) + + var player := MARK_SCENE.instantiate() as ESCPlayer + player.global_id = "player" + escoria.object_manager.register_object( + ESCObject.new(player.global_id, player), + room, + true, + false + ) + escoria.globals_manager.set_global( + escoria.room_manager.GLOBAL_ANIMATION_RESOURCES, + {}, + true + ) + + var command := SetAnimationsCommand.new() + var jester_animations := "res://game/characters/mark/mark_animations_jester.tres" + var default_animations := "res://game/characters/mark/mark_animations.tres" + + assert_int(command.run([player.global_id, jester_animations])).is_equal(ESCExecution.RC_OK) + assert_int(command.run([player.global_id, default_animations])).is_equal(ESCExecution.RC_OK) + + var animations: Dictionary = escoria.globals_manager.get_global( + escoria.room_manager.GLOBAL_ANIMATION_RESOURCES + ) + assert_bool(animations.has(player.global_id)).is_true() + assert_str(String(animations[player.global_id])).is_equal(default_animations) + + var room_key := ESCRoomObjectsKey.new() + room_key.room_global_id = room.global_id + room_key.room_instance_id = room.get_instance_id() + escoria.object_manager.unregister_object_by_global_id(player.global_id, room_key) + escoria.globals_manager.set_global( + escoria.room_manager.GLOBAL_ANIMATION_RESOURCES, + previous_animations if previous_animations != null else {}, + true + ) + player.free() + room.free() + + +func test_set_speed_rejects_negative_values_but_accepts_zero() -> void: + var room := ESCRoom.new() + room.global_id = "test_room_set_speed_contract" + escoria.object_manager.set_current_room(room) + + var player := MARK_SCENE.instantiate() as ESCPlayer + player.global_id = "player" + escoria.object_manager.register_object( + ESCObject.new(player.global_id, player), + room, + true, + false + ) + + var command := SetSpeedCommand.new() + var initial_speed := player.speed + + assert_bool(command.validate([player.global_id, -1])).is_false() + assert_int(player.speed).is_equal(initial_speed) + + assert_bool(command.validate([player.global_id, 0])).is_true() + assert_int(command.run([player.global_id, 0])).is_equal(ESCExecution.RC_OK) + assert_int(player.speed).is_equal(0) + + var room_key := ESCRoomObjectsKey.new() + room_key.room_global_id = room.global_id + room_key.room_instance_id = room.get_instance_id() + escoria.object_manager.unregister_object_by_global_id(player.global_id, room_key) + player.free() + room.free() + + +func test_trigger_actions_select_targeted_events_for_activating_object() -> void: + var script_object := _compile_fixture_script("trigger_targeted_events_use_object_id.esc") + var room := ESCRoom.new() + room.global_id = "test_trigger_targeted_events_room" + escoria.object_manager.set_current_room(room) + + var trigger_node := Node2D.new() + var trigger_object := ESCObject.new("trigger_under_test", trigger_node) + trigger_object.events = script_object.events + escoria.object_manager.register_object(trigger_object, room, true, false) + + escoria.globals_manager.set_global("trigger_in_result", "unset", true) + escoria.globals_manager.set_global("trigger_out_result", "unset", true) + + var finished_events: Array = [] + var on_front_finished := func(return_code, event_name) -> void: + if return_code == ESCExecution.RC_OK: + finished_events.append(event_name) + + escoria.event_manager.event_finished.connect(on_front_finished) + + escoria.action_manager.do( + escoria.action_manager.ACTION.TRIGGER_IN, + [trigger_object.global_id, "player", "trigger_in"] + ) + + if not await _wait_for_finished_events(finished_events, 1, "targeted trigger_in"): + escoria.event_manager.event_finished.disconnect(on_front_finished) + _cleanup_registered_object(trigger_object, trigger_node, room) + return + + assert_str(String(escoria.globals_manager.get_global("trigger_in_result"))).is_equal( + "player-in" + ) + assert_str(String(finished_events[0])).is_equal("trigger_in") + + escoria.action_manager.do( + escoria.action_manager.ACTION.TRIGGER_OUT, + [trigger_object.global_id, "player", "trigger_out"] + ) + + if not await _wait_for_finished_events(finished_events, 2, "targeted trigger_out"): + escoria.event_manager.event_finished.disconnect(on_front_finished) + _cleanup_registered_object(trigger_object, trigger_node, room) + return + + assert_str(String(escoria.globals_manager.get_global("trigger_out_result"))).is_equal( + "player-out" + ) + assert_str(String(finished_events[1])).is_equal("trigger_out") + + escoria.event_manager.event_finished.disconnect(on_front_finished) + + _cleanup_registered_object(trigger_object, trigger_node, room) + + +func test_trigger_actions_fall_back_to_targetless_events() -> void: + var script_object := _compile_fixture_script("trigger_targeted_events_use_object_id.esc") + var room := ESCRoom.new() + room.global_id = "test_trigger_targetless_events_room" + escoria.object_manager.set_current_room(room) + + var trigger_node := Node2D.new() + var trigger_object := ESCObject.new("trigger_under_test", trigger_node) + trigger_object.events = script_object.events + escoria.object_manager.register_object(trigger_object, room, true, false) + + escoria.globals_manager.set_global("trigger_in_result", "unset", true) + escoria.globals_manager.set_global("trigger_out_result", "unset", true) + + var finished_events: Array = [] + var on_front_finished := func(return_code, event_name) -> void: + if return_code == ESCExecution.RC_OK: + finished_events.append(event_name) + + escoria.event_manager.event_finished.connect(on_front_finished) + + escoria.action_manager.do( + escoria.action_manager.ACTION.TRIGGER_IN, + [trigger_object.global_id, "unexpected_actor", "trigger_in"] + ) + + if not await _wait_for_finished_events(finished_events, 1, "targetless trigger_in fallback"): + escoria.event_manager.event_finished.disconnect(on_front_finished) + _cleanup_registered_object(trigger_object, trigger_node, room) + return + + assert_str(String(escoria.globals_manager.get_global("trigger_in_result"))).is_equal( + "fallback-in" + ) + assert_str(String(finished_events[0])).is_equal("trigger_in") + + escoria.action_manager.do( + escoria.action_manager.ACTION.TRIGGER_OUT, + [trigger_object.global_id, "unexpected_actor", "trigger_out"] + ) + + if not await _wait_for_finished_events(finished_events, 2, "targetless trigger_out fallback"): + escoria.event_manager.event_finished.disconnect(on_front_finished) + _cleanup_registered_object(trigger_object, trigger_node, room) + return + + assert_str(String(escoria.globals_manager.get_global("trigger_out_result"))).is_equal( + "fallback-out" + ) + assert_str(String(finished_events[1])).is_equal("trigger_out") + + escoria.event_manager.event_finished.disconnect(on_front_finished) + + _cleanup_registered_object(trigger_object, trigger_node, room) + + +func _cleanup_registered_object(trigger_object: ESCObject, trigger_node: Node, room: ESCRoom) -> void: + var room_key := ESCRoomObjectsKey.new() + room_key.room_global_id = room.global_id + room_key.room_instance_id = room.get_instance_id() + escoria.object_manager.unregister_object_by_global_id(trigger_object.global_id, room_key) + trigger_node.free() + room.free() + + +func _wait_for_finished_events( + finished_events: Array, + expected_size: int, + context: String +) -> bool: + for _frame in range(120): + if finished_events.size() >= expected_size: + return true + + await escoria.get_tree().process_frame + + fail( + "Timed out waiting for %s; expected %d finished event(s), got %d." % [ + context, + expected_size, + finished_events.size() + ] + ) + return false + + +func _interpret_fixture(name: String, dialog_choices: Array = []) -> Dictionary: + # Shared interpreter harness for fixture-based tests. This intentionally + # runs the full scan -> parse -> resolve -> interpret pipeline so the tests + # cover the real runtime path rather than isolated visitor calls. + var source := _load_fixture(name) + var compiler := ESCCompiler.new() + var scanner := ESCScanner.new() + scanner.set_source(source) + scanner.set_filename(_fixture_path(name)) + + var tokens: Array = scanner.scan_tokens() + var parser := ESCParser.new() + parser.init(compiler, tokens, "") + + var statements := parser.parse() + assert_bool(compiler.had_error).is_false() + assert_int(statements.size()).is_equal(1) + + var interpreter := ESCInterpreter.new(ESCCompiler.load_commands(), {}) + var resolver := ESCResolver.new(interpreter) + resolver.resolve(statements) + var dialog_player = null + + if not dialog_choices.is_empty(): + # Install a temporary deterministic dialog player only for tests that + # need scripted choices, then clean it up afterward to avoid orphan nodes. + dialog_player = DialogPlayerDouble.new(dialog_choices) + escoria.set("dialog_player", dialog_player) + + await interpreter.interpret(statements) + + var globals := interpreter.get_global_values().duplicate(true) + var dialog_choices_consumed := 0 + var remaining_dialog_choices := 0 + + if dialog_player: + dialog_choices_consumed = dialog_player.get_choices_consumed() + remaining_dialog_choices = dialog_player.get_remaining_choices() + + _cleanup_dialog_test(interpreter, dialog_player) + + return { + "globals": globals, + "dialog_choices_consumed": dialog_choices_consumed, + "remaining_dialog_choices": remaining_dialog_choices, + } + + +func test_dialog_start_args_are_passed_to_runtime_dialog() -> void: + var script_object := _compile_fixture_script("dialog_start_args_reach_runtime.esc") + var dialog_player := DialogPlayerDouble.new([1]) + escoria.set("dialog_player", dialog_player) + var interpreter := ESCInterpreter.new(ESCCompiler.load_commands(), {}) + var resolver := ESCResolver.new(interpreter) + var event: ESCGrammarStmts.Event = script_object.get_event_with_target("talk") + + assert_object(event).is_not_null() + + resolver.resolve(event) + + await interpreter.interpret(event) + assert_int(dialog_player.get_choices_consumed()).is_equal(1) + + var started_dialogs := dialog_player.get_dialogs_started() + assert_int(started_dialogs.size()).is_equal(1) + assert_str(started_dialogs[0]["avatar"]).is_equal("res://game/dialog_avatars/player.tres") + assert_int(started_dialogs[0]["timeout"]).is_equal(5) + assert_int(started_dialogs[0]["timeout_option"]).is_equal(2) + assert_int(started_dialogs[0]["authored_options_count"]).is_equal(2) + assert_int(started_dialogs[0]["options_size"]).is_equal(2) + assert_str(started_dialogs[0]["visible_timeout_option"]).is_equal("Second option") + assert_array(started_dialogs[0]["option_source_indexes"]).contains_exactly([1, 2]) + + _cleanup_dialog_test(interpreter, dialog_player) + + +func test_dialog_timeout_option_uses_authored_index_after_filtering() -> void: + var script_object := _compile_fixture_script("dialog_timeout_visible_authored_default_after_filtering.esc") + var dialog_player := DialogPlayerDouble.new([1]) + escoria.set("dialog_player", dialog_player) + var interpreter := ESCInterpreter.new(ESCCompiler.load_commands(), {}) + var resolver := ESCResolver.new(interpreter) + var event: ESCGrammarStmts.Event = script_object.get_event_with_target("talk") + + assert_object(event).is_not_null() + + resolver.resolve(event) + + await interpreter.interpret(event) + assert_int(dialog_player.get_choices_consumed()).is_equal(1) + + var started_dialogs := dialog_player.get_dialogs_started() + assert_int(started_dialogs.size()).is_equal(1) + assert_int(started_dialogs[0]["timeout"]).is_equal(5) + assert_int(started_dialogs[0]["timeout_option"]).is_equal(3) + assert_int(started_dialogs[0]["authored_options_count"]).is_equal(3) + assert_int(started_dialogs[0]["options_size"]).is_equal(2) + assert_array(started_dialogs[0]["option_source_indexes"]).contains_exactly([2, 3]) + assert_array(started_dialogs[0]["option_texts"]).contains_exactly(["Second option", "Third option"]) + assert_str(started_dialogs[0]["visible_timeout_option"]).is_equal("Third option") + + _cleanup_dialog_test(interpreter, dialog_player) + + +func test_dialog_timeout_option_hidden_after_filtering_is_not_available() -> void: + var script_object := _compile_fixture_script("dialog_timeout_hidden_authored_default_after_filtering.esc") + var dialog_player := DialogPlayerDouble.new([0]) + escoria.set("dialog_player", dialog_player) + var interpreter := ESCInterpreter.new(ESCCompiler.load_commands(), {}) + var resolver := ESCResolver.new(interpreter) + var event: ESCGrammarStmts.Event = script_object.get_event_with_target("talk") + + assert_object(event).is_not_null() + + resolver.resolve(event) + + await interpreter.interpret(event) + assert_int(dialog_player.get_choices_consumed()).is_equal(1) + + var started_dialogs := dialog_player.get_dialogs_started() + assert_int(started_dialogs.size()).is_equal(1) + assert_int(started_dialogs[0]["timeout"]).is_equal(5) + assert_int(started_dialogs[0]["timeout_option"]).is_equal(1) + assert_int(started_dialogs[0]["authored_options_count"]).is_equal(3) + assert_int(started_dialogs[0]["options_size"]).is_equal(2) + assert_array(started_dialogs[0]["option_source_indexes"]).contains_exactly([2, 3]) + assert_array(started_dialogs[0]["option_texts"]).contains_exactly(["Second option", "Third option"]) + assert_object(started_dialogs[0]["visible_timeout_option"]).is_null() + + _cleanup_dialog_test(interpreter, dialog_player) + + +func test_simple_chooser_does_not_start_timer_when_timeout_default_is_hidden() -> void: + var dialog := ESCDialog.new() + dialog.timeout = 5 + dialog.timeout_option = 1 + dialog.authored_options_count = 2 + dialog.options = [ + _make_dialog_option("Visible option", 2), + ] + + var chooser = SIMPLE_CHOOSER_SCENE.instantiate() + add_child(chooser) + chooser.set_dialog(dialog) + chooser.show_chooser() + + assert_bool(chooser.get_node("Timer").is_stopped()).is_true() + assert_float(chooser.get_node("TimerProgress").value).is_equal(0.0) + + chooser.queue_free() + + +func test_simple_chooser_starts_timer_when_timeout_default_is_visible() -> void: + var dialog := ESCDialog.new() + dialog.timeout = 5 + dialog.timeout_option = 2 + dialog.authored_options_count = 2 + dialog.options = [ + _make_dialog_option("First option", 1), + _make_dialog_option("Second option", 2), + ] + + var chooser = SIMPLE_CHOOSER_SCENE.instantiate() + add_child(chooser) + chooser.set_dialog(dialog) + chooser.show_chooser() + + assert_bool(chooser.get_node("Timer").is_stopped()).is_false() + + chooser.queue_free() + + +func test_dialog_start_invalid_avatar_type_returns_error() -> void: + var script_object := _compile_fixture_script("dialog_start_invalid_avatar_type.esc") + var dialog_player := DialogPlayerDouble.new([]) + escoria.set("dialog_player", dialog_player) + var interpreter := ESCInterpreter.new(ESCCompiler.load_commands(), {}) + var resolver := ESCResolver.new(interpreter) + var event: ESCGrammarStmts.Event = script_object.get_event_with_target("talk") + + assert_object(event).is_not_null() + + resolver.resolve(event) + + await interpreter.interpret(event) + assert_int(dialog_player.get_choices_consumed()).is_equal(0) + assert_int(dialog_player.get_dialogs_started().size()).is_equal(0) + + _cleanup_dialog_test(interpreter, dialog_player) + + +func test_dialog_start_invalid_timeout_type_returns_error() -> void: + var script_object := _compile_fixture_script("dialog_start_invalid_timeout_type.esc") + var dialog_player := DialogPlayerDouble.new([]) + escoria.set("dialog_player", dialog_player) + var interpreter := ESCInterpreter.new(ESCCompiler.load_commands(), {}) + var resolver := ESCResolver.new(interpreter) + var event: ESCGrammarStmts.Event = script_object.get_event_with_target("talk") + + assert_object(event).is_not_null() + + resolver.resolve(event) + + await interpreter.interpret(event) + assert_int(dialog_player.get_choices_consumed()).is_equal(0) + assert_int(dialog_player.get_dialogs_started().size()).is_equal(0) + + _cleanup_dialog_test(interpreter, dialog_player) + + +func test_dialog_option_translation_key_is_passed_to_runtime_dialog() -> void: + var script_object := _compile_fixture_script("dialog_option_translation_key.esc") + var dialog_player := DialogPlayerDouble.new([0]) + escoria.set("dialog_player", dialog_player) + var interpreter := ESCInterpreter.new(ESCCompiler.load_commands(), {}) + var resolver := ESCResolver.new(interpreter) + var event: ESCGrammarStmts.Event = script_object.get_event_with_target("talk") + + assert_object(event).is_not_null() + + resolver.resolve(event) + + await interpreter.interpret(event) + assert_int(dialog_player.get_choices_consumed()).is_equal(1) + + var started_dialogs := dialog_player.get_dialogs_started() + assert_int(started_dialogs.size()).is_equal(1) + assert_array(started_dialogs[0]["option_keys"]).contains_exactly(["UNCLE_SVEN", ""]) + + _cleanup_dialog_test(interpreter, dialog_player) + + +func test_dialog_start_invalid_timeout_option_type_returns_error() -> void: + var script_object := _compile_fixture_script("dialog_start_invalid_timeout_option_type.esc") + var dialog_player := DialogPlayerDouble.new([]) + escoria.set("dialog_player", dialog_player) + var interpreter := ESCInterpreter.new(ESCCompiler.load_commands(), {}) + var resolver := ESCResolver.new(interpreter) + var event: ESCGrammarStmts.Event = script_object.get_event_with_target("talk") + + assert_object(event).is_not_null() + + resolver.resolve(event) + + await interpreter.interpret(event) + assert_int(dialog_player.get_choices_consumed()).is_equal(0) + assert_int(dialog_player.get_dialogs_started().size()).is_equal(0) + + _cleanup_dialog_test(interpreter, dialog_player) + + +func test_dialog_start_invalid_timeout_option_negative_returns_error() -> void: + var script_object := _compile_fixture_script("dialog_start_invalid_timeout_option_negative.esc") + var dialog_player := DialogPlayerDouble.new([]) + escoria.set("dialog_player", dialog_player) + var interpreter := ESCInterpreter.new(ESCCompiler.load_commands(), {}) + var resolver := ESCResolver.new(interpreter) + var event: ESCGrammarStmts.Event = script_object.get_event_with_target("talk") + + assert_object(event).is_not_null() + + resolver.resolve(event) + + await interpreter.interpret(event) + assert_int(dialog_player.get_choices_consumed()).is_equal(0) + assert_int(dialog_player.get_dialogs_started().size()).is_equal(0) + + _cleanup_dialog_test(interpreter, dialog_player) + + +func test_dialog_option_translation_key_beats_legacy_prefix_fallback() -> void: + var script_object := _compile_fixture_script("dialog_option_translation_key_beats_legacy_prefix.esc") + var dialog_player := DialogPlayerDouble.new([0]) + escoria.set("dialog_player", dialog_player) + var interpreter := ESCInterpreter.new(ESCCompiler.load_commands(), {}) + var resolver := ESCResolver.new(interpreter) + var event: ESCGrammarStmts.Event = script_object.get_event_with_target("talk") + + assert_object(event).is_not_null() + + resolver.resolve(event) + + await interpreter.interpret(event) + + var started_dialogs := dialog_player.get_dialogs_started() + assert_int(started_dialogs.size()).is_equal(1) + assert_array(started_dialogs[0]["option_keys"]).contains_exactly(["OUTER_KEY"]) + assert_array(started_dialogs[0]["option_texts"]).contains_exactly(["INNER_KEY:Fallback text"]) + + _cleanup_dialog_test(interpreter, dialog_player) + + +func test_dialog_option_translation_key_with_condition_survives_runtime_filtering() -> void: + var script_object := _compile_fixture_script("dialog_option_translation_key_with_condition.esc") + var dialog_player := DialogPlayerDouble.new([0]) + escoria.set("dialog_player", dialog_player) + var interpreter := ESCInterpreter.new(ESCCompiler.load_commands(), {}) + var resolver := ESCResolver.new(interpreter) + var event: ESCGrammarStmts.Event = script_object.get_event_with_target("talk") + + assert_object(event).is_not_null() + + resolver.resolve(event) + + await interpreter.interpret(event) + + var started_dialogs := dialog_player.get_dialogs_started() + assert_int(started_dialogs.size()).is_equal(1) + assert_int(started_dialogs[0]["options_size"]).is_equal(1) + assert_array(started_dialogs[0]["option_keys"]).contains_exactly(["CONDITIONAL_KEY"]) + assert_array(started_dialogs[0]["option_texts"]).contains_exactly(["Conditional option"]) + + _cleanup_dialog_test(interpreter, dialog_player) + + +func test_dialog_option_translation_key_with_expression_preserves_key_and_value() -> void: + var script_object := _compile_fixture_script("dialog_option_translation_key_with_expression.esc") + var dialog_player := DialogPlayerDouble.new([0]) + escoria.set("dialog_player", dialog_player) + var interpreter := ESCInterpreter.new(ESCCompiler.load_commands(), {}) + var resolver := ESCResolver.new(interpreter) + var event: ESCGrammarStmts.Event = script_object.get_event_with_target("talk") + + assert_object(event).is_not_null() + + resolver.resolve(event) + + await interpreter.interpret(event) + + var started_dialogs := dialog_player.get_dialogs_started() + assert_int(started_dialogs.size()).is_equal(1) + assert_array(started_dialogs[0]["option_keys"]).contains_exactly(["DYNAMIC_KEY"]) + assert_array(started_dialogs[0]["option_texts"]).contains_exactly(["Dynamic option"]) + + _cleanup_dialog_test(interpreter, dialog_player) + + +func _cleanup_dialog_test(interpreter: ESCInterpreter, dialog_player: DialogPlayerDouble) -> void: + interpreter.cleanup() + + if dialog_player: + escoria.set("dialog_player", null) + dialog_player.queue_free() + + +func test_compiler_rejects_exact_duplicate_event_targets() -> void: + var source := _load_fixture("duplicate_exact_target_event_overwrites.esc") + var compiler := ESCCompiler.new() + var script_object := compiler.compile(source) + + assert_bool(compiler.had_error).is_true() + assert_int(script_object.events.events.size()).is_equal(1) + assert_object(script_object.get_event_with_target("use", "wrench")).is_not_null() + + +func _load_fixture(name: String) -> String: + return FileAccess.get_file_as_string(_fixture_path(name)) + + +func _load_fixture_events(name: String): + var script_object := _compile_fixture_script(name) + return script_object.events + + +func _compile_fixture_script(name: String) -> ESCScript: + var source := _load_fixture(name) + var compiler := ESCCompiler.new() + var script_object := compiler.compile(source, _fixture_path(name)) + assert_bool(compiler.had_error).is_false() + return script_object + + +func _fixture_path(name: String) -> String: + return "res://addons/escoria-core/testing/ashes/interpreter/fixtures/%s" % name + + +func _make_dialog_option(text: String, source_option_index: int) -> ESCDialogOption: + var option := ESCDialogOption.new() + option.option = text + option.source_option_index = source_option_index + option.set_is_valid(true) + return option |
