diff --git a/emulo.py b/emulo.py
index 1fa0788..3956b1a 100644
--- a/emulo.py
+++ b/emulo.py
@@ -2364,11 +2364,14 @@ def write_stats(result, out_dir):
return stats
def months_between(first_date, last_date):
+ # TypeError covers a reducer that emits JSON null (or any non-sliceable
+ # value) for a date: 0 means "unknown", and both card renderers already
+ # skip the months row when it is falsy, like every other missing stat.
try:
y1, m1 = int(first_date[:4]), int(first_date[5:7])
y2, m2 = int(last_date[:4]), int(last_date[5:7])
return max(1, (y2 - y1) * 12 + (m2 - m1) + 1)
- except (ValueError, IndexError):
+ except (TypeError, ValueError, IndexError):
return 0
def fmt_tokens(n):
diff --git a/tests/test_card_null_dates.py b/tests/test_card_null_dates.py
new file mode 100644
index 0000000..daf0b74
--- /dev/null
+++ b/tests/test_card_null_dates.py
@@ -0,0 +1,119 @@
+"""A card.json whose stats carry JSON nulls must still render.
+
+A reducer that emits `"first_date": null` instead of `""` used to crash
+`emulo --card`: months_between() did `None[:4]`, which raises TypeError, and
+TypeError was not in the except tuple, so the traceback escaped both
+print_card() and render_card_html().
+
+Missing dates degrade the same way every other missing stat does on this card:
+months_between() returns 0 and both renderers already skip the months row when
+it is falsy.
+"""
+
+import contextlib
+import importlib.util
+import io
+import json
+import os
+import subprocess
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parents[1]
+EMULO = ROOT / "emulo.py"
+SPEC = importlib.util.spec_from_file_location("emulo_card_nulls", EMULO)
+emulo = importlib.util.module_from_spec(SPEC)
+SPEC.loader.exec_module(emulo)
+
+
+def card_with(stats):
+ return {
+ "archetype": "Proof-First Builder",
+ "laws": [{"text": "Always prove done.", "count": "12 sessions"}],
+ "truth": "you ship faster than you verify",
+ "stats": stats,
+ }
+
+
+class MonthsBetweenNullDatesTest(unittest.TestCase):
+ def test_null_dates_degrade_to_zero_instead_of_raising(self):
+ self.assertEqual(emulo.months_between(None, None), 0)
+ self.assertEqual(emulo.months_between(None, "2026-07-01"), 0)
+ self.assertEqual(emulo.months_between("2025-11-01", None), 0)
+
+ def test_non_string_dates_degrade_to_zero(self):
+ # a reducer emitting an epoch int or a list is just as unrenderable
+ self.assertEqual(emulo.months_between(20251101, 20260701), 0)
+ self.assertEqual(emulo.months_between(["2025-11-01"], ["2026-07-01"]), 0)
+ self.assertEqual(emulo.months_between({}, {}), 0)
+
+ def test_empty_and_malformed_strings_still_degrade_to_zero(self):
+ self.assertEqual(emulo.months_between("", ""), 0)
+ self.assertEqual(emulo.months_between("not-a-date", "also-not"), 0)
+
+ def test_real_dates_still_compute_the_span_inclusive(self):
+ self.assertEqual(emulo.months_between("2025-11-03", "2026-07-21"), 9)
+ self.assertEqual(emulo.months_between("2026-07-01", "2026-07-21"), 1)
+
+
+class CardRendersWithNullDatesTest(unittest.TestCase):
+ def test_render_card_html_survives_null_dates_and_omits_months(self):
+ html = emulo.render_card_html(card_with({
+ "sessions": 1656,
+ "tokens": 3_000_000,
+ "first_date": None,
+ "last_date": None,
+ }))
+ self.assertIn("1,656", html)
+ self.assertNotIn("months", html)
+
+ def test_render_card_html_keeps_months_when_dates_are_real(self):
+ html = emulo.render_card_html(card_with({
+ "sessions": 1656,
+ "first_date": "2025-11-03",
+ "last_date": "2026-07-21",
+ }))
+ self.assertIn("months", html)
+
+ def test_print_card_survives_null_dates(self):
+ buf = io.StringIO()
+ with contextlib.redirect_stdout(buf):
+ emulo.print_card(card_with({
+ "sessions": 1656,
+ "first_date": None,
+ "last_date": None,
+ }), still=True)
+ out = buf.getvalue()
+ self.assertIn("1,656 sessions", out)
+ self.assertNotIn("months", out)
+
+
+class CardCliWithNullDatesTest(unittest.TestCase):
+ def test_emulo_card_exits_clean_on_a_card_with_null_dates(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ out = Path(tmp) / "emulo-out"
+ out.mkdir()
+ card_path = out / "card.json"
+ card_path.write_text(json.dumps(card_with({
+ "sessions": 1656,
+ "messages": 42000,
+ "tokens": 3_000_000,
+ "first_date": None,
+ "last_date": None,
+ })), encoding="utf-8")
+ env = dict(os.environ, EMULO_NO_ANIM="1", PYTHONIOENCODING="utf-8")
+ result = subprocess.run(
+ [sys.executable, str(EMULO), "--card", str(card_path),
+ "--out", str(out), "--no-open", "--still"],
+ capture_output=True, text=True, env=env,
+ )
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertNotIn("Traceback", result.stderr)
+ self.assertTrue((out / "card.html").exists())
+
+
+if __name__ == "__main__":
+ unittest.main()