feat(P01-04-01,P01-04-02): SQLite schema + async store (D-007)
db/schema.sql + db/migrations/0001_init.sql — four tables: learner (single hardcoded 'learner-1'/'Alex' row, D-007 no auth), sessions (id, learner_id, scenario_id, started_at, ended_at, branch_path_json, outcome, cost_estimated_cents, debrief_text, cost_breakdown_json), turns (id, session_id, seq, role, asr_text, tts_text, latency_ms), progress (learner_id, scenario_id, attempts, last_outcome). db/migrate.py applies migrations idempotently via a _migrations tracking table. db/store.py — PraxisStore async access layer (aiosqlite): start_session, log_turn, end_session (branch_path + outcome + cost + debrief), update_progress (increment attempts + last_outcome), get_session, get_turns, get_learner. Type-annotated SessionRow/TurnRow dataclasses. 6 tests pass (migration creates all tables, hardcoded learner exists, idempotent migrations, start→log→end→query full session, update_progress, get_learner). ---ci--- phase: 1 milestone: v0.1 plan: 04 task: 04-01,04-02 status: execute persona: data-engineer requirements: covered: [REQ-STATE-01] ---/ci---
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
"""Unit tests for the SQLite schema + async store (TASK-04-01, TASK-04-02)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from db.migrate import apply_migrations
|
||||
from db.store import PraxisStore, HARDCODED_LEARNER_ID
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_db(tmp_path: Path) -> Path:
|
||||
return tmp_path / "test_praxis.db"
|
||||
|
||||
|
||||
def test_migration_creates_all_tables(tmp_db: Path):
|
||||
"""TASK-04-01: migration creates learner, sessions, turns, progress."""
|
||||
applied = apply_migrations(tmp_db)
|
||||
assert "0001_init" in applied
|
||||
|
||||
conn = sqlite3.connect(str(tmp_db))
|
||||
tables = {
|
||||
r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()
|
||||
}
|
||||
conn.close()
|
||||
assert {"learner", "sessions", "turns", "progress"} <= tables
|
||||
|
||||
|
||||
def test_hardcoded_learner_row_exists(tmp_db: Path):
|
||||
"""TASK-04-01: the hardcoded learner-1 'Alex' row exists (D-007, no auth)."""
|
||||
apply_migrations(tmp_db)
|
||||
conn = sqlite3.connect(str(tmp_db))
|
||||
row = conn.execute(
|
||||
"SELECT id, display_name FROM learner WHERE id = ?", (HARDCODED_LEARNER_ID,)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
assert row is not None
|
||||
assert row[0] == "learner-1"
|
||||
assert row[1] == "Alex"
|
||||
|
||||
|
||||
def test_migrations_are_idempotent(tmp_db: Path):
|
||||
"""Re-running migrations doesn't re-apply."""
|
||||
apply_migrations(tmp_db)
|
||||
applied = apply_migrations(tmp_db)
|
||||
assert applied == []
|
||||
|
||||
|
||||
def test_store_start_log_end_session(tmp_db: Path):
|
||||
"""TASK-04-02: start session → log 3 turns → end session → query returns full session."""
|
||||
store = PraxisStore(tmp_db)
|
||||
|
||||
async def _run():
|
||||
await store.init()
|
||||
sid = await store.start_session(HARDCODED_LEARNER_ID, "cs_refund_ca_v01")
|
||||
await store.log_turn(sid, 0, "assistant", tts_text="Hi, I want a refund.", latency_ms=None)
|
||||
await store.log_turn(sid, 1, "user", asr_text="I'm sorry, I can help.", latency_ms=450.0)
|
||||
await store.log_turn(sid, 2, "assistant", tts_text="Okay, what's the issue?", latency_ms=520.0)
|
||||
await store.end_session(
|
||||
sid,
|
||||
branch_path=["accept_resolution"],
|
||||
outcome="success",
|
||||
cost_cents=12,
|
||||
cost_breakdown={"tokens": 500, "minutes": 1.2, "chars": 320},
|
||||
debrief_text="You did well acknowledging the customer.",
|
||||
)
|
||||
sess = await store.get_session(sid)
|
||||
turns = await store.get_turns(sid)
|
||||
return sess, turns
|
||||
|
||||
sess, turns = asyncio.run(_run())
|
||||
assert sess is not None
|
||||
assert sess.learner_id == "learner-1"
|
||||
assert sess.scenario_id == "cs_refund_ca_v01"
|
||||
assert sess.outcome == "success"
|
||||
assert sess.branch_path == ["accept_resolution"]
|
||||
assert sess.cost_estimated_cents == 12
|
||||
assert sess.debrief_text == "You did well acknowledging the customer."
|
||||
assert sess.cost_breakdown["tokens"] == 500
|
||||
assert len(turns) == 3
|
||||
assert turns[0].role == "assistant"
|
||||
assert turns[1].asr_text == "I'm sorry, I can help."
|
||||
assert turns[2].latency_ms == 520.0
|
||||
|
||||
|
||||
def test_store_update_progress(tmp_db: Path):
|
||||
"""TASK-04-02: update_progress increments attempts + sets last_outcome."""
|
||||
store = PraxisStore(tmp_db)
|
||||
|
||||
async def _run():
|
||||
await store.init()
|
||||
await store.update_progress(HARDCODED_LEARNER_ID, "cs_refund_ca_v01", "success")
|
||||
await store.update_progress(HARDCODED_LEARNER_ID, "cs_refund_ca_v01", "failure")
|
||||
|
||||
async with store._connect() as db:
|
||||
cur = await db.execute(
|
||||
"SELECT attempts, last_outcome FROM progress WHERE learner_id = ? AND scenario_id = ?",
|
||||
(HARDCODED_LEARNER_ID, "cs_refund_ca_v01"),
|
||||
)
|
||||
return await cur.fetchone()
|
||||
|
||||
row = asyncio.run(_run())
|
||||
assert row is not None
|
||||
assert row[0] == 2 # two attempts
|
||||
assert row[1] == "failure" # last outcome
|
||||
|
||||
|
||||
def test_store_get_learner(tmp_db: Path):
|
||||
store = PraxisStore(tmp_db)
|
||||
|
||||
async def _run():
|
||||
await store.init()
|
||||
return await store.get_learner()
|
||||
|
||||
learner = asyncio.run(_run())
|
||||
assert learner["id"] == "learner-1"
|
||||
assert learner["display_name"] == "Alex"
|
||||
Reference in New Issue
Block a user