Files
DumiFlex 85152b3ec7 feat(label-grid): optional wires, one label for all, spacing and token tooltips (#178)
Optional label_text, title, row_headers and column_headers inputs beat the panel's fields. A single one-line label covers every image of every item. The panel has bottom spacing above the preview, and the label tokens explain themselves on hover.
2026-10-29 04:11:48 +01:00

320 lines
12 KiB
Python

"""engine/label_grid.py — label reading, templates, wrap, single labels, grids."""
from __future__ import annotations
from PIL import Image
from engine import label_grid as g
def img(w=200, h=100, color=(255, 0, 0)):
return Image.new("RGB", (w, h), color)
# ── labels ────────────────────────────────────────────────────────────────
def test_item_labels_split_broadcast_and_missing():
out = g.item_labels(["a\nb", "one", None, "x\ny"], [2, 3, 1, 3])
assert out == [["a", "b"], ["one"] * 3, [""], ["x", "y", ""]]
def test_item_labels_one_text_spread_over_items():
assert g.item_labels(["a\nb\nc"], [1, 2]) == [["a"], ["b", "c"]]
# line count doesn't match: first item only, as usual
assert g.item_labels(["a\nb"], [1, 2]) == [["a"], ["", ""]]
def test_item_labels_one_line_labels_every_item():
assert g.item_labels(["same"], [2, 1, 3]) == [["same"] * 2, ["same"], ["same"] * 3]
assert g.item_labels([""], [1, 2]) == [[""], ["", ""]]
def test_item_labels_without_input_or_with_fewer_entries():
assert g.item_labels(None, [2]) == [["", ""]]
assert g.item_labels(["a", "b"], [1, 1, 1]) == [["a"], ["b"], [""]]
def test_template_tokens_and_empty_template():
assert g.apply_template("#{index}/{count} f{frame} i{image}: {label}", "red", 1, 0, 4, 9) == (
"#5/9 f2 i1: red"
)
assert g.apply_template("", "kept", 0, 0, 0, 1) == "kept"
assert g.apply_template("a\\nb", "", 0, 0, 0, 1) == "a\nb"
assert g.apply_template("{unknown}", "", 0, 0, 0, 1) == "{unknown}"
def test_parts():
label = "#3 · red · pencil · 2"
assert g.split_parts(label) == ["#3", "red", "pencil", "2"]
assert g.part(label, 2) == "red"
assert g.part(label, -1) == "2"
assert g.part(label, 9) == ""
assert g.part("", 1) == ""
assert g.drop_parts(label, [2, 3]) == "#3 · 2"
assert g.drop_parts(label, [-1]) == "#3 · red · pencil"
def test_typed_headers():
assert g.typed_headers("a\n b \nc\\nd") == ["a", "b", "c\nd"]
assert g.typed_headers(" ") == []
def test_parse_color():
assert g.parse_color("#fff", (0, 0, 0, 0)) == (255, 255, 255, 255)
assert g.parse_color("#00000080", (1, 1, 1, 1)) == (0, 0, 0, 128)
assert g.parse_color("none", (1, 1, 1, 1)) == (0, 0, 0, 0)
assert g.parse_color("not a colour", (1, 2, 3, 4)) == (1, 2, 3, 4)
assert g.parse_color("red", (0, 0, 0, 0)) == (255, 0, 0, 255)
def test_fonts_fall_back_and_list_starts_with_default():
assert g.list_fonts()[0] == g.DEFAULT_FONT
font = g.load_font("no-such-font.ttf", 20)
assert font.getlength("abc") > 0
# ── wrap ──────────────────────────────────────────────────────────────────
def test_wrap_words_long_words_and_ellipsis():
font = g.load_font(g.DEFAULT_FONT, 16)
lines = g.wrap("one two three four five six seven eight nine ten", font, 60, 10)
assert len(lines) > 1
assert all(font.getlength(ln) <= 60 for ln in lines)
long = g.wrap("x" * 80, font, 50, 40)
assert len(long) > 1 and "".join(long) == "x" * 80
cut = g.wrap("word " * 60, font, 60, 2)
assert len(cut) == 2 and cut[-1].endswith("…")
assert g.wrap("a\nb", font, 500, 5) == ["a", "b"]
# ── single image ──────────────────────────────────────────────────────────
def test_label_strip_positions_change_size_the_right_way():
base = img()
top = g.label_image(base, "hello", g.Style(position="top"))
assert top.width == 200 and top.height > 100
# the image sits under the strip
assert top.getpixel((100, top.height - 1)) == (255, 0, 0)
bottom = g.label_image(base, "hello", g.Style(position="bottom"))
assert bottom.getpixel((100, 0)) == (255, 0, 0)
left = g.label_image(base, "hello", g.Style(position="left"))
assert left.height == 100 and left.width > 200
assert left.getpixel((left.width - 1, 50)) == (255, 0, 0)
right = g.label_image(base, "hello", g.Style(position="right"))
assert right.getpixel((0, 50)) == (255, 0, 0)
def test_overlay_keeps_size_and_blends():
base = img()
over = g.label_image(base, "hi", g.Style(position="over_bottom", label_color="#0000ff80"))
assert over.size == base.size
r, _g, b = over.getpixel((2, 98))
assert 100 < r < 160 and 100 < b < 160
assert over.getpixel((100, 2)) == (255, 0, 0)
def test_no_text_or_none_leaves_the_image():
base = img()
assert g.label_image(base, "", g.Style()) is base
assert g.label_image(base, "x", g.Style(position="none")) is base
assert g.label_image(base, "", g.Style(position="over_top")) is base
def test_batch_keeps_one_shape():
out = g.label_batch([img(), img()], ["short", "a much longer label " * 6], g.Style())
assert out[0].size == out[1].size
plain = g.label_batch([img(), img()], ["", ""], g.Style())
assert [p.size for p in plain] == [(200, 100), (200, 100)]
def test_font_size_auto_and_fixed():
assert g.Style().auto_size(1000) == 45
assert g.Style().auto_size(10) == 12
assert g.Style(font_size=30).auto_size(1000) == 30
# ── grid ──────────────────────────────────────────────────────────────────
def cells_from(labels, w=100, h=80, items=None):
return [
g.Cell(img(w, h, (i * 20 % 255, 0, 0)), lab, items[i] if items else i, 0)
for i, lab in enumerate(labels)
]
def test_frames_as_rows_and_columns():
cells = [g.Cell(img(), "", it, ix) for it in range(2) for ix in range(3)]
grid, _c, _r = g.arrange(cells, g.GridSpec())
assert [[c.item if c else None for c in row] for row in grid] == [[0, 0, 0], [1, 1, 1]]
grid, _c, _r = g.arrange(cells, g.GridSpec(arrange="columns"))
assert len(grid) == 3 and len(grid[0]) == 2
def test_fill_rows_with_columns_and_auto_square():
cells = cells_from([""] * 7)
grid, _c, _r = g.arrange(cells, g.GridSpec(arrange="fill", columns=3))
assert [len(r) for r in grid] == [3, 3, 3]
assert grid[2][1] is None
one_batch = [g.Cell(img(), "", 0, i) for i in range(5)]
grid, _c, _r = g.arrange(one_batch, g.GridSpec())
assert len(grid[0]) == 3 and len(grid) == 2
def test_xy_from_label_parts_any_order():
labels = ["#4 · b · y", "#1 · a · x", "#2 · a · y", "#3 · b · x"]
grid, cols, rows = g.arrange(cells_from(labels), g.GridSpec(row_part=2, column_part=3))
assert rows == ["b", "a"] and cols == ["y", "x"]
assert [[c.label for c in row] for row in grid] == [
["#4 · b · y", "#3 · b · x"],
["#2 · a · y", "#1 · a · x"],
]
def test_xy_duplicate_spot_repeats_the_row():
labels = ["#1 · a · x", "#2 · a · x", "#3 · a · y"]
grid, _cols, rows = g.arrange(cells_from(labels), g.GridSpec(row_part=2, column_part=3))
assert rows == ["a", "a"]
assert grid[1][1] is None and grid[1][0] is not None
def test_one_axis_from_labels():
labels = ["#1 · a", "#2 · b", "#3 · a"]
grid, cols, _rows = g.arrange(cells_from(labels), g.GridSpec(column_part=2))
assert cols == ["a", "b"]
assert len(grid) == 2 and grid[1][1] is None
grid, _cols, rows = g.arrange(cells_from(labels), g.GridSpec(row_part=2))
assert rows == ["a", "b"] and len(grid[0]) == 2
def test_build_grid_size_headers_and_gap():
cells = cells_from(["#1 · a · x", "#2 · a · y", "#3 · b · x", "#4 · b · y"])
style = g.Style(position="none", gap=10, margin=5)
plain = g.build_grid(cells, style, g.GridSpec(arrange="fill", columns=2))
assert plain.size == (5 * 2 + 100 * 2 + 10, 5 * 2 + 80 * 2 + 10)
headed = g.build_grid(cells, style, g.GridSpec(row_part=2, column_part=3, title="T"))
assert headed.width > plain.width and headed.height > plain.height
def test_build_grid_mixed_sizes_center_in_largest_cell():
cells = [g.Cell(img(100, 80), "", 0, 0), g.Cell(img(50, 40, (0, 255, 0)), "", 0, 1)]
out = g.build_grid(
cells, g.Style(position="none", gap=0), g.GridSpec(arrange="fill", columns=2)
)
assert out.size == (200, 80)
assert out.getpixel((150, 40)) == (0, 255, 0)
assert out.getpixel((101, 1)) == (255, 255, 255)
def test_build_grid_cell_width_scales_and_labels_add_strips():
cells = cells_from(["a", "b"], w=400, h=200)
out = g.build_grid(cells, g.Style(gap=0, cell_width=100), g.GridSpec(arrange="fill", columns=2))
assert out.width == 200 and out.height > 50
def test_build_grid_caps_huge_output(monkeypatch):
monkeypatch.setattr(g, "MAX_GRID_SIDE", 150)
out = g.build_grid(
cells_from(["", ""]),
g.Style(position="none", gap=0),
g.GridSpec(arrange="fill", columns=2),
)
assert max(out.size) == 150
def test_build_grid_rejects_nothing():
try:
g.build_grid([], g.Style(), g.GridSpec())
except ValueError:
return
raise AssertionError("expected ValueError")
# ── config ────────────────────────────────────────────────────────────────
def test_parse_config_defaults_and_recovery():
assert g.parse_config("") == g.DEFAULT_CONFIG
assert g.parse_config("not json") == g.DEFAULT_CONFIG
assert g.parse_config("[1]") == g.DEFAULT_CONFIG
cfg = g.parse_config(
{
"position": "over_bottom",
"align": "nope",
"arrange": "fill",
"columns": 999,
"gap": -4,
"font_size": 12.7,
"grid": False,
"rows_from": "part:2",
"columns_from": "part:x",
"title": "T",
"label": 5,
"unknown": 1,
}
)
assert cfg["position"] == "over_bottom"
assert cfg["align"] == "center"
assert cfg["arrange"] == "fill"
assert cfg["columns"] == 256
assert cfg["gap"] == 0
assert cfg["font_size"] == 12
assert cfg["grid"] is False
assert cfg["rows_from"] == "part:2"
assert cfg["columns_from"] == "none"
assert cfg["title"] == "T"
assert cfg["label"] == "{label}"
assert "unknown" not in cfg
def test_header_part_and_examples():
assert g.header_part("part:3") == 3
assert g.header_part("part:-1") == -1
assert g.header_part("typed") == 0
labels = [
f"#{i} · {t} · {s}"
for i, (t, s) in enumerate(
[("dawn", "a"), ("noon", "a"), ("night", "b"), ("dusk", "b"), ("x", "c")], 1
)
]
ex = g.part_examples(labels, per_part=3)
assert ex[0] == {"values": ["#1", "#2", "#3"], "count": 5}
assert ex[1] == {"values": ["dawn", "noon", "night"], "count": 5}
assert ex[2] == {"values": ["a", "b", "c"], "count": 3}
assert g.part_examples([]) == []
def test_auto_arrange_follows_the_run_shape():
assert g.auto_arrange([1]) == "fill"
assert g.auto_arrange([9]) == "fill"
assert g.auto_arrange([1, 1, 1]) == "strip"
assert g.auto_arrange([1] * 12) == "fill"
assert g.auto_arrange([4, 4]) == "rows"
assert g.auto_arrange([]) == "fill"
def test_auto_strip_and_stack_layouts():
singles = [g.Cell(img(), "", i, 0) for i in range(3)]
grid, _c, _r = g.arrange(singles, g.GridSpec(arrange="auto"))
assert len(grid) == 1 and len(grid[0]) == 3
batches = [g.Cell(img(), "", it, ix) for it in range(2) for ix in range(2)]
grid, _c, _r = g.arrange(batches, g.GridSpec(arrange="auto"))
assert [[c.item if c else None for c in row] for row in grid] == [[0, 0], [1, 1]]
grid, _c, _r = g.arrange(batches, g.GridSpec(arrange="strip"))
assert len(grid) == 1 and len(grid[0]) == 4
grid, _c, _r = g.arrange(batches, g.GridSpec(arrange="stack"))
assert len(grid) == 4 and len(grid[0]) == 1
def test_config_corpus_matches_the_canvas_mirror():
"""src/extension/label-grid.test.ts reads the same cases."""
import json
from pathlib import Path
corpus = Path(__file__).parents[1] / "fixtures" / "label-grid-config-corpus.json"
for case in json.loads(corpus.read_text(encoding="utf-8"))["cases"]:
assert g.parse_config(case["raw"]) == case["expected"], case["name"]