Files
Mainline/tests/test_canvas.py
David Gwilliam 7c26150408 test: add comprehensive unit tests for core components
- tests/test_canvas.py: 33 tests for Canvas (2D rendering surface)
- tests/test_firehose.py: 5 tests for FirehoseEffect
- tests/test_pipeline_order.py: 3 tests for execution order verification
- tests/test_renderer.py: 22 tests for ANSI parsing and PIL rendering

These tests provide solid coverage for foundational modules.
2026-03-21 13:18:08 -07:00

286 lines
10 KiB
Python

"""
Unit tests for engine.canvas.Canvas.
Tests the core 2D rendering surface without any display dependencies.
"""
from engine.canvas import Canvas, CanvasRegion
class TestCanvasRegion:
"""Tests for CanvasRegion dataclass."""
def test_is_valid_positive_dimensions(self):
"""Positive width and height returns True."""
region = CanvasRegion(0, 0, 10, 5)
assert region.is_valid() is True
def test_is_valid_zero_width(self):
"""Zero width returns False."""
region = CanvasRegion(0, 0, 0, 5)
assert region.is_valid() is False
def test_is_valid_zero_height(self):
"""Zero height returns False."""
region = CanvasRegion(0, 0, 10, 0)
assert region.is_valid() is False
def test_is_valid_negative_dimensions(self):
"""Negative dimensions return False."""
region = CanvasRegion(0, 0, -1, 5)
assert region.is_valid() is False
def test_rows_computes_correct_set(self):
"""rows() returns set of row indices in region."""
region = CanvasRegion(2, 3, 4, 2)
assert region.rows() == {3, 4}
class TestCanvas:
"""Tests for Canvas class."""
def test_init_default_dimensions(self):
"""Default width=80, height=24."""
canvas = Canvas()
assert canvas.width == 80
assert canvas.height == 24
assert len(canvas._grid) == 24
assert len(canvas._grid[0]) == 80
def test_init_custom_dimensions(self):
"""Custom dimensions are set correctly."""
canvas = Canvas(100, 50)
assert canvas.width == 100
assert canvas.height == 50
def test_clear_empties_grid(self):
"""clear() resets all cells to spaces."""
canvas = Canvas(5, 3)
canvas.put_text(0, 0, "Hello")
canvas.clear()
region = canvas.get_region(0, 0, 5, 3)
assert all(all(cell == " " for cell in row) for row in region)
def test_clear_marks_entire_canvas_dirty(self):
"""clear() marks entire canvas as dirty."""
canvas = Canvas(10, 5)
canvas.clear()
dirty = canvas.get_dirty_regions()
assert len(dirty) == 1
assert dirty[0].x == 0 and dirty[0].y == 0
assert dirty[0].width == 10 and dirty[0].height == 5
def test_put_text_single_char(self):
"""put_text writes a single character at position."""
canvas = Canvas(10, 5)
canvas.put_text(3, 2, "X")
assert canvas._grid[2][3] == "X"
def test_put_text_multiple_chars(self):
"""put_text writes multiple characters in a row."""
canvas = Canvas(10, 5)
canvas.put_text(2, 1, "ABC")
assert canvas._grid[1][2] == "A"
assert canvas._grid[1][3] == "B"
assert canvas._grid[1][4] == "C"
def test_put_text_ignores_overflow_right(self):
"""Characters beyond width are ignored."""
canvas = Canvas(5, 5)
canvas.put_text(3, 0, "XYZ")
assert canvas._grid[0][3] == "X"
assert canvas._grid[0][4] == "Y"
# Z would be at index 5, which is out of bounds
def test_put_text_ignores_overflow_bottom(self):
"""Rows beyond height are ignored."""
canvas = Canvas(5, 3)
canvas.put_text(0, 5, "test")
# Row 5 doesn't exist, nothing should be written
assert all(cell == " " for row in canvas._grid for cell in row)
def test_put_text_marks_dirty_region(self):
"""put_text marks the written area as dirty."""
canvas = Canvas(10, 5)
canvas.put_text(2, 1, "Hello")
dirty = canvas.get_dirty_regions()
assert len(dirty) == 1
assert dirty[0].x == 2 and dirty[0].y == 1
assert dirty[0].width == 5 and dirty[0].height == 1
def test_put_text_empty_string_no_dirty(self):
"""Empty string does not create dirty region."""
canvas = Canvas(10, 5)
canvas.put_text(0, 0, "")
assert not canvas.is_dirty()
def test_put_region_single_cell(self):
"""put_region writes a single cell correctly."""
canvas = Canvas(5, 5)
content = [["X"]]
canvas.put_region(2, 2, content)
assert canvas._grid[2][2] == "X"
def test_put_region_multiple_rows(self):
"""put_region writes multiple rows correctly."""
canvas = Canvas(10, 10)
content = [["A", "B"], ["C", "D"]]
canvas.put_region(1, 1, content)
assert canvas._grid[1][1] == "A"
assert canvas._grid[1][2] == "B"
assert canvas._grid[2][1] == "C"
assert canvas._grid[2][2] == "D"
def test_put_region_partial_out_of_bounds(self):
"""put_region clips content that extends beyond canvas bounds."""
canvas = Canvas(5, 5)
content = [["A", "B", "C"], ["D", "E", "F"]]
canvas.put_region(4, 4, content)
# Only cell (4,4) should be within bounds
assert canvas._grid[4][4] == "A"
# Others are out of bounds
assert canvas._grid[4][5] == " " if 5 < 5 else True # index 5 doesn't exist
assert canvas._grid[5][4] == " " if 5 < 5 else True # row 5 doesn't exist
def test_put_region_marks_dirty(self):
"""put_region marks dirty region covering written area (clipped)."""
canvas = Canvas(10, 10)
content = [["A", "B", "C"], ["D", "E", "F"]]
canvas.put_region(2, 2, content)
dirty = canvas.get_dirty_regions()
assert len(dirty) == 1
assert dirty[0].x == 2 and dirty[0].y == 2
assert dirty[0].width == 3 and dirty[0].height == 2
def test_fill_rectangle(self):
"""fill() fills a rectangular region with character."""
canvas = Canvas(10, 10)
canvas.fill(2, 2, 3, 2, "*")
for y in range(2, 4):
for x in range(2, 5):
assert canvas._grid[y][x] == "*"
def test_fill_entire_canvas(self):
"""fill() can fill entire canvas."""
canvas = Canvas(5, 3)
canvas.fill(0, 0, 5, 3, "#")
for row in canvas._grid:
assert all(cell == "#" for cell in row)
def test_fill_empty_region_no_dirty(self):
"""fill with zero dimensions does not mark dirty."""
canvas = Canvas(10, 10)
canvas.fill(0, 0, 0, 5, "X")
assert not canvas.is_dirty()
def test_fill_clips_to_bounds(self):
"""fill clips to canvas boundaries."""
canvas = Canvas(5, 5)
canvas.fill(3, 3, 5, 5, "X")
# Should only fill within bounds: (3,3) to (4,4)
assert canvas._grid[3][3] == "X"
assert canvas._grid[3][4] == "X"
assert canvas._grid[4][3] == "X"
assert canvas._grid[4][4] == "X"
# Out of bounds should remain spaces
assert canvas._grid[5] if 5 < 5 else True # row 5 doesn't exist
def test_get_region_extracts_subgrid(self):
"""get_region returns correct rectangular subgrid."""
canvas = Canvas(10, 10)
for y in range(10):
for x in range(10):
canvas._grid[y][x] = chr(ord("A") + (x % 26))
region = canvas.get_region(2, 3, 4, 2)
assert len(region) == 2
assert len(region[0]) == 4
assert region[0][0] == "C" # (2,3) = 'C'
assert region[1][2] == "E" # (4,4) = 'E'
def test_get_region_out_of_bounds_returns_spaces(self):
"""get_region pads out-of-bounds areas with spaces."""
canvas = Canvas(5, 5)
canvas.put_text(0, 0, "HELLO")
# Region overlapping right edge: cols 3-4 inside, col5+ outside
region = canvas.get_region(3, 0, 5, 2)
assert region[0][0] == "L"
assert region[0][1] == "O"
assert region[0][2] == " " # col5 out of bounds
assert all(cell == " " for cell in region[1])
def test_get_region_flat_returns_lines(self):
"""get_region_flat returns list of joined strings."""
canvas = Canvas(10, 5)
canvas.put_text(0, 0, "FIRST")
canvas.put_text(0, 1, "SECOND")
flat = canvas.get_region_flat(0, 0, 6, 2)
assert flat == ["FIRST ", "SECOND"]
def test_mark_dirty_manual(self):
"""mark_dirty() can be called manually to mark arbitrary region."""
canvas = Canvas(10, 10)
canvas.mark_dirty(5, 5, 3, 2)
dirty = canvas.get_dirty_regions()
assert len(dirty) == 1
assert dirty[0] == CanvasRegion(5, 5, 3, 2)
def test_get_dirty_rows_union(self):
"""get_dirty_rows() returns union of all dirty row indices."""
canvas = Canvas(10, 10)
canvas.put_text(0, 0, "A") # row 0
canvas.put_text(0, 2, "B") # row 2
canvas.mark_dirty(0, 1, 1, 1) # row 1
rows = canvas.get_dirty_rows()
assert rows == {0, 1, 2}
def test_is_dirty_after_operations(self):
"""is_dirty() returns True after any modifying operation."""
canvas = Canvas(10, 10)
assert not canvas.is_dirty()
canvas.put_text(0, 0, "X")
assert canvas.is_dirty()
_ = canvas.get_dirty_regions() # resets
assert not canvas.is_dirty()
def test_resize_same_size_no_change(self):
"""resize with same dimensions does nothing."""
canvas = Canvas(10, 5)
canvas.put_text(0, 0, "TEST")
canvas.resize(10, 5)
assert canvas._grid[0][0] == "T"
def test_resize_larger_preserves_content(self):
"""resize to larger canvas preserves existing content."""
canvas = Canvas(5, 3)
canvas.put_text(1, 1, "AB")
canvas.resize(10, 6)
assert canvas.width == 10
assert canvas.height == 6
assert canvas._grid[1][1] == "A"
assert canvas._grid[1][2] == "B"
# New area should be spaces
assert canvas._grid[0][0] == " "
def test_resize_smaller_truncates(self):
"""resize to smaller canvas drops content outside new bounds."""
canvas = Canvas(10, 5)
canvas.put_text(8, 4, "XYZ")
canvas.resize(5, 3)
assert canvas.width == 5
assert canvas.height == 3
# Content at (8,4) should be lost
# But content within new bounds should remain
canvas2 = Canvas(10, 5)
canvas2.put_text(2, 2, "HI")
canvas2.resize(5, 3)
assert canvas2._grid[2][2] == "H"
def test_resize_does_not_auto_mark_dirty(self):
"""resize() does not automatically mark dirty (caller responsibility)."""
canvas = Canvas(10, 10)
canvas.put_text(0, 0, "A")
_ = canvas.get_dirty_regions() # reset
canvas.resize(5, 5)
# Resize doesn't mark dirty - this is current implementation
assert not canvas.is_dirty()