"""Conformance X6: the CLI renders the --transcript output as an aligned table. The engine transcript is a list of strings, mostly shaped ``round {n} {combatant_id}: {message}`` (see ``CombatEngine._log``), with a few non-conforming lines (initiative rolls, battle-over notices). The CLI must present these entries as a three-column table (Round / Combattant / Message) with a header row, a separator line and display-width-aware padding, while non-conforming lines fall back to their raw text — never dropped. The transcript DATA (content and order) is untouched: this is presentation only. """ from __future__ import annotations import unicodedata from pathlib import Path from typing import TYPE_CHECKING from pf1e_simulator.cli import main, render_transcript_table if TYPE_CHECKING: from collections.abc import Sequence import pytest ROOT = Path(__file__).resolve().parents[1] MAP = ROOT / "data" / "maps" / "sample_arena.yaml" GOBLIN = ROOT / "data" / "monsters" / "goblin.json" ORC = ROOT / "data" / "monsters" / "orc.json" _SAMPLE = [ "initiative: goblin d20=17+6=23", "round 1 goblin: short sword vs orc -> HIT (4 dmg)", "round 1 orc: falchion vs goblin -> MISS", "round 2 goblin: confused: hits self for 3 (hp 6->3)", "battle over: goblin win in 2 rounds", ] def _display_width(text: str) -> int: """Terminal columns used by text (wide CJK counts 2, combining marks 0).""" width = 0 for char in text: if unicodedata.combining(char): continue width += 2 if unicodedata.east_asian_width(char) in ("W", "F") else 1 return width def _table_lines(transcript: Sequence[str]) -> list[str]: return render_transcript_table(transcript).splitlines() def _parsed_rows(lines: list[str]) -> list[str]: """Data rows built from parsed entries (the round column pads them left).""" return [line for line in lines[2:] if line.startswith(" ")] def test_render_headers_and_separator() -> None: lines = _table_lines(_SAMPLE) assert lines[0].split() == ["Round", "Combattant", "Message"] assert set(lines[1]) == {"-", " "} # One dash run per column, columns separated by two spaces. assert len(lines[1].split(" ")) == 3 # Header, separator, then one row per transcript entry. assert len(lines) == len(_SAMPLE) + 2 def test_render_exact_small_table() -> None: out = render_transcript_table(["round 3 goblin-2: move (1,2)"]) assert out == ( "Round Combattant Message \n" "----- ---------- ----------\n" " 3 goblin-2 move (1,2)" ) def test_render_round_column_right_aligned() -> None: lines = _table_lines(["round 1 a: x", "round 10 b: y"]) # Header "Round" is 5 wide, so round numbers are right-aligned inside it. assert lines[2].startswith(" 1 ") assert lines[3].startswith(" 10 ") def test_render_alignment_uniform_display_width() -> None: lines = _table_lines(_SAMPLE) widths = {_display_width(line) for line in [lines[0], lines[1], *_parsed_rows(lines)]} assert len(widths) == 1 def test_render_non_ascii_alignment_accented_and_cjk() -> None: transcript = [ "round 1 éowyn: attaque l'orc → 6 dégâts", "round 1 oni: 攻撃 vs orc -> HIT", "round 2 goblin: wait", ] lines = _table_lines(transcript) widths = {_display_width(line) for line in lines} assert len(widths) == 1 def test_render_raw_fallback_preserved_verbatim() -> None: lines = _table_lines(_SAMPLE) assert "initiative: goblin d20=17+6=23" in lines assert "battle over: goblin win in 2 rounds" in lines # Fallback rows keep the original order between parsed rows. assert lines.index("initiative: goblin d20=17+6=23") == 2 assert lines[-1] == "battle over: goblin win in 2 rounds" def test_render_empty_transcript_is_header_only() -> None: lines = _table_lines([]) assert lines[0].split() == ["Round", "Combattant", "Message"] assert set(lines[1]) == {"-", " "} assert len(lines) == 2 def test_render_long_transcript_stays_aligned() -> None: transcript = [f"round {n} combatant-{n}: message number {n}" for n in range(1, 301)] lines = _table_lines(transcript) widths = {_display_width(line) for line in lines} assert len(widths) == 1 assert len(lines) == 302 def test_cli_transcript_prints_table(capsys: pytest.CaptureFixture[str]) -> None: rc = main( [ "--map", str(MAP), "--side", "players", str(GOBLIN), "--side", "monsters", str(ORC), "--runs", "5", "--seed", "1", "--transcript", ] ) out = capsys.readouterr().out assert rc == 0 assert "Déroulé du premier combat" in out # Table markers: header row and separator line. assert "Round" in out assert "Combattant" in out assert "Message" in out assert any(set(line) <= {"-", " "} and "-" in line for line in out.splitlines()) # Non-conforming engine lines still show up (raw fallback). assert "initiative:" in out # The balance report must still follow the transcript table. assert "Rapport d'équilibrage" in out assert out.index("Déroulé du premier combat") < out.index("Rapport d'équilibrage")