diff --git a/tests/test_card_render.py b/tests/test_card_render.py
new file mode 100644
index 0000000..2843583
--- /dev/null
+++ b/tests/test_card_render.py
@@ -0,0 +1,437 @@
+"""Unit tests for the card display layer of emulo.py.
+
+The card is the one artifact users screenshot and post, and it renders text a
+language model wrote into a fixed-width terminal frame and into HTML. Only the
+happy path was covered (one subprocess run in test_emulo.py, one HTML label
+assertion in test_profile_store.py); everything below is the malformed-input,
+boundary and layout behaviour that a regression would otherwise reach the
+screenshot before anyone noticed.
+"""
+
+import contextlib
+import importlib.util
+import io
+import json
+import os
+import shutil
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+from unittest import mock
+
+
+ROOT = Path(__file__).resolve().parents[1]
+SPEC = importlib.util.spec_from_file_location("emulo_card", ROOT / "emulo.py")
+emulo = importlib.util.module_from_spec(SPEC)
+SPEC.loader.exec_module(emulo)
+
+
+def sample_card(**overrides):
+ card = {
+ "archetype": "Proof-First Builder",
+ "laws": [
+ {"text": "done means it runs live in production for a real user", "count": "18/20"},
+ {"text": "fix the one thing", "count": "15/20"},
+ {"text": "no filler in the report", "count": "9/20"},
+ ],
+ "truth": "asks the agent to explain his own system back to him",
+ "stats": {
+ "sessions": 1656,
+ "messages": 7678,
+ "tokens": 2950000,
+ "first_date": "2025-11-02",
+ "last_date": "2026-07-08",
+ },
+ }
+ card.update(overrides)
+ return card
+
+
+def render_terminal_card(card, columns=80, stream=None):
+ """Render the static card and return its stdout as a string."""
+ buf = stream if stream is not None else io.StringIO()
+ size = os.terminal_size((columns, 48))
+ with mock.patch.object(shutil, "get_terminal_size", return_value=size):
+ with contextlib.redirect_stdout(buf):
+ emulo.print_card(card, still=True)
+ return buf.getvalue()
+
+
+def frame_lines(rendered):
+ return [line for line in rendered.split("\n") if line.strip()]
+
+
+class TokenAndDateFormattingTest(unittest.TestCase):
+ def test_fmt_tokens_switches_units_at_the_exact_boundaries(self):
+ self.assertEqual("0", emulo.fmt_tokens(0))
+ self.assertEqual("999", emulo.fmt_tokens(999))
+ self.assertEqual("1K", emulo.fmt_tokens(1000))
+ self.assertEqual("999K", emulo.fmt_tokens(999_999))
+ self.assertEqual("1.0M", emulo.fmt_tokens(1_000_000))
+ self.assertEqual("3.0M", emulo.fmt_tokens(2_950_000))
+
+ def test_fmt_tokens_truncates_thousands_instead_of_rounding_up(self):
+ # 1999 tokens must never read as "2K" on a card people screenshot
+ self.assertEqual("1K", emulo.fmt_tokens(1999))
+
+ def test_months_between_counts_an_inclusive_span(self):
+ self.assertEqual(9, emulo.months_between("2025-11-02", "2026-07-08"))
+ self.assertEqual(1, emulo.months_between("2026-07-01", "2026-07-31"))
+ self.assertEqual(12, emulo.months_between("2025-01-05", "2025-12-31"))
+
+ def test_months_between_clamps_a_reversed_range_to_one_month(self):
+ self.assertEqual(1, emulo.months_between("2026-07-08", "2025-11-02"))
+
+ def test_months_between_returns_zero_for_unparseable_dates(self):
+ for first, last in (
+ ("", ""),
+ ("not-a-date", "2026-07-08"),
+ ("2025-11-02", "nope"),
+ ("2025", "2026"),
+ ("2026-7-8", "2026-09-01"),
+ ):
+ with self.subTest(first=first, last=last):
+ self.assertEqual(0, emulo.months_between(first, last))
+
+ @unittest.expectedFailure
+ def test_months_between_survives_null_dates(self):
+ # BUG: months_between catches (ValueError, IndexError) but a card.json
+ # with `"first_date": null` yields None, and None[:4] raises TypeError.
+ # That escapes both print_card and render_card_html, so `emulo --card`
+ # tracebacks on a reducer that emitted nulls instead of empty strings.
+ # Fix is one word: add TypeError to the except tuple. Not fixing here.
+ self.assertEqual(0, emulo.months_between(None, None))
+
+
+class LawBarTest(unittest.TestCase):
+ def test_bar_is_always_exactly_the_requested_width(self):
+ for count in ("18/20", "0/20", "20/20", "1/3"):
+ with self.subTest(count=count):
+ self.assertEqual(26, len(emulo._law_bar(count)))
+ self.assertEqual(40, len(emulo._law_bar(count, width=40)))
+
+ def test_bar_fills_in_proportion_to_the_ratio(self):
+ self.assertEqual("█" * 23 + "░" * 3, emulo._law_bar("18/20"))
+ self.assertEqual("░" * 26, emulo._law_bar("0/20"))
+ self.assertEqual("█" * 26, emulo._law_bar("20/20"))
+
+ def test_bar_clamps_ratios_outside_zero_to_one(self):
+ self.assertEqual("█" * 10, emulo._law_bar("30/20", width=10))
+ self.assertEqual("░" * 10, emulo._law_bar("-5/20", width=10))
+
+ def test_zero_denominator_does_not_divide_by_zero(self):
+ self.assertEqual("█" * 10, emulo._law_bar("18/0", width=10))
+
+ def test_non_ratio_counts_get_no_bar(self):
+ # these are the shapes a reducer actually emits when it drifts:
+ # a prose count, a bare number, a null, an over-split string
+ for count in ("2 sessions", "abc", "5", "", None, "1/2/3", "x/y"):
+ with self.subTest(count=count):
+ self.assertIsNone(emulo._law_bar(count))
+
+
+class CardHtmlTest(unittest.TestCase):
+ def test_mined_text_is_html_escaped_in_every_slot(self):
+ html = emulo.render_card_html(sample_card(
+ archetype="",
+ laws=[{"text": "ships & proves", "count": "9/20"}],
+ truth="he said 5 > 3 & meant it",
+ ))
+ self.assertNotIn("