extends SceneTree class FakePresenter: extends ConversationPresenter var presented_turn: ConversationTurn func present_turn(turn: ConversationTurn, _presentation_context: Dictionary = {}) -> Variant: presented_turn = turn.copy() return { "conversation_id": turn.get_conversation_id(), "revision": turn.get_revision(), } const CATALOG_PATH := "res://simulation/dialogue/resources/jajce_conversation_intents.tres" var failures: Array[String] = [] func _initialize() -> void: _test_catalog_and_deterministic_lifecycle() _test_revalidation_and_invalidation() _finish() func _test_catalog_and_deterministic_lifecycle() -> void: var catalog := load(CATALOG_PATH) as ConversationIntentCatalog _check( catalog != null and catalog.rebuild().is_empty(), "The Jajce intent catalog should validate" ) if catalog == null: return _check( catalog.get_intent_ids() == _sorted_ids(ConversationIntentIds.ALL), "The catalog should expose every required initial intent" ) var speaker := WorldEntityRef.create(&"npc", &"amina") var listener := WorldEntityRef.create(&"npc", &"boris") var context := _context() var first_service := ConversationService.new(catalog) var second_service := ConversationService.new(catalog) var first_id := first_service.begin(speaker, listener, context) var second_id := second_service.begin(speaker, listener, context) _check(not first_id.is_empty(), "A valid semantic conversation should begin") _check(first_id == second_id, "Equivalent begin order and context should produce a stable ID") var first_turn := first_service.get_turn(first_id) var second_turn := second_service.get_turn(second_id) _check(first_turn != null and first_turn.is_valid(), "The first planned turn should validate") if first_turn == null or second_turn == null: return _check( ( first_turn.get_revision() == 1 and first_turn.get_act().get_intent_id() == ConversationIntentIds.GREET ), "The initial turn should expose a revisioned semantic greeting" ) _check( first_turn.get_act().get_causal_topic_ids() == [&"pantry_shortage"], "The act should retain causal topic IDs without authoritative prose" ) _check( _turn_signature(first_turn) == _turn_signature(second_turn), "Equivalent semantic inputs should plan the same turn and option order" ) var fake := FakePresenter.new() var rendered: Dictionary = fake.present_turn(first_turn) _check( rendered["revision"] == 1 and fake.presented_turn.get_act().get_intent_id() == &"greet", "A presenter should receive an already-planned immutable semantic turn" ) context["topic_ids"].append(&"caller_mutation") _check( first_service.get_turn(first_id).get_causal_topic_ids() == [&"pantry_shortage"], "Conversation context should be isolated from caller mutation" ) func _test_revalidation_and_invalidation() -> void: var service := ConversationService.new() var speaker := WorldEntityRef.create(&"npc", &"amina") var listener := WorldEntityRef.create(&"npc", &"boris") _check( service.begin(speaker, listener, {"prose": "not semantic"}).is_empty(), "Unknown or prose-bearing authority keys should be rejected" ) var conversation_id := service.begin(speaker, listener, _context()) var turn := service.get_turn(conversation_id) if turn == null: _check(false, "A conversation should exist for revision tests") return var ask_work := _find_option_for_intent(turn, ConversationIntentIds.ASK_WORK) var offer_help := _find_option_for_intent(turn, ConversationIntentIds.OFFER_HELP) _check(ask_work != null and offer_help != null, "Configured semantic options should be present") if ask_work == null or offer_help == null: return _check( not offer_help.is_enabled() and offer_help.get_reason_code() == &"speaker_overcommitted", "Blocked choices should retain a semantic reason code and trace" ) var disabled := service.select_option( conversation_id, offer_help.get_option_id(), turn.get_revision() ) _check( ( not disabled.was_accepted() and disabled.get_reason_code() == ConversationService.REASON_OPTION_DISABLED and service.get_turn(conversation_id).get_revision() == 1 ), "A disabled choice should be rejected without advancing revision" ) var stale := service.select_option(conversation_id, ask_work.get_option_id(), 0) _check( ( not stale.was_accepted() and stale.get_reason_code() == ConversationService.REASON_STALE_REVISION and stale.get_current_revision() == 1 and stale.get_turn().get_revision() == 1 ), "A stale selection should return the current revision for revalidation" ) var selected := service.select_option( conversation_id, ask_work.get_option_id(), turn.get_revision() ) _check( ( selected.was_accepted() and selected.get_current_revision() == 2 and selected.get_turn().get_act().get_intent_id() == ConversationIntentIds.ASK_WORK ), "A current enabled option should advance exactly one semantic revision" ) _check( ( selected.get_turn().get_speaker().equals(listener) and selected.get_turn().get_listener().equals(speaker) ), "Selecting a listener option should swap the next turn's speaking direction" ) _check( not service.select_option(conversation_id, ask_work.get_option_id(), 1).was_accepted(), "A replayed option should fail stale-revision validation" ) var invalidations: Array[StringName] = [] service.conversation_invalidated.connect( func(id: StringName, _reason: StringName) -> void: invalidations.append(id) ) _check( service.invalidate_for_entity(listener, &"listener_unloaded") == [conversation_id], "Entity invalidation should deterministically close its active conversations" ) _check( ( invalidations == [conversation_id] and service.get_turn(conversation_id) == null and not service.has_active_conversation(conversation_id) ), "Invalidation should emit a hook and make the turn inaccessible" ) func _context() -> Dictionary: return { "conversation_key": &"pantry_encounter", "initial_intent_id": ConversationIntentIds.GREET, "topic_ids": [&"pantry_shortage"], "option_intent_ids": { ConversationIntentIds.GREET: [ConversationIntentIds.ASK_WORK, ConversationIntentIds.OFFER_HELP] }, "topics_by_intent": { ConversationIntentIds.OFFER_HELP: [&"pantry_shortage"], ConversationIntentIds.ASK_WORK: [&"pantry_shortage"], }, "blocked_intent_ids": [ConversationIntentIds.OFFER_HELP], "block_reason_by_intent": {ConversationIntentIds.OFFER_HELP: &"speaker_overcommitted"}, "reason_trace": [&"participants_nearby", &"shared_topic_available"], } func _find_option_for_intent(turn: ConversationTurn, intent_id: StringName) -> ConversationOption: for option in turn.get_options(): if option.get_intent_id() == intent_id: return option return null func _turn_signature(turn: ConversationTurn) -> Array: var signature: Array = [ turn.get_revision(), turn.get_act().get_intent_id(), turn.get_act().get_causal_topic_ids() ] for option in turn.get_options(): ( signature . append( [ option.get_option_id(), option.get_intent_id(), option.is_enabled(), option.get_reason_code(), option.get_causal_topic_ids(), ] ) ) return signature func _sorted_ids(ids: Array[StringName]) -> Array[StringName]: var sorted := ids.duplicate() sorted.sort_custom( func(left: StringName, right: StringName) -> bool: return String(left) < String(right) ) return sorted func _check(condition: bool, message: String) -> void: if not condition: failures.append(message) func _finish() -> void: if failures.is_empty(): print("[TEST] Semantic conversation domain passed") quit(0) return for failure in failures: push_error("[TEST] " + failure) quit(1)