Files
gamedev-the-steward/tests/conversation_domain_test.gd
2026-08-12 20:32:53 +02:00

232 lines
7.6 KiB
GDScript

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)