Day bars now share the chart's dot columns via one floor-edge split (_day_spans), so widths never differ by more than one dot and the wider days interleave with the narrower ones instead of pooling on the left. Every day at least three dots wide opens with its notch — the oldest day's doubling as its clearance off the axis border. Axis ticks pack as dense as two blank cells between labels allow, claimed newest day first, oldest second, then right to left, so a crowded axis thins from the middle-left instead of down to an evenly spaced handful. Months are written on the calendar anchors (the oldest day and the first of a month) rather than tracked across placed ticks. The y-gutter's ┤ tick crosses a braille row at its third dot, so each mark now quotes the value of a bar peaking at that dot — computed with timeline_rows' own baseline math — instead of the row's top edge, which sat a dot above where the glyph points. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
281 lines
12 KiB
Python
281 lines
12 KiB
Python
"""Braille chart widget tests: orientation, the zero baseline, scaling, and styles.
|
||
|
||
The module is pure Text-in/Text-out, so every behaviour is asserted straight off
|
||
the rendered braille bitmasks.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from meshterm.ui.braillechart import (
|
||
GAP,
|
||
activity_sparkline,
|
||
axis_chart,
|
||
chart_span,
|
||
timeline_rows,
|
||
y_axis_labels,
|
||
)
|
||
|
||
#: Dot bits for a column filled bottom-up to height 0–4 (left / right column).
|
||
_L = (0x00, 0x40, 0x44, 0x46, 0x47)
|
||
_R = (0x00, 0x80, 0xA0, 0xB0, 0xB8)
|
||
_BASE = chr(0x2800 | 0x40 | 0x80) # the two-bottom-dot zero flatline
|
||
_BLANK = chr(0x2800)
|
||
|
||
|
||
def _ch(*bits: int) -> str:
|
||
mask = 0
|
||
for b in bits:
|
||
mask |= b
|
||
return chr(0x2800 | mask)
|
||
|
||
|
||
# --- chart_span ---------------------------------------------------------------------
|
||
|
||
|
||
def test_chart_span_always_folds_zero_in() -> None:
|
||
"""Positive, negative, and mixed series all keep zero inside the span."""
|
||
assert chart_span([3, 7]) == (0.0, 7.0)
|
||
assert chart_span([-3, -7]) == (-7.0, 0.0)
|
||
assert chart_span([-3, 7]) == (-3.0, 7.0)
|
||
assert chart_span([None, None]) == (0.0, 0.0)
|
||
assert chart_span([1], span=(-5, 10)) == (-5.0, 10.0)
|
||
|
||
|
||
# --- timeline_rows: all-positive series ------------------------------------------------
|
||
|
||
|
||
def test_timeline_peak_fills_the_height() -> None:
|
||
"""The series' peak fills every dot row; readings pair up two per cell."""
|
||
rows = timeline_rows([12, 12, 0, 0], rows=3)
|
||
assert len(rows) == 3
|
||
assert all(len(r.plain) == 2 for r in rows)
|
||
assert rows[0].plain[0] == _ch(_L[4], _R[4]) # the peak pair tops out
|
||
assert rows[0].plain[1] == _BLANK
|
||
assert rows[2].plain[1] == _BASE # the silent pair keeps the zero line
|
||
|
||
|
||
def test_timeline_each_dot_column_has_its_own_height() -> None:
|
||
"""Two readings sharing a cell rise independently — braille's full resolution."""
|
||
rows = timeline_rows([12, 6], rows=3)
|
||
assert rows[0].plain[0] == _ch(_L[4])
|
||
assert rows[1].plain[0] == _ch(_L[4], _R[2])
|
||
assert rows[2].plain[0] == _ch(_L[4], _R[4])
|
||
|
||
|
||
def test_timeline_never_hides_a_lone_packet() -> None:
|
||
"""A tiny non-zero value still lights a dot, in the lit style not the floor's."""
|
||
rows = timeline_rows([1, 0, 1000, 1000], rows=3)
|
||
bottom = rows[2]
|
||
assert bottom.plain[0] == _ch(_L[1], _R[1]) # bar dot + the baseline continuing
|
||
assert bottom.spans[0].style == "ok"
|
||
|
||
|
||
def test_timeline_all_silent_is_a_flatline() -> None:
|
||
"""With nothing to draw the chart is a faint floor line, not a hole."""
|
||
rows = timeline_rows([0, 0, 0, 0, 0, 0], rows=2)
|
||
assert rows[1].plain == _BASE * 3
|
||
assert rows[0].plain == _BLANK * 3
|
||
assert all(span.style == "faint" for span in rows[1].spans)
|
||
|
||
|
||
def test_timeline_pads_an_odd_tail_column() -> None:
|
||
"""An odd reading count still renders whole cells, the floor continuing under it."""
|
||
rows = timeline_rows([4], rows=1)
|
||
assert len(rows[0].plain) == 1
|
||
assert rows[0].plain[0] == _ch(_L[4], _R[1]) # full bar + the padded column's floor
|
||
|
||
|
||
# --- timeline_rows: GAP (a hard break in the baseline) --------------------------------
|
||
|
||
|
||
def test_timeline_gap_breaks_the_baseline_but_zero_keeps_it() -> None:
|
||
"""A GAP column draws nothing — not even the zero line — while a 0 keeps its dot.
|
||
|
||
The cell pairs a GAP (left) with a plain 0 (right): the left column is fully
|
||
blank down to the floor, the right column still shows its faint baseline dot —
|
||
the distinction that lets a day-boundary notch reach the axis while an empty
|
||
day stays a grey flatline.
|
||
"""
|
||
rows = timeline_rows([GAP, 0], rows=1)
|
||
assert rows[0].plain[0] == _ch(_R[1]) # only the right (0) column's baseline dot
|
||
assert rows[0].spans and rows[0].spans[0].style == "faint"
|
||
|
||
|
||
def test_timeline_gap_column_stays_blank_beside_a_full_bar() -> None:
|
||
"""A GAP paired with a tall bar keeps its own column blank to the axis."""
|
||
rows = timeline_rows([GAP, 4], rows=1) # left GAP, right full-height bar
|
||
# Right column carries the whole bar; the left (GAP) column lights no dot at all,
|
||
# not even the bottom baseline the bar's cell would otherwise share.
|
||
assert rows[0].plain[0] == _ch(_R[4])
|
||
assert not (ord(rows[0].plain[0]) & 0x40) # no bottom-left dot
|
||
|
||
|
||
def test_chart_span_ignores_gap_columns() -> None:
|
||
"""GAP carries no magnitude, so it never widens the span."""
|
||
assert chart_span([GAP, 3, 7]) == (0.0, 7.0)
|
||
assert chart_span([GAP, GAP]) == (0.0, 0.0)
|
||
|
||
|
||
# --- timeline_rows: negative and mixed series ------------------------------------------
|
||
|
||
|
||
def test_timeline_negative_series_hangs_from_a_top_baseline() -> None:
|
||
"""All-negative data pins zero to the ceiling and bars grow downward."""
|
||
rows = timeline_rows([-4, -2], rows=1)
|
||
# Left column full hang (0..3); right hangs two dots (2..3); the baseline row
|
||
# (the cell's top dots) is inside both bars.
|
||
assert rows[0].plain[0] == _ch(0x40, 0x04, 0x02, 0x01, 0x10, 0x08)
|
||
|
||
|
||
def test_timeline_mixed_series_puts_zero_mid_chart() -> None:
|
||
"""With readings both sides of zero, positives rise and negatives hang."""
|
||
rows = timeline_rows([-10, 5], rows=3, span=(-10, 10))
|
||
# Baseline lands on dot row 6: the full -10 hangs 0..6, the +5 rises 6..8.
|
||
assert rows[0].plain[0] == _ch(0x80) # the rise's head, above the baseline
|
||
assert rows[1].plain[0] == _ch(0x46, 0x18) # hang and rise straddle the zero row
|
||
assert rows[2].plain[0] == _ch(0x47) # the hang's tail, below everything
|
||
|
||
|
||
def test_timeline_baseline_shows_mid_chart_in_silent_cells() -> None:
|
||
"""A silent cell of a mixed-span chart draws the zero line where zero *is*."""
|
||
rows = timeline_rows([-10, 5, None, None], rows=3, span=(-10, 10))
|
||
assert rows[1].plain[1] == _ch(0x02, 0x10) # both columns' dot on row 6, faint
|
||
assert rows[1].spans[-1].style == "faint"
|
||
assert rows[0].plain[1] == _BLANK
|
||
assert rows[2].plain[1] == _BLANK
|
||
|
||
|
||
# --- styles -----------------------------------------------------------------------------
|
||
|
||
|
||
def test_timeline_per_cell_style_callable_sees_the_cells_readings() -> None:
|
||
"""A callable style is fed each lit cell's present readings."""
|
||
seen: list[list[float]] = []
|
||
|
||
def style(values: list[float]) -> str:
|
||
seen.append(values)
|
||
return "warn"
|
||
|
||
rows = timeline_rows([2, 4, None, 6], rows=1, style=style)
|
||
assert seen == [[2, 4], [6]]
|
||
assert all(span.style == "warn" for span in rows[0].spans)
|
||
|
||
|
||
# --- activity_sparkline -------------------------------------------------------------------
|
||
|
||
|
||
def test_sparkline_puts_now_on_the_right() -> None:
|
||
"""A newest-first histogram renders with the current bucket at the right edge."""
|
||
# Newest bucket maxed, everything older silent: only the final cell's *right*
|
||
# dot column lights (with the floor dot keeping the left column's baseline).
|
||
text = activity_sparkline((21, 0, 0, 0), (1, 3, 8, 21), 4)
|
||
assert text.plain == _BASE + _ch(_R[4], 0x40)
|
||
|
||
|
||
def test_sparkline_thresholds_step_the_bar_heights() -> None:
|
||
"""The level shoulders map counts to 1–4 dots, oldest to the left."""
|
||
# Newest-first (1, 3, 8, 21) reads 21,8,3,1 left→right once flipped.
|
||
text = activity_sparkline((1, 3, 8, 21), (1, 3, 8, 21), 4)
|
||
assert text.plain == _ch(_L[4], _R[3]) + _ch(_L[2], _R[1])
|
||
|
||
|
||
def test_sparkline_pads_and_crops_to_the_window() -> None:
|
||
"""A short histogram pads with silence; a long one crops to the newest buckets."""
|
||
padded = activity_sparkline((21,), (1, 3, 8, 21), 6)
|
||
assert padded.plain == _BASE * 2 + _ch(_R[4], 0x40)
|
||
cropped = activity_sparkline((0, 0, 21, 21, 9, 9), (1, 3, 8, 21), 2)
|
||
assert cropped.plain == _BASE # only the two newest (silent) buckets survive
|
||
|
||
|
||
# --- y_axis_labels / axis_chart -------------------------------------------------------
|
||
|
||
|
||
def test_y_axis_labels_blanks_a_silent_chart() -> None:
|
||
"""A zero peak draws no marks at all."""
|
||
assert y_axis_labels(0, 3) == ["", "", ""]
|
||
|
||
|
||
def test_y_axis_labels_marks_quote_the_ticked_dot() -> None:
|
||
"""Each mark reads the value at its row's third dot — where the ┤ tick points.
|
||
|
||
peak 10 over 2 rows (8 dots): the top row's tick crosses dot 6 (a 7-dot bar,
|
||
10·7/8 → 9), the bottom row's dot 2 (a 3-dot bar, 10·3/8 → 4) — not the rows'
|
||
top-edge values (10 and 5), which sit a dot above where the glyph points.
|
||
"""
|
||
assert y_axis_labels(10, 2) == ["9", "4"]
|
||
|
||
|
||
def test_y_axis_labels_blanks_a_repeated_mark() -> None:
|
||
"""A low peak rounds a lower row to the same value as the one above: left blank."""
|
||
assert y_axis_labels(1, 3) == ["1", "", ""]
|
||
|
||
|
||
def test_axis_chart_mirrors_marks_on_both_gutters() -> None:
|
||
"""Each row's mark is framed by tick glyphs and repeated on both edges."""
|
||
rows = timeline_rows([9] * 8, rows=2)
|
||
out = axis_chart(rows, 9, 4, lambda f: "now" if f >= 1.0 else "old")
|
||
assert len(out) == 4 # 2 chart rows + bottom border + caption
|
||
assert out[0].plain == "8 ┤⣿⣿⣿⣿├ 8" # ticked-dot values (peak 9 sizes the gutter)
|
||
assert out[1].plain == "3 ┤⣿⣿⣿⣿├ 3"
|
||
assert out[2].plain.startswith(" └") and out[2].plain.endswith("┘")
|
||
|
||
|
||
def test_y_axis_labels_signed_span_quotes_both_extremes() -> None:
|
||
"""A signed chart's marks quote rise heights above zero and hang depths below."""
|
||
# rows=3 over +8..-4 (baseline on dot 4): the top tick reads a 7-dot rise (7),
|
||
# the middle a 3-dot rise (3), the bottom a 3-deep hang below zero (−2).
|
||
assert y_axis_labels(8, 3, lo=-4) == ["7", "3", "-2"]
|
||
# A symmetric span pins the middle tick onto the baseline itself: blanked zero.
|
||
assert y_axis_labels(6, 3, lo=-6) == ["5", "", "-4"]
|
||
|
||
|
||
def test_axis_chart_floor_frames_a_signed_chart_and_widens_the_gutter() -> None:
|
||
"""A signed floor draws negative marks and sizes the gutter for the widest one."""
|
||
rows = timeline_rows([5, -5], rows=3, span=(-5, 5))
|
||
out = axis_chart(rows, 5, 1, lambda f: "x", floor=-5)
|
||
assert out[0].plain.startswith(" 4 ┤") # gutter widened to two cells by the floor
|
||
assert out[2].plain.startswith("-4 ┤") # a negative mark near the floor
|
||
|
||
|
||
def test_axis_chart_honours_a_shared_label_width() -> None:
|
||
"""An explicit label_w widens the gutter past the peak's own digit count.
|
||
|
||
Lets several stacked charts (the Time Machine's per-day pair) share one gutter
|
||
width sized from the wider chart, so their marks line up column for column.
|
||
"""
|
||
rows = timeline_rows([9] * 8, rows=1)
|
||
out = axis_chart(rows, 9, 4, lambda f: "x", label_w=3)
|
||
assert out[0].plain.startswith(" 7 ┤")
|
||
|
||
|
||
# --- axis_chart column ticks ----------------------------------------------------------
|
||
|
||
|
||
def test_axis_chart_ticks_notch_the_border_and_centre_labels() -> None:
|
||
"""Explicit column ticks draw a ``┬`` on the border with the label centred beneath."""
|
||
rows = timeline_rows([9] * 8, rows=1)
|
||
out = axis_chart(rows, 9, 4, ticks=[(0, "A"), (3, "D")])
|
||
assert out[1].plain == " └┬──┬┘" # ticks at chart cells 0 and 3
|
||
assert out[2].plain == " A D" # labels centred under their ticks
|
||
|
||
|
||
def test_axis_chart_continuous_axis_notches_ticks_under_its_labels() -> None:
|
||
"""A continuous (label_at) chart now notches ┬ ticks under its ends-and-quarters labels."""
|
||
rows = timeline_rows([9] * 8, rows=1)
|
||
out = axis_chart(rows, 9, 20, lambda f: "now" if f >= 1.0 else str(round(f * 100)))
|
||
border, caption = out[1].plain, out[2].plain
|
||
assert "┬" in border # the continuous axis is ticked, not a plain rule
|
||
assert border.startswith(" └") and border.endswith("┘")
|
||
assert border[-2] == "┬" # the rightmost tick sits at the 'now' edge
|
||
assert "now" in caption and "0" in caption
|
||
|
||
|
||
def test_axis_chart_ticks_thin_a_label_that_would_collide() -> None:
|
||
"""A tick whose label would overprint the one before it is dropped, tick and all."""
|
||
rows = timeline_rows([9] * 16, rows=1)
|
||
out = axis_chart(rows, 9, 8, ticks=[(0, "aaaa"), (2, "aaaa"), (7, "b")])
|
||
border, caption = out[1].plain, out[2].plain
|
||
assert caption.count("aaaa") == 1 # the crowded twin was skipped
|
||
assert "b" in caption
|
||
assert border.count("┬") == 2 # …and its tick with it (only two survive)
|