Memories are the only drillable rows, so give them the primary "clickable" ink and demote skills (dead-ends) to the muted complement — previously the non-openable skills wore the link-looking primary color. Flipped in both the TUI and CLI palettes for parity.
187 lines
6.7 KiB
Python
187 lines
6.7 KiB
Python
"""Behavior contracts for the terminal Star Map renderer.
|
|
|
|
Asserts invariants of the timeline layout, the ported age gradient + palette, and
|
|
the constellation scrubber — never a cell snapshot, which would be a
|
|
change-detector against layout tuning.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from agent import learning_graph_render as render
|
|
|
|
LEAD_IN = render.LEAD_IN
|
|
|
|
|
|
def _payload(skills: int = 8, memories: int = 3, *, base_ts: int = 1_700_000_000):
|
|
nodes = []
|
|
for i in range(skills):
|
|
nodes.append(
|
|
{
|
|
"id": f"skill{i}",
|
|
"label": f"skill{i}",
|
|
"kind": "skill",
|
|
"timestamp": base_ts + i * 86400 * 20,
|
|
"category": "devops" if i % 2 else "research",
|
|
"useCount": i,
|
|
}
|
|
)
|
|
for j in range(memories):
|
|
nodes.append(
|
|
{
|
|
"id": f"memory:memory:{j}",
|
|
"label": f"mem {j}",
|
|
"kind": "memory",
|
|
"timestamp": base_ts + (skills + j) * 86400 * 20,
|
|
"category": "memory",
|
|
}
|
|
)
|
|
edges = [{"source": "skill0", "target": "skill1"}] if skills > 1 else []
|
|
return {
|
|
"nodes": nodes,
|
|
"edges": edges,
|
|
"clusters": [{"category": "devops", "count": skills}, {"category": "memory", "count": memories}],
|
|
"stats": {
|
|
"learned_skills": skills,
|
|
"memory_nodes": memories,
|
|
"related_edges": len(edges),
|
|
"memory_skill_edges": 0,
|
|
},
|
|
}
|
|
|
|
|
|
def _flatten(grid):
|
|
return "".join(run[0] for row in grid for run in row)
|
|
|
|
|
|
def _styles(grid):
|
|
return {run[1] for row in grid for run in row}
|
|
|
|
|
|
def test_recency_is_timed_and_bounded():
|
|
rec = render.compute_recency(_payload()["nodes"])
|
|
assert rec["timed"] is True
|
|
for ratio in rec["rec"].values():
|
|
assert LEAD_IN - 1e-9 <= ratio <= 1 + 1e-9
|
|
assert abs(min(rec["rec"].values()) - LEAD_IN) < 1e-9
|
|
assert abs(max(rec["rec"].values()) - 1.0) < 1e-9
|
|
|
|
|
|
def test_recency_ink_follows_age_gradient():
|
|
# Old quiet → recent bright (constants.ts AGE_GRADIENT), monotonic in between.
|
|
assert abs(render.recency_ink(0.0) - render.AGE_OLD_INK) < 1e-6
|
|
assert abs(render.recency_ink(1.0) - render.AGE_NEW_INK) < 1e-6
|
|
samples = [render.recency_ink(x / 10) for x in range(11)]
|
|
assert samples == sorted(samples)
|
|
|
|
|
|
def test_undated_graph_falls_back_to_ordinal():
|
|
nodes = [{"id": f"n{i}", "kind": "skill"} for i in range(5)]
|
|
rec = render.compute_recency(nodes)
|
|
assert rec["timed"] is False
|
|
assert len(set(rec["rec"].values())) == len(nodes)
|
|
|
|
|
|
def test_grid_runs_are_text_style_alpha():
|
|
# Runs are [text, style, alpha] with an optional 4th hex override for
|
|
# category-colored bars.
|
|
frame = render.render_graph(_payload(), cols=60, rows=20)
|
|
for row in frame["grid"]:
|
|
for run in row:
|
|
assert 3 <= len(run) <= 4
|
|
assert isinstance(run[0], str) and isinstance(run[1], str)
|
|
assert isinstance(run[2], (int, float)) and 0.0 <= run[2] <= 1.0
|
|
assert run[0] != ""
|
|
if len(run) == 4:
|
|
assert run[3] is None or isinstance(run[3], str)
|
|
|
|
|
|
def test_bars_render_skills_and_memories():
|
|
frame = render.render_graph(_payload(skills=10, memories=4), cols=72, rows=18, reveal=1.0)
|
|
flat = _flatten(frame["grid"])
|
|
# Skills draw as comet trails (━), memories anchor on diamonds (◆).
|
|
assert "━" in flat
|
|
assert render.MEMORY_GLYPH in flat
|
|
styles = _styles(frame["grid"])
|
|
assert render.STYLE_SKILL in styles
|
|
assert render.STYLE_MEMORY in styles
|
|
|
|
|
|
def test_run_alpha_follows_age_for_lit_stars():
|
|
# An all-skill, dated graph at full reveal: the newest star is brighter ink
|
|
# than the oldest (age gradient carried in the run alpha).
|
|
payload = _payload(skills=12, memories=0)
|
|
frame = render.render_graph(payload, cols=80, rows=20, reveal=1.0)
|
|
alphas = [run[2] for row in frame["grid"] for run in row if run[1] == render.STYLE_SKILL]
|
|
assert max(alphas) > min(alphas)
|
|
|
|
|
|
def test_reveal_monotonically_builds_up():
|
|
payload = _payload(skills=12, memories=5)
|
|
counts = [render.render_graph(payload, cols=60, rows=20, reveal=r)["visible"] for r in (0.0, 0.25, 0.5, 0.75, 1.0)]
|
|
assert counts == sorted(counts)
|
|
assert counts[-1] == len(payload["nodes"])
|
|
|
|
|
|
def test_empty_payload_renders_placeholder():
|
|
frame = render.render_graph({"nodes": []}, cols=40, rows=12)
|
|
assert frame["visible"] == 0
|
|
assert "no learning yet" in _flatten(frame["grid"])
|
|
|
|
|
|
def test_grid_fits_within_row_budget():
|
|
# The chart is a timeline of dated buckets + a trajectory row; it fills up to
|
|
# the row budget but never overflows it.
|
|
frame = render.render_graph(_payload(), cols=60, rows=14, reveal=1.0)
|
|
assert 0 < len(frame["grid"]) <= 14
|
|
|
|
|
|
def test_legend_counts_and_glyphs():
|
|
payload = _payload(skills=9, memories=4)
|
|
legend = render.build_legend(payload)
|
|
labels = {item["label"] for item in legend}
|
|
assert "skills (9)" in labels
|
|
assert "memories (4)" in labels
|
|
glyphs = {item["glyph"] for item in legend}
|
|
assert render.SKILL_GLYPH in glyphs and render.MEMORY_GLYPH in glyphs
|
|
|
|
|
|
def test_axis_labels_present_when_dated():
|
|
axis = render.axis_labels(_payload())
|
|
assert axis["start"] != "oldest" # dated → real dates
|
|
assert axis["end"] != "now"
|
|
|
|
|
|
def test_frames_play_through_grows_visibility():
|
|
payload = _payload(skills=10, memories=4)
|
|
out = render.render_frames(payload, cols=50, rows=16, frames=12)
|
|
assert out["count"] == len(payload["nodes"])
|
|
assert len(out["frames"]) == 12
|
|
assert out["frames"][0]["visible"] <= out["frames"][-1]["visible"]
|
|
assert out["frames"][-1]["visible"] == len(payload["nodes"])
|
|
assert "axis" in out
|
|
for fr in out["frames"]:
|
|
assert fr["grid"]
|
|
|
|
|
|
def test_frames_count_is_clamped():
|
|
payload = _payload(skills=3, memories=1)
|
|
assert len(render.render_frames(payload, cols=40, rows=12, frames=1)["frames"]) == 2
|
|
assert len(render.render_frames(payload, cols=40, rows=12, frames=9999)["frames"]) == 240
|
|
|
|
|
|
def test_format_date_handles_missing():
|
|
assert render.format_date(None) == "unknown"
|
|
assert render.format_date(0) == "unknown"
|
|
assert render.format_date(1_700_000_000) != "unknown"
|
|
|
|
|
|
def test_derive_palette_distinct_memory_hue():
|
|
pal = render.derive_palette("#FFD700", dark=True)
|
|
assert pal["skill"].startswith("#") and pal["memory"].startswith("#")
|
|
# Skills wear the muted complement, memories the primary ink → distinct.
|
|
assert pal["memory"].lower() != pal["skill"].lower()
|
|
|
|
|
|
def test_summary_reports_learning_totals():
|
|
lines = render.build_summary(_payload(skills=7, memories=2))
|
|
assert any("7 learned skills" in line and "2 memories" in line for line in lines)
|