From bce0a59e60c6321edd6ce273215a1e7aeae261dc Mon Sep 17 00:00:00 2001 From: ctan Date: Sun, 20 Sep 2026 17:48:47 +0200 Subject: [PATCH] fix(map): fail on blank rows inside a layer, keep trailing-space cells verbatim --- src/pf1e_simulator/map.py | 21 +++++++++++++++++---- tests/test_map.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/src/pf1e_simulator/map.py b/src/pf1e_simulator/map.py index 32e9f56..eff99da 100644 --- a/src/pf1e_simulator/map.py +++ b/src/pf1e_simulator/map.py @@ -89,14 +89,27 @@ class _MapFile(BaseModel): def _split_rows(block: str, what: str, path: Path) -> tuple[str, ...]: - rows = tuple(line.rstrip() for line in block.splitlines() if line.strip()) - if not rows: + """Split an ASCII layer into equal-width rows, failing loudly on gaps. + + Trailing empty lines are a YAML block-scalar artifact and dropped; an + empty line *inside* the layer is a missing row -- a data error, never a + silently dropped one. Rows are kept verbatim (no rstrip): trailing spaces + are cells whenever " " is in the legend, and the width/char checks catch + them otherwise. + """ + lines = block.splitlines() + while lines and lines[-1] == "": + lines.pop() + if not lines: msg = f"{what} layer is empty" raise MapValidationError(path, msg) - if len({len(row) for row in rows}) != 1: + if any(line == "" for line in lines): + msg = f"{what} layer has a blank row inside the layer" + raise MapValidationError(path, msg) + if len({len(row) for row in lines}) != 1: msg = f"{what} layer has ragged rows" raise MapValidationError(path, msg) - return rows + return tuple(lines) def _check_layer_dimensions( diff --git a/tests/test_map.py b/tests/test_map.py index 8144379..5745b46 100644 --- a/tests/test_map.py +++ b/tests/test_map.py @@ -73,6 +73,35 @@ def test_rejects_ragged_rows(tmp_path: Path) -> None: load_map(_write(tmp_path, body)) +def test_rejects_blank_row_inside_layer(tmp_path: Path) -> None: + """A truly blank line inside a layer is a missing row -- an error, never + silently dropped (the old filter shrank the row count unnoticed).""" + body = _minimal_map().replace(" ###\n #.#\n", " ###\n\n #.#\n") + with pytest.raises(MapValidationError, match="blank row"): + load_map(_write(tmp_path, body)) + + +def test_trailing_space_cells_are_kept(tmp_path: Path) -> None: + """With " " in the legend, trailing spaces are real cells: rstrip() must + not delete them (the layer keeps its full width).""" + body = ( + '\nname: "Test"\nterrain: |\n ...\n .. \n' + 'legend:\n' + ' ".": { type: floor, move_cost: 1 }\n' + ' " ": { type: floor, move_cost: 1 }\n' + ) + spec = load_map(_write(tmp_path, body)) + assert spec.terrain == ("...", ".. ") + + +def test_rejects_trailing_space_without_legend_entry(tmp_path: Path) -> None: + """Without " " in the legend a padded row is ragged/unknown-char data, + reported as an error instead of silently stripped.""" + body = _minimal_map().replace(" ###\n #.#\n ###\n", " ### \n #.# \n ### \n") + with pytest.raises(MapValidationError): + load_map(_write(tmp_path, body)) + + def test_rejects_non_five_foot_square_size(tmp_path: Path) -> None: """SRD Measuring Distance: one grid square is 5 ft; the engine geometry is hard-coded to that scale, so any other declared size is a load error