CX Discovery Notebook

This commit is contained in:
2026-07-23 12:04:37 -04:00
parent cbbc9ba839
commit 71b913d7fe
45 changed files with 23170 additions and 1830 deletions

View File

@@ -0,0 +1,7 @@
"""Make discoverylib importable even without the study venv active (the
normal setup is ``pip install -e ".[dev]"`` into the study-local ``.venv/``)."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))

View File

@@ -0,0 +1,117 @@
"""Session engine pins — status, progress, checklist, and export payload.
Progress and status are this study's "numbers"; these are the hand-checked
acceptance values the in-notebook gate re-pins (Pattern §4).
"""
from discoverylib import (
COMPLETE,
IN_PROGRESS,
NOT_STARTED,
SKIPPED,
TOPIC_KEYS,
active_topic_key,
build_session,
normalize_status,
progress,
session_json,
subtopic_checklist,
subtopic_id,
topic,
)
def test_normalize_status_defaults_unknown():
assert normalize_status("complete") == COMPLETE
assert normalize_status(None) == NOT_STARTED
assert normalize_status("garbage") == NOT_STARTED
def test_progress_all_not_started():
p = progress({})
assert p.total == 8
assert (p.completed, p.skipped, p.in_progress, p.not_started) == (0, 0, 0, 8)
assert p.fraction == 0.0
assert p.resolved == 0
assert p.label == "0/8 topics complete"
def test_progress_mixed_counts_and_fraction():
status = {
"background": COMPLETE,
"cx_strategy": COMPLETE,
"channels": IN_PROGRESS,
"agent_environment": SKIPPED,
# remaining four default to not_started
}
p = progress(status)
assert (p.completed, p.skipped, p.in_progress, p.not_started) == (2, 1, 1, 4)
assert p.resolved == 3 # complete + skipped
assert p.fraction == 2 / 8
assert p.label == "2/8 topics complete"
def test_active_topic_prefers_in_progress_then_first_open():
# in-progress wins even if a later topic is also in progress
assert active_topic_key({"channels": IN_PROGRESS}) == "channels"
# no in-progress → first not-started in canonical order
assert active_topic_key({"background": COMPLETE}) == "cx_strategy"
# everything resolved → None
all_done = {k: COMPLETE for k in TOPIC_KEYS}
assert active_topic_key(all_done) is None
def test_subtopic_checklist_marks_done():
t = topic("channels")
done = {subtopic_id("channels", "voice_metrics")}
items = subtopic_checklist(t, done)
assert len(items) == len(t.subtopics)
by_key = {i.key: i.done for i in items}
assert by_key["voice_metrics"] is True
assert by_key["inbound_context"] is False
def test_build_session_shape_and_subtopic_done_count():
status = {"channels": IN_PROGRESS, "background": COMPLETE}
notes = {"channels": " 6 channels; voice ~70% "}
done = {subtopic_id("channels", "voice_metrics"),
subtopic_id("channels", "outbound")}
s = build_session(status, notes, done)
assert len(s["topics"]) == 8
assert s["active_topic_key"] == "channels"
assert s["active_topic"].title == "Channels"
assert s["agenda_minutes"] == 110
ch = next(ts for ts in s["topics"] if ts.key == "channels")
assert ch.status == IN_PROGRESS
assert ch.subtopics_done == 2
assert ch.subtopics_total == len(topic("channels").subtopics)
assert ch.notes == "6 channels; voice ~70%" # trimmed
bg = next(ts for ts in s["topics"] if ts.key == "background")
assert bg.status == COMPLETE and bg.subtopics_done == 0
def test_session_json_is_plain_and_complete():
status = {"background": COMPLETE, "channels": IN_PROGRESS}
notes = {"background": "3 LOBs; PCI in scope"}
done = {subtopic_id("channels", "voice_metrics")}
payload = session_json(build_session(status, notes, done),
meta={"client": "Acme", "date": "2026-07-19"})
assert payload["study"] == "202607_CX_Discovery_Workshop"
assert payload["meta"]["client"] == "Acme"
assert payload["progress"]["completed"] == 1
assert payload["progress"]["fraction_complete"] == round(1 / 8, 4)
assert payload["active_topic"] == "channels"
assert len(payload["topics"]) == 8
bg = next(t for t in payload["topics"] if t["key"] == "background")
assert bg["status"] == COMPLETE
assert bg["notes"] == "3 LOBs; PCI in scope"
ch = next(t for t in payload["topics"] if t["key"] == "channels")
assert ch["subtopics_done"] == 1
# JSON-serializable (no dataclasses / sets leaked through)
import json
json.dumps(payload)

View File

@@ -0,0 +1,15 @@
"""Stage/backstage detection — Mercury kernels carry MERCURY_CONFIG_DIR."""
from discoverylib import staging
def test_backstage_prints_only_off_stage(monkeypatch, capsys):
monkeypatch.delenv("MERCURY_CONFIG_DIR", raising=False)
assert not staging.on_stage()
staging.backstage("visible")
assert capsys.readouterr().out == "visible\n"
monkeypatch.setenv("MERCURY_CONFIG_DIR", "/tmp/app")
assert staging.on_stage()
staging.backstage("hidden")
assert capsys.readouterr().out == ""

View File

@@ -0,0 +1,62 @@
"""Topic bank integrity — the verbatim facilitation record.
These pins are the anchor guard (Pattern §4): keys are stable identities
that captured notes and the JSON export key off, so renaming or reordering a
topic must be a deliberate, test-breaking act — never a silent drift.
"""
from discoverylib import TOPICS, TOPIC_KEYS, topic
from discoverylib.topics import TOPIC_BY_KEY
# ── Hand-checked shape (recount if you add/remove content) ───────────
def test_topic_count_and_order():
assert len(TOPICS) == 8
assert TOPIC_KEYS == (
"background",
"cx_strategy",
"channels",
"agent_environment",
"routing_automation",
"workforce_engagement",
"training",
"reporting_insights",
)
def test_subtopic_and_prompt_totals():
assert sum(len(t.subtopics) for t in TOPICS) == 26
assert sum(t.prompt_count for t in TOPICS) == 95
def test_agenda_minutes_sum():
from discoverylib import agenda_minutes
assert agenda_minutes() == 110
assert agenda_minutes() == sum(t.minutes for t in TOPICS)
# ── Structural invariants — hold for every topic ─────────────────────
def test_keys_unique_and_wellformed():
keys = [t.key for t in TOPICS]
assert len(keys) == len(set(keys)) # unique
for t in TOPICS:
assert t.key.replace("_", "").isalnum() # snake_case slug
assert t.title and t.scope # client-facing text present
assert t.minutes > 0
assert t.subtopics # no empty topic
sub_keys = [st.key for st in t.subtopics]
assert len(sub_keys) == len(set(sub_keys)) # unique within topic
for st in t.subtopics:
assert st.prompts # no empty sub-topic
def test_lookup_helpers():
assert topic("channels").title == "Channels"
assert TOPIC_BY_KEY["training"].minutes == 5
assert TOPICS[0].key == "background"
def test_no_double_dollar_or_raw_markup_in_scope():
# scope lines render on stage HTML — keep them plain text.
for t in TOPICS:
assert "<" not in t.scope and ">" not in t.scope