Merge feature/client-http-loop into dev
M2 client HTTP loop: the client posts its canon log to /dm/narrate, shows the prose, harvests [FACT] via TagExtractor, and degrades to an authored fallback (§13) on any failure. First end-to-end vertical slice, live-proven against qwen3.5 through Docker. 67/67 client tests, whole-branch review MERGE AS-IS.
This commit is contained in:
5
client/content/fallback/narrator.json
Normal file
5
client/content/fallback/narrator.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"narrator": [
|
||||
"The rain keeps on. The room ahead is dark and colder than it should be, and whatever is in it has been there a while."
|
||||
]
|
||||
}
|
||||
@@ -11,3 +11,21 @@ Put here:
|
||||
- HTTP client + fallback-degradation handling (§13)
|
||||
|
||||
Cross-cutting design (anything touching both client and api) goes in the root [`/docs`](../../docs), not here.
|
||||
|
||||
## Narrate live smoke (M2 client HTTP loop)
|
||||
|
||||
The `narrate_harness` scene is a throwaway proof of the client → `/dm/narrate`
|
||||
loop. It is not the game UI.
|
||||
|
||||
**Prereqs:** the proxy running and Ollama reachable.
|
||||
|
||||
1. Start the proxy: from `api/`, `uvicorn app.main:app --port 8000` (with the
|
||||
`.env` from `.env.example`; Ollama up at `OLLAMA_BASE_URL`).
|
||||
2. In the Godot editor, open `res://scenes/narrate_harness.tscn` and run it (F6).
|
||||
3. Click **Narrate this scene**. Expect grounded prose in the Narrator's voice
|
||||
(referencing Greywater / Brannoc / the washed-out bridge), tags stripped, and
|
||||
`facts harvested: N` with N ≥ 0.
|
||||
4. **Degraded path:** stop the proxy and click again. Expect the authored
|
||||
fallback line and `(degraded)` in the status — never an error.
|
||||
|
||||
Override the proxy URL with the `coc_rpg/proxy_base_url` project setting.
|
||||
|
||||
10
client/scenes/narrate_harness.tscn
Normal file
10
client/scenes/narrate_harness.tscn
Normal file
@@ -0,0 +1,10 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/harness/narrate_harness.gd" id="1_harness"]
|
||||
|
||||
[node name="NarrateHarness" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
script = ExtResource("1_harness")
|
||||
66
client/scripts/harness/narrate_harness.gd
Normal file
66
client/scripts/harness/narrate_harness.gd
Normal file
@@ -0,0 +1,66 @@
|
||||
extends Control
|
||||
## Throwaway M2 harness — the first on-screen generated prose. Wires the real
|
||||
## transport to a button and a label. NOT the game UI (a later wireframe owns
|
||||
## that). Run this scene directly (F6) with the proxy + Ollama up; see the live
|
||||
## smoke steps in client/docs/README.md.
|
||||
|
||||
const DmService = preload("res://scripts/net/dm_service.gd")
|
||||
const HttpTransport = preload("res://scripts/net/http_transport.gd")
|
||||
const FallbackLibrary = preload("res://scripts/net/fallback_library.gd")
|
||||
|
||||
var _service: DmService
|
||||
var _log: CanonLog
|
||||
var _label: RichTextLabel
|
||||
var _status: Label
|
||||
var _button: Button
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
var http := HTTPRequest.new()
|
||||
add_child(http)
|
||||
_service = DmService.new(HttpTransport.new(http), FallbackLibrary.new())
|
||||
_log = _build_scene_log()
|
||||
|
||||
var vbox := VBoxContainer.new()
|
||||
vbox.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
vbox.add_theme_constant_override("separation", 12)
|
||||
add_child(vbox)
|
||||
|
||||
_button = Button.new()
|
||||
_button.text = "Narrate this scene"
|
||||
_button.pressed.connect(_on_narrate_pressed)
|
||||
vbox.add_child(_button)
|
||||
|
||||
_label = RichTextLabel.new()
|
||||
_label.fit_content = true
|
||||
_label.custom_minimum_size = Vector2(640, 320)
|
||||
vbox.add_child(_label)
|
||||
|
||||
_status = Label.new()
|
||||
vbox.add_child(_status)
|
||||
|
||||
|
||||
func _on_narrate_pressed() -> void:
|
||||
_button.disabled = true
|
||||
_status.text = "…narrating…"
|
||||
var r := await _service.narrate(_log)
|
||||
_label.text = r.display_text
|
||||
for f in r.facts:
|
||||
_log.add_fact(f)
|
||||
_status.text = "facts harvested: %d%s" % [r.facts.size(), " (degraded)" if r.degraded else ""]
|
||||
_button.disabled = false
|
||||
|
||||
|
||||
func _build_scene_log() -> CanonLog:
|
||||
var log := CanonLog.new()
|
||||
log.player = LogPlayer.new("Aldric", "sellsword", "Fortune spits on you")
|
||||
log.set_location("greywater_docks", "the Greywater docks")
|
||||
log.party.append(PartyMember.new("brannoc_thane", "Brannoc Thane", 40))
|
||||
log.party.append(PartyMember.new("cadwyn_vell", "Cadwyn Vell", 15))
|
||||
log.push_event("Arrived at Greywater by barge before dawn")
|
||||
log.push_event("Brannoc recognised the harbourmaster and went quiet")
|
||||
log.add_fact("the eastern bridge out of Greywater is washed out")
|
||||
log.add_fact("the harbourmaster is named Oda Fenn")
|
||||
log.active_quests.append(Quest.new("find_the_ledger", "The Missing Ledger", "active", "Find who took Fenn's ledger"))
|
||||
log.add_humiliation("h_0001", "vomited on a shrine step in front of a priest", 7, 3)
|
||||
return log
|
||||
1
client/scripts/harness/narrate_harness.gd.uid
Normal file
1
client/scripts/harness/narrate_harness.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dxh7t3rdoqgq5
|
||||
25
client/scripts/net/dm_response.gd
Normal file
25
client/scripts/net/dm_response.gd
Normal file
@@ -0,0 +1,25 @@
|
||||
class_name DmResponse
|
||||
extends RefCounted
|
||||
## The raw outcome of one HTTP attempt, independent of any game meaning.
|
||||
## `status` is 0 when the transport never reached the server; `body` is the
|
||||
## parsed JSON dict, or null when the body was absent or not JSON.
|
||||
|
||||
var status: int
|
||||
var body # Variant: parsed JSON (Dictionary) or null
|
||||
var transport_ok: bool
|
||||
var error: String
|
||||
|
||||
|
||||
func _init(p_status := 0, p_body = null, p_transport_ok := false, p_error := "") -> void:
|
||||
status = p_status
|
||||
body = p_body
|
||||
transport_ok = p_transport_ok
|
||||
error = p_error
|
||||
|
||||
|
||||
static func ok(p_status: int, p_body) -> DmResponse:
|
||||
return DmResponse.new(p_status, p_body, true, "")
|
||||
|
||||
|
||||
static func failed(p_error: String) -> DmResponse:
|
||||
return DmResponse.new(0, null, false, p_error)
|
||||
1
client/scripts/net/dm_response.gd.uid
Normal file
1
client/scripts/net/dm_response.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bdy2347pcqm4v
|
||||
27
client/scripts/net/dm_service.gd
Normal file
27
client/scripts/net/dm_service.gd
Normal file
@@ -0,0 +1,27 @@
|
||||
class_name DmService
|
||||
extends RefCounted
|
||||
## The client narrate loop (charter §2: the client owns the loop and consumes
|
||||
## text). Posts the canon log to /dm/narrate through an injected transport,
|
||||
## returns the prose to display and the [FACT:] tags to harvest. On ANY failure
|
||||
## it returns an authored fallback line — never an error. It harvests facts
|
||||
## only; the Narrator has no authority over disposition or moves, so those tags
|
||||
## are dropped even if the model emits them (§6). narrate() does not mutate the
|
||||
## log — the caller applies the returned facts, keeping the state write explicit.
|
||||
|
||||
var _transport: DmTransport
|
||||
var _fallback: FallbackLibrary
|
||||
|
||||
|
||||
func _init(transport: DmTransport, fallback: FallbackLibrary) -> void:
|
||||
_transport = transport
|
||||
_fallback = fallback
|
||||
|
||||
|
||||
func narrate(canon_log: CanonLog) -> NarrateResult:
|
||||
var resp: DmResponse = await _transport.post_json(
|
||||
"/dm/narrate", {"canon_log": canon_log.to_dict()})
|
||||
if not resp.transport_ok or resp.status != 200 \
|
||||
or typeof(resp.body) != TYPE_DICTIONARY or not resp.body.has("prose"):
|
||||
return NarrateResult.fallback(_fallback.narrator_line())
|
||||
var extracted := TagExtractor.extract(str(resp.body["prose"]))
|
||||
return NarrateResult.new(extracted["clean_text"], extracted["facts"], false)
|
||||
1
client/scripts/net/dm_service.gd.uid
Normal file
1
client/scripts/net/dm_service.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://b467yu4cut0hp
|
||||
10
client/scripts/net/dm_transport.gd
Normal file
10
client/scripts/net/dm_transport.gd
Normal file
@@ -0,0 +1,10 @@
|
||||
class_name DmTransport
|
||||
extends RefCounted
|
||||
## The network seam. DmService talks to this, never to HTTPRequest directly, so
|
||||
## the loop logic is testable with an injected fake. Concrete transports
|
||||
## override post_json. The base returns a failed response (rather than assert())
|
||||
## so a missing override is a loud, catchable failure in tests.
|
||||
|
||||
func post_json(_path: String, _body: Dictionary) -> DmResponse:
|
||||
push_error("DmTransport.post_json must be overridden")
|
||||
return DmResponse.failed("transport not implemented")
|
||||
1
client/scripts/net/dm_transport.gd.uid
Normal file
1
client/scripts/net/dm_transport.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://crgmujan6vj2l
|
||||
37
client/scripts/net/fallback_library.gd
Normal file
37
client/scripts/net/fallback_library.gd
Normal file
@@ -0,0 +1,37 @@
|
||||
class_name FallbackLibrary
|
||||
extends RefCounted
|
||||
## Authored degraded-DM fallback text (charter §13). The client shows one of
|
||||
## these lines on ANY failure, in the Narrator's voice — never an error. If the
|
||||
## content file is missing or malformed the library returns a hardcoded
|
||||
## constant, so the fallback itself cannot fail.
|
||||
|
||||
const PATH := "res://content/fallback/narrator.json"
|
||||
const LAST_RESORT := "The chamber is cold. Something waits in the dark."
|
||||
|
||||
var _lines: Array = []
|
||||
|
||||
|
||||
func _init(path := PATH) -> void:
|
||||
_lines = _load(path)
|
||||
|
||||
|
||||
func narrator_line() -> String:
|
||||
return str(_lines[0]) if not _lines.is_empty() else LAST_RESORT
|
||||
|
||||
|
||||
static func _load(path: String) -> Array:
|
||||
if not FileAccess.file_exists(path):
|
||||
return []
|
||||
var text := FileAccess.get_file_as_string(path)
|
||||
# Use the instance API, not JSON.parse_string(): the static convenience
|
||||
# method pushes an engine-level error on malformed input, which is noisy
|
||||
# and (in this project's test config) turns a handled failure into a
|
||||
# reported test error. The instance parse() just returns an Error code.
|
||||
var json := JSON.new()
|
||||
if json.parse(text) != OK:
|
||||
return []
|
||||
var data = json.get_data()
|
||||
if typeof(data) != TYPE_DICTIONARY:
|
||||
return []
|
||||
var lines = data.get("narrator", [])
|
||||
return lines if typeof(lines) == TYPE_ARRAY else []
|
||||
1
client/scripts/net/fallback_library.gd.uid
Normal file
1
client/scripts/net/fallback_library.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://b8jhu0e4yg6tf
|
||||
40
client/scripts/net/http_transport.gd
Normal file
40
client/scripts/net/http_transport.gd
Normal file
@@ -0,0 +1,40 @@
|
||||
extends "res://scripts/net/dm_transport.gd"
|
||||
## The real transport: wraps an HTTPRequest Node the owner added to the tree.
|
||||
## This is the one untested shim in the slice — it needs a live Node and a
|
||||
## server, so it is verified by reading and by the harness live smoke, the same
|
||||
## way the server's HTTP boundary is covered only by its gated --run-live test.
|
||||
## A non-2xx status is NOT an error here; it is a normal DmResponse the service
|
||||
## interprets. Only a transport-level failure (no response) yields failed().
|
||||
|
||||
const ProxyConfig = preload("res://scripts/net/proxy_config.gd")
|
||||
const DmResponse = preload("res://scripts/net/dm_response.gd")
|
||||
|
||||
var _http: HTTPRequest
|
||||
|
||||
|
||||
func _init(http: HTTPRequest) -> void:
|
||||
_http = http
|
||||
|
||||
|
||||
func post_json(path: String, body: Dictionary) -> DmResponse:
|
||||
var url := ProxyConfig.base_url() + path
|
||||
var headers := ["Content-Type: application/json"]
|
||||
var payload := JSON.stringify(body)
|
||||
var err := _http.request(url, headers, HTTPClient.METHOD_POST, payload)
|
||||
if err != OK:
|
||||
return DmResponse.failed("request() failed to start: error %d" % err)
|
||||
# request_completed(result, response_code, headers, body) — awaiting a
|
||||
# multi-arg signal returns the args as an Array.
|
||||
var signal_args: Array = await _http.request_completed
|
||||
var result: int = signal_args[0]
|
||||
var response_code: int = signal_args[1]
|
||||
var raw: PackedByteArray = signal_args[3]
|
||||
if result != HTTPRequest.RESULT_SUCCESS:
|
||||
return DmResponse.failed("transport result %d" % result)
|
||||
# Parse via JSON.new() rather than JSON.parse_string(): the latter pushes an
|
||||
# engine-level error on a non-JSON body (e.g. a gateway's HTML 502), which the
|
||||
# project's gutconfig promotes to a false test failure. A non-JSON body stays
|
||||
# null here — the service treats a non-dict body as a failure and degrades.
|
||||
var json := JSON.new()
|
||||
var parsed = null if json.parse(raw.get_string_from_utf8()) != OK else json.data
|
||||
return DmResponse.ok(response_code, parsed)
|
||||
1
client/scripts/net/http_transport.gd.uid
Normal file
1
client/scripts/net/http_transport.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cuvhnqsgr3liq
|
||||
18
client/scripts/net/narrate_result.gd
Normal file
18
client/scripts/net/narrate_result.gd
Normal file
@@ -0,0 +1,18 @@
|
||||
class_name NarrateResult
|
||||
extends RefCounted
|
||||
## The game-meaningful result of one narrate call. `facts` are the [FACT:]
|
||||
## strings the caller applies to the canon log; empty when degraded.
|
||||
|
||||
var display_text: String
|
||||
var facts: Array
|
||||
var degraded: bool
|
||||
|
||||
|
||||
func _init(p_display_text := "", p_facts := [], p_degraded := false) -> void:
|
||||
display_text = p_display_text
|
||||
facts = p_facts
|
||||
degraded = p_degraded
|
||||
|
||||
|
||||
static func fallback(line: String) -> NarrateResult:
|
||||
return NarrateResult.new(line, [], true)
|
||||
1
client/scripts/net/narrate_result.gd.uid
Normal file
1
client/scripts/net/narrate_result.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dye0nd5h05cks
|
||||
14
client/scripts/net/proxy_config.gd
Normal file
14
client/scripts/net/proxy_config.gd
Normal file
@@ -0,0 +1,14 @@
|
||||
class_name ProxyConfig
|
||||
extends RefCounted
|
||||
## The client's one piece of network config: the proxy base URL. Never a key,
|
||||
## never a model name (charter §4). Overridable per environment via project
|
||||
## settings; defaults to the dev proxy.
|
||||
|
||||
const SETTING := "coc_rpg/proxy_base_url"
|
||||
const DEFAULT := "http://localhost:8000"
|
||||
|
||||
|
||||
static func base_url() -> String:
|
||||
var v = ProjectSettings.get_setting(SETTING, DEFAULT)
|
||||
var s := str(v)
|
||||
return s if s != "" else DEFAULT
|
||||
1
client/scripts/net/proxy_config.gd.uid
Normal file
1
client/scripts/net/proxy_config.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://n7p287j05isf
|
||||
19
client/tests/doubles/fake_transport.gd
Normal file
19
client/tests/doubles/fake_transport.gd
Normal file
@@ -0,0 +1,19 @@
|
||||
extends "res://scripts/net/dm_transport.gd"
|
||||
## Test double for DmTransport. Returns a canned DmResponse and records the last
|
||||
## call so tests can assert what the service posted. post_json needs no real
|
||||
## async — awaiting a plain return value resolves immediately, so the same await
|
||||
## call site in DmService drives both the real and the fake transport.
|
||||
|
||||
var _canned
|
||||
var last_path: String
|
||||
var last_body: Dictionary
|
||||
|
||||
|
||||
func set_response(resp) -> void:
|
||||
_canned = resp
|
||||
|
||||
|
||||
func post_json(path: String, body: Dictionary) -> DmResponse:
|
||||
last_path = path
|
||||
last_body = body
|
||||
return _canned
|
||||
1
client/tests/doubles/fake_transport.gd.uid
Normal file
1
client/tests/doubles/fake_transport.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://ddctedl778jdx
|
||||
80
client/tests/unit/test_dm_service.gd
Normal file
80
client/tests/unit/test_dm_service.gd
Normal file
@@ -0,0 +1,80 @@
|
||||
extends "res://addons/gut/test.gd"
|
||||
|
||||
const DmService = preload("res://scripts/net/dm_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():
|
||||
# Point the library at a missing file so narrator_line() is deterministically
|
||||
# LAST_RESORT — the degraded assertions don't depend on the authored content.
|
||||
_fallback = FallbackLibrary.new("res://content/fallback/does_not_exist.json")
|
||||
|
||||
|
||||
func _log() -> CanonLog:
|
||||
return CanonLog.new()
|
||||
|
||||
|
||||
func _service_for(resp) -> Dictionary:
|
||||
var t = FakeDmTransport.new()
|
||||
t.set_response(resp)
|
||||
return {"svc": DmService.new(t, _fallback), "transport": t}
|
||||
|
||||
|
||||
func test_success_strips_tags_and_harvests_facts():
|
||||
var body := {"prose": "The docks reek of tar. [FACT: the eastern bridge is out]"}
|
||||
var pair := _service_for(DmResponse.ok(200, body))
|
||||
var r = await pair["svc"].narrate(_log())
|
||||
assert_false(r.degraded)
|
||||
assert_eq(r.display_text, "The docks reek of tar.")
|
||||
assert_eq(r.facts, ["the eastern bridge is out"])
|
||||
|
||||
|
||||
func test_success_posts_canon_log_to_narrate_path():
|
||||
var pair := _service_for(DmResponse.ok(200, {"prose": "x"}))
|
||||
await pair["svc"].narrate(_log())
|
||||
assert_eq(pair["transport"].last_path, "/dm/narrate")
|
||||
assert_true(pair["transport"].last_body.has("canon_log"))
|
||||
|
||||
|
||||
func test_502_degrades_to_fallback_with_no_facts():
|
||||
var pair := _service_for(DmResponse.ok(502, {"detail": {"model_error": "boom"}}))
|
||||
var r = await pair["svc"].narrate(_log())
|
||||
assert_true(r.degraded)
|
||||
assert_eq(r.display_text, FallbackLibrary.LAST_RESORT)
|
||||
assert_eq(r.facts, [])
|
||||
|
||||
|
||||
func test_422_degrades():
|
||||
var pair := _service_for(DmResponse.ok(422, {"detail": {"canon_log_errors": ["x"]}}))
|
||||
var r = await pair["svc"].narrate(_log())
|
||||
assert_true(r.degraded)
|
||||
|
||||
|
||||
func test_transport_failure_degrades():
|
||||
var pair := _service_for(DmResponse.failed("connection refused"))
|
||||
var r = await pair["svc"].narrate(_log())
|
||||
assert_true(r.degraded)
|
||||
|
||||
|
||||
func test_malformed_200_missing_prose_degrades():
|
||||
var pair := _service_for(DmResponse.ok(200, {"nope": 1}))
|
||||
var r = await pair["svc"].narrate(_log())
|
||||
assert_true(r.degraded)
|
||||
|
||||
|
||||
func test_malformed_200_non_dict_body_degrades():
|
||||
var pair := _service_for(DmResponse.ok(200, null))
|
||||
var r = await pair["svc"].narrate(_log())
|
||||
assert_true(r.degraded)
|
||||
|
||||
|
||||
func test_only_facts_harvested_dispositions_and_moves_dropped():
|
||||
var body := {"prose": "Hi [FACT: a] [ADJUST_DISPOSITION: +5] [MOVE: refuse()] bye"}
|
||||
var pair := _service_for(DmResponse.ok(200, body))
|
||||
var r = await pair["svc"].narrate(_log())
|
||||
assert_eq(r.facts, ["a"])
|
||||
assert_eq(r.display_text, "Hi bye")
|
||||
1
client/tests/unit/test_dm_service.gd.uid
Normal file
1
client/tests/unit/test_dm_service.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://brm51u1xsq2iy
|
||||
33
client/tests/unit/test_fallback_library.gd
Normal file
33
client/tests/unit/test_fallback_library.gd
Normal file
@@ -0,0 +1,33 @@
|
||||
extends "res://addons/gut/test.gd"
|
||||
|
||||
const FallbackLibrary = preload("res://scripts/net/fallback_library.gd")
|
||||
|
||||
|
||||
func test_loads_authored_line_from_default_file():
|
||||
var lib = FallbackLibrary.new()
|
||||
# The authored line is distinct from LAST_RESORT, so a match proves the file was read.
|
||||
assert_ne(lib.narrator_line(), FallbackLibrary.LAST_RESORT)
|
||||
assert_true(lib.narrator_line().length() > 0)
|
||||
|
||||
|
||||
func test_missing_file_uses_last_resort():
|
||||
var lib = FallbackLibrary.new("res://content/fallback/does_not_exist.json")
|
||||
assert_eq(lib.narrator_line(), FallbackLibrary.LAST_RESORT)
|
||||
|
||||
|
||||
func test_malformed_file_uses_last_resort():
|
||||
var path := "user://bad_fallback.json"
|
||||
var f := FileAccess.open(path, FileAccess.WRITE)
|
||||
f.store_string("{ this is not valid json")
|
||||
f.close()
|
||||
var lib = FallbackLibrary.new(path)
|
||||
assert_eq(lib.narrator_line(), FallbackLibrary.LAST_RESORT)
|
||||
|
||||
|
||||
func test_empty_list_uses_last_resort():
|
||||
var path := "user://empty_fallback.json"
|
||||
var f := FileAccess.open(path, FileAccess.WRITE)
|
||||
f.store_string('{"narrator": []}')
|
||||
f.close()
|
||||
var lib = FallbackLibrary.new(path)
|
||||
assert_eq(lib.narrator_line(), FallbackLibrary.LAST_RESORT)
|
||||
1
client/tests/unit/test_fallback_library.gd.uid
Normal file
1
client/tests/unit/test_fallback_library.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bs7jxst7fmyqx
|
||||
54
client/tests/unit/test_net_primitives.gd
Normal file
54
client/tests/unit/test_net_primitives.gd
Normal file
@@ -0,0 +1,54 @@
|
||||
extends "res://addons/gut/test.gd"
|
||||
|
||||
const DmResponse = preload("res://scripts/net/dm_response.gd")
|
||||
const NarrateResult = preload("res://scripts/net/narrate_result.gd")
|
||||
const DmTransport = preload("res://scripts/net/dm_transport.gd")
|
||||
const ProxyConfig = preload("res://scripts/net/proxy_config.gd")
|
||||
|
||||
|
||||
func test_dm_response_ok():
|
||||
var r = DmResponse.ok(200, {"prose": "hi"})
|
||||
assert_eq(r.status, 200)
|
||||
assert_true(r.transport_ok)
|
||||
assert_eq(r.body, {"prose": "hi"})
|
||||
assert_eq(r.error, "")
|
||||
|
||||
|
||||
func test_dm_response_failed():
|
||||
var r = DmResponse.failed("boom")
|
||||
assert_false(r.transport_ok)
|
||||
assert_eq(r.status, 0)
|
||||
assert_eq(r.body, null)
|
||||
assert_eq(r.error, "boom")
|
||||
|
||||
|
||||
func test_narrate_result_basic():
|
||||
var r = NarrateResult.new("text", ["a"], false)
|
||||
assert_eq(r.display_text, "text")
|
||||
assert_eq(r.facts, ["a"])
|
||||
assert_false(r.degraded)
|
||||
|
||||
|
||||
func test_narrate_result_fallback():
|
||||
var r = NarrateResult.fallback("cold chamber")
|
||||
assert_eq(r.display_text, "cold chamber")
|
||||
assert_eq(r.facts, [])
|
||||
assert_true(r.degraded)
|
||||
|
||||
|
||||
func test_transport_base_returns_not_implemented():
|
||||
var t = DmTransport.new()
|
||||
var r = await t.post_json("/x", {})
|
||||
assert_false(r.transport_ok)
|
||||
assert_eq(r.error, "transport not implemented")
|
||||
|
||||
|
||||
func test_proxy_config_default():
|
||||
ProjectSettings.set_setting(ProxyConfig.SETTING, null)
|
||||
assert_eq(ProxyConfig.base_url(), ProxyConfig.DEFAULT)
|
||||
|
||||
|
||||
func test_proxy_config_override():
|
||||
ProjectSettings.set_setting(ProxyConfig.SETTING, "http://example.test:9000")
|
||||
assert_eq(ProxyConfig.base_url(), "http://example.test:9000")
|
||||
ProjectSettings.set_setting(ProxyConfig.SETTING, null)
|
||||
1
client/tests/unit/test_net_primitives.gd.uid
Normal file
1
client/tests/unit/test_net_primitives.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cpihryriwysay
|
||||
Reference in New Issue
Block a user