interactive-chat / tests /test_paging.py
Junhoee's picture
Upload 162 files
1c9c13f verified
Raw
History Blame Contribute Delete
9.27 kB
"""νŽ˜μ΄μ§€ 병합(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 # μΉ΄λ“œ μˆ˜λ³΄λ‹€ 적은 λ„˜κΉ€μœΌλ‘œ μ™„μ£Ό = 병합 λ™μž‘