Skip to content

Commit 9beb1c4

Browse files
authored
Deepen QA test architecture (#25)
1 parent 31f5f0e commit 9beb1c4

12 files changed

Lines changed: 381 additions & 124 deletions

CONTEXT.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,15 @@ policy, and cache ordering by `last_reused_at`. The code module lives at
118118
serialize the catalog result.
119119
_Avoid_: rule list route logic, cached ruling helper, rule history adapter.
120120

121+
**Foundry Import State Ledger**:
122+
The Pathfinder module behavior behind the `.foundry_chat_import_state.json` file shared by Foundry
123+
chat import dedupe and Foundry memory projection idempotency: state path naming, missing/malformed
124+
file tolerance, legacy `imported_keys` compatibility, projection key preservation, and sorted JSON
125+
write shape. The code module lives at `modules/pathfinder/app/foundry_import_state_ledger.py`.
126+
Foundry import and projection modules must not duplicate the state-file schema or read-merge-write
127+
rules.
128+
_Avoid_: Foundry state helper, chat import JSON cache, projection dedupe file.
129+
121130
**PF Archive Import Plan**:
122131
The side-effect-free Pathfinder module behavior that turns an on-disk archive root into planned
123132
Pathfinder Vault writes: markdown walk, known NPC slug discovery, route decisions, large-import
@@ -217,6 +226,7 @@ Adapters should do only translation/auth/delegation.
217226
- Sweep status store: `app/services/sweep_status_store.py`
218227
- Background scheduling seam: `app/services/task_runner.py`
219228
- PF2e Foundry NeDB chat import: `modules/pathfinder/app/foundry_chat_import.py`
229+
- Foundry Import State Ledger: `modules/pathfinder/app/foundry_import_state_ledger.py`
220230
- PF2e Rule Query: `modules/pathfinder/app/rule_query.py`
221231
- Pathfinder Player Interaction: `modules/pathfinder/app/player_interaction_orchestrator.py`
222232
- Rule Cache Catalog: `modules/pathfinder/app/rule_cache_catalog.py`

interfaces/discord/response_renderer.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ async def send_rendered_response(send_fn, response: "str | dict") -> None:
3232
if rtype == "embed":
3333
await send_fn(content=response.get("content", ""), embed=response["embed"])
3434
return
35+
if rtype == "suppressed":
36+
return
3537
await send_fn(response.get("content", str(response)))
3638
return
3739

interfaces/discord/tests/test_response_renderer.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,45 @@ async def test_send_rendered_response_text():
1111
send_fn.assert_awaited_once_with("hello")
1212

1313

14+
async def test_send_rendered_response_empty_text_noops():
15+
send_fn = AsyncMock()
16+
await response_renderer.send_rendered_response(send_fn, "")
17+
send_fn.assert_not_awaited()
18+
19+
1420
async def test_send_rendered_response_embed_dict():
1521
send_fn = AsyncMock()
1622
embed = object()
1723
await response_renderer.send_rendered_response(
1824
send_fn, {"type": "embed", "content": "c", "embed": embed}
1925
)
2026
send_fn.assert_awaited_once_with(content="c", embed=embed)
27+
28+
29+
async def test_send_rendered_response_file_dict(monkeypatch):
30+
class FakeFile:
31+
def __init__(self, file_obj, *, filename):
32+
self.file_obj = file_obj
33+
self.filename = filename
34+
35+
monkeypatch.setattr(response_renderer.discord, "File", FakeFile, raising=False)
36+
send_fn = AsyncMock()
37+
await response_renderer.send_rendered_response(
38+
send_fn,
39+
{
40+
"type": "file",
41+
"content": "download",
42+
"file_bytes": b"abc",
43+
"filename": "result.txt",
44+
},
45+
)
46+
47+
send_fn.assert_awaited_once()
48+
assert send_fn.await_args.kwargs["content"] == "download"
49+
assert send_fn.await_args.kwargs["file"].filename == "result.txt"
50+
51+
52+
async def test_send_rendered_response_suppressed_dict_noops():
53+
send_fn = AsyncMock()
54+
await response_renderer.send_rendered_response(send_fn, {"type": "suppressed"})
55+
send_fn.assert_not_awaited()

modules/pathfinder/app/foundry_chat_import.py

Lines changed: 13 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,14 @@
66
import shutil
77
import tempfile
88
from pathlib import Path
9-
from typing import Protocol
9+
from typing import Any, Callable, Protocol
10+
11+
from app.foundry_import_state_ledger import (
12+
load_imported_keys,
13+
load_projection_state_dict,
14+
save_imported_keys,
15+
state_path_for_inbox,
16+
)
1017

1118
try:
1219
import plyvel # type: ignore
@@ -21,7 +28,6 @@ async def put_note(self, path: str, content: str) -> None: ...
2128
_TAG_RE = re.compile(r"<[^>]+>")
2229
_WS_RE = re.compile(r"\s+")
2330
_IMPORTED_SUFFIX_RE = re.compile(r"_imported(?:_\d+)?$")
24-
_DEDUPE_STATE_FILE = ".foundry_chat_import_state.json"
2531

2632

2733
def _strip_html(text: str) -> str:
@@ -182,21 +188,11 @@ def _mark_leveldb_dir_imported(inbox: Path) -> list[str]:
182188

183189

184190
def _dedupe_state_path(inbox: Path) -> Path:
185-
return inbox / _DEDUPE_STATE_FILE
191+
return state_path_for_inbox(inbox)
186192

187193

188194
def _load_dedupe_keys(inbox: Path) -> set[str]:
189-
path = _dedupe_state_path(inbox)
190-
if not path.exists():
191-
return set()
192-
try:
193-
data = json.loads(path.read_text(encoding="utf-8"))
194-
except Exception:
195-
return set()
196-
keys = data.get("imported_keys") if isinstance(data, dict) else None
197-
if not isinstance(keys, list):
198-
return set()
199-
return {str(k) for k in keys}
195+
return load_imported_keys(inbox)
200196

201197

202198
def _load_projection_state(path: Path) -> dict[str, set[str]]:
@@ -214,67 +210,16 @@ def _load_projection_state(path: Path) -> dict[str, set[str]]:
214210
Pre-Phase-37 state files (only `imported_keys`) load cleanly with empty
215211
projection sets — no exceptions, no KeyError.
216212
"""
217-
out: dict[str, set[str]] = {
218-
"imported_keys": set(),
219-
"player_projection_keys": set(),
220-
"npc_projection_keys": set(),
221-
}
222-
if not path.exists():
223-
return out
224-
try:
225-
data = json.loads(path.read_text(encoding="utf-8"))
226-
except Exception:
227-
return out
228-
if not isinstance(data, dict):
229-
return out
230-
for key in (
231-
"imported_keys",
232-
"player_projection_keys",
233-
"npc_projection_keys",
234-
):
235-
val = data.get(key)
236-
if isinstance(val, list):
237-
out[key] = {str(k) for k in val}
238-
return out
239-
240-
241-
def _save_state(
242-
path: Path,
243-
*,
244-
imported_keys: set[str],
245-
player_keys: set[str] | None = None,
246-
npc_keys: set[str] | None = None,
247-
) -> None:
248-
"""Write state file. If projection keys are None, preserve legacy single-array shape.
249-
250-
When player_keys/npc_keys are provided, all three arrays are emitted. This
251-
matches the foundry_memory_projection write shape exactly.
252-
"""
253-
if player_keys is None and npc_keys is None:
254-
payload: dict[str, Any] = {"imported_keys": sorted(imported_keys)}
255-
else:
256-
payload = {
257-
"imported_keys": sorted(imported_keys),
258-
"player_projection_keys": sorted(player_keys or set()),
259-
"npc_projection_keys": sorted(npc_keys or set()),
260-
}
261-
path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
213+
return load_projection_state_dict(path)
262214

263215

264216
def _save_dedupe_keys(inbox: Path, keys: set[str]) -> None:
265-
"""Legacy single-array writer — preserved as a thin wrapper over _save_state.
217+
"""Legacy single-array writer — preserved as a thin wrapper over the ledger.
266218
267219
When projection state is also tracked on disk, the importer must NOT trample
268220
the projection arrays. Read-then-merge keeps all three buckets intact.
269221
"""
270-
path = _dedupe_state_path(inbox)
271-
existing = _load_projection_state(path)
272-
player = existing["player_projection_keys"]
273-
npc = existing["npc_projection_keys"]
274-
if player or npc:
275-
_save_state(path, imported_keys=keys, player_keys=player, npc_keys=npc)
276-
else:
277-
_save_state(path, imported_keys=keys)
222+
save_imported_keys(inbox, keys)
278223

279224

280225
def _message_key(record: dict) -> str:
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
"""Foundry import/projection state ledger.
2+
3+
Owns the ``.foundry_chat_import_state.json`` file shared by Foundry chat import
4+
dedupe and Foundry memory projection idempotency. Missing, malformed, and
5+
legacy one-array files are tolerated so existing inboxes keep importing.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import json
11+
from dataclasses import dataclass
12+
from pathlib import Path
13+
from typing import Any
14+
15+
STATE_FILE_NAME = ".foundry_chat_import_state.json"
16+
17+
18+
@dataclass(frozen=True)
19+
class FoundryImportState:
20+
imported_keys: set[str]
21+
player_projection_keys: set[str]
22+
npc_projection_keys: set[str]
23+
24+
25+
def state_path_for_inbox(inbox: Path) -> Path:
26+
return inbox / STATE_FILE_NAME
27+
28+
29+
def load_foundry_import_state(path: Path) -> FoundryImportState:
30+
out = FoundryImportState(
31+
imported_keys=set(),
32+
player_projection_keys=set(),
33+
npc_projection_keys=set(),
34+
)
35+
if not path.exists():
36+
return out
37+
try:
38+
data = json.loads(path.read_text(encoding="utf-8"))
39+
except Exception:
40+
return out
41+
if not isinstance(data, dict):
42+
return out
43+
return FoundryImportState(
44+
imported_keys=_string_set(data.get("imported_keys")),
45+
player_projection_keys=_string_set(data.get("player_projection_keys")),
46+
npc_projection_keys=_string_set(data.get("npc_projection_keys")),
47+
)
48+
49+
50+
def load_imported_keys(inbox: Path) -> set[str]:
51+
return load_foundry_import_state(state_path_for_inbox(inbox)).imported_keys
52+
53+
54+
def load_projection_state_dict(path: Path) -> dict[str, set[str]]:
55+
state = load_foundry_import_state(path)
56+
return {
57+
"imported_keys": state.imported_keys,
58+
"player_projection_keys": state.player_projection_keys,
59+
"npc_projection_keys": state.npc_projection_keys,
60+
}
61+
62+
63+
def save_imported_keys(inbox: Path, keys: set[str]) -> None:
64+
path = state_path_for_inbox(inbox)
65+
existing = load_foundry_import_state(path)
66+
if existing.player_projection_keys or existing.npc_projection_keys:
67+
_write_state(
68+
path,
69+
imported_keys=keys,
70+
player_keys=existing.player_projection_keys,
71+
npc_keys=existing.npc_projection_keys,
72+
include_projection_keys=True,
73+
)
74+
return
75+
_write_state(
76+
path,
77+
imported_keys=keys,
78+
player_keys=set(),
79+
npc_keys=set(),
80+
include_projection_keys=False,
81+
)
82+
83+
84+
def save_projection_state(
85+
path: Path,
86+
*,
87+
imported_keys: set[str],
88+
player_keys: set[str],
89+
npc_keys: set[str],
90+
) -> None:
91+
_write_state(
92+
path,
93+
imported_keys=imported_keys,
94+
player_keys=player_keys,
95+
npc_keys=npc_keys,
96+
include_projection_keys=True,
97+
)
98+
99+
100+
def _string_set(value: Any) -> set[str]:
101+
if not isinstance(value, list):
102+
return set()
103+
return {str(item) for item in value}
104+
105+
106+
def _write_state(
107+
path: Path,
108+
*,
109+
imported_keys: set[str],
110+
player_keys: set[str],
111+
npc_keys: set[str],
112+
include_projection_keys: bool,
113+
) -> None:
114+
payload: dict[str, Any] = {"imported_keys": sorted(imported_keys)}
115+
if include_projection_keys:
116+
payload["player_projection_keys"] = sorted(player_keys)
117+
payload["npc_projection_keys"] = sorted(npc_keys)
118+
path.write_text(json.dumps(payload, indent=2), encoding="utf-8")

modules/pathfinder/app/foundry_memory_projection.py

Lines changed: 16 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,15 @@
3131
"""
3232
from __future__ import annotations
3333

34-
import json
3534
import logging
3635
from pathlib import Path
3736
from typing import Any, Callable
3837

38+
from app.foundry_import_state_ledger import (
39+
load_foundry_import_state,
40+
load_projection_state_dict,
41+
save_projection_state,
42+
)
3943
from app.foundry_projection_planner import (
4044
ProjectionPlan,
4145
ProjectionState,
@@ -62,28 +66,7 @@ def _load_projection_state(path: Path) -> dict[str, set[str]]:
6266
Missing file or malformed JSON yields all-empty sets. Tolerant of legacy
6367
state files that contain only ``imported_keys``.
6468
"""
65-
out: dict[str, set[str]] = {
66-
"imported_keys": set(),
67-
"player_projection_keys": set(),
68-
"npc_projection_keys": set(),
69-
}
70-
if not path.exists():
71-
return out
72-
try:
73-
data = json.loads(path.read_text(encoding="utf-8"))
74-
except Exception:
75-
return out
76-
if not isinstance(data, dict):
77-
return out
78-
for key in (
79-
"imported_keys",
80-
"player_projection_keys",
81-
"npc_projection_keys",
82-
):
83-
val = data.get(key)
84-
if isinstance(val, list):
85-
out[key] = {str(k) for k in val}
86-
return out
69+
return load_projection_state_dict(path)
8770

8871

8972
def _save_projection_state(
@@ -94,12 +77,12 @@ def _save_projection_state(
9477
npc_keys: set[str],
9578
) -> None:
9679
"""Atomically write projection state to disk preserving all three arrays."""
97-
payload = {
98-
"imported_keys": sorted(imported_keys),
99-
"player_projection_keys": sorted(player_keys),
100-
"npc_projection_keys": sorted(npc_keys),
101-
}
102-
path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
80+
save_projection_state(
81+
path,
82+
imported_keys=imported_keys,
83+
player_keys=player_keys,
84+
npc_keys=npc_keys,
85+
)
10386

10487

10588
# Success statuses returned by append_npc_history_row that represent a real write.
@@ -192,11 +175,11 @@ async def project_foundry_chat_memory(
192175
dict with keys: player_updates, npc_updates, player_deduped, npc_deduped,
193176
unmatched_speakers, dry_run.
194177
"""
195-
state = _load_projection_state(dedupe_store_path)
178+
state = load_foundry_import_state(dedupe_store_path)
196179
projection_state = ProjectionState(
197-
imported_keys=state["imported_keys"],
198-
player_projection_keys=state["player_projection_keys"],
199-
npc_projection_keys=state["npc_projection_keys"],
180+
imported_keys=state.imported_keys,
181+
player_projection_keys=state.player_projection_keys,
182+
npc_projection_keys=state.npc_projection_keys,
200183
)
201184
plan = await build_foundry_projection_plan(
202185
records=records,

0 commit comments

Comments
 (0)