Skip to content

Commit a9ed72b

Browse files
jpheinclaude
andcommitted
test: add OpenCode adapter smoke test against real DB (#56)
Validates the adapter works with a real opencode.db (18MB, 35 sessions, 620 messages). Marked @slow so CI skips it; run with `pytest -m slow`. Covers: source_summary count, ingest yield types, drawer content shape, metadata scalar constraints, exchange format, wing derivation, source_file URI stability, and adapter lifecycle (close prevents reuse). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 31d1df6 commit a9ed72b

1 file changed

Lines changed: 303 additions & 0 deletions

File tree

tests/test_opencode_smoke.py

Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
"""Smoke tests: OpenCode adapter against a real OpenCode database.
2+
3+
Validates the adapter produces correct output from a real ``opencode.db``
4+
rather than synthetic fixtures. Marked ``slow`` so they are excluded from
5+
normal CI runs — invoke with ``pytest -m slow`` to include.
6+
7+
Requires a real OpenCode SQLite database at the standard XDG path
8+
(``~/.local/share/opencode/opencode.db``). Skipped automatically when the
9+
database is absent.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import os
15+
from pathlib import Path
16+
17+
import pytest
18+
19+
from mempalace.sources.base import (
20+
DrawerRecord,
21+
SourceItemMetadata,
22+
SourceRef,
23+
)
24+
from mempalace.sources.context import PalaceContext
25+
from mempalace.sources.opencode import OpenCodeSourceAdapter, session_source_file
26+
27+
# ---------------------------------------------------------------------------
28+
# Constants
29+
# ---------------------------------------------------------------------------
30+
31+
# The tests/conftest.py redirects HOME to a temp dir before imports, so
32+
# Path("~").expanduser() resolves to the wrong location. We recover the
33+
# real home from pwd (POSIX) or fall back to an env override.
34+
try:
35+
import pwd
36+
37+
_REAL_HOME = pwd.getpwuid(os.getuid()).pw_dir
38+
except Exception:
39+
_REAL_HOME = os.environ.get("MEMPALACE_TEST_REAL_HOME", "/home/jp")
40+
41+
_REAL_DB = Path(_REAL_HOME) / ".local/share/opencode/opencode.db"
42+
_SKIP_REASON = "Real OpenCode database not found (set MEMPALACE_TEST_REAL_HOME if needed)"
43+
44+
pytestmark = [
45+
pytest.mark.slow,
46+
pytest.mark.skipif(not _REAL_DB.is_file(), reason=_SKIP_REASON),
47+
]
48+
49+
50+
# ---------------------------------------------------------------------------
51+
# Helpers
52+
# ---------------------------------------------------------------------------
53+
54+
55+
class _StubCollection:
56+
"""Minimal stand-in satisfying ``_CollectionLike`` without a real backend."""
57+
58+
def add(self, **kwargs):
59+
pass
60+
61+
def upsert(self, **kwargs):
62+
pass
63+
64+
def query(self, **kwargs):
65+
return {"ids": [[]], "documents": [[]], "metadatas": [[]]}
66+
67+
def get(self, **kwargs):
68+
return {"ids": [], "documents": [], "metadatas": []}
69+
70+
def delete(self, **kwargs):
71+
pass
72+
73+
def count(self):
74+
return 0
75+
76+
77+
class _StubKG:
78+
def add_triple(self, subject, predicate, obj, **kwargs):
79+
pass
80+
81+
82+
def _make_palace_context() -> PalaceContext:
83+
return PalaceContext(
84+
drawer_collection=_StubCollection(),
85+
knowledge_graph=_StubKG(),
86+
palace_path="/tmp/fake-palace",
87+
adapter_name="opencode",
88+
adapter_version="0.1.0",
89+
)
90+
91+
92+
# ---------------------------------------------------------------------------
93+
# Tests
94+
# ---------------------------------------------------------------------------
95+
96+
97+
class TestOpenCodeSmokeRealDB:
98+
"""Run the adapter against the real OpenCode database."""
99+
100+
def test_source_summary_reports_sessions(self):
101+
adapter = OpenCodeSourceAdapter()
102+
try:
103+
summary = adapter.source_summary(source=SourceRef(local_path=str(_REAL_DB)))
104+
finally:
105+
adapter.close()
106+
107+
assert summary.item_count is not None
108+
assert summary.item_count > 0, "Expected at least one session in the real DB"
109+
assert str(_REAL_DB.resolve()) in summary.description
110+
111+
def test_ingest_yields_records(self):
112+
"""Full ingest: collects all yielded objects and validates shapes."""
113+
adapter = OpenCodeSourceAdapter()
114+
palace = _make_palace_context()
115+
source = SourceRef(local_path=str(_REAL_DB))
116+
117+
items: list[SourceItemMetadata] = []
118+
drawers: list[DrawerRecord] = []
119+
120+
try:
121+
for obj in adapter.ingest(source=source, palace=palace):
122+
if isinstance(obj, SourceItemMetadata):
123+
items.append(obj)
124+
elif isinstance(obj, DrawerRecord):
125+
drawers.append(obj)
126+
else:
127+
pytest.fail(f"Unexpected yielded type: {type(obj).__name__}")
128+
finally:
129+
adapter.close()
130+
131+
# --- SourceItemMetadata checks ---
132+
assert len(items) > 0, "Expected at least one SourceItemMetadata"
133+
for meta in items:
134+
assert meta.source_file.startswith("opencode://"), (
135+
f"source_file URI should start with opencode://, got: {meta.source_file}"
136+
)
137+
assert "#session=" in meta.source_file, (
138+
f"source_file URI should contain #session=, got: {meta.source_file}"
139+
)
140+
assert meta.version, "version must be non-empty"
141+
142+
# --- DrawerRecord checks ---
143+
assert len(drawers) > 0, "Expected at least one DrawerRecord from the real DB"
144+
145+
for dr in drawers:
146+
# Content sanity
147+
assert dr.content, "DrawerRecord content must be non-empty"
148+
assert len(dr.content) > 10, (
149+
f"DrawerRecord content suspiciously short ({len(dr.content)} chars)"
150+
)
151+
152+
# source_file URI shape
153+
assert dr.source_file.startswith("opencode://"), (
154+
f"source_file should start with opencode://, got: {dr.source_file}"
155+
)
156+
assert "#session=" in dr.source_file
157+
158+
# chunk_index is a non-negative int
159+
assert isinstance(dr.chunk_index, int)
160+
assert dr.chunk_index >= 0
161+
162+
# Metadata required fields
163+
md = dr.metadata
164+
assert md.get("source_file") == dr.source_file
165+
assert md.get("session_id"), "session_id must be present"
166+
assert md.get("project_dir") is not None, "project_dir must be present"
167+
assert md.get("session_created_at"), "session_created_at must be present"
168+
assert isinstance(md.get("message_count"), int)
169+
assert md["message_count"] >= 2, "sessions with < 2 messages should be skipped"
170+
assert md.get("extract_mode") == "exchange"
171+
assert md.get("opencode_db_path") == str(_REAL_DB.resolve())
172+
assert md.get("wing"), "wing must be non-empty"
173+
assert md.get("room"), "room must be non-empty"
174+
assert md.get("added_by") == "opencode-adapter"
175+
assert md.get("ingest_mode") == "chunked_content"
176+
assert md.get("filed_at"), "filed_at must be present"
177+
178+
# route_hint consistency
179+
assert dr.route_hint is not None
180+
assert dr.route_hint.wing == md["wing"]
181+
assert dr.route_hint.room == md["room"]
182+
183+
def test_source_file_stability(self):
184+
"""Verify session_source_file produces consistent URIs."""
185+
db_path = str(_REAL_DB.resolve())
186+
sid = "ses_test123"
187+
uri = session_source_file(db_path, sid)
188+
assert uri == f"opencode://{db_path}#session={sid}"
189+
# Calling again should produce the exact same URI
190+
assert session_source_file(db_path, sid) == uri
191+
192+
def test_ingest_content_has_exchange_format(self):
193+
"""Verify drawer content uses the exchange-pair format (> user quotes)."""
194+
adapter = OpenCodeSourceAdapter()
195+
palace = _make_palace_context()
196+
source = SourceRef(local_path=str(_REAL_DB))
197+
198+
sample_drawers: list[DrawerRecord] = []
199+
try:
200+
for obj in adapter.ingest(source=source, palace=palace):
201+
if isinstance(obj, DrawerRecord):
202+
sample_drawers.append(obj)
203+
if len(sample_drawers) >= 20:
204+
break
205+
finally:
206+
adapter.close()
207+
208+
assert sample_drawers, "Expected drawers for exchange format check"
209+
210+
# At least some drawers should contain the ">" prefix used for
211+
# user messages in the exchange format
212+
has_user_quote = any(">" in dr.content for dr in sample_drawers)
213+
assert has_user_quote, (
214+
"Expected at least some drawers with '>' user-quote markers "
215+
"from the exchange format"
216+
)
217+
218+
def test_wing_derived_from_project_dir(self):
219+
"""Sessions rooted in known project dirs should get meaningful wing names."""
220+
adapter = OpenCodeSourceAdapter()
221+
palace = _make_palace_context()
222+
source = SourceRef(local_path=str(_REAL_DB))
223+
224+
wings_seen: set[str] = set()
225+
try:
226+
for obj in adapter.ingest(source=source, palace=palace):
227+
if isinstance(obj, DrawerRecord):
228+
wings_seen.add(obj.metadata.get("wing", ""))
229+
finally:
230+
adapter.close()
231+
232+
assert wings_seen, "Expected at least one wing"
233+
# Should not all be the generic fallback
234+
assert wings_seen != {"opencode_general"}, (
235+
"Expected some project-specific wings, not all opencode_general"
236+
)
237+
238+
def test_adapter_close_prevents_reuse(self):
239+
"""After close(), ingest raises AdapterClosedError."""
240+
from mempalace.sources.base import AdapterClosedError
241+
242+
adapter = OpenCodeSourceAdapter()
243+
adapter.close()
244+
245+
palace = _make_palace_context()
246+
source = SourceRef(local_path=str(_REAL_DB))
247+
with pytest.raises(AdapterClosedError):
248+
list(adapter.ingest(source=source, palace=palace))
249+
250+
def test_summary_count_matches_session_table(self):
251+
"""source_summary().item_count should match the session table row count."""
252+
import sqlite3
253+
254+
conn = sqlite3.connect(str(_REAL_DB))
255+
try:
256+
(expected,) = conn.execute("SELECT COUNT(*) FROM session").fetchone()
257+
finally:
258+
conn.close()
259+
260+
adapter = OpenCodeSourceAdapter()
261+
try:
262+
summary = adapter.source_summary(source=SourceRef(local_path=str(_REAL_DB)))
263+
finally:
264+
adapter.close()
265+
266+
assert summary.item_count == expected, (
267+
f"source_summary reports {summary.item_count} sessions but DB has {expected}"
268+
)
269+
270+
def test_ingest_metadata_values_are_flat_scalars(self):
271+
"""All metadata values must be flat scalars (str/int/float/bool)."""
272+
adapter = OpenCodeSourceAdapter()
273+
palace = _make_palace_context()
274+
source = SourceRef(local_path=str(_REAL_DB))
275+
276+
try:
277+
for obj in adapter.ingest(source=source, palace=palace):
278+
if isinstance(obj, DrawerRecord):
279+
for key, val in obj.metadata.items():
280+
assert isinstance(val, (str, int, float, bool)), (
281+
f"metadata[{key!r}] has non-scalar type "
282+
f"{type(val).__name__}: {val!r}"
283+
)
284+
finally:
285+
adapter.close()
286+
287+
def test_session_ids_are_unique_per_source_item(self):
288+
"""Each SourceItemMetadata should have a unique source_file."""
289+
adapter = OpenCodeSourceAdapter()
290+
palace = _make_palace_context()
291+
source = SourceRef(local_path=str(_REAL_DB))
292+
293+
source_files: list[str] = []
294+
try:
295+
for obj in adapter.ingest(source=source, palace=palace):
296+
if isinstance(obj, SourceItemMetadata):
297+
source_files.append(obj.source_file)
298+
finally:
299+
adapter.close()
300+
301+
assert len(source_files) == len(set(source_files)), (
302+
"SourceItemMetadata source_files should be unique per session"
303+
)

0 commit comments

Comments
 (0)