# client/tests/unit/test_npc_service.gd extends "res://addons/gut/test.gd" const NpcService = preload("res://scripts/net/npc_service.gd") const DmResponse = preload("res://scripts/net/dm_response.gd") const FallbackLibrary = preload("res://scripts/net/fallback_library.gd") const FakeDmTransport = preload("res://tests/doubles/fake_transport.gd") var _fallback func before_each(): _fallback = FallbackLibrary.new( "res://content/fallback/missing.json", "res://content/fallback/missing.json") func _svc(resp) -> Dictionary: var t = FakeDmTransport.new() t.set_response(resp) return {"svc": NpcService.new(t, _fallback), "transport": t} func _speak(pair, available): return await pair["svc"].speak("fenn", "what happened?", CanonLog.new(), -10, available) func test_posts_full_request_body(): var pair = _svc(DmResponse.ok(200, {"prose": "Aye."})) await _speak(pair, ["refuse"]) assert_eq(pair["transport"].last_path, "/npc/speak") var body = pair["transport"].last_body assert_eq(body["npc_id"], "fenn") assert_eq(body["disposition"], -10) assert_eq(body["utterance"], "what happened?") assert_true(body.has("available_moves")) assert_true(body.has("canon_log")) func test_valid_move_kept_prose_cleaned(): var prose = "Aye, I knew them. [MOVE: reveal(varrell_twins)]" var pair = _svc(DmResponse.ok(200, {"prose": prose})) var r = await _speak(pair, ["reveal(varrell_twins)", "refuse"]) assert_false(r.degraded) assert_eq(r.display_text, "Aye, I knew them.") assert_eq(r.valid_moves.size(), 1) assert_eq(r.dropped_moves.size(), 0) func test_illegal_move_dropped_prose_kept(): var prose = "No. [MOVE: offer_quest(some_other)]" var pair = _svc(DmResponse.ok(200, {"prose": prose})) var r = await _speak(pair, ["offer_quest(find_the_ledger)", "refuse"]) assert_eq(r.display_text, "No.") assert_eq(r.valid_moves.size(), 0) assert_eq(r.dropped_moves.size(), 1) func test_adjust_disposition_alias_is_validated(): var prose = "Fine. [ADJUST_DISPOSITION: +5]" var pair = _svc(DmResponse.ok(200, {"prose": prose})) var r = await _speak(pair, ["adjust_disposition"]) assert_eq(r.valid_moves.size(), 1) assert_eq(r.valid_moves[0]["name"], "adjust_disposition") assert_eq(r.valid_moves[0]["args"], ["5"]) func test_end_conversation_sets_flag(): var prose = "We're done. [MOVE: end_conversation()]" var pair = _svc(DmResponse.ok(200, {"prose": prose})) var r = await _speak(pair, ["end_conversation"]) assert_true(r.ends_conversation) func test_facts_harvested(): var prose = "The bridge fell. [FACT: the eastern bridge is out]" var pair = _svc(DmResponse.ok(200, {"prose": prose})) var r = await _speak(pair, ["refuse"]) assert_eq(r.facts, ["the eastern bridge is out"]) func test_502_degrades_no_moves(): var pair = _svc(DmResponse.ok(502, {"detail": {"model_error": "boom"}})) var r = await _speak(pair, ["refuse"]) assert_true(r.degraded) assert_eq(r.display_text, FallbackLibrary.NPC_LAST_RESORT) assert_eq(r.valid_moves, []) func test_transport_failure_degrades(): var pair = _svc(DmResponse.failed("refused")) var r = await _speak(pair, ["refuse"]) assert_true(r.degraded)