| """Seam tests for the UI-agnostic orchestration layer (``dukaan.session``). |
| |
| These pin the front-end contract WITHOUT any model/server: STT, the deepagents |
| loop, image OCR and TTS are all monkeypatched, so a turn flows through pure |
| orchestration. We prove: |
| |
| * a text turn shapes the agent's reply into a ``TurnResult`` (intent badge, a |
| best-effort dashboard snapshot, no error); |
| * an empty/failed STT short-circuits into a *clarification* turn and NEVER calls |
| the agent; |
| * a failed OCR short-circuits into a *re-upload* turn; |
| * a staged write commits on "haan" and is dropped (stock untouched) on "nahi"; |
| * the result is JSON-serialisable via ``to_dict``. |
| |
| Shared-state hygiene: ``staging._PENDING`` is a process global, so every test |
| that stages clears its thread at start AND end. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import json |
|
|
| import numpy as np |
|
|
| import dukaan.agent |
| import dukaan.db |
| import dukaan.normalize |
| import dukaan.ops as ops |
| import dukaan.session as S |
| import dukaan.staging as staging |
| import dukaan.stt |
| import dukaan.tts |
| from dukaan.normalize import DescribeResult |
| from dukaan.stt import TranscribeResult |
|
|
|
|
| def _item_with_qty(min_qty: int = 3) -> str: |
| """Name of any seeded inventory item with at least ``min_qty`` in stock.""" |
| rows = dukaan.db.qx( |
| "SELECT name FROM inv.inventory WHERE qty >= ? ORDER BY item_id LIMIT 1", |
| (min_qty,), |
| ) |
| assert rows, f"seed has no item with qty >= {min_qty}" |
| return rows[0]["name"] |
|
|
|
|
| |
| def test_text_turn(seeded_db, monkeypatch): |
| """A typed turn: the agent's structured reply becomes a rendered TurnResult.""" |
| monkeypatch.setattr(dukaan.tts, "synthesize", lambda *a, **k: (16000, np.zeros(1))) |
| staging.clear_pending(staging.current_thread()) |
|
|
| monkeypatch.setattr( |
| dukaan.agent, |
| "run_agent", |
| lambda t, thread_id="default", **kw: { |
| "reply": "आज की बिक्री ₹500 रही।", |
| "messages": [], |
| "tool_calls": ["get_dashboard"], |
| "intent": "lookup", |
| "pending": None, |
| }, |
| ) |
|
|
| r = S.handle_turn(text="aaj bikri", thread_id="t1", tts=False) |
| assert r.reply_text |
| assert r.intent_badge == "lookup" |
| assert isinstance(r.dashboard_snapshot, dict) |
| assert r.error is None |
|
|
|
|
| |
| def test_stt_empty_fallback_no_agent(seeded_db, monkeypatch): |
| """A failed transcription short-circuits to a clarification; agent NOT called.""" |
| monkeypatch.setattr(dukaan.tts, "synthesize", lambda *a, **k: (16000, np.zeros(1))) |
| staging.clear_pending(staging.current_thread()) |
|
|
| monkeypatch.setattr( |
| dukaan.stt, |
| "transcribe", |
| lambda *a, **k: TranscribeResult("", "", 0.0, 1.0, False, "no_speech"), |
| ) |
|
|
| def _boom(*a, **k): |
| raise AssertionError("should not be called") |
|
|
| monkeypatch.setattr(dukaan.agent, "run_agent", _boom) |
|
|
| r = S.handle_turn( |
| audio=(16000, np.zeros(8000, dtype=np.float32)), thread_id="t1", tts=False |
| ) |
| assert r.clarification |
| assert r.reply_text == r.clarification |
|
|
|
|
| |
| def test_ocr_reupload(seeded_db, monkeypatch): |
| """A failed image describe surfaces as a needs-reupload turn with a message.""" |
| monkeypatch.setattr(dukaan.tts, "synthesize", lambda *a, **k: (16000, np.zeros(1))) |
| staging.clear_pending(staging.current_thread()) |
|
|
| monkeypatch.setattr( |
| dukaan.normalize, |
| "describe_for_agent", |
| lambda *a, **k: DescribeResult("फोटो साफ़ नहीं आई — दोबारा भेजें।", False, "reupload"), |
| ) |
|
|
| r = S.handle_turn(image=object(), thread_id="t1", tts=False) |
| assert r.needs_reupload is True |
| assert r.reply_text |
|
|
|
|
| |
| def test_confirm_pending_commits(seeded_db, monkeypatch): |
| """"haan" commits the staged sale (stock -1); "nahi" leaves stock untouched.""" |
| monkeypatch.setattr(dukaan.tts, "synthesize", lambda *a, **k: (16000, np.zeros(1))) |
| staging.clear_pending("t2") |
|
|
| name = _item_with_qty(3) |
|
|
| |
| staging.stage_op("t2", "record_sale", {"item_name": name, "qty": 1}, "1 bika") |
| before = ops.find_item(name)["qty"] |
| r = S.confirm_pending("haan", thread_id="t2", tts=False) |
| assert r.intent_badge == "write" |
| assert ops.find_item(name)["qty"] == before - 1 |
|
|
| |
| after_yes = ops.find_item(name)["qty"] |
| staging.stage_op("t2", "record_sale", {"item_name": name, "qty": 1}, "1 bika") |
| r2 = S.confirm_pending("nahi", thread_id="t2", tts=False) |
| assert ops.find_item(name)["qty"] == after_yes |
| assert staging.get_pending("t2") is None |
|
|
| staging.clear_pending("t2") |
|
|
|
|
| |
| def test_to_dict_jsonable(seeded_db, monkeypatch): |
| """``TurnResult.to_dict()`` is plain-JSON serialisable (no ndarray leaks).""" |
| monkeypatch.setattr(dukaan.tts, "synthesize", lambda *a, **k: (16000, np.zeros(1))) |
| staging.clear_pending(staging.current_thread()) |
|
|
| monkeypatch.setattr( |
| dukaan.agent, |
| "run_agent", |
| lambda t, thread_id="default": { |
| "reply": "नमस्ते!", |
| "messages": [], |
| "tool_calls": [], |
| "intent": "chat", |
| "pending": None, |
| }, |
| ) |
|
|
| r = S.handle_turn(text="hi", thread_id="t1", tts=False) |
| |
| json.dumps(r.to_dict()) |
|
|