forked from arti/vt1200
185 lines
6.8 KiB
Python
185 lines
6.8 KiB
Python
"""Tests for the tile dictionary builder and the frame quantiser.
|
|
|
|
The plan's two tests come first, verbatim. The extras pin down what the plan
|
|
text leaves implicit but the later tasks depend on:
|
|
|
|
* the tile layout -- 32 rows of four bytes, row-major, MSB first -- so a
|
|
dictionary entry is exactly the bytes the terminal's ``PIXBLT B,XY`` reads
|
|
from ``dict + index*128``;
|
|
* the index frame's size. The plan says ``quantise`` returns 1,280 index bytes
|
|
(a 40x32 screen), but its own test feeds a 128-row frame, which is only 160
|
|
tiles. A buffer shorter than the screen is therefore the *top* of the screen
|
|
and the rows it does not cover quantise the all-zero tile; that convention is
|
|
pinned here rather than left to the caller to discover;
|
|
* the Hamming tie-break: equal distance resolves to the lowest index;
|
|
* validation, so a mis-sized frame is an error here and not a mis-painted
|
|
screen later.
|
|
"""
|
|
import random
|
|
|
|
import pytest
|
|
|
|
from harness.udpstream.tilecost import build_dictionary, quantise
|
|
|
|
#: One source row of the 1280-px screen, as ``vidsrc.Rows.pack_row`` packs it.
|
|
ROW_BYTES = 160
|
|
|
|
#: A 32-px tile is four bytes wide, so a screen row is 40 tiles.
|
|
COLS = 40
|
|
|
|
#: Dictionary entry size: 32 rows of four bytes.
|
|
TILE_BYTES = 128
|
|
|
|
|
|
def _frame(seed):
|
|
r = random.Random(seed)
|
|
return bytes(r.randrange(256) for _ in range(128 * 1280 // 8))
|
|
|
|
|
|
def test_dictionary_is_deterministic_and_indexable():
|
|
frames = [_frame(i) for i in range(4)]
|
|
a = build_dictionary(frames, k=8, seed=7)
|
|
b = build_dictionary(frames, k=8, seed=7)
|
|
assert a == b and len(a) == 8 * 128
|
|
|
|
|
|
def test_quantise_returns_1280_indices_within_range():
|
|
frames = [_frame(i) for i in range(4)]
|
|
d = build_dictionary(frames, k=8, seed=7)
|
|
idx = quantise(frames[0], d)
|
|
assert len(idx) == 1280 and max(idx) < 8
|
|
|
|
|
|
# --- layout --------------------------------------------------------------
|
|
|
|
def _screen_of(patterns, seed=0):
|
|
"""Build a 1280x1024 frame whose 32x32 tiles are drawn from ``patterns``."""
|
|
r = random.Random(seed)
|
|
screen = bytearray(ROW_BYTES * 1024)
|
|
for ty in range(32):
|
|
for tx in range(COLS):
|
|
tile = patterns[r.randrange(len(patterns))]
|
|
for row in range(32):
|
|
off = (ty * 32 + row) * ROW_BYTES + tx * 4
|
|
screen[off:off + 4] = tile[row * 4:row * 4 + 4]
|
|
return bytes(screen)
|
|
|
|
|
|
def _render(indices, dictionary):
|
|
"""Blit an index frame the way the terminal will: one block per index."""
|
|
screen = bytearray(ROW_BYTES * 1024)
|
|
for ty in range(32):
|
|
for tx in range(COLS):
|
|
base = indices[ty * COLS + tx] * TILE_BYTES
|
|
for row in range(32):
|
|
off = (ty * 32 + row) * ROW_BYTES + tx * 4
|
|
screen[off:off + 4] = dictionary[base + row * 4:
|
|
base + row * 4 + 4]
|
|
return bytes(screen)
|
|
|
|
|
|
def test_quantise_picks_the_byte_block_the_blitter_reads():
|
|
"""Tile (tx, ty) is rows ty*32..+31, bytes tx*4..tx*4+4 of each row."""
|
|
frame = bytearray(ROW_BYTES * 32) # one tile row: 40 tiles
|
|
tiles = []
|
|
for tx in range(COLS):
|
|
tile = bytearray()
|
|
for row in range(32):
|
|
block = bytes((tx * 7 + row * 3 + c) & 0xFF for c in range(4))
|
|
frame[row * ROW_BYTES + tx * 4:row * ROW_BYTES + tx * 4 + 4] = block
|
|
tile += block
|
|
tiles.append(bytes(tile))
|
|
|
|
idx = quantise(bytes(frame), b"".join(tiles))
|
|
# Every tile has distance 0 to its own dictionary entry, so the first row of
|
|
# the index frame names them in order -- and nothing else could.
|
|
assert idx[:COLS] == bytes(range(COLS))
|
|
|
|
|
|
def test_rows_below_a_short_frame_quantise_the_zero_tile():
|
|
"""A 32-row frame is the top of the screen; everything below is black."""
|
|
white = bytes([0xFF]) * TILE_BYTES
|
|
black = bytes(TILE_BYTES)
|
|
dictionary = white + black # index 0 white, 1 black
|
|
frame = bytes([0xFF]) * (ROW_BYTES * 32) # one all-white tile row
|
|
|
|
idx = quantise(frame, dictionary)
|
|
assert len(idx) == 1280
|
|
assert idx[:COLS] == bytes([0]) * COLS
|
|
assert idx[COLS:] == bytes([1]) * (1280 - COLS)
|
|
|
|
|
|
def test_equal_distance_resolves_to_the_lowest_index():
|
|
white = bytes([0xFF]) * TILE_BYTES
|
|
black = bytes(TILE_BYTES)
|
|
dictionary = white + black + black # entries 1 and 2 are identical
|
|
frame = bytes(ROW_BYTES * 32) # all black
|
|
|
|
idx = quantise(frame, dictionary)
|
|
assert set(idx) == {1}
|
|
|
|
# The same tie with the duplicate *first*: an exact match at index 0 must
|
|
# stop the search, not be overtaken by a later exact match.
|
|
dictionary = black + white + black
|
|
idx = quantise(frame, dictionary)
|
|
assert set(idx) == {0}
|
|
|
|
|
|
def test_a_dictionary_of_the_frames_own_tiles_reconstructs_the_frame():
|
|
rng = random.Random(3)
|
|
patterns = [bytes(rng.randrange(256) for _ in range(TILE_BYTES))
|
|
for _ in range(5)]
|
|
frames = [_screen_of(patterns, seed=s) for s in range(3)]
|
|
|
|
d = build_dictionary(frames, k=5, seed=1)
|
|
assert len(d) == 5 * TILE_BYTES
|
|
for frame in frames:
|
|
assert _render(quantise(frame, d), d) == frame
|
|
|
|
|
|
def test_build_dictionary_does_not_depend_on_frame_order():
|
|
"""Candidates are named by content, so frame order cannot matter."""
|
|
frames = [_frame(i) for i in range(4)]
|
|
assert (build_dictionary(frames, k=4, seed=2)
|
|
== build_dictionary(list(reversed(frames)), k=4, seed=2))
|
|
|
|
|
|
def test_build_dictionary_of_a_single_repeated_tile_is_that_tile():
|
|
"""The centroid is data, not a seeded sample: one distinct tile stays."""
|
|
pattern = bytes((i * 37 + 11) & 0xFF for i in range(TILE_BYTES))
|
|
frame = _screen_of([pattern], seed=0)
|
|
|
|
assert build_dictionary([frame], k=1, seed=0) == pattern
|
|
|
|
|
|
# --- validation ----------------------------------------------------------
|
|
|
|
@pytest.mark.parametrize("k", [0, -1, 257, 1000])
|
|
def test_build_dictionary_rejects_k_that_is_not_an_index_byte(k):
|
|
with pytest.raises(ValueError):
|
|
build_dictionary([_frame(0)], k=k)
|
|
|
|
|
|
def test_build_dictionary_rejects_no_frames():
|
|
with pytest.raises(ValueError):
|
|
build_dictionary([], k=8)
|
|
|
|
|
|
def test_frames_must_be_whole_160_byte_rows_of_whole_32_row_tiles():
|
|
d = bytes(TILE_BYTES)
|
|
with pytest.raises(ValueError):
|
|
quantise(bytes(ROW_BYTES * 32 + 1), d) # half a row
|
|
with pytest.raises(ValueError):
|
|
quantise(bytes(ROW_BYTES * 33), d) # half a tile row
|
|
with pytest.raises(ValueError):
|
|
quantise(bytes(ROW_BYTES * 1056), d) # below the 1024-row screen
|
|
with pytest.raises(ValueError):
|
|
build_dictionary([bytes(ROW_BYTES * 31)], k=1)
|
|
|
|
|
|
def test_the_dictionary_is_a_whole_number_of_tiles_of_at_most_256():
|
|
frame = bytes(ROW_BYTES * 32)
|
|
for bad in (b"", bytes(TILE_BYTES + 1), bytes(257 * TILE_BYTES)):
|
|
with pytest.raises(ValueError):
|
|
quantise(frame, bad)
|