"""Guide board: schema parsers + card reset semantics (no LLM).""" import guide_board as gb TOPIC, FMT = "t", "Guide" def test_ziele_schema(): ok = gb._ziele_schema({"ziele": [{"id": "z1", "text": "Erklären, warum X", "sub": "S"}, {"id": "z2", "text": "Nennen von Y"}]}) assert [z["id"] for z in ok] == ["z1", "z2"] assert gb._ziele_schema({"ziele": []}) is None assert gb._ziele_schema({"ziele": [{"id": "z1", "text": "a"}, {"id": "z1", "text": "b"}]}) \ == [{"id": "z1", "text": "a", "sub": ""}] # duplicate ids fold assert gb._ziele_schema("quatsch") is None def test_gate_schema(): assert gb._gate_schema({"ok": True}) == [] claims = gb._gate_schema({"claims": [{"text": "Falsch", "grund": "fehlt"}]}) assert claims == [{"text": "Falsch", "grund": "fehlt"}] assert gb._gate_schema({}) is None def test_coverage_schema(): res = gb._coverage_schema({"ziele": {"z1": True, "z2": "false"}, "luecken": [{"ziel": "z2", "fehlt": "Beweis"}], "ballast": ["Abschweifung"]}, {"z1", "z2"}) assert res["ziele"] == {"z1": True, "z2": False} assert res["luecken"][0]["fehlt"] == "Beweis" assert gb._coverage_schema({"ziele": {"z1": True}}, {"z1", "z2"}) is None # z2 missing def test_problems_schema(): assert gb._problems_schema({"ok": True}) == [] assert gb._problems_schema({"problems": [{"section": "S", "problem": "zu lang"}]}) == ["zu lang"] assert gb._problems_schema({"problems": []}) is None async def test_reset_from_stage(testdb): db = testdb await db.upsert_guide_card(TOPIC, FMT, "a", "A") await db.upsert_guide_card(TOPIC, FMT, "b", "B") await db.set_guide_card(TOPIC, FMT, "a", stage="done", md="text", writer_rounds=2) await db.set_guide_card(TOPIC, FMT, "b", stage="coverage", md="text") await db.put_lernziel(TOPIC, "a", "z1", "Ziel") # reset ab writer (idx 2): beide Karten zurück, md geleert, Ziele bleiben moved = await gb.reset_from_stage(TOPIC, FMT, 2) assert moved == 2 cards = {c["block_norm"]: c for c in await db.list_guide_cards(TOPIC, FMT)} assert cards["a"]["stage"] == "writer" and cards["a"]["md"] == "" and cards["a"]["writer_rounds"] == 0 assert cards["b"]["stage"] == "writer" assert await db.list_lernziele(TOPIC, "a") # reset ab lernziele (idx 0): Ziele weg await gb.reset_from_stage(TOPIC, FMT, 0) assert not await db.list_lernziele(TOPIC, "a") assert (await db.list_guide_cards(TOPIC, FMT))[0]["stage"] == "lernziele" async def test_done_step(testdb): db = testdb assert await gb.done_step(TOPIC, FMT) == -1 await db.upsert_guide_card(TOPIC, FMT, "a", "A") assert await gb.done_step(TOPIC, FMT) == -1 # alles in lernziele await db.set_guide_card(TOPIC, FMT, "a", stage="coverage") assert await gb.done_step(TOPIC, FMT) == 3 # bis fakten_gate fertig await db.set_guide_card(TOPIC, FMT, "a", stage="done") assert await gb.done_step(TOPIC, FMT) == len(gb.GUIDE_STAGES) async def test_run_card_sets_and_clears_live_info(testdb, monkeypatch): """Regression: _live nutzte env.format_name (existiert nicht) → AttributeError beim ersten Stage-Start. Treibt eine Karte durch _run_card mit Fake-Stage.""" import asyncio from types import SimpleNamespace import guide_board as gb db = testdb await db.upsert_guide_card("t", "Guide", "alpha", "Alpha") env = SimpleNamespace(ctx=None, guide_id="g-live", topic="t", format="Guide") card = {"block_norm": "alpha", "block": "Alpha", "stage": "lernziele", "status": "open"} seen = {} async def fake_stage(env2, card2): seen.update(dict(gb._live_info)) card2["stage"] = "done" return True monkeypatch.setattr(gb, "_STAGE_FN", {"lernziele": fake_stage}) await gb._run_card(env, card, asyncio.Semaphore(1)) assert card["stage"] == "done" assert ("t", "Guide", "alpha") in seen # live info stand während der Stage assert ("t", "Guide", "alpha") not in gb._live_info # und wurde aufgeräumt def test_merge_split_sections_one_section_all_markers(): import guide_board as gb from textkit import _parse_fragment a = _parse_fragment(""" Kurzer Einstieg kompakt. YAML kompakt. Einstieg ausführlich. YAML ausführlich.""")[0] b = _parse_fragment(""" TOML kompakt. Unerwünschter zweiter Einstieg. TOML ausführlich.""")[0] merged = gb._merge_split_sections(a, b) secs = _parse_fragment(merged) assert len(secs) == 1 sec = secs[0] assert sec["title"] == "Front Matter" assert [s["title"] for s in sec["subs"]] == ["YAML-Basics", "TOML-Sektionen"] assert sec["anchor"] == "Einstieg ausführlich." # Teil-B-Einstieg verworfen assert "TOML ausführlich." in sec["md"] and "YAML kompakt." in sec["compact"] async def test_writer_splits_oversized_first_draft(testdb, monkeypatch, tmp_path): import guide_board as gb from types import SimpleNamespace db = testdb await db.upsert_guide_card("t", "Guide", "gross", "Gross") calls = [] async def fake_slot(ctx, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None): calls.append(label) part = "2" if key.endswith("-b") else "1" p = tmp_path / f"out-{key[-1]}.md" p.write_text(f"\n\n" + ("Einstieg.\n" if part == "1" else "") + f"\nText {part}.", encoding="utf-8") # payload liest die ECHTE Slot-Datei — wir schreiben direkt an deren Pfad import re as _re m = _re.search(r"(/\S+\.md)", prompt) with open(m.group(1), "w", encoding="utf-8") as f: f.write(p.read_text(encoding="utf-8")) return "ok", payload(None) monkeypatch.setattr(gb, "run_single_slot", fake_slot) subs = [{"title": f"Sub {i}", "level": "beginner", "relevance": "relevant"} for i in range(31)] env = SimpleNamespace(ctx=SimpleNamespace(topic="t", provider="p", is_cancelled=lambda: False), guide_id="g", topic="t", format="Guide", instructions="", subs_by_title={"Gross": subs}, spec="", slot=lambda name: tmp_path / name) monkeypatch.setattr(gb, "_card_facts", lambda e, b: "") card = {"block_norm": "gross", "block": "Gross", "stage": "writer", "status": "open", "writer_rounds": 0, "gate_info": "", "md": "", "chapter": "K1"} ok = await gb._stage_writer(env, card) assert ok is True assert [c for c in calls if "(1/2)" in c] and [c for c in calls if "(2/2)" in c] from textkit import _parse_fragment secs = _parse_fragment(card["md"]) assert len(secs) == 1 and [s["title"] for s in secs[0]["subs"]] == ["Sub 1", "Sub 2"] assert card["stage"] == "fakten_gate"