feat(cli): render --transcript output as a table
This commit is contained in:
+7
-1
@@ -80,8 +80,14 @@ def test_cli_transcript_prints_first_battle_log(capsys: pytest.CaptureFixture[st
|
||||
out = capsys.readouterr().out
|
||||
assert rc == 0
|
||||
assert "Déroulé du premier combat" in out
|
||||
# X6: the transcript is rendered as a table (header + separator); engine
|
||||
# lines outside the "round N id: message" shape (initiative rolls) stay
|
||||
# visible as raw fallback rows.
|
||||
assert "Round" in out
|
||||
assert "Combattant" in out
|
||||
assert "Message" in out
|
||||
assert "initiative:" in out
|
||||
assert "round 1" in out
|
||||
assert "goblin" in out
|
||||
# The balance report must still be printed after the transcript.
|
||||
assert "Rapport d'équilibrage" in out
|
||||
assert out.index("Déroulé du premier combat") < out.index("Rapport d'équilibrage")
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"""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")
|
||||
Reference in New Issue
Block a user