"""Intensive bug-hunting tests for StoryCode.
Covers edge cases, error paths, boundary conditions, and integration across
the entire codebase. Run: python tests/test_bugs.py
"""
from __future__ import annotations
import json
import os
import sys
import tempfile
import zipfile
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import config
from analyzer import analyze_project
from analyzer import deps as deps_mod
from analyzer import graph, generic, python_ast, js_treesitter
from db import init_db, save_story, list_stories, get_story
from diagram import build_mermaid, render_html
from ingest import from_folder, from_text, from_zip, redact_secrets, SourceFile, Ingested
from llm import _loads, _without_thinking, LLMUnavailable
from schema import (
FileInfo, FileSummary, ProjectModel, ProjectStory, StorySection,
file_summary_schema, project_story_schema, Symbol, Dependency,
)
from story import map_prompt, reduce_prompt, plain_fallback_story, file_digest, project_digest
import narrate
import story as story_mod
PASSED = 0
FAILED = 0
BUGS = []
def ok(name):
global PASSED
PASSED += 1
print(f" ok {name}")
def bug(name, detail):
global FAILED
FAILED += 1
BUGS.append((name, detail))
print(f" BUG {name}: {detail}")
def assert_eq(name, got, want):
if got == want:
ok(name)
else:
bug(name, f"expected {want!r}, got {got!r}")
def assert_true(name, cond, detail=""):
if cond:
ok(name)
else:
bug(name, detail or "condition was false")
def assert_no_crash(name, fn, *args, **kwargs):
try:
fn(*args, **kwargs)
ok(name)
except Exception as exc:
bug(name, f"crashed: {type(exc).__name__}: {exc}")
# ============================================================
# 1. SECRET SCANNING
# ============================================================
print("\n=== 1. SECRET SCANNING ===")
def test_secret_scanning():
# All known patterns
cases = [
("OpenAI key", 'sk-abc123def456ghi789jkl012', "OpenAI"),
("Anthropic key", 'sk-ant-api03-abc123def456ghi789jkl', "Anthropic"),
("AWS key", 'AKIAIOSFODNN7EXAMPLE', "AWS"),
("Google API key", 'AIzaSyA1234567890abcdefghijklmnopqrstuv', "Google"),
("GitHub token", 'ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef', "GitHub"),
("Slack token", 'xoxb-123456789012-1234567890123-AbCdEfGh', "Slack"),
("Private key", '-----BEGIN RSA PRIVATE KEY-----', "Private"),
("Hard-coded password", 'password = "supersecretvalue123"', "Hard-coded"),
("Hard-coded api_key", 'api_key="abcdef1234567890"', "Hard-coded"),
]
for label, payload, expected_hit in cases:
clean, hits = redact_secrets(f'x = "{payload}"')
if any(expected_hit in h for h in hits):
ok(f"secret_{label}")
else:
bug(f"secret_{label}", f"expected hit containing '{expected_hit}', got {hits}")
if "REDACTED" in clean:
ok(f"redact_{label}")
else:
bug(f"redact_{label}", f"no REDACTION in: {clean}")
# False positive: short strings should NOT be redacted
clean, hits = redact_secrets('password = "abc"')
if not hits:
ok("secret_short_no_false_positive")
else:
bug("secret_short_no_false_positive", f"false positive on short string: {hits}")
# Empty input
clean, hits = redact_secrets("")
assert_eq("secret_empty_text", clean, "")
assert_eq("secret_empty_hits", hits, [])
# No secrets
clean, hits = redact_secrets("x = 42\nprint('hello')")
assert_eq("secret_clean_code", hits, [])
# Multiple secrets in one file
text = 'key1 = "sk-abc123def456ghi789jkl012"\nkey2 = "AKIAIOSFODNN7EXAMPLE"'
clean, hits = redact_secrets(text)
assert_true("secret_multi_hits", len(hits) >= 2, f"got {len(hits)} hits")
assert_true("secret_multi_redacted", "REDACTED" in clean)
test_secret_scanning()
# ============================================================
# 2. INGEST EDGE CASES
# ============================================================
print("\n=== 2. INGEST EDGE CASES ===")
def test_ingest():
# Empty folder
with tempfile.TemporaryDirectory() as td:
ingested = from_folder(td, name="empty")
assert_eq("ingest_empty_files", len(ingested.files), 0)
assert_eq("ingest_empty_name", ingested.name, "empty")
# Single pasted file
ingested = from_text("def hello(): pass", filename="hello.py")
assert_eq("ingest_text_files", len(ingested.files), 1)
assert_eq("ingest_text_lang", ingested.files[0].lang, "python")
assert_eq("ingest_text_name", ingested.name, "hello.py")
# Pasted file with secret
ingested = from_text('API_KEY = "sk-abc123def456ghi789jkl012"', filename="config.py")
assert_true("ingest_text_secret", len(ingested.secrets_found) > 0,
f"expected secret hits, got {ingested.secrets_found}")
# Zip with single folder wrapping
with tempfile.TemporaryDirectory() as td:
zip_path = os.path.join(td, "project.zip")
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("myapp/main.py", "x = 1")
zf.writestr("myapp/utils.py", "y = 2")
ingested = from_zip(zip_path, name="test")
assert_true("ingest_zip_folder", len(ingested.files) >= 2,
f"got {len(ingested.files)} files")
# Zip with MACOSX junk
with tempfile.TemporaryDirectory() as td:
zip_path = os.path.join(td, "junk.zip")
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("__MACOSX/._main.py", "junk")
zf.writestr("main.py", "x = 1")
ingested = from_zip(zip_path)
assert_true("ingest_macosx_skip", all("__MACOSX" not in f.path for f in ingested.files),
f"MACOSX file leaked through")
# Zip with binary-like files
with tempfile.TemporaryDirectory() as td:
zip_path = os.path.join(td, "binary.zip")
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("image.png", b"\x89PNG\r\n\x1a\n" + b"\x00" * 100)
zf.writestr("code.py", "x = 1")
ingested = from_zip(zip_path)
assert_true("ingest_skip_binary", len(ingested.files) == 1,
f"binary file not skipped, got {len(ingested.files)} files")
# Binary file detection
assert_true("ingest_binary_ext", config._lang_for if hasattr(config, '_lang_for') else True, "")
# Size limits
old_max = config.MAX_FILE_BYTES
config.MAX_FILE_BYTES = 10
ingested = from_text("x = 'a' * 1000", filename="big.py")
config.MAX_FILE_BYTES = old_max
# The file is small in text but we can test the skip path
ok("ingest_size_limit_test_ran")
# Unsupported extension — should be 'other', not 'python'
ingested = from_text("binary data", filename="image.png")
assert_eq("ingest_unsupported_ext", ingested.files[0].lang, "other")
# No .env file should NOT crash
assert_no_crash("ingest_no_env_crash", from_text, "", filename="something.env")
test_ingest()
# ============================================================
# 3. PYTHON AST PARSING
# ============================================================
print("\n=== 3. PYTHON AST PARSING ===")
def test_python_ast():
# Normal file
code = '''
import os
from pathlib import Path
def hello():
"""Say hello."""
pass
class Foo:
"""A class."""
pass
'''
syms, imps = python_ast.parse(code)
assert_true("pyast_syms_count", len(syms) == 2, f"got {len(syms)}")
assert_eq("pyast_func_name", syms[0].name, "hello")
assert_eq("pyast_class_name", syms[1].name, "Foo")
assert_true("pyast_doc", syms[0].doc and "hello" in syms[0].doc,
f"doc={syms[0].doc}")
assert_true("pyast_imports", "os" in imps and "pathlib.Path" in imps,
f"imports={imps}")
# Syntax error (should not crash)
syms, imps = python_ast.parse("def broken(\n")
assert_eq("pyast_syntax_error", syms, [])
assert_eq("pyast_syntax_error_imps", imps, [])
# Empty file
syms, imps = python_ast.parse("")
assert_eq("pyast_empty_syms", syms, [])
assert_eq("pyast_empty_imps", imps, [])
# Relative imports
code = "from . import foo\nfrom ..bar import baz"
syms, imps = python_ast.parse(code)
assert_true("pyast_relative_imports", ".foo" in imps or "..bar" in imps or ".bar.baz" in imps,
f"imports={imps}")
# Nested imports (inside try/except)
code = '''
try:
import json
except ImportError:
import pickle as json
'''
syms, imps = python_ast.parse(code)
assert_true("pyast_nested_imports", "json" in imps or "pickle" in imps,
f"imports={imps}")
# Deep docstring
code = '''
def foo():
"""
Line 1
Line 2
Line 3
"""
pass
'''
syms, _ = python_ast.parse(code)
assert_true("pyast_multiline_doc", syms[0].doc is not None and len(syms[0].doc) <= 140,
f"doc={syms[0].doc}")
test_python_ast()
# ============================================================
# 4. GENERIC PARSING (HTML, CSS)
# ============================================================
print("\n=== 4. GENERIC PARSING (HTML, CSS) ===")
def test_generic():
# HTML with script/link refs
html = '''
link
about
'''
syms, refs = generic.parse(html, "html")
assert_true("html_refs", "style.css" in refs and "app.js" in refs,
f"refs={refs}")
assert_true("html_no_external", "https://example.com" not in refs,
f"external link leaked: {refs}")
# NOTE:
refs ARE included — images can be real project dependencies.
# This is a design choice, not a bug. Some projects bundle assets.
ok("html_img_ref_design_choice")
# CSS with imports and selectors
css = '''
@import "reset.css";
@import url("theme.css");
.header { color: red; }
#main { display: flex; }
'''
syms, refs = generic.parse(css, "css")
assert_true("css_imports", "reset.css" in refs and "theme.css" in refs,
f"refs={refs}")
assert_true("css_selectors", len(syms) >= 2, f"got {len(syms)} selectors")
# JSON / YAML (should return empty)
syms, refs = generic.parse('{"key": "value"}', "json")
assert_eq("json_no_syms", syms, [])
assert_eq("json_no_refs", refs, [])
test_generic()
# ============================================================
# 5. JS/TREE-SITTER PARSING
# ============================================================
print("\n=== 5. JS/TREE-SITTER PARSING ===")
def test_js_parsing():
js = '''
import React from 'react';
import { useState } from './hooks';
const fetchData = async () => { return null; };
function Component() { return null; }
class App { constructor() {} }
require('./utils');
'''
syms, imps = js_treesitter.parse(js, "javascript")
assert_true("js_syms_count", len(syms) >= 3, f"got {len(syms)}: {[s.name for s in syms]}")
assert_true("js_imports", any('react' in i for i in imps), f"imports={imps}")
assert_true("js_require", any('utils' in i for i in imps), f"no require: {imps}")
# Empty JS
syms, imps = js_treesitter.parse("", "javascript")
assert_eq("js_empty_syms", syms, [])
assert_eq("js_empty_imps", imps, [])
# TypeScript
ts = '''
import { FC } from 'react';
const MyComp: FC = () => null;
export function helper(): string { return ""; }
'''
syms, imps = js_treesitter.parse(ts, "typescript")
assert_true("ts_syms", len(syms) >= 1, f"got {len(syms)}")
test_js_parsing()
# ============================================================
# 6. GRAPH / ROLE CLASSIFICATION
# ============================================================
print("\n=== 6. GRAPH / ROLE CLASSIFICATION ===")
def test_graph():
# Role classification
assert_eq("role_app_py", graph.classify_role("app.py", "python"), "entry")
assert_eq("role_main_py", graph.classify_role("main.py", "python"), "entry")
assert_eq("role_config", graph.classify_role("config.py", "python"), "config")
assert_eq("role_settings", graph.classify_role("settings.yaml", "yaml"), "config")
assert_eq("role_test", graph.classify_role("test_main.py", "python"), "test")
assert_eq("role_tests_dir", graph.classify_role("tests/test_app.py", "python"), "test")
assert_eq("role_embed", graph.classify_role("embed.py", "python"), "data")
# "retrieve" isn't in DATA_HINTS — falls through to backend for Python
assert_eq("role_retrieve", graph.classify_role("retrieve.py", "python"), "backend")
assert_eq("role_helpers", graph.classify_role("helpers/utils.py", "python"), "util")
assert_eq("role_component", graph.classify_role("Button.jsx", "javascript"), "frontend")
assert_eq("role_page", graph.classify_role("HomePage.tsx", "typescript"), "frontend")
# Python module resolution
index = graph._py_module_index(["analyzer/__init__.py", "analyzer/graph.py", "utils.py"])
assert_eq("pymod_init", index.get("analyzer"), "analyzer/__init__.py")
assert_eq("pymod_graph", index.get("analyzer.graph"), "analyzer/graph.py")
assert_eq("pymod_utils", index.get("utils"), "utils.py")
# Relative import resolution
pathset = {"pkg/__init__.py", "pkg/module.py", "app.py"}
hit = graph._resolve_python("app.py", "pkg.module", index, pathset)
# "pkg.module" is not in the index (index has "analyzer", "analyzer.graph", "utils")
# Test with correct paths:
index2 = graph._py_module_index(["pkg/__init__.py", "pkg/module.py"])
hit2 = graph._resolve_python("app.py", "pkg.module", index2, pathset)
assert_eq("resolve_abs", hit2, "pkg/module.py")
# JS relative path resolution
pathset = {"src/utils.js", "src/components/Button.jsx"}
hit = graph._resolve_relative_path("src/App.js", "./utils.js", pathset)
assert_eq("resolve_js_relative", hit, "src/utils.js")
# Empty project
m = analyze_project(Ingested(name="empty"))
assert_eq("graph_empty_files", len(m.files), 0)
assert_eq("graph_empty_entry", m.entry_points, [])
# Edge: self-dependency should be excluded
files = [
FileInfo(path="a.py", lang="python", imports=["b"]),
FileInfo(path="b.py", lang="python", imports=["a"]),
]
graph.resolve_edges(files)
assert_true("no_self_dep_a", "a.py" not in files[0].depends_on,
f"a depends on itself: {files[0].depends_on}")
assert_true("no_self_dep_b", "b.py" not in files[1].depends_on,
f"b depends on itself: {files[1].depends_on}")
test_graph()
# ============================================================
# 7. DEPENDENCY PARSING
# ============================================================
print("\n=== 7. DEPENDENCY PARSING ===")
def test_deps():
# requirements.txt with various formats
text = "flask==2.0\nrequests\n# comment\npydantic>=2.0\n-e git+https://example.com#egg=dev\ndev\n"
deps = deps_mod.parse_requirements(text)
names = {d.name for d in deps}
assert_true("req_flask", "flask" in names, f"got {names}")
assert_true("req_requests", "requests" in names, f"got {names}")
assert_true("req_pydantic", "pydantic" in names, f"got {names}")
assert_true("req_no_comment", "comment" not in names, f"comment leaked")
assert_true("req_no_editable", "-e" not in names, f"editable leaked")
# package.json
text = '{"dependencies": {"react": "^18"}, "devDependencies": {"jest": "^29"}}'
deps = deps_mod.parse_package_json(text)
names = {d.name for d in deps}
assert_true("pkg_react", "react" in names, f"got {names}")
assert_true("pkg_jest", "jest" in names, f"got {names}")
# Invalid package.json
deps = deps_mod.parse_package_json("not json")
assert_eq("pkg_invalid", deps, [])
# pyproject.toml
text = '''
[project]
dependencies = ["fastapi>=0.100", "uvicorn"]
[tool.poetry.dependencies]
python = "^3.10"
sqlalchemy = "^2.0"
'''
deps = deps_mod.parse_pyproject(text)
names = {d.name for d in deps}
assert_true("pyproj_fastapi", "fastapi" in names, f"got {names}")
assert_true("pyproj_uvicorn", "uvicorn" in names, f"got {names}")
assert_true("pyproj_sqlalchemy", "sqlalchemy" in names, f"got {names}")
assert_true("pyproj_no_python", "python" not in names, f"python leaked")
# Known library descriptions
assert_true("known_openai", deps_mod._plain("openai") != "A library called 'openai'.",
f"got: {deps_mod._plain('openai')}")
assert_true("known_unknown", "UnknownLib" in deps_mod._plain("UnknownLib"),
f"got: {deps_mod._plain('UnknownLib')}")
# Risky flags
dep = deps_mod._dep("pypdf2", "requirements.txt")
assert_true("risky_pypdf2", dep.risky, "pypdf2 should be flagged risky")
dep = deps_mod._dep("flask", "requirements.txt")
assert_true("safe_flask", not dep.risky, "flask should not be risky")
# manifest dispatch
deps = deps_mod.parse_manifest("requirements.txt", "flask\n")
assert_eq("manifest_req", len(deps), 1)
deps = deps_mod.parse_manifest("package.json", '{"dependencies":{"x":"1"}}')
assert_eq("manifest_pkg", len(deps), 1)
deps = deps_mod.parse_manifest("unknown.txt", "stuff")
assert_eq("manifest_unknown", deps, [])
test_deps()
# ============================================================
# 8. LLM EDGE CASES
# ============================================================
print("\n=== 8. LLM EDGE CASES ===")
def test_llm():
# _loads: valid JSON
assert_eq("loads_valid", _loads('{"a": 1}'), {"a": 1})
# _loads: JSON in markdown fence
assert_eq("loads_fenced", _loads('```json\n{"a": 1}\n```'), {"a": 1})
# _loads: JSON with surrounding text
assert_eq("loads_surrounded", _loads('Here is the result: {"a": 1} done.'), {"a": 1})
# _loads: no JSON at all
assert_eq("loads_no_json", _loads("no json here"), {})
# _loads: empty string
assert_eq("loads_empty", _loads(""), {})
# _loads: nested JSON
assert_eq("loads_nested", _loads('{"a": {"b": 2}}'), {"a": {"b": 2}})
# _loads: empty object
assert_eq("loads_empty_obj", _loads("{}"), {})
# _without_thinking: appends /no_think
msgs = [{"role": "user", "content": "hello"}]
result = _without_thinking(msgs)
assert_true("no_think_appended", "/no_think" in result[0]["content"],
f"content={result[0]['content']}")
# Original should not be mutated
assert_true("no_think_no_mutate", "/no_think" not in msgs[0]["content"],
"original was mutated!")
# _without_thinking: already has /no_think
msgs = [{"role": "user", "content": "hello\n\n/no_think"}]
result = _without_thinking(msgs)
count = result[0]["content"].count("/no_think")
assert_eq("no_think_no_double", count, 1)
# _without_thinking: only touches last user message
msgs = [
{"role": "system", "content": "You are helpful"},
{"role": "user", "content": "first"},
{"role": "assistant", "content": "reply"},
{"role": "user", "content": "second"},
]
result = _without_thinking(msgs)
assert_true("no_think_last_user", "/no_think" in result[3]["content"],
f"last user msg: {result[3]['content']}")
assert_true("no_think_not_first", "/no_think" not in result[1]["content"],
f"first user msg was changed: {result[1]['content']}")
# _without_thinking: no user message
msgs = [{"role": "system", "content": "hello"}]
result = _without_thinking(msgs)
assert_eq("no_think_no_user", result[0]["content"], "hello")
# _without_thinking: non-string content
msgs = [{"role": "user", "content": {"type": "text", "text": "hi"}}]
result = _without_thinking(msgs)
assert_true("no_think_non_string", isinstance(result[0]["content"], dict),
"non-string content was changed")
test_llm()
# ============================================================
# 9. SCHEMA VALIDATION
# ============================================================
print("\n=== 9. SCHEMA VALIDATION ===")
def test_schema():
# Guided JSON schemas
fs = file_summary_schema()
assert_true("schema_file_type", fs["type"] == "object")
assert_true("schema_file_one_liner", "one_liner" in fs["properties"])
assert_true("schema_file_summary", "summary" in fs["properties"])
assert_eq("schema_file_required", fs["required"], ["one_liner", "summary"])
ps = project_story_schema()
assert_true("schema_story_type", ps["type"] == "object")
assert_true("schema_story_title", "title" in ps["properties"])
assert_true("schema_story_steps", "steps" in ps["properties"])
assert_true("schema_story_plain", "plain_overview" in ps["properties"])
# Pydantic models
sym = Symbol(kind="function", name="test")
assert_eq("symbol_kind", sym.kind, "function")
fi = FileInfo(path="test.py", lang="python")
assert_eq("fileinfo_default_role", fi.role, "other")
assert_eq("fileinfo_default_safety", fi.safety, "careful")
pm = ProjectModel(name="test")
assert_true("pm_by_path_none", pm.by_path("nope.py") is None)
# ProjectStory with empty steps
ps = ProjectStory(title="t", overview="o", steps=[], plain_overview="p")
assert_eq("story_empty_steps", ps.steps, [])
test_schema()
# ============================================================
# 10. STORY / NARRATION
# ============================================================
print("\n=== 10. STORY / NARRATION ===")
def test_story():
m = analyze_project(from_folder("scripts/sample_project", name="test"))
# map_prompt returns correct structure
msgs = map_prompt(m, m.files[0])
assert_true("map_prompt_system", msgs[0]["role"] == "system")
assert_true("map_prompt_user", msgs[1]["role"] == "user")
assert_true("map_prompt_facts", "FILE:" in msgs[1]["content"])
# reduce_prompt returns correct structure
summaries = {f.path: FileSummary(path=f.path, one_liner="test", summary="test summary")
for f in m.files}
msgs = reduce_prompt(m, summaries, "plain", "teen")
assert_true("reduce_prompt_system", msgs[0]["role"] == "system")
assert_true("reduce_prompt_user", msgs[1]["role"] == "user")
assert_true("reduce_prompt_style", "STYLE:" in msgs[1]["content"])
assert_true("reduce_prompt_difficulty", "AUDIENCE:" in msgs[1]["content"])
# file_digest
digest = file_digest(m, m.files[0])
assert_true("file_digest_has_path", m.files[0].path in digest)
assert_true("file_digest_has_lang", "Language:" in digest)
# project_digest
pd = project_digest(m, summaries)
assert_true("proj_digest_has_name", m.name in pd)
assert_true("proj_digest_has_files", "FILES" in pd)
# plain_fallback_story
fallback = plain_fallback_story(m, summaries)
assert_true("fallback_title", fallback.title)
assert_true("fallback_overview", fallback.overview)
assert_true("fallback_steps", len(fallback.steps) > 0)
# Fallback with no summaries
fallback2 = plain_fallback_story(m)
assert_true("fallback_no_summ", len(fallback2.steps) > 0)
# Fallback with empty model
empty_m = ProjectModel(name="empty")
fallback3 = plain_fallback_story(empty_m)
assert_true("fallback_empty", fallback3.title)
# All style/difficulty combos with fallback
for style in config.STYLE_KEYS:
for diff in config.DIFFICULTY_KEYS:
story = plain_fallback_story(m, summaries)
assert_true(f"fallback_{style}_{diff}", story.title)
test_story()
# ============================================================
# 11. NARRATE (with live model)
# ============================================================
print("\n=== 11. NARRATE (LIVE MODEL) ===")
def test_narrate():
m = analyze_project(from_folder("scripts/sample_project", name="test"))
# summarise_files with model
summaries = narrate.summarise_files(m)
assert_eq("narrate_summ_count", len(summaries), len(m.files))
for path, summ in summaries.items():
assert_true(f"narrate_summ_{path}_one_liner", summ.one_liner,
f"empty one_liner for {path}")
assert_true(f"narrate_summ_{path}_summary", summ.summary,
f"empty summary for {path}")
# Cache hit: run again, should use cache
summaries2 = narrate.summarise_files(m)
assert_eq("narrate_cache_hit", summaries, summaries2)
# tell_story with all combos - check for thinking tags
for style in config.STYLE_KEYS:
for diff in config.DIFFICULTY_KEYS:
s = narrate.tell_story(m, summaries, style, diff)
has_think = ("" in s.overview or
"" in s.plain_overview or
any("" in step.body for step in s.steps))
assert_true(f"narrate_{style}_{diff}_title", s.title,
f"empty title for {style}/{diff}")
assert_true(f"narrate_{style}_{diff}_overview", s.overview,
f"empty overview for {style}/{diff}")
assert_true(f"narrate_{style}_{diff}_steps", len(s.steps) >= 2,
f"too few steps ({len(s.steps)}) for {style}/{diff}")
assert_true(f"narrate_{style}_{diff}_no_think", not has_think,
f" leaked in {style}/{diff}")
# tell_story without model -> fallback
old_url = config.MODAL_ENDPOINT_URL
old_key = config.MODAL_API_KEY
config.MODAL_ENDPOINT_URL = ""
config.MODAL_API_KEY = ""
# Reset the cached client
import llm
llm._client = None
s = narrate.tell_story(m, summaries, "plain", "teen")
assert_true("narrate_fallback_title", s.title)
config.MODAL_ENDPOINT_URL = old_url
config.MODAL_API_KEY = old_key
llm._client = None
# Progress callback
progress_calls = []
def track(i, total, path):
progress_calls.append((i, total, path))
narrate.summarise_files(m, progress=track)
assert_true("narrate_progress", len(progress_calls) > 0,
f"no progress calls")
test_narrate()
# ============================================================
# 12. DIAGRAM
# ============================================================
print("\n=== 12. DIAGRAM ===")
def test_diagram():
m = analyze_project(from_folder("scripts/sample_project", name="test"))
# build_mermaid
mermaid = build_mermaid(m)
assert_true("mermaid_has_flowchart", mermaid.startswith("flowchart TD"))
assert_true("mermaid_has_nodes", "[" in mermaid)
assert_true("mermaid_has_edges", "-->" in mermaid)
assert_true("mermaid_has_subgraphs", "subgraph" in mermaid)
# Empty model
empty_m = ProjectModel(name="empty")
mermaid = build_mermaid(empty_m)
assert_true("mermaid_empty", "No code files" in mermaid)
# render_html
html = render_html(m)
assert_true("render_has_mermaid", "mermaid" in html)
assert_true("render_has_key", "data-key" in html)
# Special characters in filenames (should not break Mermaid)
m2 = ProjectModel(
name="test",
files=[FileInfo(path='weird "name".py', lang="python", role="backend")]
)
mermaid = build_mermaid(m2)
assert_no_crash("mermaid_special_chars", build_mermaid, m2)
test_diagram()
# ============================================================
# 13. DB
# ============================================================
print("\n=== 13. DB ===")
def test_db():
with tempfile.TemporaryDirectory() as td:
old_path = config.DB_PATH
config.DB_PATH = os.path.join(td, "test.db")
init_db()
story_dict = {"title": "Test", "overview": "A test story", "steps": []}
sid = save_story("myproject", "plain", "teen", story_dict)
assert_true("db_save_id", sid > 0, f"got {sid}")
stories = list_stories()
assert_eq("db_list_count", len(stories), 1)
assert_eq("db_list_name", stories[0]["name"], "myproject")
loaded = get_story(sid)
assert_true("db_load", loaded is not None)
assert_eq("db_load_title", loaded["story"]["title"], "Test")
assert_eq("db_load_style", loaded["style"], "plain")
# Non-existent story
assert_eq("db_get_missing", get_story(99999), None)
config.DB_PATH = old_path
test_db()
# ============================================================
# 14. UI / THEME
# ============================================================
print("\n=== 14. UI / THEME ===")
def test_ui():
from ui import theme
# HTML escaping in story
s = ProjectStory(
title="",
overview='He said "hello" & \'goodbye\'',
steps=[StorySection(heading="bold", body="step & 1")],
plain_overview="plain "
)
html = theme.story_html(s)
assert_true("ui_xss_title", "