"""페이지 병합(Pager) 회귀 — 파티션 불변식 · 숨은 상태 등가성 · 복원 렌더. 핵심 회귀: 병합해도 카드당 부수효과(쇠약도/신뢰축)는 정확히 1회 — 카드 단위 순수 걷기와 최종 숨은 상태·엔딩이 완전히 일치해야 한다. """ import pytest from engine import router from engine.repositories.content import ContentRepository from engine.repositories.play_session import PlaySessionRepository from engine.services.paging import Pager from engine.services.story import StoryEngine @pytest.fixture(scope="module") def repo(): return ContentRepository("content") @pytest.fixture(scope="module") def pager(repo): cuts = {cid for cid in router.IMAGE_MAP if repo.has_card(cid)} solo = cuts | {c.id for c in repo.all_cards() if c.can_chat} return Pager(repo, solo_ids=solo, cut_ids=cuts) def pick(choices, policy): idx = next((i for i, c in enumerate(choices) if c.axis == policy), None) if idx is None: idx = next((i for i, c in enumerate(choices) if c.axis in (None, "neutral")), 0) return idx def play_paged(repo, pager, policy): """라우터와 동일한 페이지 걷기. (engine, [PageView…]) 반환.""" engine = StoryEngine(repo) pages = [pager.complete(engine, engine.start())] while pages[-1].ending is None: page = pages[-1] if page.choices: _, step = engine.choose(pick(page.choices, policy)) else: step = engine.advance() pages.append(pager.complete(engine, step)) return engine, pages def play_cards(repo, policy): """카드 단위 순수 걷기 (기준 대조군).""" engine = StoryEngine(repo) step = engine.start() while step.ending is None: if step.choices: _, step = engine.choose(pick(step.choices, policy)) else: step = engine.advance() return engine # ── 파티션 불변식 ─────────────────────────────────────────── def test_partitions_cover_all_cards_once_in_order(repo, pager): flat = [cid for part in pager.partitions for cid in part] assert flat == [c.id for c in repo.all_cards()] def test_partition_break_invariants(repo, pager): for part in pager.partitions: cards = [repo.get_card(cid) for cid in part] for a, b in zip(cards, cards[1:]): # 페이지 내부는 선형 링크 + 결정 지점/에피소드 경계 없음 assert a.next == b.id assert not a.choices and not a.next_options assert a.episode_id == b.episode_id # solo(연출 컷·채팅 앵커)는 단독 페이지 for c in cards: if c.id in pager.solo: assert len(part) == 1 def test_balanced_density_band(repo, pager): """균형 분할 — 병합(멀티카드) 페이지는 목표 대비 과적/빈약 없이 밴드 안에 든다.""" def chars(part): return sum(len(b.text) for cid in part for b in repo.get_card(cid).narration) multi = [chars(p) for p in pager.partitions if len(p) > 1] assert multi, "병합 페이지가 있어야 한다" assert max(multi) <= pager.target * 1.7 # 그리디 과적(기존 964자) 재발 방지 assert min(multi) >= pager.target * 0.55 # 파편 병합 실패 방지 def test_merging_actually_reduces_pages(repo, pager): assert len(pager.partitions) < repo.total_cards # 61보다 적어야 병합 의미가 있다 assert pager.display_total == len(pager.partitions) + len(pager.cuts & set(pager.part_of)) # ── 숨은 상태 등가성 (핵심 회귀) ──────────────────────────── @pytest.mark.parametrize("policy", ["trust", "doubt", "neutral"]) def test_paged_walk_equals_card_walk(repo, pager, policy): paged, _ = play_paged(repo, pager, policy) plain = play_cards(repo, policy) assert paged.ending.id == plain.ending.id assert paged.state.snapshot() == plain.state.snapshot() # 부수효과 정확히 1회 assert paged.visited == plain.visited # ── 진행도 표시 ───────────────────────────────────────────── def test_display_index_monotonic_over_playthrough(repo, pager): engine, pages = play_paged(repo, pager, "trust") seen = [pager.display_index(p.last.id) for p in pages if p.ending is None] assert seen == sorted(seen) # 진행도는 절대 뒤로 가지 않는다 assert seen[0] == 1 assert seen[-1] <= pager.display_total # ── 복원 렌더 ─────────────────────────────────────────────── def test_render_current_matches_played_page(repo, pager): mid = StoryEngine(repo) mid_pages = [pager.complete(mid, mid.start())] for _ in range(3): mid_pages.append(pager.complete(mid, mid.advance() if not mid_pages[-1].choices else mid.choose(pick(mid_pages[-1].choices, "trust"))[1])) restored = StoryEngine.restore(repo, mid.snapshot()) view = pager.render_current(restored) last_seen = mid_pages[-1] assert [b.text for b in view.narration] == [b.text for b in last_seen.narration] assert view.first.id == last_seen.first.id and view.last.id == last_seen.last.id # ── 블록 역할 유도 (lead_in) ──────────────────────────────── def test_lead_in_roles_derived_on_load(repo): """발화 직전의 짧은 prose는 lead_in — E04-10 '한 문장 페이지' 회귀.""" card = repo.get_card("E04-10") assert card.narration[0].type == "prose" assert card.narration[0].role == "lead_in" # 39자 리드인 assert card.narration[2].role is None # 여파 prose는 미지정 # 긴 prose(140자 초과)가 발화 앞에 있어도 리드인이 아니다 — E01-03 첫 블록 long_case = repo.get_card("E01-03") assert long_case.narration[0].type == "prose" assert len(long_case.narration[0].text) > 140 assert long_case.narration[1].type == "character_dialogue" assert long_case.narration[0].role is None def test_thin_card_warnings(): """C안 — 화면 최소 밀도 미달 카드는 validate 경고에 잡힌다.""" from pathlib import Path from engine.schemas.validate import check_links, load_content episodes, r_cards, chapter, *_ = load_content(Path("content")) _, warnings = check_links(episodes, chapter, r_cards) flagged = [w for w in warnings if "미달" in w] assert any(w.startswith("E05-05") for w in flagged) # 96자 채팅 카드 assert any(w.startswith("E06-10C") for w in flagged) # 154자 분기 변형 # ── 라우터 레벨 ───────────────────────────────────────────── @pytest.mark.asyncio async def test_background_follows_place_tags_and_meta_has_intro(tmp_path, monkeypatch): """장소 태그 → 배경 매핑이 완주 동안 반영되고, 인트로 시퀀스가 메타에 실린다.""" monkeypatch.setattr(router, "_playdb", PlaySessionRepository(tmp_path / "s.db")) monkeypatch.setattr(router, "_sessions", {}) meta = await router.meta() assert meta["intro_images"] == router.INTRO_IMAGES and len(meta["intro_images"]) == 3 data = await router.create_session() sid, step = data["session_id"], data["step"] seen = {step["background"]} portraits = set() while step["ending"] is None: portraits |= {n["portrait"] for n in step["narration"] if n["type"] == "character_dialogue" and n["portrait"]} if step["choices"]: step = (await router.choose(sid, router.ChooseBody(index=0)))["step"] else: step = (await router.advance(sid))["step"] seen.add(step["background"]) # 침실·만찬홀(식당)·숲길(안개 숲)은 어느 경로든 지나간다 + 엔딩은 배경 없음 assert {"/assets/배경/침실.png", "/assets/배경/식당.png", "/assets/배경/안개 숲.png"} <= seen assert step["background"] is None # 화자 초상 — 카르밀라·아버지는 어느 경로든 발화한다 (VN 발화 연출) assert {"/assets/인물/카르밀라.png", "/assets/인물/아버지.png"} <= portraits @pytest.mark.asyncio async def test_router_serves_merged_pages(tmp_path, monkeypatch): monkeypatch.setattr(router, "_playdb", PlaySessionRepository(tmp_path / "s.db")) monkeypatch.setattr(router, "_sessions", {}) data = await router.create_session() sid, step = data["session_id"], data["step"] total = step["progress"]["total"] assert total == router._pager.display_total clicks = 1 while step["ending"] is None: if step["choices"]: step = (await router.choose(sid, router.ChooseBody(index=0)))["step"] else: step = (await router.advance(sid))["step"] clicks += 1 assert clicks < 61 # 카드 수보다 적은 넘김으로 완주 = 병합 동작