Spaces:
Sleeping
Sleeping
| """νμ΄μ§ λ³ν©(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 | |
| def repo(): | |
| return ContentRepository("content") | |
| 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)) | |
| # ββ μ¨μ μν λ±κ°μ± (ν΅μ¬ νκ·) ββββββββββββββββββββββββββββ | |
| 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μ λΆκΈ° λ³ν | |
| # ββ λΌμ°ν° λ 벨 βββββββββββββββββββββββββββββββββββββββββββββ | |
| 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 | |
| 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 # μΉ΄λ μλ³΄λ€ μ μ λκΉμΌλ‘ μμ£Ό = λ³ν© λμ | |