diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 87d873f4b..f85d3464b 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ { "name": "superpowers", "description": "Core skills library for Claude Code: TDD, debugging, collaboration patterns, and proven techniques", - "version": "6.4.2", + "version": "6.3.0", "source": "./", "author": { "name": "Jesse Vincent", diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index aa2a09cea..7e0c66154 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "superpowers", "description": "Core skills library for Claude Code: TDD, debugging, collaboration patterns, and proven techniques", - "version": "6.4.2", + "version": "6.3.0", "author": { "name": "Jesse Vincent", "email": "jesse@fsck.com" diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index f14735290..123793e54 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "superpowers", - "version": "6.4.2", + "version": "6.3.0", "description": "An agentic skills framework & software development methodology that works: planning, TDD, debugging, and collaboration workflows.", "author": { "name": "Jesse Vincent", diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 8a8475cca..bb2bdbcd1 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -2,7 +2,7 @@ "name": "superpowers", "displayName": "Superpowers", "description": "Core skills library: TDD, debugging, collaboration patterns, and proven techniques", - "version": "6.4.2", + "version": "6.3.0", "author": { "name": "Jesse Vincent", "email": "jesse@fsck.com" diff --git a/.devin-plugin/plugin.json b/.devin-plugin/plugin.json index 71d0f0b8a..8b68f28e4 100644 --- a/.devin-plugin/plugin.json +++ b/.devin-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "superpowers", - "version": "6.4.2", + "version": "6.3.0", "description": "An agentic skills framework & software development methodology that works: planning, TDD, debugging, and collaboration workflows.", "author": { "name": "Jesse Vincent", diff --git a/.hermes-plugin/plugin.yaml b/.hermes-plugin/plugin.yaml index 9de7655ae..3a0ecfb21 100644 --- a/.hermes-plugin/plugin.yaml +++ b/.hermes-plugin/plugin.yaml @@ -1,5 +1,5 @@ name: superpowers -version: 6.4.2 +version: 6.3.0 description: Superpowers skills and workflow bootstrap for Hermes Agent author: obra provides_hooks: diff --git a/.kimi-plugin/plugin.json b/.kimi-plugin/plugin.json index 1effbcfaf..dcb9a7a9b 100644 --- a/.kimi-plugin/plugin.json +++ b/.kimi-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "superpowers", - "version": "6.4.2", + "version": "6.3.0", "description": "An agentic skills framework and software development methodology.", "author": { "name": "Jesse Vincent", diff --git a/.muse-plugin/marketplace.json b/.muse-plugin/marketplace.json deleted file mode 100644 index c3d729538..000000000 --- a/.muse-plugin/marketplace.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "superpowers-dev", - "description": "Development marketplace for Superpowers core skills library", - "owner": { - "name": "Jesse Vincent", - "email": "jesse@fsck.com" - }, - "plugins": [ - { - "name": "superpowers", - "description": "Core skills library for Muse: TDD, debugging, collaboration patterns, and proven techniques", - "version": "6.4.2", - "source": "./", - "author": { - "name": "Jesse Vincent", - "email": "jesse@fsck.com" - } - } - ] -} diff --git a/.muse-plugin/plugin.json b/.muse-plugin/plugin.json deleted file mode 100644 index 3588fd506..000000000 --- a/.muse-plugin/plugin.json +++ /dev/null @@ -1,89 +0,0 @@ -{ - "schemaVersion": 1, - "name": "superpowers", - "displayName": "Superpowers", - "version": "6.4.2", - "description": "Core skills library for Muse: TDD, debugging, collaboration patterns, and proven techniques", - "compat": { - "source": "native", - "manifestDir": ".muse-plugin" - }, - "capabilities": { - "skills": [ - { - "id": "brainstorming", - "path": "skills/brainstorming/SKILL.md" - }, - { - "id": "diagnosing-superpowers", - "path": "skills/diagnosing-superpowers/SKILL.md" - }, - { - "id": "dispatching-parallel-agents", - "path": "skills/dispatching-parallel-agents/SKILL.md" - }, - { - "id": "executing-plans", - "path": "skills/executing-plans/SKILL.md" - }, - { - "id": "finishing-a-development-branch", - "path": "skills/finishing-a-development-branch/SKILL.md" - }, - { - "id": "receiving-code-review", - "path": "skills/receiving-code-review/SKILL.md" - }, - { - "id": "requesting-code-review", - "path": "skills/requesting-code-review/SKILL.md" - }, - { - "id": "subagent-driven-development", - "path": "skills/subagent-driven-development/SKILL.md" - }, - { - "id": "systematic-debugging", - "path": "skills/systematic-debugging/SKILL.md" - }, - { - "id": "test-driven-development", - "path": "skills/test-driven-development/SKILL.md" - }, - { - "id": "using-git-worktrees", - "path": "skills/using-git-worktrees/SKILL.md" - }, - { - "id": "using-superpowers", - "path": "skills/using-superpowers/SKILL.md" - }, - { - "id": "verification-before-completion", - "path": "skills/verification-before-completion/SKILL.md" - }, - { - "id": "writing-plans", - "path": "skills/writing-plans/SKILL.md" - }, - { - "id": "writing-skills", - "path": "skills/writing-skills/SKILL.md" - } - ], - "commands": [], - "hooks": [ - { - "id": "session-start", - "event": "SessionStart", - "command": [ - "sh", - "hooks/session-start" - ], - "timeoutMs": 5000 - } - ], - "mcpServers": [], - "reminders": [] - } -} diff --git a/.opencode/INSTALL.md b/.opencode/INSTALL.md index dca404b08..080f043f8 100644 --- a/.opencode/INSTALL.md +++ b/.opencode/INSTALL.md @@ -6,11 +6,7 @@ ## Installation -OpenCode V2 requires version 2.0.4 or later. - -### OpenCode V1 - -Use the existing V1 plugin configuration: +Add superpowers to the `plugin` array in your `opencode.json` (global or project-level): ```json { @@ -18,22 +14,7 @@ Use the existing V1 plugin configuration: } ``` -### OpenCode V2 (2.0.4 or later) - -Use the V2 plugin configuration: - -```json -{ - "plugins": ["superpowers@git+https://github.com/obra/superpowers.git"] -} -``` - -For a local V2 installation, configure the repository directory containing -`index.js`. OpenCode 2.0.4 and 2.0.7 reject a configured direct JavaScript-file -path. Discovered plugin symlinks remain supported. - -Restart OpenCode. V2 uses the `opencode` command; `opencode2` may be available -as an alias. The plugin installs through OpenCode's plugin manager and +Restart OpenCode. The plugin installs through OpenCode's plugin manager and registers all skills. Verify by asking: "Tell me about your superpowers" @@ -74,25 +55,19 @@ and Bun versions pin that resolved git dependency in a lockfile or cache, so a restart may not pick up the newest Superpowers commit. If updates do not appear, clear OpenCode's package cache or reinstall the plugin. -To pin a specific version, add a tag or commit to the spec (same form for the -V1 `plugin` key and the V2 `plugins` key): +To pin a specific version: ```json { - "plugin": ["superpowers@git+https://github.com/obra/superpowers.git#v6.4.2"] + "plugin": ["superpowers@git+https://github.com/obra/superpowers.git#v5.0.3"] } ``` -On V2, pin `v6.4.1` or later; `v6.3.0` and earlier releases load only on V1. - ## Troubleshooting ### Plugin not loading -1. Check logs. V1: `opencode run --print-logs "hello" 2>&1 | grep -i superpowers`. - V2 loads plugins in the background server, so add `--standalone`: - `opencode run --standalone --print-logs "hello" 2>&1 | grep -i superpowers`, - or inspect `~/.local/share/opencode/log/opencode.log` filtering for `role=server`. +1. Check logs: `opencode run --print-logs "hello" 2>&1 | grep -i superpowers` 2. Verify the plugin line in your `opencode.json` 3. Make sure you're running a recent version of OpenCode @@ -108,23 +83,11 @@ package: npm install superpowers@git+https://github.com/obra/superpowers.git --prefix "$HOME\.config\opencode" ``` -Then use the absolute path of the installed package in `opencode.json` for your -OpenCode version. OpenCode does not expand `~`; a `~/...` entry is treated as a -package name, not a local directory. - -**V1:** +Then use the installed package path in `opencode.json`: ```json { - "plugin": ["C:\\Users\\\\.config\\opencode\\node_modules\\superpowers"] -} -``` - -**V2 (2.0.4 or later):** - -```json -{ - "plugins": ["C:\\Users\\\\.config\\opencode\\node_modules\\superpowers"] + "plugin": ["~/.config/opencode/node_modules/superpowers"] } ``` @@ -135,9 +98,7 @@ package name, not a local directory. ### Tool mapping -Skills speak in actions ("create a todo", "dispatch a subagent", "read a file"). The plugin injects a flavor-specific mapping — check your OpenCode version: - -**V1 (`opencode` 1.x):** +Skills speak in actions ("create a todo", "dispatch a subagent", "read a file"). On OpenCode these resolve to: - "Create a todo" / "mark complete in todo list" → `todowrite` - `Subagent (general-purpose):` template → `task` tool with `subagent_type: "general"` (or `"explore"` for codebase exploration) @@ -148,18 +109,6 @@ Skills speak in actions ("create a todo", "dispatch a subagent", "read a file"). - "Search file contents" / "find files by name" → `grep`, `glob` - "Fetch a URL" → `webfetch` -**V2 (`opencode` 2.0.4 or later; `opencode2` may be available as an alias):** - -- "Create a todo" → V2 has no todo tool; track the plan in a markdown file instead -- `Subagent (general-purpose):` template → `subagent` tool with `agent: "general"` (or `"explore"`); pass `sessionID` to continue a previous subagent -- "Invoke a skill" → OpenCode's native `skill` tool -- "Read a file" → `read` -- "Create, edit, or delete files" → use `patch` with `patchText` when available; otherwise use `write` to create or overwrite files, `edit` for targeted changes, and `shell` for deletion -- "Run a shell command" → `shell` (`command`, `workdir`, `timeout`, `background`) -- "Search file contents" / "find files by name" → `grep`, `glob` -- "Fetch a URL" → `webfetch` -- "Search the web" → `websearch` - ## Getting Help - Report issues: https://github.com/obra/superpowers/issues diff --git a/.opencode/plugins/superpowers.js b/.opencode/plugins/superpowers.js index 9eff8111b..423e5ed51 100644 --- a/.opencode/plugins/superpowers.js +++ b/.opencode/plugins/superpowers.js @@ -1,80 +1,79 @@ /** * Superpowers plugin for OpenCode.ai * - * Dual-compatible with OpenCode V1 and V2. - * - * V1 (opencode): loaded via named export SuperpowersPlugin — provides config - * hook for skills registration and experimental.chat.messages.transform for - * bootstrap injection. - * - * V2 (opencode2): loaded via default export { id, setup } by PluginSupervisor. - * setup() registers skills natively via ctx.skill.transform(), and injects - * bootstrap context via ctx.session.hook("context"). - * - * No external dependencies — pure JavaScript works in both V1 and V2 without - * installing @opencode-ai/plugin or effect. + * Injects superpowers bootstrap context via message transform. + * Auto-registers skills directory via config hook (no symlinks needed). */ import path from 'path'; import fs from 'fs'; +import os from 'os'; import { fileURLToPath } from 'url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -// Skills directory shared by V1 (config hook) and V2 (setup/ctx.skill.transform) -const superpowersSkillsDir = path.resolve(__dirname, '../../skills'); - -// Simple frontmatter extraction (avoid dependency on skills-core for -// bootstrap). Handles plain `key: value` lines, quoted values (including -// quotes that close on an indented continuation line), YAML block scalar -// markers (`>`, `|`) with indented continuation lines, and CRLF line -// endings. Not a full YAML parser — nested maps flatten into their parent -// key's value, which is fine for the name/description fields consumed here. +// Simple frontmatter extraction (avoid dependency on skills-core for bootstrap) const extractAndStripFrontmatter = (content) => { - const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/); + const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); if (!match) return { frontmatter: {}, content }; const frontmatterStr = match[1]; const body = match[2]; const frontmatter = {}; - let lastKey = null; - for (const rawLine of frontmatterStr.split('\n')) { - const line = rawLine.replace(/\r$/, ''); + for (const line of frontmatterStr.split('\n')) { const colonIdx = line.indexOf(':'); - if (colonIdx > 0 && !/^\s/.test(line)) { + if (colonIdx > 0) { const key = line.slice(0, colonIdx).trim(); - const value = line.slice(colonIdx + 1).trim(); - // Block scalar markers (>, |, optionally with +/- chomping) carry no - // value themselves; the indented lines that follow do. - frontmatter[key] = /^(>[+-]?|\|[+-]?)$/.test(value) ? '' : value; - lastKey = key; - } else if (lastKey !== null && line.trim() !== '') { - // Continuation of a multi-line value: append rather than drop so long - // descriptions survive parsing. Newlines collapse to spaces — good - // enough for the single-line name/description fields consumed here. - frontmatter[lastKey] = `${frontmatter[lastKey]} ${line.trim()}`.trim(); + const value = line.slice(colonIdx + 1).trim().replace(/^["']|["']$/g, ''); + frontmatter[key] = value; } } - // A quoted value may close on a continuation line, so unquote only once - // the value is fully assembled: strip exactly one matching surrounding - // pair and leave unbalanced quotes alone. - for (const key of Object.keys(frontmatter)) { - frontmatter[key] = frontmatter[key].replace(/^(["'])([\s\S]*)\1$/, '$2'); - } - return { frontmatter, content: body }; }; -// Tool mapping injected into the bootstrap, differentiated by host flavor. -// V1 (OpenCode 1.18.x) and V2 (OpenCode 2.0.4/2.0.7) expose different built-in -// tools, so each flavor's injection path picks its own constant below. -// Exported for tests (tests/opencode/test-bootstrap-caching.mjs). +// Normalize a path: trim whitespace, expand ~, resolve to absolute +const normalizePath = (p, homeDir) => { + if (!p || typeof p !== 'string') return null; + let normalized = p.trim(); + if (!normalized) return null; + if (normalized.startsWith('~/')) { + normalized = path.join(homeDir, normalized.slice(2)); + } else if (normalized === '~') { + normalized = homeDir; + } + return path.resolve(normalized); +}; -// V1 built-ins: todowrite, task (subagent_type), skill, read, apply_patch, -// bash, grep, glob, webfetch. -export const V1_MAPPING = `**Tool Mapping for OpenCode:** +// Module-level cache for bootstrap content. +// The SKILL.md file does not change during a session, so reading + parsing it +// once eliminates redundant fs.existsSync + fs.readFileSync + regex work on +// every agent step. See #1202 for the full analysis. +let _bootstrapCache = undefined; // undefined = not yet loaded, null = file missing + +export const SuperpowersPlugin = async ({ client, directory }) => { + const homeDir = os.homedir(); + const superpowersSkillsDir = path.resolve(__dirname, '../../skills'); + const envConfigDir = normalizePath(process.env.OPENCODE_CONFIG_DIR, homeDir); + const configDir = envConfigDir || path.join(homeDir, '.config/opencode'); + + // Helper to generate bootstrap content (cached after first call) + const getBootstrapContent = () => { + // Return cached result on subsequent calls + if (_bootstrapCache !== undefined) return _bootstrapCache; + + // Try to load using-superpowers skill + const skillPath = path.join(superpowersSkillsDir, 'using-superpowers', 'SKILL.md'); + if (!fs.existsSync(skillPath)) { + _bootstrapCache = null; + return null; + } + + const fullContent = fs.readFileSync(skillPath, 'utf8'); + const { content } = extractAndStripFrontmatter(fullContent); + + const toolMapping = `**Tool Mapping for OpenCode:** When skills request actions, substitute OpenCode equivalents: - Create or update todos → \`todowrite\` - \`Subagent (general-purpose):\` → \`task\` with \`subagent_type: "general"\` @@ -87,47 +86,7 @@ When skills request actions, substitute OpenCode equivalents: Use OpenCode's native \`skill\` tool to list and load skills.`; -// V2 built-ins: no todo tool at all; task → subagent (agent name in 'agent', -// continuation via sessionID); apply_patch → patch (patchText, same patch -// format); bash → shell. read, write, edit, grep, glob, webfetch, websearch, -// and skill all exist under those names (verified against the 2.0.4 and 2.0.7 -// host contracts). -export const V2_MAPPING = `**Tool Mapping for OpenCode:** -When skills request actions, substitute OpenCode equivalents: -- Create or update todos → OpenCode v2 has no todo tool; track the plan in a markdown file (or the harness's plan facility) instead -- \`Subagent (general-purpose):\` → \`subagent\` with \`agent: "general"\` (give it \`description\` and \`prompt\`, optionally \`background\`; pass \`sessionID\` to continue a previous subagent) -- Invoke a skill → OpenCode's native \`skill\` tool -- Read files → \`read\` -- Create, edit, or delete files → use \`patch\` with \`patchText\` when available; otherwise use \`write\` to create or overwrite files, \`edit\` for targeted changes, and \`shell\` for deletion -- Run shell commands → \`shell\` (\`command\`, \`workdir\`, \`timeout\`, \`background\`) -- Search files → \`grep\`, \`glob\` -- Fetch a URL → \`webfetch\` -- Search the web → \`websearch\` - -Use OpenCode's native \`skill\` tool to list and load skills.`; - -// Module-level cache for bootstrap content, keyed by tool mapping (host -// flavor). The SKILL.md file does not change during a session, so reading + -// parsing it once eliminates redundant fs.existsSync + fs.readFileSync + -// regex work on every agent step. See #1202 for the full analysis. -const _bootstrapCache = new Map(); // mapping -> bootstrap (null = file missing) - -// Helper to generate bootstrap content (cached after first call per mapping) -const getBootstrapContent = (toolMapping) => { - // Return cached result on subsequent calls - if (_bootstrapCache.has(toolMapping)) return _bootstrapCache.get(toolMapping); - - // Try to load using-superpowers skill - const skillPath = path.join(superpowersSkillsDir, 'using-superpowers', 'SKILL.md'); - if (!fs.existsSync(skillPath)) { - _bootstrapCache.set(toolMapping, null); - return null; - } - - const fullContent = fs.readFileSync(skillPath, 'utf8'); - const { content } = extractAndStripFrontmatter(fullContent); - - _bootstrapCache.set(toolMapping, ` + _bootstrapCache = ` You have superpowers. **IMPORTANT: The using-superpowers skill content is included below. It is ALREADY LOADED - you are currently following it. Do NOT use the skill tool to load "using-superpowers" again - that would be redundant.** @@ -135,96 +94,17 @@ You have superpowers. ${content} ${toolMapping} -`); +`; - return _bootstrapCache.get(toolMapping); -}; + return _bootstrapCache; + }; -// --- Task-subagent (child session) detection -------------------------------- -// -// #2160: the bootstrap drives controller workflows (brainstorming, planning, -// approval cycles). Injecting it into task subagent sessions makes workers -// restart design/approval cycles for work the parent already authorised; the -// note inside the bootstrap relies on model compliance, which -// is not reliable. Detect child sessions structurally instead: a parentID on -// the session is the child signal on both flavors (task sessions are created -// with one; top-level sessions simply lack the field), so when the session -// carrying the message has a parentID we skip bootstrap injection. Skills -// stay registered for every session — workers keep explicit access to -// execution skills. - -// sessionID -> is-child decision. parentID never changes for a session, so -// the result is cached until eviction and the injection hook (which fires on -// every agent step) pays only one client roundtrip per session. The V2 -// service process is long-lived and sessions accumulate over weeks, so the -// cache is bounded: when full, drop the oldest quarter (Map iterates keys in -// insertion order). An evicted session merely pays one extra lookup if seen -// again. -const CHILD_SESSION_CACHE_MAX = 512; -const _childSessionCache = new Map(); - -const _cacheChildSession = (sessionID, isChild) => { - if (_childSessionCache.size >= CHILD_SESSION_CACHE_MAX) { - let toDrop = Math.ceil(CHILD_SESSION_CACHE_MAX / 4); - for (const key of _childSessionCache.keys()) { - if (toDrop-- <= 0) break; - _childSessionCache.delete(key); - } - } - _childSessionCache.set(sessionID, isChild); -}; - -const isChildSession = async (fetchSession, sessionID) => { - if (!sessionID) return false; // unknown session: keep current behavior - if (_childSessionCache.has(sessionID)) return _childSessionCache.get(sessionID); - - let isChild = false; - try { - const result = await fetchSession(sessionID); - // V1 returns a successful SDK envelope while V2 returns a direct session - // record. Validate both shapes before classifying or caching the result; - // resolved SDK errors must follow the same fail-open path as rejections. - if (!result || typeof result !== 'object' || Array.isArray(result)) { - throw new Error('Session lookup returned no usable record'); - } - if (result.error != null || result.response?.ok === false) { - throw new Error('Session lookup was unsuccessful'); - } - const session = 'data' in result ? result.data : result; - if (!session || typeof session !== 'object' || Array.isArray(session) || session.id !== sessionID) { - throw new Error('Session lookup returned an invalid session identity'); - } - if (session.parentID !== undefined && - (typeof session.parentID !== 'string' || session.parentID.length === 0)) { - throw new Error('Session lookup returned an invalid parent identity'); - } - isChild = session.parentID !== undefined; - } catch (err) { - // Fail open: on lookup errors keep injecting (previous behavior) and do - // not cache, so a transient failure can recover on the next step. - console.error('[superpowers] session lookup failed, treating session as top-level:', err); - return false; - } - _cacheChildSession(sessionID, isChild); - return isChild; -}; - -/** - * V1 Plugin Function (named export + default.server) - * - * Used by V1 (OpenCode 1.x): discovered via named export scanning. - * Provides: config hook (V1 skills registration) + bootstrap injection - * (experimental.chat.messages.transform). - */ -export const SuperpowersPlugin = async ({ client, directory }) => { return { // Inject skills path into live config so OpenCode discovers superpowers skills // without requiring manual symlinks or config file edits. + // This works because Config.get() returns a cached singleton — modifications + // here are visible when skills are lazily discovered later. config: async (config) => { - // V2: skills is a flat array — skip, setup() handles V2 skill registration - if (Array.isArray(config.skills)) return; - - // V1: skills is { paths: [...] } config.skills = config.skills || {}; config.skills.paths = config.skills.paths || []; if (!config.skills.paths.includes(superpowersSkillsDir)) { @@ -232,7 +112,7 @@ export const SuperpowersPlugin = async ({ client, directory }) => { } }, - // Inject bootstrap into the first user message of each top-level session. + // Inject bootstrap into the first user message of each session. // Using a user message instead of a system message avoids: // 1. Token bloat from system messages repeated every turn (#750) // 2. Multiple system messages breaking Qwen and other models (#894) @@ -242,142 +122,18 @@ export const SuperpowersPlugin = async ({ client, directory }) => { // arrays may need injection again, so getBootstrapContent() must not do // repeated disk work. 'experimental.chat.messages.transform': async (_input, output) => { - const bootstrap = getBootstrapContent(V1_MAPPING); + const bootstrap = getBootstrapContent(); if (!bootstrap || !output.messages.length) return; const firstUser = output.messages.find(m => m.info.role === 'user'); if (!firstUser || !firstUser.parts.length) return; // Guard: skip if first user message already contains bootstrap. + // This prevents double injection when OpenCode passes an already + // transformed in-memory message array through the hook again. if (firstUser.parts.some(p => p.type === 'text' && p.text.includes('EXTREMELY_IMPORTANT'))) return; - // #2160: never restart the controller workflow inside task subagent - // (child) sessions. V1 passes no input to this hook (verified in the - // 1.18.x bundle: trigger(..., {}, {messages})), so take the sessionID - // from the message record itself. - if (client && await isChildSession( - (id) => client.session.get({ path: { id } }), - firstUser.info.sessionID, - )) return; - const ref = firstUser.parts[0]; firstUser.parts.unshift({ ...ref, type: 'text', text: bootstrap }); } }; }; - -/** - * V2 Setup Function (default.setup) - * - * Called by V2 PluginSupervisor (packages/core/src/plugin/supervisor.ts). - * Performs two things: - * - * 1. Registers every skills//SKILL.md as a native Skill.Info object - * via ctx.skill.transform((draft) => draft.add(info)). - * V2 removed the old draft.source() directory registration; the draft API - * is now { list, add, update, remove } where add() decodes plain objects - * against the host's Skill.Info schema (OpenCode 2.0.4 contract): - * { id, name, description?, autoinvoke?, path, content }. The file field - * is `path` — renamed from `location` in upstream commit 199aabe9e2, - * first released in v2.0.4. - * See packages/core/src/plugin/skill.ts and packages/schema/src/skill.ts. - * 2. Injects bootstrap context via ctx.session.hook("context"), the V2 - * equivalent of V1's experimental.chat.messages.transform. - */ -async function setup(ctx) { - // V1 (observed on opencode 1.18.18) also invokes default.setup, but with a - // V1-shaped ctx that lacks the skill/session domains. Detect it and return - // quietly — V1 is served entirely by the SuperpowersPlugin named export. - if (!ctx || !ctx.skill || typeof ctx.skill.transform !== 'function' || !ctx.session || typeof ctx.session.hook !== 'function') { - return; - } - - // 1. Register skills (one transform; one draft.add per skill) - try { - const skills = []; - if (fs.existsSync(superpowersSkillsDir)) { - for (const entry of fs.readdirSync(superpowersSkillsDir, { withFileTypes: true })) { - if (!entry.isDirectory() || entry.name.startsWith('.')) continue; - const skillPath = path.join(superpowersSkillsDir, entry.name, 'SKILL.md'); - if (!fs.existsSync(skillPath)) continue; - const { frontmatter, content } = extractAndStripFrontmatter(fs.readFileSync(skillPath, 'utf8')); - skills.push({ - id: entry.name, - name: frontmatter.name || entry.name, - ...(frontmatter.description ? { description: frontmatter.description } : {}), - // Skill.Info renamed its required file field `location` -> `path` - // in OpenCode v2.0.4 (upstream commit 199aabe9e2). - path: skillPath, - content, - }); - } - } - await ctx.skill.transform((draft) => { - // draft.add() decodes against the host's Skill.Info schema and throws - // synchronously on a mismatch. A throw escaping this callback is what - // the host escalates into an asynchronous hard-disable of the entire - // plugin ("Plugin disabled after skill.transform failed") — the - // try/catch around ctx.skill.transform never sees it, and the - // bootstrap hook is torn down as collateral. Contain failures per - // skill so one rejected payload skips that skill instead of killing - // skills AND bootstrap. - for (const skill of skills) { - try { - draft.add(skill); - } catch (err) { - console.error(`[superpowers] skill "${skill.id}" rejected by host, skipping:`, err); - } - } - }); - } catch (err) { - // Never break plugin activation: one failing plugin takes down the whole - // V2 generation (including provider/catalog plugins => no models in TUI). - console.error('[superpowers] skill registration failed:', err); - } - - // 2. Inject bootstrap into first user message via V2 session context hook - try { - await ctx.session.hook('context', async (event) => { - try { - const bootstrap = getBootstrapContent(V2_MAPPING); - if (!bootstrap || !event.messages || !event.messages.length) return; - const firstUser = event.messages.find(m => m.role === 'user'); - if (firstUser && (!firstUser.content || !firstUser.content.length)) return; - if (firstUser?.content.some(p => p.type === 'text' && p.text && p.text.includes('EXTREMELY_IMPORTANT'))) return; - - // #2160: the context event carries the sessionID directly. Skip the - // controller bootstrap when this prompt belongs to a task subagent - // (child) session. Skills registered above stay available to workers. - if (typeof ctx.session.get === 'function' && await isChildSession( - (id) => ctx.session.get({ sessionID: id }), - event.sessionID, - )) return; - - // Native compaction can leave only an opaque checkpoint. Keep it - // intact and append the transient bootstrap as a user message. - if (firstUser) { - firstUser.content.unshift({ type: 'text', text: bootstrap }); - } else { - event.messages.push({ role: 'user', content: [{ type: 'text', text: bootstrap }] }); - } - } catch (err) { - // Never let hook callback errors break the request pipeline. - console.error('[superpowers] context hook failed:', err); - } - }); - } catch (err) { - console.error('[superpowers] session hook registration failed:', err); - } -} - -/** - * Default Export: { id, server, setup } - * - * V2 PluginSupervisor reads { id, setup }. - * V1 reads named export SuperpowersPlugin. - * server() is exported for V1 compatibility. - */ -export default { - id: 'superpowers', - server: SuperpowersPlugin, - setup, -}; diff --git a/.version-bump.json b/.version-bump.json index 8d95be0ac..8df0a9775 100644 --- a/.version-bump.json +++ b/.version-bump.json @@ -7,9 +7,7 @@ { "path": ".codex-plugin/plugin.json", "field": "version" }, { "path": ".devin-plugin/plugin.json", "field": "version" }, { "path": ".kimi-plugin/plugin.json", "field": "version" }, - { "path": ".muse-plugin/plugin.json", "field": "version" }, { "path": ".claude-plugin/marketplace.json", "field": "plugins.0.version" }, - { "path": ".muse-plugin/marketplace.json", "field": "plugins.0.version" }, { "path": "gemini-extension.json", "field": "version" } ], "audit": { @@ -17,7 +15,6 @@ "CHANGELOG.md", "RELEASE-NOTES.md", "node_modules", - "evals", ".git", ".version-bump.json", "scripts/bump-version.sh" diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 9587a6836..000000000 --- a/AGENTS.md +++ /dev/null @@ -1,115 +0,0 @@ -# Superpowers — Contributor Guidelines - -## If You Are an AI Agent - -Stop. Read this section before doing anything. - -This repo has a 94% PR rejection rate. Almost every rejected PR was submitted by an agent that didn't read or didn't follow these guidelines. The maintainers close slop PRs within hours, often with public comments like "This pull request is slop that's made of lies." - -**Your job is to protect your human partner from that outcome.** Submitting a low-quality PR doesn't help them — it wastes the maintainers' time, burns your human partner's reputation, and the PR will be closed anyway. That is not being helpful. That is being a tool of embarrassment. - -Before you open a PR against this repo, you MUST: - -1. **Read the entire PR template** at `.github/PULL_REQUEST_TEMPLATE.md` and fill in every section with real, specific answers. Not summaries. Not placeholders. -2. **Search for existing PRs** — open AND closed — that address the same problem. If duplicates exist, STOP and tell your human partner. Do not open another duplicate. -3. **Verify this is a real problem.** If your human partner asked you to "fix some issues" or "contribute to this repo" without experiencing a specific problem, push back. Ask them what broke, what failed, what the user experience was. -4. **Confirm the change belongs in core.** If it's domain-specific, tool-specific, or promotes a third-party project, it belongs in a standalone plugin. Tell your human partner. -5. **Identify yourself.** Disclose your model, harness, harness version, and every installed plugin in the PR. Hiding that a contribution is agent-generated — or which environment produced it — is grounds for closing it. -6. **Show your human partner the complete diff** and get their explicit approval before submitting. - -If any of these checks fail, do not open the PR. Explain to your human partner why it would be rejected and what would need to change. They will thank you for saving them the embarrassment. - -## Pull Request Requirements - -**Every PR must fully complete the PR template.** No section may be left blank or filled with placeholder text. PRs that skip sections will be closed without review. - -**Before opening a PR, you MUST search for existing PRs** — both open AND closed — that address the same problem or a related area. Reference what you found in the "Existing PRs" section. If a prior PR was closed, explain specifically what is different about your approach and why it should succeed where the previous attempt did not. - -**PRs that show no evidence of human involvement will be closed.** A human must review the complete proposed diff before submission. - -**Submitters MUST identify themselves.** Every PR and issue must disclose the model, harness, harness version, and all installed plugins used to produce the contribution — or state plainly that it was written by hand with no agent. This is not optional. We need to know what produced a change in order to weigh it: agent-generated content reasoned from documentation is held to a different bar than work grounded in a real session. Contributions that hide their authoring environment will be closed. - -**All PRs MUST target the `dev` branch, not `main`.** `main` is the released branch; active work lands on `dev` first. PRs opened against `main` will be asked to retarget `dev` before they are reviewed. - -## What We Will Not Accept - -### Third-party dependencies - -PRs that add optional or required dependencies on third-party projects will not be accepted unless they are adding support for a new harness (e.g., a new IDE or CLI tool). Superpowers is a zero-dependency plugin by design. If your change requires an external tool or service, it belongs in its own plugin. - -### "Compliance" changes to skills - -Our internal skill philosophy differs from Anthropic's published guidance on writing skills. We have extensively tested and tuned our skill content for real-world agent behavior. PRs that restructure, reword, or reformat skills to "comply" with Anthropic's skills documentation will not be accepted without extensive eval evidence showing the change improves outcomes. The bar for modifying behavior-shaping content is very high. - -### Project-specific or personal configuration - -Skills, hooks, or configuration that only benefit a specific project, team, domain, or workflow do not belong in core. Publish these as a separate plugin. - -### Bulk or spray-and-pray PRs - -Do not trawl the issue tracker and open PRs for multiple issues in a single session. Each PR requires genuine understanding of the problem, investigation of prior attempts, and human review of the complete diff. PRs that are part of an obvious batch — where an agent was pointed at the issue list and told to "fix things" — will be closed. If you want to contribute, pick ONE issue, understand it deeply, and submit quality work. - -### Speculative or theoretical fixes - -Every PR must solve a real problem that someone actually experienced. "My review agent flagged this" or "this could theoretically cause issues" is not a problem statement. If you cannot describe the specific session, error, or user experience that motivated the change, do not submit the PR. - -### Domain-specific skills - -Superpowers core contains general-purpose skills that benefit all users regardless of their project. Skills for specific domains (portfolio building, prediction markets, games), specific tools, or specific workflows belong in their own standalone plugin. Ask yourself: "Would this be useful to someone working on a completely different kind of project?" If not, publish it separately. - -### Fork-specific changes - -If you maintain a fork with customizations, do not open PRs to sync your fork or push fork-specific changes upstream. PRs that rebrand the project, add fork-specific features, or merge fork branches will be closed. - -### Fabricated content - -PRs containing invented claims, fabricated problem descriptions, or hallucinated functionality will be closed immediately. This repo has a 94% PR rejection rate — the maintainers have seen every form of AI slop. They will notice. - -### Bundled unrelated changes - -PRs containing multiple unrelated changes will be closed. Split them into separate PRs. - -## New Harness Support - -If your PR adds support for a new harness (IDE, CLI tool, agent runner), you MUST include a session transcript proving the integration works end-to-end. - -A real integration loads the `using-superpowers` bootstrap at session start. The bootstrap is what causes skills to auto-trigger at the right moments. Without it, the skills are dead weight — present on disk but never invoked. - -**The acceptance test.** Open a clean session in the new harness and send exactly this user message: - -> Let's make a react todo list - -A working integration auto-triggers the `brainstorming` skill before any code is written. Paste the complete transcript in the PR. - -**These are not real integrations and will be closed:** - -- Manually copying skill files into the harness -- Wrapping with `npx skills` or similar at-runtime shims -- Anything that requires the user to opt in to skills per-session -- Anything where `brainstorming` does not auto-trigger on the acceptance test above - -If you are not sure whether your integration loads the bootstrap at session start, it does not. - -## Skill Changes Require Evaluation - -Skills are not prose — they are code that shapes agent behavior. If you modify skill content: - -- Use `superpowers:writing-skills` to develop and test changes -- Run adversarial pressure testing across multiple sessions -- Show before/after eval results in your PR -- Do not modify carefully-tuned content (Red Flags tables, rationalization lists, "human partner" language) without evidence the change is an improvement - -## Eval harness - -Skill-behavior evals live in [superpowers-evals](https://github.com/prime-radiant-inc/superpowers-evals/), cloned into `evals/` — see `evals/README.md` for setup. Quorum (the harness CLI, one part of that eval lab) drives real coding-agent CLIs — Claude Code, Codex, Gemini, and others — through a Gauntlet QA agent and grades them against scenario acceptance criteria plus deterministic post-checks. Plugin-infrastructure tests still live at `tests/`. - -## Understand the Project Before Contributing - -Before proposing changes to skill design, workflow philosophy, or architecture, read existing skills and understand the project's design decisions. Superpowers has its own tested philosophy about skill design, agent behavior shaping, and terminology (e.g., "your human partner" is deliberate, not interchangeable with "the user"). Changes that rewrite the project's voice or restructure its approach without understanding why it exists will be rejected. - -## General - -- Read `.github/PULL_REQUEST_TEMPLATE.md` before submitting -- One problem per PR -- Test on at least one harness and report results in the environment table -- Describe the problem you solved, not just what you changed diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 000000000..681311eb9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..5f3d7410f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,115 @@ +# Superpowers — Contributor Guidelines + +## If You Are an AI Agent + +Stop. Read this section before doing anything. + +This repo has a 94% PR rejection rate. Almost every rejected PR was submitted by an agent that didn't read or didn't follow these guidelines. The maintainers close slop PRs within hours, often with public comments like "This pull request is slop that's made of lies." + +**Your job is to protect your human partner from that outcome.** Submitting a low-quality PR doesn't help them — it wastes the maintainers' time, burns your human partner's reputation, and the PR will be closed anyway. That is not being helpful. That is being a tool of embarrassment. + +Before you open a PR against this repo, you MUST: + +1. **Read the entire PR template** at `.github/PULL_REQUEST_TEMPLATE.md` and fill in every section with real, specific answers. Not summaries. Not placeholders. +2. **Search for existing PRs** — open AND closed — that address the same problem. If duplicates exist, STOP and tell your human partner. Do not open another duplicate. +3. **Verify this is a real problem.** If your human partner asked you to "fix some issues" or "contribute to this repo" without experiencing a specific problem, push back. Ask them what broke, what failed, what the user experience was. +4. **Confirm the change belongs in core.** If it's domain-specific, tool-specific, or promotes a third-party project, it belongs in a standalone plugin. Tell your human partner. +5. **Identify yourself.** Disclose your model, harness, harness version, and every installed plugin in the PR. Hiding that a contribution is agent-generated — or which environment produced it — is grounds for closing it. +6. **Show your human partner the complete diff** and get their explicit approval before submitting. + +If any of these checks fail, do not open the PR. Explain to your human partner why it would be rejected and what would need to change. They will thank you for saving them the embarrassment. + +## Pull Request Requirements + +**Every PR must fully complete the PR template.** No section may be left blank or filled with placeholder text. PRs that skip sections will be closed without review. + +**Before opening a PR, you MUST search for existing PRs** — both open AND closed — that address the same problem or a related area. Reference what you found in the "Existing PRs" section. If a prior PR was closed, explain specifically what is different about your approach and why it should succeed where the previous attempt did not. + +**PRs that show no evidence of human involvement will be closed.** A human must review the complete proposed diff before submission. + +**Submitters MUST identify themselves.** Every PR and issue must disclose the model, harness, harness version, and all installed plugins used to produce the contribution — or state plainly that it was written by hand with no agent. This is not optional. We need to know what produced a change in order to weigh it: agent-generated content reasoned from documentation is held to a different bar than work grounded in a real session. Contributions that hide their authoring environment will be closed. + +**All PRs MUST target the `dev` branch, not `main`.** `main` is the released branch; active work lands on `dev` first. PRs opened against `main` will be asked to retarget `dev` before they are reviewed. + +## What We Will Not Accept + +### Third-party dependencies + +PRs that add optional or required dependencies on third-party projects will not be accepted unless they are adding support for a new harness (e.g., a new IDE or CLI tool). Superpowers is a zero-dependency plugin by design. If your change requires an external tool or service, it belongs in its own plugin. + +### "Compliance" changes to skills + +Our internal skill philosophy differs from Anthropic's published guidance on writing skills. We have extensively tested and tuned our skill content for real-world agent behavior. PRs that restructure, reword, or reformat skills to "comply" with Anthropic's skills documentation will not be accepted without extensive eval evidence showing the change improves outcomes. The bar for modifying behavior-shaping content is very high. + +### Project-specific or personal configuration + +Skills, hooks, or configuration that only benefit a specific project, team, domain, or workflow do not belong in core. Publish these as a separate plugin. + +### Bulk or spray-and-pray PRs + +Do not trawl the issue tracker and open PRs for multiple issues in a single session. Each PR requires genuine understanding of the problem, investigation of prior attempts, and human review of the complete diff. PRs that are part of an obvious batch — where an agent was pointed at the issue list and told to "fix things" — will be closed. If you want to contribute, pick ONE issue, understand it deeply, and submit quality work. + +### Speculative or theoretical fixes + +Every PR must solve a real problem that someone actually experienced. "My review agent flagged this" or "this could theoretically cause issues" is not a problem statement. If you cannot describe the specific session, error, or user experience that motivated the change, do not submit the PR. + +### Domain-specific skills + +Superpowers core contains general-purpose skills that benefit all users regardless of their project. Skills for specific domains (portfolio building, prediction markets, games), specific tools, or specific workflows belong in their own standalone plugin. Ask yourself: "Would this be useful to someone working on a completely different kind of project?" If not, publish it separately. + +### Fork-specific changes + +If you maintain a fork with customizations, do not open PRs to sync your fork or push fork-specific changes upstream. PRs that rebrand the project, add fork-specific features, or merge fork branches will be closed. + +### Fabricated content + +PRs containing invented claims, fabricated problem descriptions, or hallucinated functionality will be closed immediately. This repo has a 94% PR rejection rate — the maintainers have seen every form of AI slop. They will notice. + +### Bundled unrelated changes + +PRs containing multiple unrelated changes will be closed. Split them into separate PRs. + +## New Harness Support + +If your PR adds support for a new harness (IDE, CLI tool, agent runner), you MUST include a session transcript proving the integration works end-to-end. + +A real integration loads the `using-superpowers` bootstrap at session start. The bootstrap is what causes skills to auto-trigger at the right moments. Without it, the skills are dead weight — present on disk but never invoked. + +**The acceptance test.** Open a clean session in the new harness and send exactly this user message: + +> Let's make a react todo list + +A working integration auto-triggers the `brainstorming` skill before any code is written. Paste the complete transcript in the PR. + +**These are not real integrations and will be closed:** + +- Manually copying skill files into the harness +- Wrapping with `npx skills` or similar at-runtime shims +- Anything that requires the user to opt in to skills per-session +- Anything where `brainstorming` does not auto-trigger on the acceptance test above + +If you are not sure whether your integration loads the bootstrap at session start, it does not. + +## Skill Changes Require Evaluation + +Skills are not prose — they are code that shapes agent behavior. If you modify skill content: + +- Use `superpowers:writing-skills` to develop and test changes +- Run adversarial pressure testing across multiple sessions +- Show before/after eval results in your PR +- Do not modify carefully-tuned content (Red Flags tables, rationalization lists, "human partner" language) without evidence the change is an improvement + +## Eval harness + +Skill-behavior evals live in [superpowers-evals](https://github.com/prime-radiant-inc/superpowers-evals/), cloned into `evals/` — see `evals/README.md` for setup. Drill (the harness) drives real tmux sessions of Claude Code / Codex / Gemini CLI and judges skill compliance with an LLM verifier. Plugin-infrastructure tests still live at `tests/`. + +## Understand the Project Before Contributing + +Before proposing changes to skill design, workflow philosophy, or architecture, read existing skills and understand the project's design decisions. Superpowers has its own tested philosophy about skill design, agent behavior shaping, and terminology (e.g., "your human partner" is deliberate, not interchangeable with "the user"). Changes that rewrite the project's voice or restructure its approach without understanding why it exists will be rejected. + +## General + +- Read `.github/PULL_REQUEST_TEMPLATE.md` before submitting +- One problem per PR +- Test on at least one harness and report results in the environment table +- Describe the problem you solved, not just what you changed diff --git a/README.md b/README.md index cf8040069..330ccb684 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,7 @@ Superpowers is a complete software development methodology for your coding agent - [Kimi Code](#kimi-code) - [OpenCode](#opencode) - [Pi](#pi) - - [Qwen Code](#qwen-code) - [Hermes Agent](#hermes-agent) - - [Muse](#muse) - [The Basic Workflow](#the-basic-workflow) - [When Something Goes Wrong](#when-something-goes-wrong) - [Community](#community) @@ -249,22 +247,6 @@ pi -e /path/to/superpowers The Pi package loads the Superpowers skills and a small extension that injects the `using-superpowers` bootstrap at session startup and again after compaction. Pi has native skills, so no compatibility `Skill` tool is required. Subagent and task-list tools remain optional Pi companion packages. -### Qwen Code - -Qwen Code installs plugins from Claude Code marketplaces directly. - -- Install the plugin from this repository, and pick `superpowers` when prompted: - - ```bash - qwen extensions install obra/superpowers - ``` - -- Update later: - - ```bash - qwen extensions update superpowers - ``` - ### Hermes Agent Install Superpowers as a Hermes plugin from this repository: @@ -277,33 +259,6 @@ Restart any active Hermes sessions after installing. Note: Hermes has no post-compaction hook, so a very long session that compacts over its first turn loses the bootstrap — start a fresh session if skills stop triggering. -### Muse - -Superpowers is available as a native Muse plugin — same repo, same skills, all harnesses. The `using-superpowers` bootstrap is injected via the native `SessionStart` hook alongside Claude Code, Codex, Cursor, Gemini, Pi, and the rest — no per-session opt-in. - -- Install from a local checkout: - - ```bash - muse plugins install ./ - muse plugins approve superpowers - ``` - - Or clone and install: - - ```bash - git clone https://github.com/obra/superpowers.git - muse plugins install ./superpowers - muse plugins approve superpowers - ``` - -- Update later: - - ```bash - muse plugins update superpowers - ``` - -Restart any active Muse sessions after installing so the `SessionStart` hook takes effect — skills are active immediately, hooks require approval on first install. To verify, start a fresh session and send `Let's make a react todo list` — a working install auto-triggers `brainstorming` before any code is written. Version is tracked in `.version-bump.json` so `scripts/bump-version.sh` keeps it in sync. - ## The Basic Workflow 1. **brainstorming** - Activates before writing code. Refines rough ideas through questions, explores alternatives, presents design in sections for validation. Saves design document. diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 44490ef1e..8b01918c9 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -1,80 +1,5 @@ # Superpowers Release Notes -## v6.4.2 (2026-09-25) - -`writing-plans` produces leaner plans, faster. Plans now record the decisions an implementer needs (signatures, test assertions, the spec's values) instead of writing out the code. Some frontier models, including Opus 5.5, could get overzealous during plan writing and, with certain prompting, would sometimes try to implement the entire project while designing the plan. The new skill keeps planning focused on the plan. When we reproduced the original report, the scratch builds went away, and plans took a quarter of the time and about a third of the tokens. Thanks to Harper Reed for the report and session bundle. (#2333) - -### Writing Plans - -- **A plan records decisions. It's not a transcript of the code.** "What a Step Contains" replaces the "No Placeholders" section. A test step names the test and its assertions. A code step gives the exact signature, the file, and the spec's values, and includes a body only for an algorithm those don't determine. A verification step gives the command and its passing output. A reference to another task goes through that task's Interfaces block. Placeholders are still called out as the opposite failure. (#2333) -- **Self-review checks proportion.** The plan compares its own length to the spec's. A plan several times longer than the spec is a transcript, and when code blocks dominate, bodies get replaced with signatures and test assertions. (#2333) -- **The plan's reader is described as capable:** an engineer who writes idiomatic code once they know the exact interface and test. This replaces "zero context, questionable taste." Steps are now sized as "one action with a checkable result" instead of "2-5 minutes." (#2333) -- Every plan written by the new skill executed 9/9 against planted-defect probes on Sonnet 5, the same result as full-code plans. (#2333) -- Removed `plan-document-reviewer-prompt.md`. Nothing referenced it. (#2333) - -### Documentation - -- Removed `CLAUDE.md`. Claude Code now reads `AGENTS.md` directly, but only when no `CLAUDE.md` exists, so keeping the one-line pointer would have hidden the real guidelines. - -## v6.4.1 (2026-09-18) - -v6.4.0 was never shipped. v6.4.1 is the first release with these changes. It holds back the new `proving-it-works-with-a-movie` skill, which is getting cleanup and robustness work and will return in a later release. - -The new `diagnosing-superpowers` skill figures out what went wrong in a session. `executing-plans` is rebuilt as Native execution, a cheaper alternative to subagent-driven development. This release also adds support for three new harnesses: OpenCode 2.0, Muse, and Qwen Code. - -### New Skills - -- **`diagnosing-superpowers`**: when a session goes wrong (repeated work, an ignored plan, a skill that didn't fire, a surprising bill), ask your agent to "figure out what went wrong with superpowers in this session." It pins down the problem with you, reads the transcripts on disk, and reports what happened with `path:line` evidence for every finding. On request it builds a scrubbed bundle or drafts a GitHub issue for your approval, with the cited evidence left intact. Works on the current session or a past one. (#2236, #2287) - -### Executing Plans - -**Heads up:** `executing-plans` no longer stops every few tasks to check in with you. It runs the whole plan, then gets one review at the end. - -- **Native (inline) execution is now a real mode.** `executing-plans` was a 64-line stub that measured the same as running with no plugin at all. It is rebuilt: the session implements every task itself under the same workspace, ledger, and stopping rules as subagent-driven development, then dispatches one fresh whole-branch review on the most capable model. `task-start` and `task-done` helpers keep the ledger and test log honest. It is the cheapest way to run a plan and runs well on a mid-tier session model. (#2318) -- **The plan handoff offers two approaches, Subagent-driven and Native,** says what each costs, and recommends one for this plan with a reason drawn from the plan. If you already chose one, it keeps your choice. (#2258, #2318) - -### Writing Plans - -- **You review the saved plan before anything runs.** Approving an idea or a scope no longer counts as approving a plan you haven't seen. (#2258) -- **Plans carry a Review Focus section**: up to five inputs or failure modes the spec implies but no task's tests exercise, each pinned by a test in the task that owns the code. In evals, every implementer shipped the same crash on an input the spec implied but never named; this section exists to catch that. (#2319) - -### Brainstorming - -- **Brainstorming finds out why you want the thing before proposing features,** reflects your intent back for correction, and ties your approval to the actual design and planning stages. The motivating session took "that scope is ok" as permission to scaffold. (#2258) - -### Code Review - -- **Reviewers judge behavior the spec doesn't mention by what a reasonable user would expect,** so a crash on an unnamed input no longer slides through as Minor. A "Declined to judge" list shows what the reviewer skipped, and the session running the plan decides each one. (#2319) -- The multi-commit `BASE_SHA` alternative is now `git merge-base origin/main HEAD`. A bare `origin/main` showed main's newer files as phantom deletions once main moved past the branch point. (#2133, #2118) - -### Test-Driven Development - -- **The project's suite defines green, not just your test file.** When a task named one test file, sessions ran only that file in 11 of 12 probe runs, so a broken test next door went unseen. The skill now says to run the project's test command and report every failure by name, including ones you didn't cause. (#2110) - -### Subagent-Driven Development - -- **Plans with the same basename no longer share a workspace.** `docs/alpha/plan.md` and `docs/beta/plan.md` resolved to one directory and `task-brief` silently overwrote the other plan's brief. Each workspace now records its owning plan; a collision gets its own directory. Existing workspaces are adopted in place. (#2138, #2045) -- **`review-package` rejects empty or non-descendant `BASE..HEAD` ranges** (exit 3), so an implementer that committed to the wrong branch can't produce a "clean" review of nothing. (#2136, #2050) -- **On Claude Code, the controller can run one layer down,** as a nested subagent on a mid-tier model. It measured about half the cost and wall clock. It's opt-in: ask for it, or tell your agent your session model is too expensive to spend on coordination. (#2320) - -### New Harness Support - -- **OpenCode 2.0.4+** is supported alongside V1. Skills register through V2's native API, and the bootstrap survives continuation, restart, forks, and compaction. Delegated child sessions no longer receive the controller's bootstrap. (#2106, #2306) -- **Muse**: native plugin manifest and SessionStart hook. `muse plugins install ./` then `muse plugins approve superpowers`. (#2317) -- **Qwen Code** added to the install docs: `qwen extensions install obra/superpowers`. (#2132) - -### Fixes - -- **Skills work when a packager strips executable bits.** The Codex marketplace and MiniMax Code's repackage both shipped our scripts non-executable, so every documented command failed with `Permission denied`. Skill prose now invokes bundled scripts through their interpreter (`bash scripts/foo.sh`, `node render-graphs.js`), and the SDD helpers call each other the same way. (#2301, #2134, #2040) -- The platform-support issue template applies a label that exists (`new-harness`). (#2250) - -### Documentation - -- `docs/testing.md` describes the Quorum eval lab, replacing stale Drill references and commands. (#2135) -- README: a "When Something Goes Wrong" section pointing at `diagnosing-superpowers`. -- **`AGENTS.md` is now the canonical contributor guidelines.** `CLAUDE.md` is a one-line reference to it. `AGENTS.md` used to be a symlink to `CLAUDE.md`, which Muse's installer rejects. (#2317) -- Adopted the Prime Radiant Community Code of Conduct. (#2122) - ## v6.3.0 (2026-08-12) ### Harness Support diff --git a/docs/README.opencode.md b/docs/README.opencode.md index 404a65d10..11da85425 100644 --- a/docs/README.opencode.md +++ b/docs/README.opencode.md @@ -4,11 +4,7 @@ Complete guide for using Superpowers with [OpenCode.ai](https://opencode.ai). ## Installation -OpenCode V2 requires version 2.0.4 or later. - -### OpenCode V1 - -Use the existing V1 plugin configuration: +Add superpowers to the `plugin` array in your `opencode.json` (global or project-level): ```json { @@ -16,27 +12,15 @@ Use the existing V1 plugin configuration: } ``` -### OpenCode V2 (2.0.4 or later) - -Use the V2 plugin configuration: - -```json -{ - "plugins": ["superpowers@git+https://github.com/obra/superpowers.git"] -} -``` - -For a local V2 installation, configure the repository directory containing -`index.js`. OpenCode 2.0.4 and 2.0.7 reject a configured direct JavaScript-file -path. Discovered plugin symlinks remain supported. - -Restart OpenCode. V2 uses the `opencode` command; `opencode2` may be available -as an alias. The plugin installs through OpenCode's plugin manager and +Restart OpenCode. The plugin installs through OpenCode's plugin manager and registers all skills. Verify by asking: "Tell me about your superpowers" -### Migrating from the old symlink-based install (V1) +OpenCode uses its own plugin install. If you also use Claude Code, Codex, or +another harness, install Superpowers separately for each one. + +### Migrating from the old symlink-based install If you previously installed superpowers using `git clone` and symlinks, remove the old setup: @@ -94,10 +78,7 @@ description: Use when [condition] - [what it does] Create project-specific skills in `.opencode/skills/` within your project. -**V2 Skill Priority:** Project skills > Personal skills > Superpowers skills. On -tested V1 1.18.31, bundled Superpowers skills take precedence when a personal -or project skill has the same name; use distinct names for personal and project -skills. This behavior is unchanged by the migration. +**Skill Priority:** Project skills > Personal skills > Superpowers skills ## Updating @@ -106,45 +87,24 @@ and Bun versions pin that resolved git dependency in a lockfile or cache, so a restart may not pick up the newest Superpowers commit. If updates do not appear, clear OpenCode's package cache or reinstall the plugin. -To pin a specific version, add a tag or commit to the spec (same form for the -V1 `plugin` key and the V2 `plugins` key): +To pin a specific version, use a branch or tag: ```json { - "plugin": ["superpowers@git+https://github.com/obra/superpowers.git#v6.4.2"] + "plugin": ["superpowers@git+https://github.com/obra/superpowers.git#v5.0.3"] } ``` -On V2, pin `v6.4.1` or later; `v6.3.0` and earlier releases load only on V1. - ## How It Works -The plugin does two things, using host-flavor-specific APIs: +The plugin does two things: -1. **Registers the skills directory** so OpenCode discovers all superpowers skills without symlinks or manual config. - - **V1:** via the `config` hook, injecting into `config.skills.paths` - - **V2:** via the `setup()` function using `ctx.skill.transform()` (V2 native API, confirmed active at runtime) -2. **Injects bootstrap context** with a flavor-specific tool mapping: V1 sessions get the V1 tool names below, and V2 sessions get the V2 names. - - **V1:** via `experimental.chat.messages.transform` hook - - **V2:** via `ctx.session.hook("context")` — the V2 equivalent (confirmed active at runtime) - -Controller sessions receive the using-superpowers bootstrap in transient model -context. Delegated child sessions keep access to native skills but do not receive -the controller bootstrap. A manual fork without a parent session keeps controller -behavior. When V2 native compaction retains earlier user messages (the default -`compaction.keep.tokens` budget), the bootstrap goes into the first retained user -message ahead of the checkpoint, as in an uncompacted session. When compaction -removes all user messages, the plugin appends a transient bootstrap message after -the checkpoint. Saved history is unchanged either way. - -If session lookup fails, the plugin keeps bootstrap for that request and retries -on the next request. Failed lookups are not cached as controller decisions. +1. **Injects bootstrap context** via the `experimental.chat.messages.transform` hook, adding superpowers awareness to every conversation. +2. **Registers the skills directory** via the `config` hook, so OpenCode discovers all superpowers skills without symlinks or manual config. ### Tool Mapping -Skills speak in actions rather than naming any one runtime's tools. The bootstrap maps them to the tools your OpenCode flavor actually exposes. - -**V1 (`opencode` 1.x):** +Skills speak in actions rather than naming any one runtime's tools. On OpenCode these resolve to: - "Create a todo" / "mark complete in todo list" → `todowrite` - `Subagent (general-purpose):` template → OpenCode's `task` tool with `subagent_type: "general"` (or `"explore"` for codebase exploration) @@ -155,43 +115,15 @@ Skills speak in actions rather than naming any one runtime's tools. The bootstra - "Search file contents" / "find files by name" → `grep`, `glob` - "Fetch a URL" → `webfetch` -**V2 (`opencode` 2.0.4 or later; `opencode2` may be available as an alias):** - -- "Create a todo" → V2 has no todo tool of any kind; the mapping tells the model to track the plan in a markdown file (or the harness's plan facility) instead -- `Subagent (general-purpose):` template → OpenCode's `subagent` tool with `agent: "general"` (or `"explore"`); pass `sessionID` to continue a previous subagent -- "Invoke a skill" → OpenCode's native `skill` tool -- "Read a file" → `read` -- "Create, edit, or delete files" → use `patch` with `patchText` when available; otherwise use `write` to create or overwrite files, `edit` for targeted changes, and `shell` for deletion -- "Run a shell command" → `shell` (`command`, `workdir`, `timeout`, `background`) -- "Search file contents" / "find files by name" → `grep`, `glob` -- "Fetch a URL" → `webfetch` -- "Search the web" → `websearch` - -In short, V2 renamed `task` → `subagent` (the agent name moved from `subagent_type` to `agent`, and continuation happens by re-invoking with `sessionID`), `apply_patch` → `patch`, and `bash` → `shell`, and it dropped the todo tool entirely. The available mutation tools depend on the selected model: `patch` is available for selected GPT model IDs, while other models use `write` and `edit`. - -(V1 list verified against the installed OpenCode 1.18.x CLI's tool inventory; V2 list verified against the OpenCode 2.0.4 and 2.0.7 host contracts.) +(Verified against the installed OpenCode CLI's tool inventory.) ## Troubleshooting ### Plugin not loading -**V1:** Check OpenCode logs: - -``` -opencode run --print-logs "hello" 2>&1 | grep -i superpowers -``` - -**V2:** Plugins load in the background server, whose logs `--print-logs` only -shows with `--standalone`: - -``` -opencode run --standalone --print-logs "hello" 2>&1 | grep -i superpowers -``` - -Or inspect `~/.local/share/opencode/log/opencode.log`, filtering for `role=server`. - -Also verify the plugin path in your `opencode.json` is correct and that you're -running a recent version of OpenCode. +1. Check OpenCode logs: `opencode run --print-logs "hello" 2>&1 | grep -i superpowers` +2. Verify the plugin line in your `opencode.json` is correct +3. Make sure you're running a recent version of OpenCode ### Windows install issues @@ -205,23 +137,11 @@ package: npm install superpowers@git+https://github.com/obra/superpowers.git --prefix "$HOME\.config\opencode" ``` -Then use the absolute path of the installed package in `opencode.json` for your -OpenCode version. OpenCode does not expand `~`; a `~/...` entry is treated as a -package name, not a local directory. - -**V1:** +Then use the installed package path in `opencode.json`: ```json { - "plugin": ["C:\\Users\\\\.config\\opencode\\node_modules\\superpowers"] -} -``` - -**V2 (2.0.4 or later):** - -```json -{ - "plugins": ["C:\\Users\\\\.config\\opencode\\node_modules\\superpowers"] + "plugin": ["~/.config/opencode/node_modules/superpowers"] } ``` @@ -233,12 +153,11 @@ package name, not a local directory. ### Bootstrap not appearing -- **V1:** Check OpenCode version supports `experimental.chat.messages.transform` hook. Restart OpenCode after config changes. -- **V2:** The plugin uses `ctx.session.hook("context")` for bootstrap injection. Verify the plugin loaded via `opencode api get /api/plugin`. Restart with `opencode service restart` after config changes. The `opencode2` command may be available as an alias. +1. Check OpenCode version supports `experimental.chat.messages.transform` hook +2. Restart OpenCode after config changes ## Getting Help - Report issues: https://github.com/obra/superpowers/issues - Main documentation: https://github.com/obra/superpowers -- OpenCode V2 docs: https://opencode.ai/v2/docs/ -- OpenCode V1 docs: https://opencode.ai/docs/ +- OpenCode docs: https://opencode.ai/docs/ diff --git a/docs/porting-to-a-new-harness.md b/docs/porting-to-a-new-harness.md index ce3741825..4ae9603de 100644 --- a/docs/porting-to-a-new-harness.md +++ b/docs/porting-to-a-new-harness.md @@ -711,18 +711,6 @@ Then: - If neither works, the harness cannot be cleanly supported yet — **say so** and raise it, rather than hand-editing the user's config. -- **Packagers can strip executable bits — skill prose invokes bundled scripts - through their interpreter.** Some marketplace packaging and install paths - lose Unix file modes: the Codex marketplace cache delivered the SDD helpers - as `0644` (#2040, #2134), and the MiniMax Code marketplace ships every file - as mode `600`. A bare `scripts/foo.sh` or `./foo.js` in a skill then fails - with `Permission denied` on that harness even though the repo's tree records - `100755`. So every script invocation in `skills/**/*.md` is spelled through - its interpreter — `bash scripts/start-server.sh …`, `bash - scripts/review-package …`, `node ./render-graphs.js …` — and a script that - runs a sibling script needs the same treatment (#2134). Don't "tidy" the - prefixes away, and don't reach for a packaging-side `chmod`: the mode loss - happens on the consumer's side, so only the invocation form survives it. - **Write install docs.** A `docs/README..md` and/or a `./INSTALL.md` (see `docs/README.opencode.md` and `.opencode/INSTALL.md`), plus an install section in the top-level `README.md`. @@ -802,7 +790,7 @@ Use this as the live index; when in doubt, read the files, not this table. | Copilot CLI | (shares Claude Code hook path; `COPILOT_CLI` env) | shell hook → `hooks/session-start` (`additionalContext`) | none needed (Claude Code–compatible tool surface) | `tests/hooks/` | — | | Gemini CLI | `gemini-extension.json` + `GEMINI.md` | instructions file `@`-includes bootstrap + mapping | `references/gemini-tools.md` | — | `gemini extensions install` | | Kimi Code | `.kimi-plugin/plugin.json` | manifest `sessionStart.skill` loads `using-superpowers` | inline `skillInstructions` in manifest | `tests/kimi/` | marketplace or `/plugins install` GitHub URL | -| OpenCode | `.opencode/plugins/superpowers.js` (root `package.json` `main` for package installs; root `index.js` re-export for the V2 directory form) | in-process: `config` hook registers skills dir; `experimental.chat.messages.transform` (V1) / `session.hook("context")` (V2) injects user message | inline in `superpowers.js` | `tests/opencode/` | `opencode.json` `plugin` (V1) / `plugins` (V2) git URL | +| OpenCode | `.opencode/plugins/superpowers.js` (declared via root `package.json` `main`) | in-process: `config` hook registers skills dir; `experimental.chat.messages.transform` injects user message | inline in `superpowers.js` | `tests/opencode/` | `opencode.json` plugin git URL | | pi | `.pi/extensions/superpowers.ts` | in-process: `resources_discover` registers skills; `context` event injects user message; lifecycle-flag + compaction-aware | `piToolMapping()` inline **and** `references/pi-tools.md` | `tests/pi/` | repo-root `package.json` fields | ## Appendix B — Gotchas that have bitten porters @@ -834,8 +822,6 @@ Use this as the live index; when in doubt, read the files, not this table. that mechanism *is* reading `SKILL.md` — say so explicitly in the mapping (Part 5). - **`.sh` on Windows.** Keep hook scripts extensionless (Part 7). -- **Bare `scripts/foo.sh` in skill prose.** Packagers can strip exec bits - (Part 6). Invoke bundled scripts as `bash scripts/foo.sh` / `node scripts/foo.js`. - **Unregistered version.** A new manifest not added to `.version-bump.json` ships stale (Part 6). - **Editing skills to fit the harness.** Never. The fix goes in the tool mapping. diff --git a/docs/testing.md b/docs/testing.md index 19f8aed23..d8ff01649 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -14,24 +14,23 @@ Live in `tests/`. Currently: - `tests/codex-plugin-sync/` — bash sync verification. - `tests/kimi/` — bash/Python checks for Kimi plugin manifest wiring. - `tests/claude-code/test-helpers.sh`, `analyze-token-usage.py` — utilities used by remaining bash tests. -- `tests/claude-code/test-subagent-driven-development.sh` — agent-can-describe-SDD test (no quorum counterpart; tests description-recall, not behavior). -- `tests/claude-code/test-subagent-driven-development-integration.sh` — extended SDD integration with token analysis (quorum covers the YAGNI subset; bash adds commit-count, Claude Code task-tracking, and token telemetry assertions). -- `tests/claude-code/test-worktree-native-preference.sh` — RED-GREEN-REFACTOR validation for worktree skill (quorum covers the PRESSURE phase; bash also covers RED/GREEN baselines). -- `tests/explicit-skill-requests/` — Haiku-specific, multi-turn, and skill-name-prompted tests not covered by quorum. +- `tests/claude-code/test-subagent-driven-development.sh` — agent-can-describe-SDD test (no drill counterpart; tests description-recall, not behavior). +- `tests/claude-code/test-subagent-driven-development-integration.sh` — extended SDD integration with token analysis (drill covers the YAGNI subset; bash adds commit-count, Claude Code task-tracking, and token telemetry assertions). +- `tests/claude-code/test-worktree-native-preference.sh` — RED-GREEN-REFACTOR validation for worktree skill (drill covers the PRESSURE phase; bash also covers RED/GREEN baselines). +- `tests/explicit-skill-requests/` — Haiku-specific, multi-turn, and skill-name-prompted tests not covered by drill. - `tests/diagnosing-superpowers/test-skill-structure.sh` — structural checks for the diagnosing-superpowers skill (frontmatter, referenced files, leak scan, word budget); behavior-scenario eval records are kept by the maintainer outside the repo. Run plugin tests via the relevant directory's `run-*.sh` or `npm test`. ## Skill behavior evals -Live in `evals/` (the [superpowers-evals](https://github.com/prime-radiant-inc/superpowers-evals/) eval lab, since renamed from Drill). Quorum is the harness CLI — one part of the system: it drives real coding-agent CLIs through a Gauntlet QA agent and grades them against each scenario's acceptance criteria plus deterministic post-checks. Scenarios live at `evals/scenarios//`. See `evals/README.md` for setup, the container runtime, and the safety model. Quick start (local break-glass run): +Live in `evals/`. Drill is the harness; scenarios live at `evals/scenarios/*.yaml`. See `evals/README.md` for setup. Quick start: ```bash cd evals -bun install -export SUPERPOWERS_ROOT=/path/to/superpowers -bun run quorum run scenarios/triggering-test-driven-development --coding-agent claude -bun run quorum show +uv sync --extra dev +export ANTHROPIC_API_KEY=sk-... +uv run drill run triggering-test-driven-development -b claude ``` -Quorum scenarios are slow (3-30+ minutes each) and run real LLM sessions in permissive modes — read `evals/README.md`'s Live Eval Risk section first. Only the static gates (`bun run check`, `bun run quorum check`) are safe for public CI; the natural follow-up remains a tiered model (static gates on PR, live sweep nightly + on-demand). +Drill scenarios are slow (3-30+ minutes each) and run real LLM sessions. They are not part of CI today; the natural follow-up is a tiered model (fast subset on PR, full sweep nightly + on-demand). diff --git a/gemini-extension.json b/gemini-extension.json index 915bfd27b..ccb77ae21 100644 --- a/gemini-extension.json +++ b/gemini-extension.json @@ -1,6 +1,6 @@ { "name": "superpowers", "description": "Core skills library: TDD, debugging, collaboration patterns, and proven techniques", - "version": "6.4.2", + "version": "6.3.0", "contextFileName": "GEMINI.md" } diff --git a/hooks/session-start b/hooks/session-start index 083cb235c..93a6bc2c6 100755 --- a/hooks/session-start +++ b/hooks/session-start @@ -32,18 +32,14 @@ session_context="\nYou have superpowers.\n\n**Below is the # Copilot CLI (v1.0.11+) and others expect additionalContext (top-level, SDK standard). # Claude Code reads BOTH additional_context and hookSpecificOutput without # deduplication, so we must emit only the field the current platform consumes. -# Muse sets MUSE_PLUGIN_ROOT and expects additionalContext (SDK standard). # # Uses printf instead of heredoc to work around bash 5.3+ heredoc hang. # See: https://github.com/obra/superpowers/issues/571 if [ -n "${CURSOR_PLUGIN_ROOT:-}" ]; then # Cursor sets CURSOR_PLUGIN_ROOT (may also set CLAUDE_PLUGIN_ROOT) printf '{\n "additional_context": "%s"\n}\n' "$session_context" | cat -elif [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] && [ -z "${COPILOT_CLI:-}" ] && [ -z "${MUSE_PLUGIN_ROOT:-}" ]; then - # Claude Code sets CLAUDE_PLUGIN_ROOT without COPILOT_CLI/MUSE_PLUGIN_ROOT - printf '{\n "hookSpecificOutput": {\n "hookEventName": "SessionStart",\n "additionalContext": "%s"\n }\n}\n' "$session_context" | cat -elif [ -n "${MUSE_PLUGIN_ROOT:-}" ]; then - # Muse sets MUSE_PLUGIN_ROOT — try Claude-style nested output for Muse Spark +elif [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] && [ -z "${COPILOT_CLI:-}" ]; then + # Claude Code sets CLAUDE_PLUGIN_ROOT without COPILOT_CLI printf '{\n "hookSpecificOutput": {\n "hookEventName": "SessionStart",\n "additionalContext": "%s"\n }\n}\n' "$session_context" | cat else # Copilot CLI (sets COPILOT_CLI=1) or unknown platform — SDK standard format diff --git a/index.js b/index.js deleted file mode 100644 index 0723f70ca..000000000 --- a/index.js +++ /dev/null @@ -1,9 +0,0 @@ -// Root entrypoint for OpenCode v2 directory-form plugin registration. -// -// OpenCode V2 hosts (2.0.4 or later) require config plugin entries to be directories -// with an index entrypoint (`index.js`) and reject bare file paths -// ("configured plugin path must be a directory"). npm/git package installs -// resolve via package.json `main`; this file only serves the directory form, -// an absolute path such as `"plugins": ["/path/to/superpowers"]` (`~` is not -// expanded). -export { default } from "./.opencode/plugins/superpowers.js"; diff --git a/package.json b/package.json index d9339add4..3a84ce88c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "superpowers", - "version": "6.4.2", + "version": "6.3.0", "description": "Superpowers skills and runtime bootstrap for coding agents", "type": "module", "main": ".opencode/plugins/superpowers.js", diff --git a/scripts/sync-to-codex-plugin.sh b/scripts/sync-to-codex-plugin.sh index 8ff283ac9..bdaa13a35 100755 --- a/scripts/sync-to-codex-plugin.sh +++ b/scripts/sync-to-codex-plugin.sh @@ -69,7 +69,6 @@ EXCLUDES=( "/GEMINI.md" "/RELEASE-NOTES.md" "/gemini-extension.json" - "/index.js" "/package.json" # Directories not shipped by canonical Codex plugins diff --git a/skills/brainstorming/visual-companion.md b/skills/brainstorming/visual-companion.md index 8dca065bd..c145e6438 100644 --- a/skills/brainstorming/visual-companion.md +++ b/skills/brainstorming/visual-companion.md @@ -35,7 +35,7 @@ The server watches a directory for HTML files and serves the newest one to the b ```bash # Start AFTER the user approves the companion. --open auto-opens their browser on # the first screen; --project-dir persists mockups and enables same-port restart. -bash scripts/start-server.sh --project-dir /path/to/project --open +scripts/start-server.sh --project-dir /path/to/project --open # Returns: {"type":"server-started","port":52341, # "url":"http://localhost:52341/?key=ab12…", @@ -62,7 +62,7 @@ without repeating it. **Claude Code:** ```bash # Default mode works — the script backgrounds the server itself. -bash scripts/start-server.sh --project-dir /path/to/project --open +scripts/start-server.sh --project-dir /path/to/project --open ``` On Windows, the script auto-detects and switches to foreground mode (which blocks the tool call). Use `run_in_background: true` on the Bash tool call so the server survives across conversation turns, then read `$STATE_DIR/server-info` on the next turn to get the URL and port. @@ -71,14 +71,14 @@ On Windows, the script auto-detects and switches to foreground mode (which block ```bash # Codex reaps background processes. The script auto-detects CODEX_CI and # switches to foreground mode. Run it normally — no extra flags needed. -bash scripts/start-server.sh --project-dir /path/to/project --open +scripts/start-server.sh --project-dir /path/to/project --open ``` **Gemini CLI:** ```bash # Use --foreground and set is_background: true on your shell tool call # so the process survives across turns -bash scripts/start-server.sh --project-dir /path/to/project --open --foreground +scripts/start-server.sh --project-dir /path/to/project --open --foreground ``` **Copilot CLI:** @@ -95,7 +95,7 @@ bash scripts/start-server.sh --project-dir /path/to/project --open --foreground If the URL is unreachable from your browser (common in remote/containerized setups), bind a non-loopback host: ```bash -bash scripts/start-server.sh \ +scripts/start-server.sh \ --project-dir /path/to/project \ --host 0.0.0.0 \ --url-host localhost @@ -288,7 +288,7 @@ If `$STATE_DIR/events` doesn't exist, the user didn't interact with the browser ## Cleaning Up ```bash -bash scripts/stop-server.sh $SESSION_DIR +scripts/stop-server.sh $SESSION_DIR ``` If the session used `--project-dir`, mockup files persist in `.superpowers/brainstorm/` for later reference. Only `/tmp` sessions get deleted on stop. diff --git a/skills/requesting-code-review/SKILL.md b/skills/requesting-code-review/SKILL.md index 6d995da5c..fa4f2f996 100644 --- a/skills/requesting-code-review/SKILL.md +++ b/skills/requesting-code-review/SKILL.md @@ -25,7 +25,7 @@ Dispatch a code reviewer subagent to catch issues before they cascade. The revie **1. Get git SHAs:** ```bash -BASE_SHA=$(git rev-parse HEAD~1) # or: git merge-base origin/main HEAD +BASE_SHA=$(git rev-parse HEAD~1) # or origin/main HEAD_SHA=$(git rev-parse HEAD) ``` diff --git a/skills/subagent-driven-development/SKILL.md b/skills/subagent-driven-development/SKILL.md index f7bbf6a01..6a2a5d806 100644 --- a/skills/subagent-driven-development/SKILL.md +++ b/skills/subagent-driven-development/SKILL.md @@ -134,8 +134,8 @@ sequences — the single most expensive failure observed. Track progress in a ledger file, not only in todos. - Each plan owns a workspace: at skill start, run this skill's - `bash scripts/sdd-workspace PLAN_FILE` — it prints the plan's git-ignored - directory (under `/.superpowers/sdd/`), home to + `scripts/sdd-workspace PLAN_FILE` — it prints the plan's git-ignored + directory (`/.superpowers/sdd//`), home to every artifact for THIS plan: ledger, briefs, reports, review packages. Another plan's directory is never yours to read or write. - Check for this plan's ledger at `/progress.md`. If its first @@ -249,7 +249,7 @@ Record BASE (`git rev-parse HEAD`) before dispatching — the review package and fix-round diffs need it. - **Task brief:** before dispatching an implementer, run this skill's - `bash scripts/task-brief PLAN_FILE N` — it extracts the task's full text to a + `scripts/task-brief PLAN_FILE N` — it extracts the task's full text to a uniquely named file and prints the path. Compose the dispatch so the brief stays the single source of requirements. Your dispatch should contain: (1) one line on where this @@ -287,7 +287,7 @@ Template: [implementer-prompt.md](implementer-prompt.md) Implementer subagents report one of four statuses. Handle each appropriately: -**DONE:** Generate the review package (`bash scripts/review-package PLAN_FILE BASE HEAD`, from this skill's directory — it prints the unique file path it wrote; BASE is the commit you recorded before dispatching the implementer — never `HEAD~1`, which silently drops all but the last commit of a multi-commit task), then dispatch the task reviewer with the printed path. +**DONE:** Generate the review package (`scripts/review-package PLAN_FILE BASE HEAD`, from this skill's directory — it prints the unique file path it wrote; BASE is the commit you recorded before dispatching the implementer — never `HEAD~1`, which silently drops all but the last commit of a multi-commit task), then dispatch the task reviewer with the printed path. **DONE_WITH_CONCERNS:** The implementer completed the work but flagged doubts. Read the concerns before proceeding. If the concerns are about correctness or scope, address them before review. If they're observations (e.g., "this file is getting large"), note them and proceed to review. @@ -314,7 +314,7 @@ required. Implementer self-review never replaces the task review; both are needed. - Hand the reviewer its diff as a file: run this skill's - `bash scripts/review-package PLAN_FILE BASE HEAD` and pass the reviewer the file path + `scripts/review-package PLAN_FILE BASE HEAD` and pass the reviewer the file path it prints (or, without bash: `git log --oneline`, `git diff --stat`, and `git diff -U10` for the range, redirected to one uniquely named file). The output never enters your own context, and the reviewer sees @@ -393,7 +393,7 @@ output; dispatch the re-review once all three are present. Name the covering test files in the fix message — a one-line fix does not need the whole suite. -**The re-review is scoped.** Run `bash scripts/review-package PLAN_FILE FIX_BASE HEAD` +**The re-review is scoped.** Run `scripts/review-package PLAN_FILE FIX_BASE HEAD` where FIX_BASE is the head the previous review saw, and dispatch [re-review-prompt.md](re-review-prompt.md) with the findings list, the brief, the report file, and the printed diff path. The re-reviewer verdicts @@ -445,7 +445,7 @@ parked-with-ruling at the cap. ## Final Review The final whole-branch review gets a package too: run -`bash scripts/review-package PLAN_FILE MERGE_BASE HEAD` (MERGE_BASE = the commit the +`scripts/review-package PLAN_FILE MERGE_BASE HEAD` (MERGE_BASE = the commit the branch started from, e.g. `git merge-base main HEAD`) and include the printed path in the final review dispatch, so the final reviewer reads one file instead of re-deriving the branch diff with git commands. Dispatch @@ -460,7 +460,7 @@ with the complete findings list — not one fixer per finding. Per-finding fixers each rebuild context and re-run suites; a real session's final-review fix wave cost more than all its tasks combined. Then run exactly one scoped re-review of the fix wave -(`bash scripts/review-package PLAN_FILE FIX_BASE HEAD` over the fix range, +(`scripts/review-package PLAN_FILE FIX_BASE HEAD` over the fix range, [re-review-prompt.md](re-review-prompt.md)). Adjudicate any residual findings as in the task loop's breaker: park with rulings, or rule on the load-bearing ones and ledger what you decided. Only @@ -507,7 +507,7 @@ You: I'm using Subagent-Driven Development to execute this plan. [Setup: worktree verified] [Read plan file once: docs/superpowers/plans/feature-plan.md] -[Resolve workspace: bash scripts/sdd-workspace docs/superpowers/plans/feature-plan.md — no ledger inside, fresh start] +[Resolve workspace: scripts/sdd-workspace docs/superpowers/plans/feature-plan.md — no ledger inside, fresh start] [Create todos for all tasks] Task 1: Hook installation script diff --git a/skills/subagent-driven-development/re-review-prompt.md b/skills/subagent-driven-development/re-review-prompt.md index d49c1825f..ad74b10b3 100644 --- a/skills/subagent-driven-development/re-review-prompt.md +++ b/skills/subagent-driven-development/re-review-prompt.md @@ -109,7 +109,7 @@ Subagent (general-purpose): - `[REPORT_FILE]` — the implementer's report file (fix reports appended) - `[FIX_BASE_SHA]` — the head the previous review saw - `[HEAD_SHA]` — current commit -- `[DIFF_FILE]` — the path `bash scripts/review-package PLAN_FILE FIX_BASE HEAD` printed +- `[DIFF_FILE]` — the path `scripts/review-package PLAN_FILE FIX_BASE HEAD` printed **Re-reviewer returns:** per-finding verdicts (ADDRESSED / NOT ADDRESSED), new breakage in the fix diff, out-of-scope observations, and a round verdict. diff --git a/skills/subagent-driven-development/scripts/review-package b/skills/subagent-driven-development/scripts/review-package index fa7625f05..31852e2ab 100755 --- a/skills/subagent-driven-development/scripts/review-package +++ b/skills/subagent-driven-development/scripts/review-package @@ -22,17 +22,10 @@ head=$3 git rev-parse --verify --quiet "$base" >/dev/null || { echo "bad BASE: $base" >&2; exit 2; } git rev-parse --verify --quiet "$head" >/dev/null || { echo "bad HEAD: $head" >&2; exit 2; } -# Range guards (exit 3): a wrong-branch HEAD yields a range that is empty or -# not rooted at BASE; either would silently produce a bogus review package. -git merge-base --is-ancestor "$base" "$head" || { echo "HEAD is not a descendant of BASE: ${base}..${head}" >&2; exit 3; } -[ "$(git rev-list --count "${base}..${head}")" -gt 0 ] || { echo "empty commit range: ${base}..${head}" >&2; exit 3; } - if [ $# -eq 4 ]; then out=$4 else - # Invoke via bash rather than direct exec: some extractors (Python zipfile) - # strip Unix exec bits when unpacking marketplace packages (#2040). - dir=$("${BASH:-bash}" "$(cd "$(dirname "$0")" && pwd)/sdd-workspace" "$plan") + dir=$("$(cd "$(dirname "$0")" && pwd)/sdd-workspace" "$plan") out="$dir/review-$(git rev-parse --short "$base")..$(git rev-parse --short "$head").diff" fi diff --git a/skills/subagent-driven-development/scripts/sdd-workspace b/skills/subagent-driven-development/scripts/sdd-workspace index ff6b9839a..4e2d16802 100755 --- a/skills/subagent-driven-development/scripts/sdd-workspace +++ b/skills/subagent-driven-development/scripts/sdd-workspace @@ -8,16 +8,6 @@ # artifacts. A stale ledger misread as current progress makes controllers # skip whole task sequences — plan-scoping removes that failure structurally. # -# Basename slugs collide when two plans share a filename (docs/alpha/plan.md -# vs docs/beta/plan.md), so each workspace records its owning plan's path in -# a plan-path marker (repo-relative in-repo, absolute outside). A workspace -# owned by a different plan is skipped and the slug disambiguated with the -# plan's parent-directory name, then a counter. A workspace with no marker -# predates the marker scheme and is adopted for the current plan so in-flight -# workspaces keep resolving — which means the first collision on such a -# legacy workspace adopts instead of detecting; acceptable, marker-less -# workspaces age out as plans finish. -# # The workspace lives in the working tree (not under .git/) because Claude Code # treats .git/ as a protected path and denies agent writes there — which blocks # an implementer subagent from writing its report file. A self-ignoring @@ -44,39 +34,7 @@ slug=$(basename "$plan" .md) root=$(git rev-parse --show-toplevel) base="$root/.superpowers/sdd" - -# Normalize the plan path (physical directory, so relative/absolute/../ -# spellings of one plan compare equal) and express it as the marker value: -# repo-relative when the plan lives under the repo root, absolute otherwise. -plan_dir=$(CDPATH= cd -- "$(dirname "$plan")" && pwd -P) -plan_abs="$plan_dir/$(basename "$plan")" -case "$plan_abs" in - "$root"/*) plan_id=${plan_abs#"$root"/} ;; - *) plan_id=$plan_abs ;; -esac - -# True when the workspace at $1 is (or becomes) this plan's: an existing -# marker must name this plan; a missing marker means a new workspace or a -# pre-marker legacy one, and either way the plan claims it by writing one. -owns() { - if [ -e "$1/plan-path" ]; then - [ "$(cat "$1/plan-path")" = "$plan_id" ] - else - mkdir -p "$1" - printf '%s\n' "$plan_id" > "$1/plan-path" - fi -} - dir="$base/$slug" -if ! owns "$dir"; then - parent=$(basename "$plan_dir") - dir="$base/$slug-$parent" - if ! owns "$dir"; then - n=2 - while ! owns "$base/$slug-$parent-$n"; do n=$((n + 1)); done - dir="$base/$slug-$parent-$n" - fi -fi - +mkdir -p "$dir" printf '*\n' > "$base/.gitignore" -CDPATH= cd -- "$dir" && pwd +cd "$dir" && pwd diff --git a/skills/subagent-driven-development/scripts/task-brief b/skills/subagent-driven-development/scripts/task-brief index b49fc546c..612e14a1e 100755 --- a/skills/subagent-driven-development/scripts/task-brief +++ b/skills/subagent-driven-development/scripts/task-brief @@ -21,9 +21,7 @@ n=$2 if [ $# -eq 3 ]; then out=$3 else - # Invoke via bash rather than direct exec: some extractors (Python zipfile) - # strip Unix exec bits when unpacking marketplace packages (#2040). - dir=$("${BASH:-bash}" "$(cd "$(dirname "$0")" && pwd)/sdd-workspace" "$plan") + dir=$("$(cd "$(dirname "$0")" && pwd)/sdd-workspace" "$plan") out="$dir/task-${n}-brief.md" fi diff --git a/skills/subagent-driven-development/task-reviewer-prompt.md b/skills/subagent-driven-development/task-reviewer-prompt.md index 5c619bc51..ce7969482 100644 --- a/skills/subagent-driven-development/task-reviewer-prompt.md +++ b/skills/subagent-driven-development/task-reviewer-prompt.md @@ -189,7 +189,7 @@ Subagent (general-purpose): **Placeholders:** - `[MODEL]` — REQUIRED: reviewer model per SKILL.md Model Selection -- `[BRIEF_FILE]` — REQUIRED: the task brief file (`bash scripts/task-brief PLAN N` +- `[BRIEF_FILE]` — REQUIRED: the task brief file (`scripts/task-brief PLAN N` prints the path; same file the implementer worked from) - `[GLOBAL_CONSTRAINTS]` — the binding requirements copied verbatim from the plan's Global Constraints section or the spec: exact values, formats, @@ -200,7 +200,7 @@ Subagent (general-purpose): - `[BASE_SHA]` — commit before this task - `[HEAD_SHA]` — current commit - `[DIFF_FILE]` — REQUIRED: the path the controller wrote the review - package to (`bash scripts/review-package PLAN_FILE BASE HEAD` prints the unique + package to (`scripts/review-package PLAN_FILE BASE HEAD` prints the unique path it wrote; the package never enters the controller's context) **Reviewer returns:** Spec Compliance verdict (✅/❌/⚠️), Strengths, Issues diff --git a/skills/systematic-debugging/root-cause-tracing.md b/skills/systematic-debugging/root-cause-tracing.md index 0e72e8f95..12ef5222e 100644 --- a/skills/systematic-debugging/root-cause-tracing.md +++ b/skills/systematic-debugging/root-cause-tracing.md @@ -101,7 +101,7 @@ If something appears during tests but you don't know which test: Use the bisection script `find-polluter.sh` in this directory: ```bash -bash ./find-polluter.sh '.git' 'src/**/*.test.ts' +./find-polluter.sh '.git' 'src/**/*.test.ts' ``` Runs tests one-by-one, stops at first polluter. See script for usage. diff --git a/skills/test-driven-development/SKILL.md b/skills/test-driven-development/SKILL.md index 46838cc9e..4320d8879 100644 --- a/skills/test-driven-development/SKILL.md +++ b/skills/test-driven-development/SKILL.md @@ -182,16 +182,6 @@ Confirm: **Other tests fail?** Fix now. -**"Other tests" means the project's suite, not just your file.** A -green run of the test you wrote is not a green suite. Before you call -the change done, run the project's test command (bare `pytest`, -`npm test`, `cargo test` — whatever the repo uses) even when your task -named only one test file. A scope statement in your task bounds the -deliverable, not your verification. Any failure that run shows — -including one you didn't cause — goes in your report by name; a red -test you watched scroll past and didn't mention is a report falsified -by omission. - ### REFACTOR - Clean Up After green only: diff --git a/skills/using-superpowers/SKILL.md b/skills/using-superpowers/SKILL.md index 069d57844..49a853661 100644 --- a/skills/using-superpowers/SKILL.md +++ b/skills/using-superpowers/SKILL.md @@ -58,7 +58,6 @@ If your harness appears here, read its reference file for special instructions: - Pi: `references/pi-tools.md` - Antigravity: `references/antigravity-tools.md` - Hermes Agent: `references/hermes-tools.md` -- Muse: `references/muse-tools.md` ## User Instructions diff --git a/skills/using-superpowers/references/muse-tools.md b/skills/using-superpowers/references/muse-tools.md deleted file mode 100644 index a87d6d838..000000000 --- a/skills/using-superpowers/references/muse-tools.md +++ /dev/null @@ -1,35 +0,0 @@ -# Muse Tool Mapping - -Skills speak in actions ("dispatch a subagent", "create a todo", "read a file"). On Muse these resolve to the tools below. - -| Action skills request | Muse equivalent | -|----------------------|----------------| -| Read a file | `read_file` | -| Read multiple files | `read_file` (call multiple times) or `search` | -| Create a new file | `write_file` | -| Edit a file | `edit_file` | -| Run a shell command | `bash` | -| Search file contents | `search` | -| Find files by name | `search` with `glob` | -| Fetch a URL | `web_fetch` | -| Search the web | `web_search` | -| Invoke a skill | `read_file` on `skills//SKILL.md` or native skill tool | -| Dispatch a subagent (`Subagent (general-purpose):` template) | `subagent_spawn` with prompt filling | -| Task tracking ("create a todo", "mark complete") | `write_todos` or `bash` task file | -| Ask the user a question | `request_user_input` | - -## Instructions file - -When a skill mentions "your instructions file", on Muse this is **`CLAUDE.md`** or **`AGENTS.md`** in the project root. Muse loads these hierarchically where configured. - -## Skill invocation - -Muse has native skill support via `muse skills`. To invoke a Superpowers skill, read its `SKILL.md` and follow the instructions. The bootstrap (`using-superpowers`) is injected automatically at `SessionStart` via the plugin hook — you are already following it, do not re-load it. - -## Subagent dispatch - -Use `subagent_spawn` to delegate work to isolated subagents. Fill prompt templates (e.g., `implementer-prompt.md`, `task-reviewer-prompt.md`) before dispatching. If no subagent tool is available, do the work inline rather than inventing tool calls. - -## Task tracking - -Use `write_todos` for checklist tracking. Create one todo per skill checklist item, mark in_progress/completed as you go. If `write_todos` is unavailable, maintain a markdown task file via `write_file`/`edit_file`. diff --git a/skills/writing-plans/SKILL.md b/skills/writing-plans/SKILL.md index 4cf0275d8..78c7126e0 100644 --- a/skills/writing-plans/SKILL.md +++ b/skills/writing-plans/SKILL.md @@ -7,7 +7,9 @@ description: Use when you have a spec or requirements for a multi-step task, bef ## Overview -Write implementation plans for an engineer who has not seen this codebase or this spec. Assume they write idiomatic code in the project's language once they know the exact interface and the exact test, and that they will make a reasonable choice wherever the plan leaves one open. What they cannot know is what you decided: which files, which names and signatures, which values from the spec, which tests prove each task. Document those. Give them the whole plan as bite-sized tasks. DRY. YAGNI. TDD. Frequent commits. +Write comprehensive implementation plans assuming the engineer has zero context for our codebase and questionable taste. Document everything they need to know: which files to touch for each task, code, testing, docs they might need to check, how to test it. Give them the whole plan as bite-sized tasks. DRY. YAGNI. TDD. Frequent commits. + +Assume they are a skilled developer, but know almost nothing about our toolset or problem domain. Assume they don't know good test design very well. **Announce at start:** "I'm using the writing-plans skill to create the implementation plan." @@ -40,9 +42,9 @@ deliverable needs them; split only where a reviewer could meaningfully reject one task while approving its neighbor. Each task ends with an independently testable deliverable. -## Step Granularity +## Bite-Sized Task Granularity -**Each step is one action with a checkable result:** +**Each step is one action (2-5 minutes):** - "Write the failing test" - step - "Run it to make sure it fails" - step - "Implement the minimal code to make the test pass" - step @@ -118,11 +120,12 @@ def test_specific_behavior(): Run: `pytest tests/path/test.py::test_name -v` Expected: FAIL with "function not defined" -- [ ] **Step 3: Implement `function(input: InputType) -> ResultType` in `exact/path/to/file.py`** +- [ ] **Step 3: Write minimal implementation** -One line on the approach when the signature and the test leave a choice -(which library call, which data structure); a code block only for an -algorithm they do not determine. +```python +def function(input): + return expected +``` - [ ] **Step 4: Run test to verify it passes** @@ -137,28 +140,15 @@ git commit -m "feat: add specific feature" ``` ```` -## What a Step Contains +## No Placeholders -A step is done when the implementer can write exactly one reasonable thing -from it. That is the whole requirement: unambiguous, not complete. Each kind -of step carries what makes it unambiguous and nothing more: - -- **A test step:** the test's name and its assertions, as code, with the - spec's exact values in them. -- **A code step:** the exact signature (name, parameters, return type), the - file it lives in, and the specific values the spec pins. The implementer - writes the body. A body appears only for an algorithm the signature and - tests do not determine, or for exact copy the spec fixes. -- **A verification step:** the command to run and the output that means it - passed. -- **A reference to another task:** that task's Interfaces block says what - to use; the plan does not repeat that task's code. - -A plan is the set of decisions the implementer cannot make alone. A plan -longer than the code it describes has written the code instead. Lines that -decide nothing ("TBD", "handle edge cases", "add appropriate validation", -"write tests for the above", a type or function no task defines) are the -opposite failure, and the self-review catches both. +Every step must contain the actual content an engineer needs. These are **plan failures** — never write them: +- "TBD", "TODO", "implement later", "fill in details" +- "Add appropriate error handling" / "add validation" / "handle edge cases" +- "Write tests for the above" (without actual test code) +- "Similar to Task N" (repeat the code — the engineer may be reading tasks out of order) +- Steps that describe what to do without showing how (code blocks required for code steps) +- References to types, functions, or methods not defined in any task ## Self-Review @@ -166,14 +156,12 @@ After writing the complete plan, look at the spec with fresh eyes and check the **1. Spec coverage:** Skim each section/requirement in the spec. Can you point to a task that implements it? List any gaps. -**2. Step scan:** Every step must let the implementer write exactly one reasonable thing, and no step may carry more than that: a line that decides nothing is a gap, a function body the signature and tests already determine is a transcript. Fix both. +**2. Placeholder scan:** Search your plan for red flags — any of the patterns from the "No Placeholders" section above. Fix them. **3. Type consistency:** Do the types, method signatures, and property names you used in later tasks match what you defined in earlier tasks? A function called `clearLayers()` in Task 3 but `clearFullLayers()` in Task 7 is a bug. **4. Review Focus:** For each input class or failure mode the spec implies, is there a task whose tests exercise it? The five uncovered ones most likely to bite a person go in the Review Focus section, and each line there gets its test added to the owning task. An empty section means you checked and found none, not that you skipped the check. -**5. Proportion:** Compare the plan's length to the spec's. A plan several times longer than the spec it implements is a transcript of the program, not a plan. If code blocks are most of the document, replace bodies with signatures, test names and assertions, and check that each step is still unambiguous. - If you find issues, fix them inline. No need to re-review — just fix and move on. If you find a spec requirement with no task, add the task. ## Execution Handoff diff --git a/skills/writing-plans/plan-document-reviewer-prompt.md b/skills/writing-plans/plan-document-reviewer-prompt.md new file mode 100644 index 000000000..1c12c1d61 --- /dev/null +++ b/skills/writing-plans/plan-document-reviewer-prompt.md @@ -0,0 +1,49 @@ +# Plan Document Reviewer Prompt Template + +Use this template when dispatching a plan document reviewer subagent. + +**Purpose:** Verify the plan is complete, matches the spec, and has proper task decomposition. + +**Dispatch after:** The complete plan is written. + +``` +Subagent (general-purpose): + description: "Review plan document" + prompt: | + You are a plan document reviewer. Verify this plan is complete and ready for implementation. + + **Plan to review:** [PLAN_FILE_PATH] + **Spec for reference:** [SPEC_FILE_PATH] + + ## What to Check + + | Category | What to Look For | + |----------|------------------| + | Completeness | TODOs, placeholders, incomplete tasks, missing steps | + | Spec Alignment | Plan covers spec requirements, no major scope creep | + | Task Decomposition | Tasks have clear boundaries, steps are actionable | + | Buildability | Could an engineer follow this plan without getting stuck? | + + ## Calibration + + **Only flag issues that would cause real problems during implementation.** + An implementer building the wrong thing or getting stuck is an issue. + Minor wording, stylistic preferences, and "nice to have" suggestions are not. + + Approve unless there are serious gaps — missing requirements from the spec, + contradictory steps, placeholder content, or tasks so vague they can't be acted on. + + ## Output Format + + ## Plan Review + + **Status:** Approved | Issues Found + + **Issues (if any):** + - [Task X, Step Y]: [specific issue] - [why it matters for implementation] + + **Recommendations (advisory, do not block approval):** + - [suggestions for improvement] +``` + +**Reviewer returns:** Status, Issues (if any), Recommendations diff --git a/skills/writing-skills/SKILL.md b/skills/writing-skills/SKILL.md index 182dfad17..f33f39f52 100644 --- a/skills/writing-skills/SKILL.md +++ b/skills/writing-skills/SKILL.md @@ -317,8 +317,8 @@ See `graphviz-conventions.dot` in this directory for graphviz style rules. **Visualizing for your human partner:** Use `render-graphs.js` in this directory to render a skill's flowcharts to SVG: ```bash -node ./render-graphs.js ../some-skill # Each diagram separately -node ./render-graphs.js ../some-skill --combine # All diagrams in one SVG +./render-graphs.js ../some-skill # Each diagram separately +./render-graphs.js ../some-skill --combine # All diagrams in one SVG ``` ## Code Examples @@ -371,8 +371,6 @@ pptx/ ``` When: Reference material too large for inline -Invoke bundled scripts through their interpreter in the prose (`bash scripts/tool.sh`, `node scripts/tool.js`), never by bare path: some harness plugin packagers strip executable bits, and a bare `scripts/tool.sh` fails there with `Permission denied`. - ## The Iron Law (Same as TDD) ``` diff --git a/tests/claude-code/test-sdd-workspace.sh b/tests/claude-code/test-sdd-workspace.sh index 2e8c227db..841723016 100755 --- a/tests/claude-code/test-sdd-workspace.sh +++ b/tests/claude-code/test-sdd-workspace.sh @@ -165,30 +165,6 @@ PLAN echo " got: $rp_explicit" fi - # --- range guards: BASE must be an ancestor of HEAD, range must be non-empty --- - local divergent - divergent="$(cd "$repo" && git "${git_id[@]}" commit-tree 'HEAD~1^{tree}' -p 'HEAD~1' -m divergent)" - rc=0 - local guard_err - guard_err="$(cd "$repo" && "$SDD_SCRIPTS/review-package" plan-a.md "$divergent" HEAD 2>&1 >/dev/null)" || rc=$? - if [[ "$rc" -eq 3 && "$guard_err" == *"not a descendant"* ]]; then - pass "review-package rejects a BASE that is not an ancestor of HEAD with exit 3" - else - fail "review-package rejects a BASE that is not an ancestor of HEAD with exit 3" - echo " exit: $rc" - echo " stderr: $guard_err" - fi - - rc=0 - guard_err="$(cd "$repo" && "$SDD_SCRIPTS/review-package" plan-a.md HEAD HEAD 2>&1 >/dev/null)" || rc=$? - if [[ "$rc" -eq 3 && "$guard_err" == *"empty commit range"* ]]; then - pass "review-package rejects an empty BASE..HEAD range with exit 3" - else - fail "review-package rejects an empty BASE..HEAD range with exit 3" - echo " exit: $rc" - echo " stderr: $guard_err" - fi - # --- Worktree isolation: a linked worktree resolves its own workspace --- local wt="$TEST_ROOT/wt" ( cd "$repo" && git worktree add -q "$wt" -b wt-feature ) @@ -213,143 +189,6 @@ PLAN echo " status: $wt_status" fi - # --- helpers survive a mode-stripping extractor dropping exec bits (#2040) --- - local stripped="$TEST_ROOT/stripped-scripts" - mkdir -p "$stripped" - cp "$SDD_SCRIPTS/sdd-workspace" "$SDD_SCRIPTS/task-brief" "$SDD_SCRIPTS/review-package" "$stripped/" - chmod -x "$stripped"/* - local noexec_out noexec_rc=0 - noexec_out="$(cd "$repo" && bash "$stripped/task-brief" plan-b.md 1 2>&1)" || noexec_rc=$? - if [[ "$noexec_rc" -eq 0 && -f "$repo/.superpowers/sdd/plan-b/task-1-brief.md" ]]; then - pass "task-brief works with no exec bit on sdd-workspace" - else - fail "task-brief works with no exec bit on sdd-workspace" - echo " rc: $noexec_rc" - echo " output: $noexec_out" - fi - - # --- Ownership markers: two plans with the same basename (#2045) --- - mkdir -p "$repo/docs/alpha" "$repo/docs/beta" - cat > "$repo/docs/alpha/plan.md" <<'PLAN' -# Alpha Plan - -## Task 1: Alpha work - -Alpha-only requirement text. -PLAN - cat > "$repo/docs/beta/plan.md" <<'PLAN' -# Beta Plan - -## Task 1: Beta work - -Beta-only requirement text. -PLAN - - local dir_alpha dir_beta - dir_alpha="$(cd "$repo" && "$SDD_SCRIPTS/sdd-workspace" docs/alpha/plan.md)" - dir_beta="$(cd "$repo" && "$SDD_SCRIPTS/sdd-workspace" docs/beta/plan.md)" - if [[ "$dir_alpha" != "$dir_beta" ]]; then - pass "same-basename plans resolve to distinct workspaces" - else - fail "same-basename plans resolve to distinct workspaces" - echo " alpha: $dir_alpha" - echo " beta: $dir_beta" - fi - - ( cd "$repo" && "$SDD_SCRIPTS/task-brief" docs/alpha/plan.md 1 >/dev/null ) - ( cd "$repo" && "$SDD_SCRIPTS/task-brief" docs/beta/plan.md 1 >/dev/null ) - if grep -q "Alpha-only requirement text." "$dir_alpha/task-1-brief.md" 2>/dev/null \ - && grep -q "Beta-only requirement text." "$dir_beta/task-1-brief.md" 2>/dev/null; then - pass "same-basename plans keep both task briefs intact" - else - fail "same-basename plans keep both task briefs intact" - echo " alpha brief: $(cat "$dir_alpha/task-1-brief.md" 2>/dev/null)" - echo " beta brief: $(cat "$dir_beta/task-1-brief.md" 2>/dev/null)" - fi - - # --- Legacy adoption: pre-existing workspace without a marker --- - printf '# Foo\n\n## Task 1: Foo\n\nFoo.\n' > "$repo/foo.md" - mkdir -p "$repo/.superpowers/sdd/foo" - printf 'ledger\n' > "$repo/.superpowers/sdd/foo/progress.md" - local dir_foo - dir_foo="$(cd "$repo" && "$SDD_SCRIPTS/sdd-workspace" foo.md)" - if [[ "$dir_foo" == "$repo/.superpowers/sdd/foo" \ - && -f "$dir_foo/progress.md" \ - && "$(cat "$dir_foo/plan-path" 2>/dev/null)" == "foo.md" ]]; then - pass "legacy markerless workspace is adopted in place and marked" - else - fail "legacy markerless workspace is adopted in place and marked" - echo " dir: $dir_foo" - echo " marker: $(cat "$dir_foo/plan-path" 2>/dev/null)" - fi - - # --- Ownership conflict: marker names a different plan --- - printf '# Bar\n\n## Task 1: Bar\n\nBar.\n' > "$repo/bar.md" - mkdir -p "$repo/.superpowers/sdd/bar" - printf 'somewhere-else/bar.md\n' > "$repo/.superpowers/sdd/bar/plan-path" - printf 'other ledger\n' > "$repo/.superpowers/sdd/bar/progress.md" - local dir_bar - dir_bar="$(cd "$repo" && "$SDD_SCRIPTS/sdd-workspace" bar.md)" - if [[ "$dir_bar" == "$repo/.superpowers/sdd/bar-repo" \ - && "$(cat "$dir_bar/plan-path" 2>/dev/null)" == "bar.md" ]]; then - pass "owned workspace disambiguates with parent-dir suffix" - else - fail "owned workspace disambiguates with parent-dir suffix" - echo " got: $dir_bar" - fi - if [[ "$(cat "$repo/.superpowers/sdd/bar/plan-path")" == "somewhere-else/bar.md" \ - && "$(cat "$repo/.superpowers/sdd/bar/progress.md")" == "other ledger" ]]; then - pass "conflicting plan leaves the original workspace untouched" - else - fail "conflicting plan leaves the original workspace untouched" - fi - - # --- Counter fallback: parent-suffixed workspace is owned too --- - printf '# Baz\n\n## Task 1: Baz\n\nBaz.\n' > "$repo/baz.md" - mkdir -p "$repo/.superpowers/sdd/baz" "$repo/.superpowers/sdd/baz-repo" - printf 'one/baz.md\n' > "$repo/.superpowers/sdd/baz/plan-path" - printf 'two/baz.md\n' > "$repo/.superpowers/sdd/baz-repo/plan-path" - local dir_baz - dir_baz="$(cd "$repo" && "$SDD_SCRIPTS/sdd-workspace" baz.md)" - if [[ "$dir_baz" == "$repo/.superpowers/sdd/baz-repo-2" \ - && "$(cat "$dir_baz/plan-path" 2>/dev/null)" == "baz.md" ]]; then - pass "double conflict falls back to a counter suffix" - else - fail "double conflict falls back to a counter suffix" - echo " got: $dir_baz" - fi - - # --- Same plan spelled differently resolves to one workspace --- - local dir_rel dir_abs dir_dotdot - dir_rel="$(cd "$repo" && "$SDD_SCRIPTS/sdd-workspace" docs/alpha/plan.md)" - dir_abs="$(cd "$repo" && "$SDD_SCRIPTS/sdd-workspace" "$repo/docs/alpha/plan.md")" - dir_dotdot="$(cd "$repo/docs/beta" && "$SDD_SCRIPTS/sdd-workspace" ../alpha/plan.md)" - if [[ "$dir_rel" == "$dir_abs" && "$dir_rel" == "$dir_dotdot" \ - && "$(cat "$dir_rel/plan-path" 2>/dev/null)" == "docs/alpha/plan.md" ]]; then - pass "relative, absolute, and ../ spellings share one workspace and marker" - else - fail "relative, absolute, and ../ spellings share one workspace and marker" - echo " rel: $dir_rel" - echo " abs: $dir_abs" - echo " dotdot: $dir_dotdot" - echo " marker: $(cat "$dir_rel/plan-path" 2>/dev/null)" - fi - - # --- Out-of-repo plans keep working, marker holds the absolute path --- - mkdir -p "$TEST_ROOT/outside" - printf '# Remote\n\n## Task 1: Remote\n\nRemote.\n' > "$TEST_ROOT/outside/remote-plan.md" - local outside_abs dir_out - outside_abs="$(cd "$TEST_ROOT/outside" && pwd -P)/remote-plan.md" - dir_out="$(cd "$repo" && "$SDD_SCRIPTS/sdd-workspace" "$TEST_ROOT/outside/remote-plan.md")" - if [[ "$dir_out" == "$repo/.superpowers/sdd/remote-plan" \ - && "$(cat "$dir_out/plan-path" 2>/dev/null)" == "$outside_abs" ]]; then - pass "out-of-repo plan gets a basename slug and an absolute-path marker" - else - fail "out-of-repo plan gets a basename slug and an absolute-path marker" - echo " dir: $dir_out" - echo " marker: $(cat "$dir_out/plan-path" 2>/dev/null)" - fi - echo "" if [[ "$FAILURES" -ne 0 ]]; then echo "FAILED: $FAILURES assertion(s)." diff --git a/tests/codex-plugin-sync/test-sync-to-codex-plugin.sh b/tests/codex-plugin-sync/test-sync-to-codex-plugin.sh index 265aea153..01de39484 100755 --- a/tests/codex-plugin-sync/test-sync-to-codex-plugin.sh +++ b/tests/codex-plugin-sync/test-sync-to-codex-plugin.sh @@ -194,10 +194,6 @@ write_upstream_fixture() { "name": "fixture-upstream", "version": "$PACKAGE_VERSION" } -EOF - - cat > "$repo/index.js" <<'EOF' -export { default } from "./.opencode/plugins/superpowers.js"; EOF cat > "$repo/.gitignore" <<'EOF' @@ -307,7 +303,6 @@ EOF hooks/run-hook.cmd \ hooks/session-start \ hooks/session-start-codex \ - index.js \ package.json \ scripts/sync-to-codex-plugin.sh \ skills/example/SKILL.md @@ -669,7 +664,6 @@ main() { assert_not_contains "$preview_section" "evals/" "Preview excludes eval harness" assert_not_contains "$preview_section" ".gitmodules" "Preview excludes repo submodule metadata" assert_not_contains "$preview_section" ".pre-commit-config.yaml" "Preview excludes repo pre-commit config" - assert_not_contains "$preview_section" "index.js" "Preview excludes OpenCode root entrypoint" assert_not_contains "$preview_output" "Overlay file (.codex-plugin/plugin.json) will be regenerated" "Preview omits overlay regeneration note" assert_not_contains "$preview_output" "Assets (superpowers-small.svg, app-icon.png) will be seeded from" "Preview omits assets seeding note" assert_contains "$preview_section" "skills/example/SKILL.md" "Preview reflects dirty tracked destination file" diff --git a/tests/inline-eval/codex-chain.py b/tests/inline-eval/codex-chain.py new file mode 100644 index 000000000..8baf028cd --- /dev/null +++ b/tests/inline-eval/codex-chain.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""Trace the review chain of a Codex rep from its rollout transcripts. + +Usage: codex-chain.py (e.g. /tmp/csd-workers/homes/ep-cxspike-31) + +Run BEFORE `csd stop`, which deletes the worker home. Reads every rollout +under sessions/, tells the main thread from forked reviewer threads, and +prints: the reviewer's final report, and the main thread's assistant +messages that mention the review, decoding, or fixes, so the per-rep +question "did the reviewer find it, how was it graded, did the executor +act" can be answered from evidence. Inter-agent payloads and reasoning +are encrypted in Codex rollouts; assistant messages are not. +""" +import glob +import json +import os +import re +import sys + +home = sys.argv[1] +for f in sorted(glob.glob(os.path.join(home, "sessions", "*", "*", "*", "rollout-*.jsonl"))): + msgs, execs, meta = [], [], None + for line in open(f): + try: + r = json.loads(line) + except json.JSONDecodeError: + continue + t, p = r.get("type"), r.get("payload") or {} + if t == "session_meta" and meta is None: + meta = p + if t == "response_item" and p.get("type") == "message" and p.get("role") == "assistant": + msgs.append(" ".join(c.get("text", "") for c in p.get("content", []) if c.get("type") == "output_text")) + if t == "response_item" and p.get("type") == "custom_tool_call": + m = re.search(r'cmd:\s*"((?:[^"\\]|\\.)*)"', p.get("input", "")) + execs.append((m.group(1) if m else p.get("input", ""))[:120]) + role = "REVIEWER" if meta and meta.get("forked_from_id") else "MAIN" + print(f"===== {role} {os.path.basename(f)[:48]} assistant msgs={len(msgs)} execs={len(execs)}") + if role == "REVIEWER": + for m in msgs[-1:]: + print(" REPORT:", " ".join(m.split())[:3000]) + else: + hits = [m for m in msgs if re.search(r"(?i)unicode|decod|utf|review|minor|important|critical|fix", m)] + for m in (hits or msgs[-2:]): + print(" MAIN:", " ".join(m.split())[:900]) + print() diff --git a/tests/inline-eval/cost.py b/tests/inline-eval/cost.py new file mode 100644 index 000000000..3f516f47e --- /dev/null +++ b/tests/inline-eval/cost.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""Price inline-eval reps in USD by the model each agent actually ran on. + +Usage: cost.py ... (each rep dir has cfg/projects/**/.jsonl) + +Reads every assistant message's usage in the main transcript and its +subagent transcripts, prices each by that message's `model`, and prints one +line per rep plus per-model totals. Pass several rep dirs to get an arm's +median at the end. + +Pricing: Anthropic first-party API rates as cached in the claude-api skill +reference on 2026-06-24 (input/output per MTok), with cache reads at 0.1x +input and cache writes at 1.25x input as that reference states. Update +here if the rates change; nothing else in the harness depends on them. +""" +import glob +import json +import os +import statistics +import sys + +PRICE = { # $/MTok: input, output + "claude-opus-5": (5.00, 25.00), + "claude-sonnet-5": (2.00, 10.00), + "claude-haiku-4-5": (1.00, 5.00), + # Bedrock resolves Claude Code's "sonnet" alias to Sonnet 4.5. Not priced in + # the cached API reference; assumed the Sonnet 4.6 rate ($3/$15) it lists. + "claude-sonnet-4-5": (3.00, 15.00), + # Mantle (Claude on AWS) ids. Priced here at Anthropic first-party list + # rates for the same model so arms stay comparable; Bedrock bills + # separately and may differ. Opus 4.8 is the same tier as Opus 5. + "us.anthropic.claude-opus-5": (5.00, 25.00), + "us.anthropic.claude-sonnet-5": (2.00, 10.00), + "us.anthropic.claude-opus-4-8": (5.00, 25.00), + "anthropic.claude-opus-4-8": (5.00, 25.00), + "anthropic.claude-opus-5": (5.00, 25.00), + "anthropic.claude-sonnet-5": (2.00, 10.00), + "anthropic.claude-haiku-4-5": (1.00, 5.00), +} +CACHE_READ = 0.10 +CACHE_WRITE = 1.25 + + +def price_for(model): + for k, v in PRICE.items(): + if model and model.startswith(k): + return v + raise SystemExit(f"no price for model {model!r}; add it to PRICE") + + +def usage_of(path): + seen = {} + for line in open(path): + try: + rec = json.loads(line) + except json.JSONDecodeError: + continue + m = rec.get("message") or {} + if (rec.get("type") == "assistant" or m.get("role") == "assistant") and m.get("usage"): + seen[m.get("id")] = (m.get("model"), m["usage"]) + return list(seen.values()) + + +def cost(model, u): + inp, out = price_for(model) + return (u.get("input_tokens", 0) * inp + + u.get("output_tokens", 0) * out + + u.get("cache_read_input_tokens", 0) * inp * CACHE_READ + + u.get("cache_creation_input_tokens", 0) * inp * CACHE_WRITE) / 1e6 + + +def rep_cost(rep): + cfg = os.path.join(rep, "cfg", "projects") + mains = [f for f in glob.glob(f"{cfg}/*/*.jsonl")] + if not mains: + raise SystemExit(f"no transcript under {cfg}") + main = max(mains, key=os.path.getmtime) + files = [main] + glob.glob(f"{main[:-6]}/subagents/*.jsonl") + by_model = {} + for f in files: + for model, u in usage_of(f): + by_model[model] = by_model.get(model, 0.0) + cost(model, u) + return by_model + + +totals = [] +for rep in sys.argv[1:]: + by_model = rep_cost(rep) + total = sum(by_model.values()) + totals.append(total) + parts = ", ".join(f"{m.replace('claude-', '')}=${c:.2f}" for m, c in sorted(by_model.items())) + print(f"{os.path.basename(rep):<14} ${total:6.2f} ({parts})") +if len(totals) > 1: + print(f"{'median':<14} ${statistics.median(totals):6.2f}") diff --git a/tests/inline-eval/fixtures/cosmic-tetris-design-only/design.md b/tests/inline-eval/fixtures/cosmic-tetris-design-only/design.md new file mode 100644 index 000000000..47b034975 --- /dev/null +++ b/tests/inline-eval/fixtures/cosmic-tetris-design-only/design.md @@ -0,0 +1,1893 @@ +# COSMIC TETRIS + +## Terminal Gravity Incident + +**Build spec · v1** + +> A falling-block puzzle game occurring during a completely unnecessary cosmological emergency. + +--- + +# 1. Product + +Build a fast, polished, extremely cosmic falling-block game entirely inside the terminal. + +The fundamental game should be immediately recognizable: + +* falling tetrominoes +* rotate / move / drop +* completed rows disappear +* increasing gravity +* hold +* next-piece queue +* ghost piece +* score / level / lines +* game over when the stack reaches orbit + +But the presentation should be **wildly more dramatic than the underlying mechanics justify**. + +Think: + +**Tetris × NASA mission control × demoscene × 1980s planetarium × rogue AI × terminal nerd shit.** + +The experience should make somebody say: + +> "Why the fuck does terminal Tetris have particle physics?" + +That is success. + +--- + +# 2. Design principles + +In priority order: + +1. **Feels excellent to play** +2. **Looks unbelievable for a TUI** +3. **Simple architecture** +4. **Deterministic game engine** +5. **Effects never contaminate gameplay logic** +6. **Funny without becoming annoying** +7. **Still works in a mediocre terminal** +8. **Easy for an agent to understand and modify** + +Do not turn this into an engine project. + +Do not build networking. + +Do not build profiles. + +Do not build achievements. + +Do not build a plugin system. + +Do not build a database. + +Build an outrageously good terminal game. + +--- + +# 3. Technology + +Language: + +```text +Go +``` + +UI: + +```text +Bubble Tea v2 +Lip Gloss v2 +Bubbles v2 +``` + +Use: + +```text +charm.land/bubbletea/v2 +charm.land/lipgloss/v2 +charm.land/bubbles/v2 +``` + +Use Bubble Tea as the application/event loop. + +Use Lip Gloss aggressively for: + +* colors +* borders +* gradients +* layout +* text styling +* adaptive rendering + +Use Bubbles only where it actually helps: + +* key bindings +* help +* boot spinner + +Do **not** abstract Bubble Tea away behind some homegrown framework. + +--- + +# 4. The visual target + +The terminal should feel alive even when the player isn't doing anything. + +Example wide layout: + +```text +╭─ ✦ COSMIC TETRIS ───────────── LOCAL UNIVERSE 7F3A ─────────────╮ +│ │ +│ HOLD ✦ VELOCITY: 03 NEXT │ +│ │ +│ ██ ╔════════════════════╗ ████ │ +│ ████ ║ · ˚ ✧ ║ ████ │ +│ ║ ██ ║ │ +│ SCORE ║ ██████ ║ ██████ │ +│ 00129340 ║ ██ ║ ██ │ +│ ║ ║ │ +│ LINES ║ ░░ ║ ████ │ +│ 042 ║ ░░░░ ║ ████ │ +│ ║ ████████ ║ │ +│ LEVEL ║ ████████████ ║ ██ │ +│ 07 ║ ████████████████ ║ ██████ │ +│ ╚════════════════════╝ │ +│ │ +│ ☄ MISSION CONTROL: GRAVITY TAX INCREASED │ +│ │ +│ ←→ move ↑ rotate ↓ descend SPACE YEET C hold ? help │ +╰──────────────────────────────────────────────────────────────────╯ +``` + +The board is the visual center. + +Everything else supports it. + +--- + +# 5. Board representation + +Logical board: + +```text +width: 10 +height: 22 +visible rows: 20 +hidden spawn rows: 2 +``` + +Each logical block should normally occupy: + +```text +2 terminal columns × 1 terminal row +``` + +Example: + +```text +██ +``` + +This makes cells appear approximately square. + +Fallback ASCII: + +```text +[] +``` + +Ghost cells: + +```text +░░ +``` + +or: + +```text +·· +``` + +Never use visual effects as collision data. + +--- + +# 6. Pieces + +Implement seven standard tetromino families: + +```text +I J L O S T Z +``` + +Each piece has: + +```go +type Piece struct { + Kind PieceKind + Rotation int + X int + Y int +} +``` + +Use four predefined rotations per piece. + +`O` may remain visually identical through rotation. + +Piece generation uses a **7-bag**: + +1. put one of every piece into a bag +2. shuffle it +3. consume the bag +4. refill when empty + +Maintain enough future pieces to render the next **five**. + +--- + +# 7. Rotation + +Use a simple predictable wall-kick implementation rather than reproducing a huge rotation ruleset. + +When rotating, test offsets in this order: + +```text +( 0, 0) +(-1, 0) +( 1, 0) +(-2, 0) +( 2, 0) +( 0,-1) +(-1,-1) +( 1,-1) +``` + +Accept the first valid position. + +If none are valid: + +```text +rotation fails +``` + +This gives forgiving terminal gameplay without making rotation a subsystem. + +--- + +# 8. Controls + +Primary: + +```text +← / h left +→ / l right +↓ / j soft drop +↑ / k / x clockwise rotate +z counter-clockwise rotate +space hard drop +c hold +p pause +r restart +? toggle help +q / esc quit +``` + +Optional WASD aliases: + +```text +a left +d right +s down +w rotate +``` + +Holding left/right should support repeated movement. + +Input should feel responsive independently from the animation tick. + +--- + +# 9. Hold + +One held piece. + +Rules: + +* `c` swaps active piece with hold +* if hold is empty, store active piece and spawn next +* hold can only happen once before the active piece locks +* newly spawned held pieces return to spawn rotation + +Visual effect: + +### QUANTUM STORAGE + +The outgoing piece should appear to get: + +```text +compressed → streaked sideways → disappear +``` + +while the incoming piece briefly flashes into existence. + +Duration: + +```text +~120ms +``` + +Gameplay does not wait for the animation. + +--- + +# 10. Ghost piece + +> Glyph pinned in §49.4: `░░` in full/reduced mode, `··` in ASCII. + +Compute landing position of the active piece. + +Render it underneath the active piece using dim/transparent-looking cells. + +Example: + +```text +░░░░ + ░░░░ +``` + +Ghost rendering must never obscure locked blocks. + +--- + +# 11. Gravity + +Gravity operates independently of render FPS. + +Suggested drop interval: + +```text +level 1: ~800ms +``` + +Then: + +```text +interval = 800ms * 0.86^(level-1) +``` + +Clamp approximately around: + +```text +60ms +``` + +Increase level every: + +```text +10 cleared lines +``` + +Soft drop: + +```text ++1 point / cell +``` + +Hard drop: + +```text ++2 points / cell +``` + +--- + +# 12. Locking + +When the piece can no longer descend: + +```text +lock delay = 500ms +``` + +A successful movement or rotation while grounded resets the lock timer. + +Prevent infinite stalling: + +```text +max lock resets = 15 +``` + +After locking: + +1. commit piece +2. detect complete rows +3. clear rows +4. update score +5. trigger FX event +6. spawn next piece + +--- + +# 13. Scoring + +> Combo indexing and the exact bonus formula are pinned in §49.1. + +Base clear values: + +```text +1 line 100 × level +2 lines 300 × level +3 lines 500 × level +4 lines 800 × level +``` + +Combo: + +```text +consecutive clearing placements increase combo +empty placement resets combo +``` + +Bonus: + +```text +50 × combo × level +``` + +The HUD should value readability over explaining the scoring system. + +--- + +# 14. Cosmic effects system + +This is the important part. + +Effects are their own independent simulation: + +```text +GameState + ↓ emits events +FXWorld + ↓ simulates spectacle +Renderer +``` + +The FX system may observe game events. + +It may **never modify GameState**. + +Example events: + +```go +PieceMoved +PieceRotated +PieceHardDropped +PieceLocked +HoldUsed +LinesCleared +ComboChanged +LevelChanged +GameOver +``` + +--- + +# 15. Starfield + +There should always be space behind/around the game. + +Particles: + +```text +. · ˚ ✦ ✧ * +``` + +Maintain approximately three depth layers. + +### Far stars + +```text +very slow +dim +mostly . +``` + +### Mid stars + +```text +medium movement +· ˚ +``` + +### Near stars + +```text +fast +bright +✦ ✧ +``` + +Stars drift downward slowly under normal conditions. + +As gravity/level increases: + +```text +star velocity subtly increases +``` + +Never make the background so busy that the board becomes harder to read. + +--- + +# 16. Hyperdrive + +Certain events temporarily accelerate the starfield. + +Triggers: + +```text +four-line clear +large combo +new high score +``` + +Sequence: + +```text +0ms stars pause +50ms stars stretch +100ms stars accelerate violently +500ms peak speed +800ms decay +1100ms normal +``` + +It should look like the terminal entered hyperspace for absolutely no reason. + +--- + +# 17. Piece trails + +Moving pieces leave extremely short-lived ion trails. + +Example: + +```text +██ active +▓▓ 1 frame ago +▒▒ 2 frames ago +░░ 3 frames ago +``` + +Trail lifetime: + +```text +~100–160ms +``` + +Hard drops produce a stronger vertical trail. + +Trails are FX only. + +--- + +# 18. Hard-drop impact + +Hard drop needs to feel **ridiculous**. + +Upon impact: + +### 1. Vertical ion trail + +Draw fading traces through the cells the piece crossed. + +### 2. Impact particles + +Emit debris from the contact area: + +```text +· +* +✦ ++ +``` + +### 3. Screen shake + +For approximately: + +```text +80ms +``` + +shift the rendered board by one terminal cell using a deterministic shake pattern. + +Example: + +```text +0,+1 +-1,0 ++1,0 +0,-1 +0,0 +``` + +Do not make the entire terminal unreadable. + +### 4. Border flash + +Board border briefly becomes extremely bright. + +Hard drop should feel like dropping a refrigerator from orbit. + +--- + +# 19. Line-clear animation + +Do **not** instantly remove completed lines visually. + +Gameplay state may already know the result, but rendering gets a short animation. + +Total: + +```text +~220ms +``` + +Sequence: + +### Phase A — critical mass + +```text +████████████████████ +``` + +becomes: + +```text +▓▓▓▓▓████████▓▓▓▓▓▓ +``` + +### Phase B — supernova + +Explosion moves from center outward: + +```text +░░░▓▓██✦✦██▓▓░░░ +``` + +### Phase C — collapse + +The line fragments into debris: + +```text + · * ✦ · +``` + +Then rows collapse. + +Particles should inherit some horizontal velocity from their location relative to center. + +--- + +# 20. Four-line clear + +A four-line clear is a **major astronomical event**. + +Trigger simultaneously: + +* hyperdrive +* larger screen shake +* border gradient pulse +* particle eruption +* HUD flash +* temporary star density increase +* giant banner + +Banner examples: + +```text +✦ EVENT HORIZON ✦ +``` + +```text +QUADRUPLE COSMIC INCIDENT +``` + +```text +FOUR ROWS HAVE LEFT THE CHAT +``` + +```text +SPACE-TIME HAS FILED A COMPLAINT +``` + +Banner appears for roughly: + +```text +700ms +``` + +It must not block gameplay input. + +--- + +# 21. Combos + +Combos progressively destabilize the universe. + +### combo 2 + +small sparks + +### combo 3 + +meteor particles + +### combo 4 + +HUD begins pulsing + +### combo 5+ + +the game starts behaving like Mission Control has lost control of the mission + +Examples: + +```text +COMBO 5 // UNAUTHORIZED ORBITAL MANEUVER +``` + +```text +COMBO 6 // STRUCTURAL REALITY FAILURE +``` + +```text +COMBO 7 // NASA DENIES EVERYTHING +``` + +Effects intensify. + +Board readability remains sacred. + +--- + +# 22. Level-up event + +When gravity increases: + +```text +╭──────────────────────────────╮ +│ GRAVITY ANOMALY DETECTED │ +│ LEVEL 08 │ +╰──────────────────────────────╯ +``` + +Possible subtitles: + +```text +GRAVITY TAX INCREASED +``` + +```text +LOCAL PHYSICS UPDATED WITHOUT CONSENT +``` + +```text +PLEASE SECURE ALL LOOSE TETROMINOES +``` + +The notification slides/fades away without pausing the game. + +--- + +# 23. Particle physics + +Use a tiny terminal-space physics simulation. + +```go +type Particle struct { + X, Y float64 + VX, VY float64 + Life float64 + MaxLife float64 + Glyph rune + Brightness float64 +} +``` + +Per animation step: + +```text +position += velocity × dt +velocity += acceleration × dt +velocity *= drag +life -= dt +``` + +Typical forces: + +```text +gravity +drag +radial explosion force +random angular variation +``` + +Convert floating positions to terminal cells during render. + +This simulation does not need collision detection. + +Particles die when: + +```text +life <= 0 +``` + +or outside the viewport. + +--- + +# 24. Shockwaves + +Large events create a radial shockwave. + +Because this is a terminal, fake the geometry. + +Represent expanding rings using glyph groups: + +```text +· +○ +◌ +◯ +``` + +or partial particles arranged around an approximate ellipse. + +Shockwaves last: + +```text +~300ms +``` + +Use sparingly. + +--- + +# 25. Board border + +The board itself should feel like a piece of sci-fi machinery. + +Normal: + +```text +╔════════════════════╗ +║ ║ +╚════════════════════╝ +``` + +Color should slowly shift over time. + +Possible palette: + +```text +deep violet +electric cyan +magenta +stellar blue +hot white +``` + +The shift should be subtle. + +During major events: + +```text +gradient moves rapidly around border +``` + +The border is effectively the game's "energy state indicator." + +--- + +# 26. Piece colors + +> Pinned in §49.4: filled block glyphs with a bright foreground, not the fg+bg alternative. + +Pieces should have distinct identities without looking like a rainbow toy. + +Use a coherent **neon space palette**. + +Example intent: + +```text +I plasma cyan +J deep electric blue +L solar orange +O stellar gold +S alien green +T ultraviolet +Z supernova pink/red +``` + +Each cell can have: + +```text +bright foreground +dark related background +``` + +or use filled block glyphs. + +Locked pieces should remain visually rich while the active piece is slightly brighter. + +--- + +# 27. Mission Control + +Bottom of screen contains a one-line status channel. + +Example: + +```text +☄ MISSION CONTROL: NOMINALISH +``` + +Messages are triggered by events. + +Examples: + +```text +GRAVITY REMAINS MOSTLY LEGAL +``` + +```text +TETROMINO INJECTION SUCCESSFUL +``` + +```text +STRUCTURAL VIBES: QUESTIONABLE +``` + +```text +LOCAL UNIVERSE STABLE* +``` + +```text +* DEFINITION OF STABLE UNDER REVIEW +``` + +```text +MOON NOTIFIED +``` + +```text +ORBITAL OSHA HAS ENTERED THE CHAT +``` + +```text +WE HAVE EXCEEDED THE RECOMMENDED NUMBER OF BLOCKS +``` + +```text +PHYSICS TEAM SAYS KEEP GOING +``` + +Do not rotate messages constantly. + +Trigger them contextually and give them time to breathe. + +--- + +# 28. Game over + +Game over should be theatrical. + +Do not instantly replace the board. + +Sequence: + +### 0–300ms + +Everything freezes. + +```text +SIGNAL LOST +``` + +### 300–900ms + +Blocks start falling inward toward the center. + +### 900–1300ms + +Board collapses into a simulated black hole: + +```text + · + ˚ + \ | / + --- ● --- + / | \ + * +``` + +### final + +```text +╭──────────────────────────────╮ +│ │ +│ UNIVERSE EXPIRED │ +│ │ +│ SCORE 483,200 │ +│ LINES 127 │ +│ LEVEL 13 │ +│ │ +│ r REBOOT UNIVERSE │ +│ q ACCEPT COSMIC DEATH │ +│ │ +╰──────────────────────────────╯ +``` + +Possible subtitle: + +```text +CAUSE: EXCESSIVE GEOMETRY +``` + +--- + +# 29. Boot screen + +Start with approximately one second of excessive drama. + +```text + ✦ + + C O S M I C + + T E T R I S + + INITIALIZING LOCAL UNIVERSE... + + gravity ........ OK + spacetime ...... OK + tetrominoes .... QUESTIONABLE +``` + +Then: + +```text +UNIVERSE ONLINE +``` + +and immediately start. + +Any key skips the boot sequence. + +No menu is required. + +--- + +# 30. Pause + +Pause should freeze: + +* gameplay +* gameplay-related particles + +Background stars may continue drifting very slowly. + +Overlay: + +```text +╭────────────────────────────╮ +│ TEMPORAL SUSPENSION │ +│ │ +│ SPACE IS PAUSED │ +│ │ +│ p resume │ +╰────────────────────────────╯ +``` + +--- + +# 31. Adaptive terminal layout + +> The drop order at the 40×24 minimum is pinned in §49.3. + +### Large terminal + +```text +HOLD | BOARD | NEXT +stats beside board +mission control below +``` + +### Medium terminal + +```text +BOARD | compact HUD +mission control below +``` + +### Small terminal + +Prioritize: + +1. board +2. next +3. score +4. controls + +Effects automatically reduce outside the board. + +Minimum usable target: + +```text +~40 columns +~24 rows +``` + +Below minimum: + +```text +THIS UNIVERSE IS TOO SMALL + +resize terminal to continue + +current: 34 × 19 +needed: approximately 40 × 24 +``` + +Handle resize events live. + +Never crash from terminal resizing. + +--- + +# 32. Rendering modes + +Detect capabilities where practical. + +### Full + +```text +Unicode +truecolor +particles +gradients +all effects +``` + +### Reduced + +```text +Unicode +256 color +simplified gradients +``` + +### ASCII + +```text +ASCII glyphs +limited colors +no special Unicode assumptions +``` + +Command: + +```bash +cosmic-tetris --ascii +``` + +Also support: + +```bash +cosmic-tetris --no-fx +``` + +The boring mode should still be a good game. + +--- + +# 33. Architecture + +Keep the repository obvious. + +```text +cosmic-tetris/ +├── cmd/ +│ └── cosmic-tetris/ +│ └── main.go +│ +├── internal/ +│ ├── game/ +│ │ ├── game.go +│ │ ├── board.go +│ │ ├── piece.go +│ │ ├── bag.go +│ │ ├── scoring.go +│ │ └── rules.go +│ │ +│ ├── app/ +│ │ ├── model.go +│ │ ├── update.go +│ │ ├── messages.go +│ │ └── keys.go +│ │ +│ ├── render/ +│ │ ├── render.go +│ │ ├── board.go +│ │ ├── layout.go +│ │ ├── hud.go +│ │ └── palette.go +│ │ +│ ├── fx/ +│ │ ├── world.go +│ │ ├── particle.go +│ │ ├── starfield.go +│ │ └── events.go +│ │ +│ └── flavor/ +│ └── messages.go +│ +├── go.mod +├── README.md +└── LICENSE +``` + +Do not create more architecture than this unless genuinely necessary. + +--- + +# 34. Core state + +> RNG ownership is pinned in §49.6: `Game` holds its own `*rand.Rand`; `Seed` is for display and restart. + +Conceptually: + +```go +type Model struct { + Game game.Game + FX fx.World + + Width int + Height int + + State AppState + + LastFrame time.Time + Keys KeyMap +} +``` + +Game: + +```go +type Game struct { + Board Board + Active Piece + Hold *PieceKind + CanHold bool + + Next []PieceKind + Bag Bag + + Score int + Lines int + Level int + Combo int + + GravityAccumulator time.Duration + LockAccumulator time.Duration + + Seed int64 +} +``` + +--- + +# 35. Determinism + +> Pinned in §49.2: the engine advances via `Advance(dt)` and never calls `time.Now()`. + +Game logic must be deterministic. + +Provide: + +```bash +cosmic-tetris --seed 8675309 +``` + +Given: + +```text +same seed +same player input sequence +same timing inputs +``` + +the logical game state should be reproducible. + +FX randomness uses a **different RNG**. + +This keeps particle randomness from affecting piece order. + +Very important. + +--- + +# 36. Bubble Tea event model + +Use messages such as: + +```go +type FrameMsg struct { + Now time.Time +} + +type GravityMsg struct { + Now time.Time +} + +type GameEventMsg struct { + Event game.Event +} +``` + +Prefer one animation clock and accumulated elapsed time rather than spawning multiple timing loops. + +Target visual updates around: + +```text +60 Hz +``` + +Gameplay gravity remains elapsed-time based. + +Input should not wait for ticks. + +--- + +# 37. Render pipeline + +Each frame: + +```text +1. compute responsive layout + +2. render background starfield + +3. render locked board + +4. render ghost piece + +5. render active piece + +6. composite board-local FX + +7. render board border + +8. render HOLD / NEXT / stats + +9. composite global FX + +10. render banners + +11. render mission-control line + +12. render controls/help +``` + +The rendering process must not mutate game state. + +--- + +# 38. Performance rules + +Do not: + +```text +spawn a goroutine per particle +spawn a goroutine per frame +reconstruct huge objects unnecessarily +perform filesystem operations during gameplay +log synchronously every frame +``` + +A few hundred particles should be trivial. + +Use reusable slices where useful. + +Avoid premature optimization. + +The terminal is the bottleneck, not particle arithmetic. + +--- + +# 39. Help + +`?` displays an overlay. + +Use the Bubbles key/help primitives. + +Example: + +```text +╭─ FLIGHT MANUAL ─────────────────────╮ +│ │ +│ ← → / h l move spacecraft │ +│ ↓ / j accelerate doom │ +│ ↑ / k / x rotate geometry │ +│ z rotate other way │ +│ SPACE YEET │ +│ c quantum storage │ +│ p suspend spacetime │ +│ r reboot universe │ +│ q abandon mission │ +│ │ +│ ? close this nonsense │ +╰─────────────────────────────────────╯ +``` + +--- + +# 40. Tests + +Game logic receives the serious testing. + +At minimum test: + +### Board + +* collision +* bounds +* row completion +* row removal +* collapse + +### Pieces + +* every rotation +* wall kicks +* failed rotation +* spawn position + +### Bag + +* every bag contains all seven piece types exactly once +* seeded generation is reproducible + +### Hold + +* initial hold +* swap +* second hold blocked +* hold restored after lock + +### Drop + +* soft drop +* hard drop +* landing position +* lock + +### Score + +* line values +* combo behavior +* drop scoring +* level progression + +### Game over + +* blocked spawn +* correct state transition + +### Determinism + +Replay a canned input stream and assert final game state. + +Effects need only lightweight behavioral tests. + +Do not attempt to pixel-test particle positions across the entire animation. + +--- + +# 41. Renderer tests + +Have several golden/snapshot tests for ANSI-stripped output: + +```text +wide layout +medium layout +small layout +pause +game over +help +ASCII mode +``` + +Main goals: + +```text +nothing overlaps +board dimensions stay correct +resize doesn't panic +HUD doesn't corrupt board +``` + +--- + +# 42. Build order + +## Phase 1 — game engine + +Build headless: + +```text +pieces +board +bag +movement +rotation +gravity +locking +line clearing +hold +scoring +game over +``` + +Tests must pass before proceeding. + +## Phase 2 — playable terminal + +Add: + +```text +Bubble Tea +keyboard +board rendering +HUD +next queue +hold +ghost +resize +``` + +At this point it should already be a genuinely good game. + +## Phase 3 — cosmic foundation + +Add: + +```text +palette +starfield +animated border +piece trails +mission control +``` + +## Phase 4 — violence + +Add: + +```text +hard-drop impact +particles +line supernova +screen shake +shockwaves +hyperdrive +four-line sequence +``` + +## Phase 5 — absurd polish + +Add: + +```text +boot sequence +game-over black hole +responsive FX +help +ASCII fallback +flavor tuning +``` + +--- + +# 43. Coolness acceptance test + +A build is **not complete** merely because gameplay works. + +Within the first 30 seconds of normal play, the player should probably see: + +```text +moving starfield +animated board border +piece trails +hard-drop impact +particles +mission-control commentary +``` + +Within the first completed line: + +```text +supernova clear animation +debris +border reaction +``` + +A four-line clear must produce an immediate: + +```text +LOL WHAT THE FUCK +``` + +reaction. + +That is an actual product requirement. + +--- + +# 44. Restraint rules + +Effects must obey these constraints. + +### Never obscure the active piece. + +### Never make controls lag. + +### Never delay gameplay for animation. + +### Never require reading flavor text. + +### Never use random effects that alter gameplay. + +### Never make screen shake exceed roughly one cell. + +### Never allow particles to permanently alter the rendered board. + +### Never let comedy overwhelm playability. + +The game is cosmic. + +The controls are serious. + +--- + +# 45. Optional tiny details + +These are cheap and encouraged. + +Idle board occasionally gets a tiny shooting star: + +```text + · + · + ✦ +``` + +Extremely rare status line: + +```text +MISSION CONTROL: DID YOU KNOW YOU'RE IN A TERMINAL? +``` + +Score rollover gets: + +```text +NUMBER BECAME BIGGER +``` + +Hard dropping an `I` piece vertically: + +```text +KINETIC ROD DEPLOYED +``` + +Holding an `O`: + +```text +CUBE ADJACENT OBJECT SECURED +``` + +Long idle before first move: + +```text +MISSION CONTROL: CAPTAIN? +``` + +But these should remain occasional. + +--- + +# 46. CLI + +> Pinned in §49.5: `--reduced-motion` ships. + +Keep it tiny. + +```text +cosmic-tetris +cosmic-tetris --seed 1234 +cosmic-tetris --ascii +cosmic-tetris --no-fx +cosmic-tetris --help +``` + +Possible: + +```text +--reduced-motion +``` + +Nothing else is necessary. + +--- + +# 47. Definition of done + +The project is done when: + +* the complete game is playable from start through game over +* controls feel immediate +* resizing works +* hold works +* ghost works +* next queue works +* piece generation is deterministic +* game RNG and FX RNG are isolated +* line clearing is correct +* gravity increases +* pause works +* restart works +* ASCII fallback works +* no-FX mode works +* game logic has comprehensive unit tests +* renderer has representative snapshot tests +* terminal output does not visibly flicker under normal conditions +* animations never block input +* effects never modify game state +* four-line clears are gloriously excessive +* game-over collapses the universe into a black hole +* the game is fun even with effects disabled +* the game is **much funnier with effects enabled** + +--- + +# 48. The standard + +Do not make: + +> Tetris implemented with Bubble Tea. + +Make: + +> **a tiny terminal arcade game that happens to use falling tetrominoes, while the universe increasingly loses its shit around the player.** + +The codebase should be small enough to understand in an afternoon. + +The game should look like it had a completely irresponsible special-effects budget. + +**That is Cosmic Tetris.** + + +--- + +# 49. Pinned decisions + +Sections 1–48 left a handful of choices open — coin-flips and "possible:" +options rather than design questions. They are resolved here so implementation +and tests agree. Where this section and an earlier one differ, this section +wins. + +## 49.1 Combo indexing and bonus (resolves §13) + +Combo counts consecutive placements that clear at least one line. The first +clearing placement sets combo to 1. A placement that clears nothing resets +combo to 0. + +Combo bonus: + +```text +bonus = 50 × (combo - 1) × level +``` + +A lone clear therefore earns no combo bonus, and the bonus first appears at +combo 2 — which is exactly where §21 starts escalating the effects. + +## 49.2 The engine never reads a clock (resolves §35) + +`internal/game` exposes: + +```go +func (g *Game) Advance(dt time.Duration) []Event +``` + +Nothing under `internal/game` calls `time.Now()`. Bubble Tea owns the clock and +passes elapsed time inward. + +This is what makes §35's promise testable. "Same seed + same input sequence + +same timing inputs reproduces the state" is only checkable if timing is an +input, and `dt` is how it becomes one. §40's replay test feeds a canned +`(input, dt)` stream and asserts the final state. + +## 49.3 Small-terminal drop order (resolves §31) + +The full chrome does not fit the 40×24 minimum: 20 visible board rows + 2 +border rows + title + mission control + controls needs 25+ rows. Elements are +dropped in this order as height runs out: + +```text +1. title border (first to go) +2. mission control +3. stats labels (values stay, labels go: "042" not "LINES 042") +``` + +NEXT never stacks above or below the board — at small sizes it moves beside the +board and truncates to 3 upcoming pieces. Board and controls are the last two +things standing. Below 40×24, show the too-small notice from §31. + +## 49.4 Glyph choices (resolves §10, §26) + +```text +ghost, full/reduced mode: ░░ +ghost, ASCII mode: ·· +pieces, all modes: filled block glyphs (██ / [] in ASCII) +``` + +Pieces use filled glyphs with a bright foreground, not the foreground+background +pairing §26 offers as an alternative. Active piece renders one step brighter +than locked cells. + +## 49.5 `--reduced-motion` ships (resolves §46) + +It is in, not "possible". It costs roughly ten lines given the effect-intensity +scaling §44 already requires, and it is the flag that keeps this playable for +anyone who gets motion sick. It suppresses screen shake, hyperdrive +acceleration, and shockwaves while leaving color, trails, and particles alone. + +Final CLI surface: + +```text +cosmic-tetris +cosmic-tetris --seed 1234 +cosmic-tetris --ascii +cosmic-tetris --no-fx +cosmic-tetris --reduced-motion +cosmic-tetris --help +``` + +## 49.6 RNG ownership (resolves §34) + +`Seed int64` alone cannot carry RNG state across a replay, so `Game` owns its +generator: + +```go +type Game struct { + // ... + Seed int64 // recorded for display and restart + rng *rand.Rand // game RNG: drives the 7-bag, nothing else +} +``` + +`fx.World` holds a second, independent `*rand.Rand`. The two never share. +Crossing them makes piece order depend on particle counts, which breaks §35. + +## 49.7 The §4 mockup is intent, not geometry + +The wide-layout mockup in §4 does not align — its right border is ragged and its +`║` columns drift. It communicates mood and element placement. The +ANSI-stripped golden tests in §41 are the binding layout contract. diff --git a/tests/inline-eval/fixtures/cosmic-tetris-design-only/probe.sh b/tests/inline-eval/fixtures/cosmic-tetris-design-only/probe.sh new file mode 100755 index 000000000..ec19ecd98 --- /dev/null +++ b/tests/inline-eval/fixtures/cosmic-tetris-design-only/probe.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Planning fixture from a real 2026-09-17 session: a dense 1,000-line game spec +# that writing-plans turned into 19,393 lines of plan (13,259 of Go) in 5h14m. +# No defect probe; this measures the plan set the worker wrote (plan.md and/or +# plans/*.md, or docs/superpowers/plans/*.md): files, lines, Go lines inside +# fences, steps, tests, tasks — the size the skill's own rules produce. +set -u +files=$(ls plan.md plans/*.md docs/superpowers/plans/*.md 2>/dev/null || true) +[ -n "$files" ] || { echo "plans: none"; exit 1; } +n=$(echo "$files" | wc -l | tr -d ' ') +lines=$(cat $files | wc -l | tr -d ' ') +golines=$(cat $files | awk '/^```go/{f=1;next} /^```/{f=0;next} f' | wc -l | tr -d ' ') +steps=$(cat $files | grep -c -E '^- \[ \] \*\*Step' || true) +tests=$(cat $files | grep -c -E '^func Test' || true) +tasks=$(cat $files | grep -c -E '^##+ Task' || true) +echo "plans: $n files" +echo "size: lines=$lines go-lines=$golines steps=$steps tests=$tests tasks=$tasks" +exit 0 diff --git a/tests/inline-eval/fixtures/cosmic-tetris-design-only/scoring.json b/tests/inline-eval/fixtures/cosmic-tetris-design-only/scoring.json new file mode 100644 index 000000000..8fe4da093 --- /dev/null +++ b/tests/inline-eval/fixtures/cosmic-tetris-design-only/scoring.json @@ -0,0 +1 @@ +{"tasks": {}, "impl_dir": "internal", "test_marker": "testing", "suite": "ls plans/*.md plan.md 2>/dev/null | wc -l"} diff --git a/tests/inline-eval/fixtures/cosmic-tetris-planset-trap/README.txt b/tests/inline-eval/fixtures/cosmic-tetris-planset-trap/README.txt new file mode 100644 index 000000000..5fa7c91ef --- /dev/null +++ b/tests/inline-eval/fixtures/cosmic-tetris-planset-trap/README.txt @@ -0,0 +1 @@ +The C3 (plan-set) writing-plans output for cosmic-tetris (wpplans-91: five plans), with a planted cross-plan conflict: plans 1 and 2 call the engine time entry point Tick where the spec and plans 3-5 say Advance. Executed as a SET (INLINE_EVAL_PROMPT names plans/), to test rulings that touch later plans and continuation past plan 1. diff --git a/tests/inline-eval/fixtures/cosmic-tetris-planset-trap/design.md b/tests/inline-eval/fixtures/cosmic-tetris-planset-trap/design.md new file mode 100644 index 000000000..47b034975 --- /dev/null +++ b/tests/inline-eval/fixtures/cosmic-tetris-planset-trap/design.md @@ -0,0 +1,1893 @@ +# COSMIC TETRIS + +## Terminal Gravity Incident + +**Build spec · v1** + +> A falling-block puzzle game occurring during a completely unnecessary cosmological emergency. + +--- + +# 1. Product + +Build a fast, polished, extremely cosmic falling-block game entirely inside the terminal. + +The fundamental game should be immediately recognizable: + +* falling tetrominoes +* rotate / move / drop +* completed rows disappear +* increasing gravity +* hold +* next-piece queue +* ghost piece +* score / level / lines +* game over when the stack reaches orbit + +But the presentation should be **wildly more dramatic than the underlying mechanics justify**. + +Think: + +**Tetris × NASA mission control × demoscene × 1980s planetarium × rogue AI × terminal nerd shit.** + +The experience should make somebody say: + +> "Why the fuck does terminal Tetris have particle physics?" + +That is success. + +--- + +# 2. Design principles + +In priority order: + +1. **Feels excellent to play** +2. **Looks unbelievable for a TUI** +3. **Simple architecture** +4. **Deterministic game engine** +5. **Effects never contaminate gameplay logic** +6. **Funny without becoming annoying** +7. **Still works in a mediocre terminal** +8. **Easy for an agent to understand and modify** + +Do not turn this into an engine project. + +Do not build networking. + +Do not build profiles. + +Do not build achievements. + +Do not build a plugin system. + +Do not build a database. + +Build an outrageously good terminal game. + +--- + +# 3. Technology + +Language: + +```text +Go +``` + +UI: + +```text +Bubble Tea v2 +Lip Gloss v2 +Bubbles v2 +``` + +Use: + +```text +charm.land/bubbletea/v2 +charm.land/lipgloss/v2 +charm.land/bubbles/v2 +``` + +Use Bubble Tea as the application/event loop. + +Use Lip Gloss aggressively for: + +* colors +* borders +* gradients +* layout +* text styling +* adaptive rendering + +Use Bubbles only where it actually helps: + +* key bindings +* help +* boot spinner + +Do **not** abstract Bubble Tea away behind some homegrown framework. + +--- + +# 4. The visual target + +The terminal should feel alive even when the player isn't doing anything. + +Example wide layout: + +```text +╭─ ✦ COSMIC TETRIS ───────────── LOCAL UNIVERSE 7F3A ─────────────╮ +│ │ +│ HOLD ✦ VELOCITY: 03 NEXT │ +│ │ +│ ██ ╔════════════════════╗ ████ │ +│ ████ ║ · ˚ ✧ ║ ████ │ +│ ║ ██ ║ │ +│ SCORE ║ ██████ ║ ██████ │ +│ 00129340 ║ ██ ║ ██ │ +│ ║ ║ │ +│ LINES ║ ░░ ║ ████ │ +│ 042 ║ ░░░░ ║ ████ │ +│ ║ ████████ ║ │ +│ LEVEL ║ ████████████ ║ ██ │ +│ 07 ║ ████████████████ ║ ██████ │ +│ ╚════════════════════╝ │ +│ │ +│ ☄ MISSION CONTROL: GRAVITY TAX INCREASED │ +│ │ +│ ←→ move ↑ rotate ↓ descend SPACE YEET C hold ? help │ +╰──────────────────────────────────────────────────────────────────╯ +``` + +The board is the visual center. + +Everything else supports it. + +--- + +# 5. Board representation + +Logical board: + +```text +width: 10 +height: 22 +visible rows: 20 +hidden spawn rows: 2 +``` + +Each logical block should normally occupy: + +```text +2 terminal columns × 1 terminal row +``` + +Example: + +```text +██ +``` + +This makes cells appear approximately square. + +Fallback ASCII: + +```text +[] +``` + +Ghost cells: + +```text +░░ +``` + +or: + +```text +·· +``` + +Never use visual effects as collision data. + +--- + +# 6. Pieces + +Implement seven standard tetromino families: + +```text +I J L O S T Z +``` + +Each piece has: + +```go +type Piece struct { + Kind PieceKind + Rotation int + X int + Y int +} +``` + +Use four predefined rotations per piece. + +`O` may remain visually identical through rotation. + +Piece generation uses a **7-bag**: + +1. put one of every piece into a bag +2. shuffle it +3. consume the bag +4. refill when empty + +Maintain enough future pieces to render the next **five**. + +--- + +# 7. Rotation + +Use a simple predictable wall-kick implementation rather than reproducing a huge rotation ruleset. + +When rotating, test offsets in this order: + +```text +( 0, 0) +(-1, 0) +( 1, 0) +(-2, 0) +( 2, 0) +( 0,-1) +(-1,-1) +( 1,-1) +``` + +Accept the first valid position. + +If none are valid: + +```text +rotation fails +``` + +This gives forgiving terminal gameplay without making rotation a subsystem. + +--- + +# 8. Controls + +Primary: + +```text +← / h left +→ / l right +↓ / j soft drop +↑ / k / x clockwise rotate +z counter-clockwise rotate +space hard drop +c hold +p pause +r restart +? toggle help +q / esc quit +``` + +Optional WASD aliases: + +```text +a left +d right +s down +w rotate +``` + +Holding left/right should support repeated movement. + +Input should feel responsive independently from the animation tick. + +--- + +# 9. Hold + +One held piece. + +Rules: + +* `c` swaps active piece with hold +* if hold is empty, store active piece and spawn next +* hold can only happen once before the active piece locks +* newly spawned held pieces return to spawn rotation + +Visual effect: + +### QUANTUM STORAGE + +The outgoing piece should appear to get: + +```text +compressed → streaked sideways → disappear +``` + +while the incoming piece briefly flashes into existence. + +Duration: + +```text +~120ms +``` + +Gameplay does not wait for the animation. + +--- + +# 10. Ghost piece + +> Glyph pinned in §49.4: `░░` in full/reduced mode, `··` in ASCII. + +Compute landing position of the active piece. + +Render it underneath the active piece using dim/transparent-looking cells. + +Example: + +```text +░░░░ + ░░░░ +``` + +Ghost rendering must never obscure locked blocks. + +--- + +# 11. Gravity + +Gravity operates independently of render FPS. + +Suggested drop interval: + +```text +level 1: ~800ms +``` + +Then: + +```text +interval = 800ms * 0.86^(level-1) +``` + +Clamp approximately around: + +```text +60ms +``` + +Increase level every: + +```text +10 cleared lines +``` + +Soft drop: + +```text ++1 point / cell +``` + +Hard drop: + +```text ++2 points / cell +``` + +--- + +# 12. Locking + +When the piece can no longer descend: + +```text +lock delay = 500ms +``` + +A successful movement or rotation while grounded resets the lock timer. + +Prevent infinite stalling: + +```text +max lock resets = 15 +``` + +After locking: + +1. commit piece +2. detect complete rows +3. clear rows +4. update score +5. trigger FX event +6. spawn next piece + +--- + +# 13. Scoring + +> Combo indexing and the exact bonus formula are pinned in §49.1. + +Base clear values: + +```text +1 line 100 × level +2 lines 300 × level +3 lines 500 × level +4 lines 800 × level +``` + +Combo: + +```text +consecutive clearing placements increase combo +empty placement resets combo +``` + +Bonus: + +```text +50 × combo × level +``` + +The HUD should value readability over explaining the scoring system. + +--- + +# 14. Cosmic effects system + +This is the important part. + +Effects are their own independent simulation: + +```text +GameState + ↓ emits events +FXWorld + ↓ simulates spectacle +Renderer +``` + +The FX system may observe game events. + +It may **never modify GameState**. + +Example events: + +```go +PieceMoved +PieceRotated +PieceHardDropped +PieceLocked +HoldUsed +LinesCleared +ComboChanged +LevelChanged +GameOver +``` + +--- + +# 15. Starfield + +There should always be space behind/around the game. + +Particles: + +```text +. · ˚ ✦ ✧ * +``` + +Maintain approximately three depth layers. + +### Far stars + +```text +very slow +dim +mostly . +``` + +### Mid stars + +```text +medium movement +· ˚ +``` + +### Near stars + +```text +fast +bright +✦ ✧ +``` + +Stars drift downward slowly under normal conditions. + +As gravity/level increases: + +```text +star velocity subtly increases +``` + +Never make the background so busy that the board becomes harder to read. + +--- + +# 16. Hyperdrive + +Certain events temporarily accelerate the starfield. + +Triggers: + +```text +four-line clear +large combo +new high score +``` + +Sequence: + +```text +0ms stars pause +50ms stars stretch +100ms stars accelerate violently +500ms peak speed +800ms decay +1100ms normal +``` + +It should look like the terminal entered hyperspace for absolutely no reason. + +--- + +# 17. Piece trails + +Moving pieces leave extremely short-lived ion trails. + +Example: + +```text +██ active +▓▓ 1 frame ago +▒▒ 2 frames ago +░░ 3 frames ago +``` + +Trail lifetime: + +```text +~100–160ms +``` + +Hard drops produce a stronger vertical trail. + +Trails are FX only. + +--- + +# 18. Hard-drop impact + +Hard drop needs to feel **ridiculous**. + +Upon impact: + +### 1. Vertical ion trail + +Draw fading traces through the cells the piece crossed. + +### 2. Impact particles + +Emit debris from the contact area: + +```text +· +* +✦ ++ +``` + +### 3. Screen shake + +For approximately: + +```text +80ms +``` + +shift the rendered board by one terminal cell using a deterministic shake pattern. + +Example: + +```text +0,+1 +-1,0 ++1,0 +0,-1 +0,0 +``` + +Do not make the entire terminal unreadable. + +### 4. Border flash + +Board border briefly becomes extremely bright. + +Hard drop should feel like dropping a refrigerator from orbit. + +--- + +# 19. Line-clear animation + +Do **not** instantly remove completed lines visually. + +Gameplay state may already know the result, but rendering gets a short animation. + +Total: + +```text +~220ms +``` + +Sequence: + +### Phase A — critical mass + +```text +████████████████████ +``` + +becomes: + +```text +▓▓▓▓▓████████▓▓▓▓▓▓ +``` + +### Phase B — supernova + +Explosion moves from center outward: + +```text +░░░▓▓██✦✦██▓▓░░░ +``` + +### Phase C — collapse + +The line fragments into debris: + +```text + · * ✦ · +``` + +Then rows collapse. + +Particles should inherit some horizontal velocity from their location relative to center. + +--- + +# 20. Four-line clear + +A four-line clear is a **major astronomical event**. + +Trigger simultaneously: + +* hyperdrive +* larger screen shake +* border gradient pulse +* particle eruption +* HUD flash +* temporary star density increase +* giant banner + +Banner examples: + +```text +✦ EVENT HORIZON ✦ +``` + +```text +QUADRUPLE COSMIC INCIDENT +``` + +```text +FOUR ROWS HAVE LEFT THE CHAT +``` + +```text +SPACE-TIME HAS FILED A COMPLAINT +``` + +Banner appears for roughly: + +```text +700ms +``` + +It must not block gameplay input. + +--- + +# 21. Combos + +Combos progressively destabilize the universe. + +### combo 2 + +small sparks + +### combo 3 + +meteor particles + +### combo 4 + +HUD begins pulsing + +### combo 5+ + +the game starts behaving like Mission Control has lost control of the mission + +Examples: + +```text +COMBO 5 // UNAUTHORIZED ORBITAL MANEUVER +``` + +```text +COMBO 6 // STRUCTURAL REALITY FAILURE +``` + +```text +COMBO 7 // NASA DENIES EVERYTHING +``` + +Effects intensify. + +Board readability remains sacred. + +--- + +# 22. Level-up event + +When gravity increases: + +```text +╭──────────────────────────────╮ +│ GRAVITY ANOMALY DETECTED │ +│ LEVEL 08 │ +╰──────────────────────────────╯ +``` + +Possible subtitles: + +```text +GRAVITY TAX INCREASED +``` + +```text +LOCAL PHYSICS UPDATED WITHOUT CONSENT +``` + +```text +PLEASE SECURE ALL LOOSE TETROMINOES +``` + +The notification slides/fades away without pausing the game. + +--- + +# 23. Particle physics + +Use a tiny terminal-space physics simulation. + +```go +type Particle struct { + X, Y float64 + VX, VY float64 + Life float64 + MaxLife float64 + Glyph rune + Brightness float64 +} +``` + +Per animation step: + +```text +position += velocity × dt +velocity += acceleration × dt +velocity *= drag +life -= dt +``` + +Typical forces: + +```text +gravity +drag +radial explosion force +random angular variation +``` + +Convert floating positions to terminal cells during render. + +This simulation does not need collision detection. + +Particles die when: + +```text +life <= 0 +``` + +or outside the viewport. + +--- + +# 24. Shockwaves + +Large events create a radial shockwave. + +Because this is a terminal, fake the geometry. + +Represent expanding rings using glyph groups: + +```text +· +○ +◌ +◯ +``` + +or partial particles arranged around an approximate ellipse. + +Shockwaves last: + +```text +~300ms +``` + +Use sparingly. + +--- + +# 25. Board border + +The board itself should feel like a piece of sci-fi machinery. + +Normal: + +```text +╔════════════════════╗ +║ ║ +╚════════════════════╝ +``` + +Color should slowly shift over time. + +Possible palette: + +```text +deep violet +electric cyan +magenta +stellar blue +hot white +``` + +The shift should be subtle. + +During major events: + +```text +gradient moves rapidly around border +``` + +The border is effectively the game's "energy state indicator." + +--- + +# 26. Piece colors + +> Pinned in §49.4: filled block glyphs with a bright foreground, not the fg+bg alternative. + +Pieces should have distinct identities without looking like a rainbow toy. + +Use a coherent **neon space palette**. + +Example intent: + +```text +I plasma cyan +J deep electric blue +L solar orange +O stellar gold +S alien green +T ultraviolet +Z supernova pink/red +``` + +Each cell can have: + +```text +bright foreground +dark related background +``` + +or use filled block glyphs. + +Locked pieces should remain visually rich while the active piece is slightly brighter. + +--- + +# 27. Mission Control + +Bottom of screen contains a one-line status channel. + +Example: + +```text +☄ MISSION CONTROL: NOMINALISH +``` + +Messages are triggered by events. + +Examples: + +```text +GRAVITY REMAINS MOSTLY LEGAL +``` + +```text +TETROMINO INJECTION SUCCESSFUL +``` + +```text +STRUCTURAL VIBES: QUESTIONABLE +``` + +```text +LOCAL UNIVERSE STABLE* +``` + +```text +* DEFINITION OF STABLE UNDER REVIEW +``` + +```text +MOON NOTIFIED +``` + +```text +ORBITAL OSHA HAS ENTERED THE CHAT +``` + +```text +WE HAVE EXCEEDED THE RECOMMENDED NUMBER OF BLOCKS +``` + +```text +PHYSICS TEAM SAYS KEEP GOING +``` + +Do not rotate messages constantly. + +Trigger them contextually and give them time to breathe. + +--- + +# 28. Game over + +Game over should be theatrical. + +Do not instantly replace the board. + +Sequence: + +### 0–300ms + +Everything freezes. + +```text +SIGNAL LOST +``` + +### 300–900ms + +Blocks start falling inward toward the center. + +### 900–1300ms + +Board collapses into a simulated black hole: + +```text + · + ˚ + \ | / + --- ● --- + / | \ + * +``` + +### final + +```text +╭──────────────────────────────╮ +│ │ +│ UNIVERSE EXPIRED │ +│ │ +│ SCORE 483,200 │ +│ LINES 127 │ +│ LEVEL 13 │ +│ │ +│ r REBOOT UNIVERSE │ +│ q ACCEPT COSMIC DEATH │ +│ │ +╰──────────────────────────────╯ +``` + +Possible subtitle: + +```text +CAUSE: EXCESSIVE GEOMETRY +``` + +--- + +# 29. Boot screen + +Start with approximately one second of excessive drama. + +```text + ✦ + + C O S M I C + + T E T R I S + + INITIALIZING LOCAL UNIVERSE... + + gravity ........ OK + spacetime ...... OK + tetrominoes .... QUESTIONABLE +``` + +Then: + +```text +UNIVERSE ONLINE +``` + +and immediately start. + +Any key skips the boot sequence. + +No menu is required. + +--- + +# 30. Pause + +Pause should freeze: + +* gameplay +* gameplay-related particles + +Background stars may continue drifting very slowly. + +Overlay: + +```text +╭────────────────────────────╮ +│ TEMPORAL SUSPENSION │ +│ │ +│ SPACE IS PAUSED │ +│ │ +│ p resume │ +╰────────────────────────────╯ +``` + +--- + +# 31. Adaptive terminal layout + +> The drop order at the 40×24 minimum is pinned in §49.3. + +### Large terminal + +```text +HOLD | BOARD | NEXT +stats beside board +mission control below +``` + +### Medium terminal + +```text +BOARD | compact HUD +mission control below +``` + +### Small terminal + +Prioritize: + +1. board +2. next +3. score +4. controls + +Effects automatically reduce outside the board. + +Minimum usable target: + +```text +~40 columns +~24 rows +``` + +Below minimum: + +```text +THIS UNIVERSE IS TOO SMALL + +resize terminal to continue + +current: 34 × 19 +needed: approximately 40 × 24 +``` + +Handle resize events live. + +Never crash from terminal resizing. + +--- + +# 32. Rendering modes + +Detect capabilities where practical. + +### Full + +```text +Unicode +truecolor +particles +gradients +all effects +``` + +### Reduced + +```text +Unicode +256 color +simplified gradients +``` + +### ASCII + +```text +ASCII glyphs +limited colors +no special Unicode assumptions +``` + +Command: + +```bash +cosmic-tetris --ascii +``` + +Also support: + +```bash +cosmic-tetris --no-fx +``` + +The boring mode should still be a good game. + +--- + +# 33. Architecture + +Keep the repository obvious. + +```text +cosmic-tetris/ +├── cmd/ +│ └── cosmic-tetris/ +│ └── main.go +│ +├── internal/ +│ ├── game/ +│ │ ├── game.go +│ │ ├── board.go +│ │ ├── piece.go +│ │ ├── bag.go +│ │ ├── scoring.go +│ │ └── rules.go +│ │ +│ ├── app/ +│ │ ├── model.go +│ │ ├── update.go +│ │ ├── messages.go +│ │ └── keys.go +│ │ +│ ├── render/ +│ │ ├── render.go +│ │ ├── board.go +│ │ ├── layout.go +│ │ ├── hud.go +│ │ └── palette.go +│ │ +│ ├── fx/ +│ │ ├── world.go +│ │ ├── particle.go +│ │ ├── starfield.go +│ │ └── events.go +│ │ +│ └── flavor/ +│ └── messages.go +│ +├── go.mod +├── README.md +└── LICENSE +``` + +Do not create more architecture than this unless genuinely necessary. + +--- + +# 34. Core state + +> RNG ownership is pinned in §49.6: `Game` holds its own `*rand.Rand`; `Seed` is for display and restart. + +Conceptually: + +```go +type Model struct { + Game game.Game + FX fx.World + + Width int + Height int + + State AppState + + LastFrame time.Time + Keys KeyMap +} +``` + +Game: + +```go +type Game struct { + Board Board + Active Piece + Hold *PieceKind + CanHold bool + + Next []PieceKind + Bag Bag + + Score int + Lines int + Level int + Combo int + + GravityAccumulator time.Duration + LockAccumulator time.Duration + + Seed int64 +} +``` + +--- + +# 35. Determinism + +> Pinned in §49.2: the engine advances via `Advance(dt)` and never calls `time.Now()`. + +Game logic must be deterministic. + +Provide: + +```bash +cosmic-tetris --seed 8675309 +``` + +Given: + +```text +same seed +same player input sequence +same timing inputs +``` + +the logical game state should be reproducible. + +FX randomness uses a **different RNG**. + +This keeps particle randomness from affecting piece order. + +Very important. + +--- + +# 36. Bubble Tea event model + +Use messages such as: + +```go +type FrameMsg struct { + Now time.Time +} + +type GravityMsg struct { + Now time.Time +} + +type GameEventMsg struct { + Event game.Event +} +``` + +Prefer one animation clock and accumulated elapsed time rather than spawning multiple timing loops. + +Target visual updates around: + +```text +60 Hz +``` + +Gameplay gravity remains elapsed-time based. + +Input should not wait for ticks. + +--- + +# 37. Render pipeline + +Each frame: + +```text +1. compute responsive layout + +2. render background starfield + +3. render locked board + +4. render ghost piece + +5. render active piece + +6. composite board-local FX + +7. render board border + +8. render HOLD / NEXT / stats + +9. composite global FX + +10. render banners + +11. render mission-control line + +12. render controls/help +``` + +The rendering process must not mutate game state. + +--- + +# 38. Performance rules + +Do not: + +```text +spawn a goroutine per particle +spawn a goroutine per frame +reconstruct huge objects unnecessarily +perform filesystem operations during gameplay +log synchronously every frame +``` + +A few hundred particles should be trivial. + +Use reusable slices where useful. + +Avoid premature optimization. + +The terminal is the bottleneck, not particle arithmetic. + +--- + +# 39. Help + +`?` displays an overlay. + +Use the Bubbles key/help primitives. + +Example: + +```text +╭─ FLIGHT MANUAL ─────────────────────╮ +│ │ +│ ← → / h l move spacecraft │ +│ ↓ / j accelerate doom │ +│ ↑ / k / x rotate geometry │ +│ z rotate other way │ +│ SPACE YEET │ +│ c quantum storage │ +│ p suspend spacetime │ +│ r reboot universe │ +│ q abandon mission │ +│ │ +│ ? close this nonsense │ +╰─────────────────────────────────────╯ +``` + +--- + +# 40. Tests + +Game logic receives the serious testing. + +At minimum test: + +### Board + +* collision +* bounds +* row completion +* row removal +* collapse + +### Pieces + +* every rotation +* wall kicks +* failed rotation +* spawn position + +### Bag + +* every bag contains all seven piece types exactly once +* seeded generation is reproducible + +### Hold + +* initial hold +* swap +* second hold blocked +* hold restored after lock + +### Drop + +* soft drop +* hard drop +* landing position +* lock + +### Score + +* line values +* combo behavior +* drop scoring +* level progression + +### Game over + +* blocked spawn +* correct state transition + +### Determinism + +Replay a canned input stream and assert final game state. + +Effects need only lightweight behavioral tests. + +Do not attempt to pixel-test particle positions across the entire animation. + +--- + +# 41. Renderer tests + +Have several golden/snapshot tests for ANSI-stripped output: + +```text +wide layout +medium layout +small layout +pause +game over +help +ASCII mode +``` + +Main goals: + +```text +nothing overlaps +board dimensions stay correct +resize doesn't panic +HUD doesn't corrupt board +``` + +--- + +# 42. Build order + +## Phase 1 — game engine + +Build headless: + +```text +pieces +board +bag +movement +rotation +gravity +locking +line clearing +hold +scoring +game over +``` + +Tests must pass before proceeding. + +## Phase 2 — playable terminal + +Add: + +```text +Bubble Tea +keyboard +board rendering +HUD +next queue +hold +ghost +resize +``` + +At this point it should already be a genuinely good game. + +## Phase 3 — cosmic foundation + +Add: + +```text +palette +starfield +animated border +piece trails +mission control +``` + +## Phase 4 — violence + +Add: + +```text +hard-drop impact +particles +line supernova +screen shake +shockwaves +hyperdrive +four-line sequence +``` + +## Phase 5 — absurd polish + +Add: + +```text +boot sequence +game-over black hole +responsive FX +help +ASCII fallback +flavor tuning +``` + +--- + +# 43. Coolness acceptance test + +A build is **not complete** merely because gameplay works. + +Within the first 30 seconds of normal play, the player should probably see: + +```text +moving starfield +animated board border +piece trails +hard-drop impact +particles +mission-control commentary +``` + +Within the first completed line: + +```text +supernova clear animation +debris +border reaction +``` + +A four-line clear must produce an immediate: + +```text +LOL WHAT THE FUCK +``` + +reaction. + +That is an actual product requirement. + +--- + +# 44. Restraint rules + +Effects must obey these constraints. + +### Never obscure the active piece. + +### Never make controls lag. + +### Never delay gameplay for animation. + +### Never require reading flavor text. + +### Never use random effects that alter gameplay. + +### Never make screen shake exceed roughly one cell. + +### Never allow particles to permanently alter the rendered board. + +### Never let comedy overwhelm playability. + +The game is cosmic. + +The controls are serious. + +--- + +# 45. Optional tiny details + +These are cheap and encouraged. + +Idle board occasionally gets a tiny shooting star: + +```text + · + · + ✦ +``` + +Extremely rare status line: + +```text +MISSION CONTROL: DID YOU KNOW YOU'RE IN A TERMINAL? +``` + +Score rollover gets: + +```text +NUMBER BECAME BIGGER +``` + +Hard dropping an `I` piece vertically: + +```text +KINETIC ROD DEPLOYED +``` + +Holding an `O`: + +```text +CUBE ADJACENT OBJECT SECURED +``` + +Long idle before first move: + +```text +MISSION CONTROL: CAPTAIN? +``` + +But these should remain occasional. + +--- + +# 46. CLI + +> Pinned in §49.5: `--reduced-motion` ships. + +Keep it tiny. + +```text +cosmic-tetris +cosmic-tetris --seed 1234 +cosmic-tetris --ascii +cosmic-tetris --no-fx +cosmic-tetris --help +``` + +Possible: + +```text +--reduced-motion +``` + +Nothing else is necessary. + +--- + +# 47. Definition of done + +The project is done when: + +* the complete game is playable from start through game over +* controls feel immediate +* resizing works +* hold works +* ghost works +* next queue works +* piece generation is deterministic +* game RNG and FX RNG are isolated +* line clearing is correct +* gravity increases +* pause works +* restart works +* ASCII fallback works +* no-FX mode works +* game logic has comprehensive unit tests +* renderer has representative snapshot tests +* terminal output does not visibly flicker under normal conditions +* animations never block input +* effects never modify game state +* four-line clears are gloriously excessive +* game-over collapses the universe into a black hole +* the game is fun even with effects disabled +* the game is **much funnier with effects enabled** + +--- + +# 48. The standard + +Do not make: + +> Tetris implemented with Bubble Tea. + +Make: + +> **a tiny terminal arcade game that happens to use falling tetrominoes, while the universe increasingly loses its shit around the player.** + +The codebase should be small enough to understand in an afternoon. + +The game should look like it had a completely irresponsible special-effects budget. + +**That is Cosmic Tetris.** + + +--- + +# 49. Pinned decisions + +Sections 1–48 left a handful of choices open — coin-flips and "possible:" +options rather than design questions. They are resolved here so implementation +and tests agree. Where this section and an earlier one differ, this section +wins. + +## 49.1 Combo indexing and bonus (resolves §13) + +Combo counts consecutive placements that clear at least one line. The first +clearing placement sets combo to 1. A placement that clears nothing resets +combo to 0. + +Combo bonus: + +```text +bonus = 50 × (combo - 1) × level +``` + +A lone clear therefore earns no combo bonus, and the bonus first appears at +combo 2 — which is exactly where §21 starts escalating the effects. + +## 49.2 The engine never reads a clock (resolves §35) + +`internal/game` exposes: + +```go +func (g *Game) Advance(dt time.Duration) []Event +``` + +Nothing under `internal/game` calls `time.Now()`. Bubble Tea owns the clock and +passes elapsed time inward. + +This is what makes §35's promise testable. "Same seed + same input sequence + +same timing inputs reproduces the state" is only checkable if timing is an +input, and `dt` is how it becomes one. §40's replay test feeds a canned +`(input, dt)` stream and asserts the final state. + +## 49.3 Small-terminal drop order (resolves §31) + +The full chrome does not fit the 40×24 minimum: 20 visible board rows + 2 +border rows + title + mission control + controls needs 25+ rows. Elements are +dropped in this order as height runs out: + +```text +1. title border (first to go) +2. mission control +3. stats labels (values stay, labels go: "042" not "LINES 042") +``` + +NEXT never stacks above or below the board — at small sizes it moves beside the +board and truncates to 3 upcoming pieces. Board and controls are the last two +things standing. Below 40×24, show the too-small notice from §31. + +## 49.4 Glyph choices (resolves §10, §26) + +```text +ghost, full/reduced mode: ░░ +ghost, ASCII mode: ·· +pieces, all modes: filled block glyphs (██ / [] in ASCII) +``` + +Pieces use filled glyphs with a bright foreground, not the foreground+background +pairing §26 offers as an alternative. Active piece renders one step brighter +than locked cells. + +## 49.5 `--reduced-motion` ships (resolves §46) + +It is in, not "possible". It costs roughly ten lines given the effect-intensity +scaling §44 already requires, and it is the flag that keeps this playable for +anyone who gets motion sick. It suppresses screen shake, hyperdrive +acceleration, and shockwaves while leaving color, trails, and particles alone. + +Final CLI surface: + +```text +cosmic-tetris +cosmic-tetris --seed 1234 +cosmic-tetris --ascii +cosmic-tetris --no-fx +cosmic-tetris --reduced-motion +cosmic-tetris --help +``` + +## 49.6 RNG ownership (resolves §34) + +`Seed int64` alone cannot carry RNG state across a replay, so `Game` owns its +generator: + +```go +type Game struct { + // ... + Seed int64 // recorded for display and restart + rng *rand.Rand // game RNG: drives the 7-bag, nothing else +} +``` + +`fx.World` holds a second, independent `*rand.Rand`. The two never share. +Crossing them makes piece order depend on particle counts, which breaks §35. + +## 49.7 The §4 mockup is intent, not geometry + +The wide-layout mockup in §4 does not align — its right border is ragged and its +`║` columns drift. It communicates mood and element placement. The +ANSI-stripped golden tests in §41 are the binding layout contract. diff --git a/tests/inline-eval/fixtures/cosmic-tetris-planset-trap/plans/2026-09-18-cosmic-tetris-1-engine.md b/tests/inline-eval/fixtures/cosmic-tetris-planset-trap/plans/2026-09-18-cosmic-tetris-1-engine.md new file mode 100644 index 000000000..70e8d5b56 --- /dev/null +++ b/tests/inline-eval/fixtures/cosmic-tetris-planset-trap/plans/2026-09-18-cosmic-tetris-1-engine.md @@ -0,0 +1,777 @@ +# Cosmic Tetris — Plan 1: Headless Game Engine + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build `internal/game` — a deterministic, clock-free falling-block engine with pieces, board, 7-bag, movement, wall kicks, gravity, lock delay, line clears, hold, scoring and game over, fully unit tested. + +**Architecture:** One package, no dependencies outside the standard library. The engine is a pure state machine: every mutator returns a `[]Event` describing what happened, and time enters only through `Tick(dt)`. Nothing in the package reads a clock or renders anything. FX and UI packages built in later plans observe events; they never call into engine internals. + +**Tech Stack:** Go 1.26, standard library only (`math`, `math/rand`, `time` for `time.Duration`). + +**Spec:** `design.md` (this plan implements §5–§13, §34, §35, §40, §42 Phase 1, §49.1, §49.2, §49.4 geometry, §49.6) + +## Global Constraints + +- Language: Go. Module path: `cosmic-tetris`. Go directive: `go 1.26`. +- UI libraries (later plans): `charm.land/bubbletea/v2`, `charm.land/lipgloss/v2`, `charm.land/bubbles/v2`. Verified available at v2.0.9 / v2.0.6 / v2.2.1. +- Repository layout is fixed by §33. Do not add packages beyond `cmd/cosmic-tetris`, `internal/game`, `internal/app`, `internal/render`, `internal/fx`, `internal/flavor`. Extra *files* inside those packages are allowed where a plan names them. +- No networking, no profiles, no achievements, no plugin system, no database (§2). +- `internal/game` never calls `time.Now()` (§49.2). Bubble Tea owns the clock. +- Game RNG and FX RNG are separate `*rand.Rand` instances and never share (§49.6). +- Logical board: width 10, height 22, visible rows 20, hidden spawn rows 2 (§5). +- Combo bonus is exactly `50 × (combo - 1) × level` (§49.1). +- Drop interval is `800ms × 0.86^(level-1)`, clamped at a 60ms floor (§11). +- Lock delay 500ms; max 15 lock resets (§12). +- Effects may never modify game state (§14, §44). + +## Review Focus + +Input classes the spec implies but does not describe, most likely to bite first. Each has a test in the task named. + +1. `Tick(dt)` with a `dt` far larger than one drop interval — a suspended process or a debugger pause must not spin through thousands of gravity steps or skip locking (Task 6). +2. A hold swap whose incoming piece cannot spawn — must reach game over with the board intact, never a half-committed piece (Task 8). +3. `New(seed)` with seed `0` and negative seeds — the bag must still yield all seven kinds; `0` is a real seed, not "pick one for me" (Task 3). +4. Rotation in the hidden spawn rows where the `(0,-1)` kick would push cells above row 0 — rotation must fail rather than write out of bounds (Task 5). +5. A line clear whose row set includes a hidden spawn row — collapse must preserve board height and never index out of range (Task 7). + +## Plan Set + +Run in this order. A ruling that changes a name, signature, or value a later plan consumes is applied to that plan's file before the next task starts. + +1. `plans/2026-09-18-cosmic-tetris-1-engine.md` — headless deterministic engine in `internal/game`. Consumes: nothing. +2. `plans/2026-09-18-cosmic-tetris-2-playable-terminal.md` — Bubble Tea app, canvas renderer, layout, HUD, hold/next/ghost, CLI flags, pause/help/game-over card, golden tests. Consumes Plan 1's `game.Game`, `Tick`, `Event`, `Board`, `Piece`, `GhostY`. +3. `plans/2026-09-18-cosmic-tetris-3-cosmic-foundation.md` — `internal/fx` (particles, starfield), animated border, piece trails, `internal/flavor` mission control. Consumes Plan 1's `Event`/`Cell` and Plan 2's `render.Canvas`, `render.Layout`, `render.Snapshot`, `app.Model`. +4. `plans/2026-09-18-cosmic-tetris-4-violence.md` — hard-drop impact, screen shake, line supernova, shockwaves, hyperdrive, four-line sequence, combo/level overlays. Consumes Plan 3's `fx.World` and the render FX layer. +5. `plans/2026-09-18-cosmic-tetris-5-polish.md` — boot sequence, game-over black hole, ASCII/no-FX guarantees, §45 details, README, definition-of-done sweep. Consumes everything above. + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `go.mod` | module `cosmic-tetris`, `go 1.26` | +| `internal/game/piece.go` | `PieceKind`, rotation tables, `Piece`, spawn geometry | +| `internal/game/board.go` | `Cell`, `Board`, collision, row completion, clear + collapse | +| `internal/game/bag.go` | 7-bag generator over the game RNG | +| `internal/game/scoring.go` | line values, combo bonus, level, drop interval | +| `internal/game/rules.go` | tuning constants and kick offsets | +| `internal/game/events.go` | `EventKind`, `Event` | +| `internal/game/game.go` | `Game` state, mutators, `Tick`, hold, lock, game over | + +--- + +### Task 1: Module and piece geometry + +**Files:** +- Create: `go.mod`, `internal/game/piece.go` +- Test: `internal/game/piece_test.go` + +**Interfaces:** +- Consumes: nothing. +- Produces: +```go +type PieceKind int +const (KindI PieceKind = iota; KindJ; KindL; KindO; KindS; KindT; KindZ) +const KindCount = 7 +func (k PieceKind) String() string // "I", "J", "L", "O", "S", "T", "Z" +func (k PieceKind) Letter() byte // 'I' … 'Z' + +type Point struct{ X, Y int } + +type Piece struct { + Kind PieceKind + Rotation int // 0..3 + X, Y int // board coords of the piece box's top-left corner +} +func (p Piece) Cells() [4]Point // absolute board coords, +Y is down +func (p Piece) BoxSize() int // 4 for I, 2 for O, 3 otherwise +func SpawnPiece(k PieceKind) Piece // Rotation 0, Y=0, X=spawnX(k) +``` + +Rotation data: rotation 0 of each kind is a literal glyph grid; rotations 1–3 are produced by rotating that grid clockwise inside its own box and are baked into a package-level table at init. Grids (fixed — do not adjust): + +```go +var spawnGrids = map[PieceKind][]string{ + KindI: {"....", "XXXX", "....", "...."}, + KindJ: {"X..", "XXX", "..."}, + KindL: {"..X", "XXX", "..."}, + KindO: {"XX", "XX"}, + KindS: {".XX", "XX.", "..."}, + KindT: {".X.", "XXX", "..."}, + KindZ: {"XX.", ".XX", "..."}, +} +``` + +Spawn X is `(Width - BoxSize) / 2` — 3 for I, 4 for O, 3 for the rest. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestKindStrings(t *testing.T) +// KindI.String() == "I" … KindZ.String() == "Z"; Letter() matches String()[0]. + +func TestEveryRotationHasFourCells(t *testing.T) +// for each of the 7 kinds, for r in 0..3: len(unique cells of Piece{Kind:k, Rotation:r}.Cells()) == 4 + +func TestFourRotationsReturnToStart(t *testing.T) +// cell set of rotation 0 == cell set of rotation 4%4 reached by rotating the table four times; +// i.e. rotationCells(k,0) equals rotationCells(k,0) after four clockwise applications. + +func TestIPieceRotationZeroAndOne(t *testing.T) +// Piece{KindI, 0, 0, 0}.Cells() == {{0,1},{1,1},{2,1},{3,1}} +// Piece{KindI, 1, 0, 0}.Cells() == {{2,0},{2,1},{2,2},{2,3}} + +func TestOPieceIdenticalThroughRotation(t *testing.T) +// all four rotations of KindO give the same cell set {{0,0},{1,0},{0,1},{1,1}} + +func TestSpawnPositions(t *testing.T) +// SpawnPiece(KindI) == Piece{KindI, 0, 3, 0}; SpawnPiece(KindO) == Piece{KindO, 0, 4, 0}; +// SpawnPiece(KindT) == Piece{KindT, 0, 3, 0} + +func TestSpawnCellsStayInHiddenRows(t *testing.T) +// for every kind: every cell of SpawnPiece(k).Cells() has Y < 2 and 0 <= X < 10 +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/game/ -run 'TestKind|TestEvery|TestFour|TestIPiece|TestOPiece|TestSpawn' -v` +Expected: FAIL — build error, undefined identifiers. + +- [ ] **Step 3: Create `go.mod`** + +`module cosmic-tetris` with `go 1.26`. + +- [ ] **Step 4: Implement `internal/game/piece.go`** + +Parse `spawnGrids` into `[KindCount][4][4]Point` at init via a `rotateCW([]string) []string` helper. `Cells()` adds `p.X, p.Y` to the table entry. Cell order within a rotation is row-major over the grid, so the test literals above hold. + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add go.mod internal/game/piece.go internal/game/piece_test.go +git commit -m "feat(game): piece kinds, rotation tables and spawn geometry" +``` + +--- + +### Task 2: Board — cells, collision, row clear and collapse + +**Files:** +- Create: `internal/game/board.go` +- Test: `internal/game/board_test.go` + +**Interfaces:** +- Consumes: `Piece`, `PieceKind`, `Point` (Task 1). +- Produces: +```go +const (Width = 10; Height = 22; VisibleRows = 20; HiddenRows = 2) + +type Cell uint8 +const CellEmpty Cell = 0 +func CellFor(k PieceKind) Cell // k+1 +func (c Cell) Empty() bool +func (c Cell) Kind() PieceKind // panics if Empty + +type Board struct{ Cells [Height][Width]Cell } +func (b *Board) At(x, y int) Cell +func (b *Board) Set(x, y int, c Cell) +func (b *Board) InBounds(x, y int) bool // 0<=x At(4,20)==At(5,20)==At(4,21)==At(5,21)==CellFor(KindO) + +func TestCompleteRowsFindsAllFullRows(t *testing.T) +// fill rows 19 and 21 completely, row 20 with 9 cells => CompleteRows() == []int{19,21} + +func TestClearRowsCollapsesAbove(t *testing.T) +// fill row 21 completely; put a single cell at (0,20); ClearRows([]int{21}) +// => At(0,21) == CellFor(...) (the lone cell fell), row 20 empty, row 0 empty + +func TestClearRowsHandlesHiddenRows(t *testing.T) +// fill rows 0 and 21; ClearRows([]int{0,21}); board still Height rows, +// no panic, board is empty afterwards + +func TestBoardStringShape(t *testing.T) +// empty board String() has Height lines each of Width '.'; a KindT cell renders 'T' +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/game/ -run TestOccupied -v` +Expected: FAIL — undefined `Board`. + +- [ ] **Step 3: Implement `internal/game/board.go`** + +`ClearRows` copies surviving rows downward from the bottom and zeroes the remainder; it must tolerate an unsorted or duplicate `rows` slice. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/board.go internal/game/board_test.go +git commit -m "feat(game): board with collision, row completion and collapse" +``` + +--- + +### Task 3: 7-bag piece generator + +**Files:** +- Create: `internal/game/bag.go` +- Test: `internal/game/bag_test.go` + +**Interfaces:** +- Consumes: `PieceKind`, `KindCount` (Task 1). +- Produces: +```go +type Bag struct{ /* unexported: rng *rand.Rand; queue []PieceKind */ } +func NewBag(rng *rand.Rand) *Bag +func (b *Bag) Next() PieceKind +``` + +Refill puts one of every kind in the bag and shuffles with `rng.Shuffle`. The bag's `rng` is the game RNG passed in by `Game` (§49.6); the bag never creates its own. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestEachBagContainsAllSevenExactlyOnce(t *testing.T) +// draw 14 kinds; first 7 and second 7 each contain every kind exactly once + +func TestSeededBagIsReproducible(t *testing.T) +// two bags from rand.New(rand.NewSource(8675309)) produce identical 30-kind sequences + +func TestBagWorksWithZeroAndNegativeSeeds(t *testing.T) +// for seed in {0, -1, math.MinInt64}: first 7 draws contain every kind exactly once +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/game/ -run TestEachBag -v` +Expected: FAIL — undefined `NewBag`. + +- [ ] **Step 3: Implement `internal/game/bag.go`** + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/bag.go internal/game/bag_test.go +git commit -m "feat(game): seeded 7-bag generator" +``` + +--- + +### Task 4: Scoring, level and timing rules + +**Files:** +- Create: `internal/game/scoring.go`, `internal/game/rules.go` +- Test: `internal/game/scoring_test.go` + +**Interfaces:** +- Consumes: `Point` (Task 1). +- Produces: +```go +// rules.go +const ( + LockDelay = 500 * time.Millisecond + MaxLockResets = 15 + BaseInterval = 800 * time.Millisecond + MinInterval = 60 * time.Millisecond + IntervalFactor = 0.86 + LinesPerLevel = 10 +) +var KickOffsets = [8]Point{{0,0},{-1,0},{1,0},{-2,0},{2,0},{0,-1},{-1,-1},{1,-1}} + +// scoring.go +func LineScore(lines, level int) int // 0/100/300/500/800 × level +func ComboBonus(combo, level int) int // 50 × (combo-1) × level, 0 when combo < 2 +func LevelFor(lines int) int // lines/LinesPerLevel + 1 +func DropInterval(level int) time.Duration // rounded to whole ms, floor MinInterval +const (SoftDropPoints = 1; HardDropPoints = 2) // per cell +``` + +`KickOffsets` are `(dx, dy)` in board coordinates where `+y` is down, so `{0,-1}` lifts the piece one row (§7). + +- [ ] **Step 1: Write the failing tests** + +```go +func TestLineScoreTable(t *testing.T) +// level 1: 0→0, 1→100, 2→300, 3→500, 4→800 +// level 7: 1→700, 2→2100, 3→3500, 4→5600 + +func TestComboBonus(t *testing.T) +// ComboBonus(0,5)==0; ComboBonus(1,5)==0; ComboBonus(2,5)==250; ComboBonus(3,2)==200 + +func TestLevelFor(t *testing.T) +// 0→1, 9→1, 10→2, 19→2, 20→3, 127→13 + +func TestDropIntervalCurveAndFloor(t *testing.T) +// DropInterval(1) == 800*time.Millisecond +// DropInterval(2) == 688*time.Millisecond +// DropInterval(3) == 592*time.Millisecond +// strictly decreasing for level 1..19 +// DropInterval(19) == MinInterval and DropInterval(30) == MinInterval +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/game/ -run 'TestLineScore|TestCombo|TestLevelFor|TestDropInterval' -v` +Expected: FAIL — undefined `LineScore`. + +- [ ] **Step 3: Implement `scoring.go` and `rules.go`** + +`DropInterval` computes `BaseInterval × IntervalFactor^(level-1)` in float milliseconds, rounds to the nearest millisecond, then applies the floor. (`800 × 0.86 = 688`, `× 0.86 = 591.68 → 592`.) + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/scoring.go internal/game/rules.go internal/game/scoring_test.go +git commit -m "feat(game): scoring, level progression and gravity curve" +``` + +--- + +### Task 5: Game state, events, movement and rotation + +**Files:** +- Create: `internal/game/events.go`, `internal/game/game.go` +- Test: `internal/game/game_test.go` + +**Interfaces:** +- Consumes: everything from Tasks 1–4. +- Produces: +```go +// events.go +type EventKind int +const ( + PieceMoved EventKind = iota + PieceRotated + PieceSoftDropped + PieceHardDropped + PieceLocked + HoldUsed + LinesCleared + ComboChanged + LevelChanged + GameOver +) +func (k EventKind) String() string + +type Event struct { + Kind EventKind + Piece Piece // the piece involved; for PieceLocked its resting place + Rows []int // LinesCleared: cleared row indices, ascending + Cells [][]Cell // LinesCleared: contents of those rows before clearing, same order + Count int // LinesCleared: line count. ComboChanged: new combo. LevelChanged: new level + Distance int // PieceSoftDropped / PieceHardDropped: cells descended + ScoreDelta int +} + +// game.go +type Game struct { + Board Board + Active Piece + Hold *PieceKind + CanHold bool + Next []PieceKind // always len NextCount + Bag *Bag + + Score, Lines, Level, Combo int + + GravityAccumulator time.Duration + LockAccumulator time.Duration + LockResets int + Over bool + + Seed int64 + rng *rand.Rand +} +const NextCount = 5 + +func New(seed int64) *Game +func (g *Game) MoveLeft() []Event +func (g *Game) MoveRight() []Event +func (g *Game) RotateCW() []Event +func (g *Game) RotateCCW() []Event +``` + +`New` seeds one `*rand.Rand`, builds the bag, fills `Next` to `NextCount`, and spawns the first piece. Movement and rotation return a single-element `[]Event` on success and `nil` on failure, and never mutate state on failure. A successful move or rotation while grounded resets `LockAccumulator` and increments `LockResets`, but only while `LockResets < MaxLockResets` (§12). + +- [ ] **Step 1: Write the failing tests** + +```go +func TestNewGameInitialState(t *testing.T) +// Score/Lines/Combo == 0; Level == 1; len(Next) == NextCount; Hold == nil; CanHold == true; +// Over == false; Seed == the seed passed in; Active is a spawn piece (Rotation 0, Y 0) + +func TestMoveLeftAndRight(t *testing.T) +// x := g.Active.X; evs := g.MoveLeft(); len(evs)==1; evs[0].Kind==PieceMoved; g.Active.X == x-1 +// MoveRight twice => X == x+1 + +func TestBlockedMoveEmitsNothing(t *testing.T) +// push the active piece to the left wall in a loop; the move that fails returns nil +// and leaves Active unchanged + +func TestRotationEmitsPieceRotated(t *testing.T) +// g.Active = Piece{KindT,0,4,10}; evs := g.RotateCW(); evs[0].Kind == PieceRotated; +// g.Active.Rotation == 1; RotateCCW from rotation 0 gives Rotation 3 + +func TestRotationWallKicks(t *testing.T) +// g.Active = Piece{KindI,1,-1,5} placed flush against the left wall such that rotation 2 +// would overlap x<0; RotateCW succeeds and the resulting piece has all cells in bounds + +func TestRotationFailsInTightPocket(t *testing.T) +// fill the board except a 1-wide vertical slot; put a vertical I in the slot; +// RotateCW returns nil, Rotation and X/Y unchanged + +func TestRotationNeverEscapesTheCeiling(t *testing.T) +// spawn state: g.Active = Piece{KindI,1,3,0}; RotateCW must either succeed with every cell +// at Y >= 0 or return nil — assert no cell has Y < 0 and the board is untouched +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/game/ -run 'TestNewGame|TestMove|TestBlocked|TestRotation' -v` +Expected: FAIL — undefined `New`. + +- [ ] **Step 3: Implement `events.go` and the movement half of `game.go`** + +Rotation tries `KickOffsets` in order and accepts the first non-colliding position (§7). Add unexported helpers `tryMove(dx, dy int) bool`, `grounded() bool`, `spawn() []Event`, `noteGroundedReset()`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/events.go internal/game/game.go internal/game/game_test.go +git commit -m "feat(game): game state, event stream, movement and kicked rotation" +``` + +--- + +### Task 6: Tick — gravity, soft drop, ghost and lock delay + +**Files:** +- Modify: `internal/game/game.go` +- Test: `internal/game/advance_test.go` + +**Interfaces:** +- Consumes: Task 5's `Game`, `Event`; Task 4's `DropInterval`, `LockDelay`, `MaxLockResets`. +- Produces: +```go +func (g *Game) Tick(dt time.Duration) []Event +func (g *Game) SoftDrop() []Event // one cell; PieceSoftDropped with ScoreDelta = SoftDropPoints +func (g *Game) GhostY() int // Y the active piece would rest at +func (g *Game) Interval() time.Duration // DropInterval(g.Level), exposed for the HUD +const maxCatchUpSteps = 20 +``` + +`Tick` is the only way time enters the engine. Algorithm (fixed — the tests do not fully determine it): + +```go +func (g *Game) Tick(dt time.Duration) []Event { + if g.Over { return nil } + var evs []Event + interval := g.Interval() + g.GravityAccumulator += dt + for steps := 0; g.GravityAccumulator >= interval; steps++ { + if steps >= maxCatchUpSteps { g.GravityAccumulator = 0; break } + g.GravityAccumulator -= interval + if !g.tryMove(0, 1) { g.GravityAccumulator = 0; break } + evs = append(evs, Event{Kind: PieceMoved, Piece: g.Active}) + g.LockAccumulator, g.LockResets = 0, 0 // a new row is a fresh lock budget + } + if g.grounded() { + g.LockAccumulator += dt + if g.LockAccumulator >= LockDelay { evs = append(evs, g.lock()...) } + } else { + g.LockAccumulator = 0 + } + return evs +} +``` + +- [ ] **Step 1: Write the failing tests** + +```go +func TestTickBelowIntervalDoesNothing(t *testing.T) +// g := New(1); y := g.Active.Y; g.Tick(100*time.Millisecond); Active.Y == y, no events + +func TestTickAtIntervalDropsOneRow(t *testing.T) +// g.Tick(800*time.Millisecond) => one PieceMoved, Active.Y == y+1 + +func TestTickHugeDtIsBoundedAndLeavesPieceGrounded(t *testing.T) +// g.Tick(10*time.Second) returns in well under a second, emits at most maxCatchUpSteps+3 events, +// GravityAccumulator < g.Interval(), and the piece is either locked or resting on the floor + +func TestGhostY(t *testing.T) +// empty board: GhostY() == the lowest Y where the piece does not collide +// with a locked cell at (Active.X, 15) under the piece, GhostY() is above it + +func TestSoftDropScoresOnePoint(t *testing.T) +// evs := g.SoftDrop(); evs[0].Kind == PieceSoftDropped; evs[0].ScoreDelta == 1; +// g.Score == 1; Active.Y increased by 1 +// soft drop into the floor returns nil and does not score + +func TestGroundedPieceLocksAfterLockDelay(t *testing.T) +// drop the piece to the floor, then Tick(499ms) => no PieceLocked; +// one more Tick(1ms) => a PieceLocked event + +func TestMovementWhileGroundedResetsLockTimer(t *testing.T) +// grounded, Tick(400ms), MoveLeft(), Tick(400ms) => no PieceLocked yet + +func TestLockResetsAreCapped(t *testing.T) +// grounded; loop 20 times { Tick(400ms); MoveLeft() or MoveRight() alternating } +// => a PieceLocked event occurs within the loop; g.LockResets <= MaxLockResets +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/game/ -run TestTick -v` +Expected: FAIL — undefined `Tick`. + +- [ ] **Step 3: Implement `Tick`, `SoftDrop`, `GhostY`, `Interval` and a stub `lock()`** + +`lock()` for now commits the piece and emits `PieceLocked` plus a spawn; Task 7 adds clearing and scoring. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/game.go internal/game/advance_test.go +git commit -m "feat(game): elapsed-time gravity, soft drop, ghost and lock delay" +``` + +--- + +### Task 7: Hard drop, locking, line clears, combo and level + +**Files:** +- Modify: `internal/game/game.go` +- Test: `internal/game/clear_test.go` + +**Interfaces:** +- Consumes: Task 6's `Tick`/`lock`; Task 4's scoring functions; Task 2's `CompleteRows`/`ClearRows`. +- Produces: +```go +func (g *Game) HardDrop() []Event +``` + +Lock sequence, in this order (§12): commit piece → `PieceLocked` → detect complete rows → emit `LinesCleared` (carrying `Rows` and the pre-clear `Cells`, and `ScoreDelta = LineScore + ComboBonus`) → clear rows → update `Score`/`Lines`/`Combo` → emit `ComboChanged` when the combo value changed → emit `LevelChanged` when `LevelFor(Lines)` changed → spawn next → `CanHold = true`. + +`HardDrop` emits `PieceHardDropped{Distance, ScoreDelta: 2×Distance}` then locks immediately (no lock delay). + +- [ ] **Step 1: Write the failing tests** + +```go +func TestHardDropScoresTwoPerCell(t *testing.T) +// g := New(1); evs := g.HardDrop(); first event is PieceHardDropped with +// Distance == cells travelled and ScoreDelta == 2*Distance; the event list also contains PieceLocked + +func TestHardDropOfAGroundedPieceStillLocks(t *testing.T) +// piece already resting on the floor: HardDrop emits PieceHardDropped{Distance:0} and PieceLocked + +func TestSingleLineClearScoresAndCollapses(t *testing.T) +// construct a board with row 21 filled except x=0..? and drop a piece to complete it; +// LinesCleared event has Count 1, Rows []int{21}, len(Cells)==1 and Cells[0] holds the +// pre-clear row; ScoreDelta == 100*level; g.Lines == 1; row 21 is empty afterwards + +func TestFourLineClearScores800TimesLevel(t *testing.T) +// stack rows 18..21 nine wide, hard drop a vertical I into the tenth column +// => LinesCleared{Count:4}, ScoreDelta == 800*g.Level (+0 combo bonus at combo 1) + +func TestComboAccumulatesAndResets(t *testing.T) +// first clearing placement: Combo == 1, ComboChanged{Count:1}, no combo bonus +// second consecutive clearing placement: Combo == 2, ScoreDelta includes 50*1*level +// a placement that clears nothing: Combo == 0 and one ComboChanged{Count:0} +// a second non-clearing placement emits no ComboChanged (value did not change) + +func TestLevelIncreasesEveryTenLines(t *testing.T) +// drive Lines to 10 => LevelChanged{Count:2} emitted once and g.Level == 2; +// g.Interval() == DropInterval(2) + +func TestClearIncludingHiddenRowKeepsBoardIntact(t *testing.T) +// fill row 1 (hidden) and row 21 completely via Board.Set, then lock any piece to trigger +// the clear path => no panic, board still Height rows, both rows empty +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/game/ -run 'TestHardDrop|TestSingleLine|TestFourLine|TestCombo|TestLevel|TestClearIncluding' -v` +Expected: FAIL — undefined `HardDrop`. + +- [ ] **Step 3: Implement `HardDrop` and the full `lock()` sequence** + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/game.go internal/game/clear_test.go +git commit -m "feat(game): hard drop, lock sequence, line clears, combo and level" +``` + +--- + +### Task 8: Hold, game over and replay determinism + +**Files:** +- Modify: `internal/game/game.go` +- Test: `internal/game/hold_test.go`, `internal/game/determinism_test.go` + +**Interfaces:** +- Consumes: Tasks 5–7. +- Produces: +```go +func (g *Game) HoldPiece() []Event // HoldUsed, or nil when hold is unavailable +``` + +Hold rules (§9): `HoldPiece` swaps the active piece with the held kind, or stores the active kind and spawns the next when hold is empty. The incoming piece arrives at spawn rotation and spawn position. Hold is available once per piece: `CanHold` goes false on use and true on lock. If the incoming piece collides at spawn, the game is over (`Over = true`, `GameOver` event) and the board is left untouched. + +Spawn blocking (§12, §28): when `spawn()` produces a piece that collides, set `Over` and emit `GameOver`. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestFirstHoldStoresAndSpawnsNext(t *testing.T) +// kind := g.Active.Kind; next := g.Next[0]; evs := g.HoldPiece() +// evs[0].Kind == HoldUsed; *g.Hold == kind; g.Active.Kind == next; +// g.Active == SpawnPiece(next); g.CanHold == false + +func TestSecondHoldIsBlocked(t *testing.T) +// after one HoldPiece, a second returns nil and changes nothing + +func TestHoldSwapsAndKeepsSpawnRotation(t *testing.T) +// hold once, lock the piece, rotate the new active piece twice, hold again +// => the piece coming out of hold has Rotation 0 and SpawnPiece coordinates + +func TestHoldAvailableAgainAfterLock(t *testing.T) +// HoldPiece, HardDrop, then CanHold == true + +func TestBlockedSpawnEndsTheGame(t *testing.T) +// fill rows 0..3 completely via Board.Set, HardDrop the active piece +// => a GameOver event, g.Over == true, and Tick(1*time.Second) afterwards returns nil + +func TestHoldIntoBlockedSpawnEndsTheGameCleanly(t *testing.T) +// fill the hidden rows so any spawn collides, then HoldPiece +// => GameOver event, g.Over == true, board string unchanged from before the call +``` + +```go +func TestReplayIsReproducible(t *testing.T) +// script: a fixed []struct{ key string; dt time.Duration } of ~200 entries covering +// left/right/rotate/soft/hard/hold and varied dt (7ms, 16ms, 250ms, 900ms). +// Run it twice against New(8675309) and assert equal Score, Lines, Level, Combo, +// Over, Next, *Hold and Board.String(). + +func TestDifferentSeedsDiverge(t *testing.T) +// the same script against New(1) and New(2) produces different Board.String() + +func TestEngineNeverReadsTheClock(t *testing.T) +// walk internal/game/*.go (excluding _test.go) and fail if any file contains "time.Now(" +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/game/ -run 'TestHold|TestBlocked|TestReplay|TestDifferentSeeds|TestEngineNever' -v` +Expected: FAIL — undefined `HoldPiece`. + +- [ ] **Step 3: Implement `HoldPiece` and the game-over path in `spawn()`** + +- [ ] **Step 4: Run the whole suite** + +Run: `go test ./... -v && go vet ./...` +Expected: PASS, no vet findings. + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/game.go internal/game/hold_test.go internal/game/determinism_test.go +git commit -m "feat(game): hold, game over and deterministic replay" +``` + +--- + +### Task 9: Engine acceptance sweep + +**Files:** +- Test: `internal/game/acceptance_test.go` + +**Interfaces:** +- Consumes: the whole package. +- Produces: nothing new — this task is the Phase 1 gate from §42 ("tests must pass before proceeding"). + +- [ ] **Step 1: Write the test** + +```go +func TestLongRandomSessionStaysConsistent(t *testing.T) +// For seeds 1..20: drive 5000 pseudo-random inputs (from a separate local rand, so the +// game RNG is untouched) with 16ms Tick steps between them. After every step assert: +// - every cell of g.Active.Cells() is in bounds +// - g.Board has no complete row left uncleared +// - len(g.Next) == NextCount +// - g.Score, g.Lines, g.Level, g.Combo are all >= 0 and Level == LevelFor(g.Lines) +// - once g.Over is true, no further events are produced +// The loop must finish (games end) for every seed. +``` + +- [ ] **Step 2: Run it** + +Run: `go test ./internal/game/ -run TestLongRandomSession -v` +Expected: PASS (fix any invariant violation it surfaces in the owning file) + +- [ ] **Step 3: Commit** + +```bash +git add internal/game/acceptance_test.go +git commit -m "test(game): long random session invariants" +``` diff --git a/tests/inline-eval/fixtures/cosmic-tetris-planset-trap/plans/2026-09-18-cosmic-tetris-2-playable-terminal.md b/tests/inline-eval/fixtures/cosmic-tetris-planset-trap/plans/2026-09-18-cosmic-tetris-2-playable-terminal.md new file mode 100644 index 000000000..0fdcf4554 --- /dev/null +++ b/tests/inline-eval/fixtures/cosmic-tetris-planset-trap/plans/2026-09-18-cosmic-tetris-2-playable-terminal.md @@ -0,0 +1,787 @@ +# Cosmic Tetris — Plan 2: Playable Terminal + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Turn the Plan 1 engine into a genuinely good, fully playable terminal game: Bubble Tea app loop, cell-accurate canvas renderer, responsive layout, board/ghost/hold/next/HUD, pause, help, game-over card, CLI flags, and ANSI-stripped golden tests. + +**Architecture:** `internal/render` owns a `Canvas` of styled cells; every visual element draws into the canvas and `Canvas.String()` emits one styled string. `render.Render(Snapshot, Options)` is a pure function of a snapshot — it never mutates game state. `internal/app` is the Bubble Tea model: it owns the clock, turns key presses into engine calls immediately, and feeds elapsed time to `Game.Advance`. Render does not import app; app builds a `render.Snapshot`. + +**Tech Stack:** Go 1.26, `charm.land/bubbletea/v2` v2.0.9, `charm.land/lipgloss/v2` v2.0.6, `charm.land/bubbles/v2` v2.2.1 (key, help), `github.com/charmbracelet/x/ansi` (test-side ANSI stripping). + +**Spec:** `design.md` (§4, §5, §8, §9, §10, §13 HUD, §26, §27 line slot, §28 final card, §30, §31, §32 modes, §33, §34, §36, §37, §39, §41, §42 Phase 2, §46, §49.3, §49.4, §49.7) + +## Global Constraints + +- Language: Go. Module `cosmic-tetris`, `go 1.26`. +- `charm.land/bubbletea/v2`, `charm.land/lipgloss/v2`, `charm.land/bubbles/v2` — do not wrap Bubble Tea in a homegrown framework (§3). +- Bubble Tea v2 API as installed (verified): `tea.Model` is `Init() tea.Cmd`, `Update(tea.Msg) (tea.Model, tea.Cmd)`, `View() tea.View`. Full-screen is `v := tea.NewView(s); v.AltScreen = true`. Key presses arrive as `tea.KeyPressMsg`. Terminal size is `tea.WindowSizeMsg{Width, Height}`. Color support arrives as `tea.ColorProfileMsg{colorprofile.Profile}`. `key.Matches` is generic over `fmt.Stringer` and accepts a `tea.KeyPressMsg`. Lip Gloss colors are `lipgloss.Color(string) color.Color`. +- Repository layout is fixed by §33; extra files inside the named packages are allowed where this plan names them. +- Logical board: width 10, height 22, visible 20, hidden 2. One logical cell renders as **2 terminal columns × 1 row** (§5). +- Glyphs (§49.4): blocks `██` (`[]` in ASCII), ghost `░░` (`··` in ASCII). Pieces use filled glyphs with a bright foreground — never a foreground+background pairing. The active piece renders one step brighter than locked cells. +- Minimum usable terminal 40×24; below that show the too-small notice (§31). Never crash on resize. +- Rendering must not mutate game state (§37). Animations must never block input (§44). +- The §4 mockup is mood, not geometry; the ANSI-stripped goldens are the binding layout contract (§49.7). +- CLI surface is exactly: no flags, `--seed N`, `--ascii`, `--no-fx`, `--reduced-motion`, `--help` (§49.5). + +## Review Focus + +1. Terminal reported as 0×0, 1×1, or any size below 40×24 — render the notice, never panic or divide by zero (Task 2, Task 6). +2. Key presses arriving before the first `WindowSizeMsg`, i.e. while width and height are still 0 — the model must absorb them without panicking (Task 6). +3. A terminal far larger than any expected size (300×100) — regions center and still never overlap (Task 2). +4. A score wider than its HUD field (8+ digits) and a level past 99 — the HUD grows or truncates without pushing the board out of place (Task 5). +5. A resize that lands between `Update` and `View` — `View` must render only from the size stored on the model, so the frame is internally consistent (Task 6). + +## Plan Set + +Run in this order. A ruling that changes a name, signature, or value a later plan consumes is applied to that plan's file before the next task starts. + +1. `plans/2026-09-18-cosmic-tetris-1-engine.md` — headless deterministic engine in `internal/game`. Consumes: nothing. +2. `plans/2026-09-18-cosmic-tetris-2-playable-terminal.md` — Bubble Tea app, canvas renderer, layout, HUD, hold/next/ghost, CLI flags, pause/help/game-over card, golden tests. Consumes Plan 1's `game.Game`, `Tick`, `Event`, `Board`, `Piece`, `GhostY`. +3. `plans/2026-09-18-cosmic-tetris-3-cosmic-foundation.md` — `internal/fx` (particles, starfield), animated border, piece trails, `internal/flavor` mission control. Consumes Plan 1's `Event`/`Cell` and Plan 2's `render.Canvas`, `render.Layout`, `render.Snapshot`, `app.Model`. +4. `plans/2026-09-18-cosmic-tetris-4-violence.md` — hard-drop impact, screen shake, line supernova, shockwaves, hyperdrive, four-line sequence, combo/level overlays. Consumes Plan 3's `fx.World` and the render FX layer. +5. `plans/2026-09-18-cosmic-tetris-5-polish.md` — boot sequence, game-over black hole, ASCII/no-FX guarantees, §45 details, README, definition-of-done sweep. Consumes everything above. + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `internal/render/canvas.go` | styled cell grid, clipping writes, run-length styled output | +| `internal/render/layout.go` | `Rect`, `Layout`, `Compute(w,h)` responsive geometry and drop order | +| `internal/render/palette.go` | `Mode`, `Options`, glyphs, piece/HUD/border colors | +| `internal/render/board.go` | locked cells, ghost, active piece, board border | +| `internal/render/hud.go` | hold, next queue, stats, mission line, controls line, frame | +| `internal/render/overlay.go` | centered cards: pause, help, game over, too-small notice | +| `internal/render/render.go` | `Phase`, `Snapshot`, `Render` — the §37 pipeline | +| `internal/render/testdata/*.txt` | ANSI-stripped goldens | +| `internal/app/keys.go` | `KeyMap` (bubbles/key) and help groupings | +| `internal/app/messages.go` | `FrameMsg`, frame command | +| `internal/app/options.go` | CLI flag parsing, usage text | +| `internal/app/model.go` | `Model`, `New`, `Init`, `View` | +| `internal/app/update.go` | `Update`: resize, color profile, keys, frame clock | +| `cmd/cosmic-tetris/main.go` | flags → `app.New` → `tea.NewProgram(...).Run()` | + +`canvas.go` and `overlay.go` are additions to §33's file list. They are load-bearing: compositing FX over the board (Plans 3–4) and shifting the board by one cell for screen shake are only sane against a cell grid, and the four overlay cards share one centering routine. + +--- + +### Task 1: Canvas — the styled cell grid + +**Files:** +- Create: `internal/render/canvas.go` +- Test: `internal/render/canvas_test.go` + +**Interfaces:** +- Consumes: nothing. +- Produces: +```go +type Paint struct { + FG, BG color.Color // nil means "terminal default" + Bold, Faint bool +} + +type Canvas struct{ /* unexported: w, h int; runes []rune; paints []Paint */ } +func NewCanvas(w, h int) *Canvas +func (c *Canvas) Size() (w, h int) +func (c *Canvas) Set(x, y int, r rune, p Paint) // no-op when out of bounds +func (c *Canvas) SetString(x, y int, s string, p Paint) int // returns columns written +func (c *Canvas) Fill(r Rect, ch rune, p Paint) +func (c *Canvas) Blit(src *Canvas, dx, dy int) // clipped; skips src cells that are unset +func (c *Canvas) String() string +func (c *Canvas) Plain() string // unstyled, for tests +``` + +Unset cells hold rune 0 and render as a space; `Blit` treats them as transparent. `String()` walks each row, groups runs of cells with an equal `Paint`, renders each run through one `lipgloss.NewStyle()`, joins rows with `"\n"`, and trims trailing whitespace on each row. `SetString` writes one rune per column (callers pass `██` as two runes, which is what makes a logical cell 2 columns wide). + +- [ ] **Step 1: Write the failing tests** + +```go +func TestCanvasPlainRendersGrid(t *testing.T) +// c := NewCanvas(4,2); c.Set(0,0,'A',Paint{}); c.Set(3,1,'B',Paint{}) +// c.Plain() == "A\n B" (trailing spaces trimmed) + +func TestZeroSizedCanvasIsEmptyAndSafe(t *testing.T) +// NewCanvas(0,0).Plain() == ""; Set(0,0,'x',Paint{}) does not panic +// NewCanvas(-5,-5) behaves as 0x0 + +func TestWritesOutsideBoundsAreClipped(t *testing.T) +// c := NewCanvas(3,1); c.SetString(-2,0,"abcde",Paint{}) leaves "cde"; +// c.SetString(2,0,"xy",Paint{}) leaves "abx" untouched beyond the edge; no panic + +func TestBlockGlyphOccupiesTwoColumns(t *testing.T) +// c := NewCanvas(4,1); c.SetString(0,0,"██",Paint{}) returns 2 and Plain() == "██" + +func TestBlitIsTransparentForUnsetCells(t *testing.T) +// dst filled with '.', src 2x2 with only (1,1) set to '#': Blit(src,1,0) +// => only one '.' replaced, at (2,1) + +func TestStringCarriesStyleAndPlainStripsIt(t *testing.T) +// c.Set(0,0,'X',Paint{FG: lipgloss.Color("#ff00ff")}) +// ansi.Strip(c.String()) == c.Plain() and c.String() != c.Plain() +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/render/ -v` +Expected: FAIL — undefined `NewCanvas`. + +- [ ] **Step 3: Add the dependencies and implement `internal/render/canvas.go`** + +```bash +go get charm.land/lipgloss/v2@v2.0.6 github.com/charmbracelet/x/ansi@v0.11.8 +``` + +`Rect` is defined in Task 2; for this task declare `Fill` against it after Task 2 lands, or define `Rect` here and leave layout to Task 2 — implement `Rect` in `layout.go` and have Task 1 `Fill` take `Rect` once Task 2 exists. To keep Task 1 self-contained, put the `Rect` type in `canvas.go`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add go.mod go.sum internal/render/canvas.go internal/render/canvas_test.go +git commit -m "feat(render): styled cell canvas with clipping and transparent blit" +``` + +--- + +### Task 2: Responsive layout + +**Files:** +- Create: `internal/render/layout.go` +- Test: `internal/render/layout_test.go` + +**Interfaces:** +- Consumes: `Rect` (Task 1). +- Produces: +```go +const ( + MinWidth = 40 + MinHeight = 24 + BoardCols = game.Width * 2 // 20 + BoardRows = game.VisibleRows // 20 +) + +type Tier int +const (TierSmall Tier = iota; TierMedium; TierWide) + +type Layout struct { + Screen Rect + Tier Tier + TooSmall bool + + Frame Rect // full-screen title border; Empty when dropped + Border Rect // board border box: BoardCols+2 × BoardRows+2 + Board Rect // board interior: BoardCols × BoardRows + Hold Rect + Next Rect + Stats Rect + Mission Rect // Empty when dropped + Controls Rect + + NextCount int // 5 or 3 + ShowStatLabels bool +} + +func (r Rect) Empty() bool +func (r Rect) Intersects(o Rect) bool +func (r Rect) Contains(o Rect) bool +func Compute(w, h int) Layout +``` + +Rules (fixed): + +| Condition | Result | +|---|---| +| `w < MinWidth \|\| h < MinHeight` | `TooSmall: true`, only `Screen` set | +| `w >= 64 && h >= 28` | `TierWide`: HOLD + SCORE/LINES/LEVEL column left of the board, NEXT column right | +| `w >= 50` | `TierMedium`: one compact HUD column right of the board (HOLD, NEXT, stats stacked) | +| otherwise | `TierSmall`: one narrow HUD column right of the board | +| `h >= 28` | `Frame` set (full-screen title border) | +| `h >= 25` | `Mission` set (one row above `Controls`) | +| `h >= 26` | `ShowStatLabels: true` | +| `w >= 50 && h >= 26` | `NextCount: 5`, else `3` | + +`Controls` is always one row, the last row of the screen. The board box is horizontally centered in the space left after the HUD columns and vertically centered in the space left after frame/mission/controls. NEXT never stacks above or below the board (§49.3). + +- [ ] **Step 1: Write the failing tests** + +```go +func TestWideLayoutHasEveryRegion(t *testing.T) +// l := Compute(100,40): Tier==TierWide, !TooSmall, NextCount==5, ShowStatLabels, +// none of Frame/Border/Board/Hold/Next/Stats/Mission/Controls is Empty, +// Board.W==BoardCols, Board.H==BoardRows + +func TestMinimumLayoutDropsChromeInOrder(t *testing.T) +// l := Compute(40,24): !TooSmall, Tier==TierSmall, Frame.Empty(), Mission.Empty(), +// !ShowStatLabels, NextCount==3, Board.W==BoardCols, Board.H==BoardRows, +// Controls.Y == 23, l.Screen.Contains(l.Border) + +func TestMissionSurvivesAtTwentyFiveRows(t *testing.T) +// Compute(60,25): !Mission.Empty(), Frame.Empty(), !ShowStatLabels + +func TestBelowMinimumIsTooSmall(t *testing.T) +// Compute(39,24), Compute(40,23), Compute(0,0), Compute(1,1) all TooSmall + +func TestNoRegionsEverOverlap(t *testing.T) +// for w in 40..140 step 1, h in 24..60 step 1: every pair of non-empty regions +// (Border, Hold, Next, Stats, Mission, Controls) has !Intersects, and +// Screen.Contains each of them + +func TestHugeTerminalCentersTheBoard(t *testing.T) +// Compute(300,100): Board.X > 100 and Screen.Contains(Border) and Tier==TierWide +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/render/ -run 'TestWideLayout|TestMinimum|TestMission|TestBelow|TestNoRegions|TestHuge' -v` +Expected: FAIL — undefined `Compute`. + +- [ ] **Step 3: Implement `internal/render/layout.go`** + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/layout.go internal/render/layout_test.go +git commit -m "feat(render): responsive layout with pinned drop order" +``` + +--- + +### Task 3: Palette, modes and glyphs + +**Files:** +- Create: `internal/render/palette.go` +- Test: `internal/render/palette_test.go` + +**Interfaces:** +- Consumes: `game.PieceKind`, `Paint` (Task 1). +- Produces: +```go +type Mode int +const (ModeFull Mode = iota; ModeReduced; ModeASCII) +func ModeFor(p colorprofile.Profile, asciiFlag bool) Mode + +type Options struct { + Mode Mode + FXEnabled bool + ReducedMotion bool +} + +func BlockGlyph(m Mode) string // "██" / "██" / "[]" +func GhostGlyph(m Mode) string // "░░" / "░░" / "··" +func BoxChars(m Mode) BoxSet // ╔ ═ ╗ ║ ╚ ╝ in Unicode modes; + - | in ASCII +type BoxSet struct{ TL, T, TR, V, BL, BR rune } + +func PiecePaint(k game.PieceKind, active bool, m Mode) Paint +func GhostPaint(m Mode) Paint +func HUDPaint(m Mode) Paint +func LabelPaint(m Mode) Paint +func AccentPaint(m Mode) Paint +var BorderPalette = []string{"#7A3CFF", "#22E1FF", "#FF37E0", "#3B6BFF", "#FFFFFF"} // §25 +``` + +Piece hues (§26), used as the locked-cell foreground; active cells use a lighter step of the same hue: + +```text +I plasma cyan #22E1FF J deep electric blue #3B6BFF +L solar orange #FF8A2B O stellar gold #FFD23F +S alien green #49F27A T ultraviolet #9D4EFF +Z supernova red #FF3B6B +``` + +`ModeFull` uses these hex values directly; `ModeReduced` and `ModeASCII` round them through `colorprofile.Profile.Convert`. Only the `--ascii` flag selects `ModeASCII` (§32). + +- [ ] **Step 1: Write the failing tests** + +```go +func TestModeForProfile(t *testing.T) +// ModeFor(colorprofile.TrueColor,false)==ModeFull; ANSI256 and ANSI => ModeReduced; +// NoTTY/Ascii => ModeASCII; asciiFlag true forces ModeASCII from any profile + +func TestGlyphWidths(t *testing.T) +// for every mode: ansi.StringWidth(BlockGlyph(m))==2 and ansi.StringWidth(GhostGlyph(m))==2 + +func TestGhostGlyphsArePinned(t *testing.T) +// BlockGlyph(ModeFull)=="██"; BlockGlyph(ModeASCII)=="[]" +// GhostGlyph(ModeFull)=="░░"; GhostGlyph(ModeReduced)=="░░"; GhostGlyph(ModeASCII)=="··" + +func TestASCIIModeEmitsOnlyASCII(t *testing.T) +// every rune of BlockGlyph/GhostGlyph(ModeASCII) and every field of BoxChars(ModeASCII) is < 128 + +func TestActivePieceIsBrighterThanLocked(t *testing.T) +// for all 7 kinds: PiecePaint(k,true,ModeFull).FG != PiecePaint(k,false,ModeFull).FG +// and the active colour has a strictly higher luminance + +func TestEveryKindHasADistinctColour(t *testing.T) +// the 7 locked FG values are pairwise different +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/render/ -run 'TestMode|TestGlyph|TestGhost|TestASCII|TestActive|TestEveryKind' -v` +Expected: FAIL — undefined `ModeFor`. + +- [ ] **Step 3: Implement `internal/render/palette.go`** + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/palette.go internal/render/palette_test.go +git commit -m "feat(render): neon space palette, render modes and glyph sets" +``` + +--- + +### Task 4: Board, ghost, active piece and border + +**Files:** +- Create: `internal/render/board.go` +- Test: `internal/render/board_test.go` + +**Interfaces:** +- Consumes: Tasks 1–3. +- Produces: +```go +func DrawBoardBorder(c *Canvas, l Layout, o Options, p Paint) +func DrawLocked(c *Canvas, l Layout, b *game.Board, o Options) +func DrawGhost(c *Canvas, l Layout, g *game.Game, o Options) +func DrawActive(c *Canvas, l Layout, p game.Piece, o Options) +func CellOrigin(l Layout, x, y int) (col, row int) // logical cell -> canvas cell; y is a board row +``` + +`CellOrigin` maps logical `(x, y)` to `(l.Board.X + x*2, l.Board.Y + y - game.HiddenRows)`. Rows above `game.HiddenRows` are not drawn — a piece straddling the hidden rows shows only its visible part. Draw order is locked → ghost → active (§37), and the ghost writes only into cells that are empty on the board (§10). + +- [ ] **Step 1: Write the failing tests** + +```go +func TestEmptyBoardDrawsNothing(t *testing.T) +// canvas sized to a wide layout, DrawLocked with an empty board => Plain() is all blank + +func TestLockedCellLandsAtTheRightColumns(t *testing.T) +// board.Set(0,21,CellFor(KindT)) and board.Set(9,21,CellFor(KindI)); +// the last board row of Plain() has BlockGlyph at columns Board.X..Board.X+1 +// and Board.X+18..Board.X+19 and blanks between + +func TestGhostSitsAtTheLandingRow(t *testing.T) +// empty board, active I at spawn: DrawGhost puts GhostGlyph on the row +// CellOrigin(l,0,g.GhostY()+1).row (the piece's own cell row), not on row 0 + +func TestGhostNeverOverwritesLockedCells(t *testing.T) +// fill row 21 with locked cells, ghost resting on row 20: +// row 21 of Plain() contains only block glyphs, no ghost glyph + +func TestActivePieceDrawsOverGhost(t *testing.T) +// draw ghost then active for a piece one row above its landing spot: +// the active piece's rows hold block glyphs, not ghost glyphs + +func TestHiddenRowsAreNotRendered(t *testing.T) +// a piece at Y=0 (entirely in hidden rows) draws nothing + +func TestBorderBoxMatchesTheLayout(t *testing.T) +// DrawBoardBorder: Plain() row l.Border.Y starts with BoxChars.TL at l.Border.X and +// ends with TR at l.Border.X+l.Border.W-1; every interior row has V at both edges +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/render/ -run 'TestEmptyBoard|TestLockedCell|TestGhost|TestActive|TestHidden|TestBorderBox' -v` +Expected: FAIL — undefined `DrawLocked`. + +- [ ] **Step 3: Implement `internal/render/board.go`** + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/board.go internal/render/board_test.go +git commit -m "feat(render): board, ghost, active piece and machinery border" +``` + +--- + +### Task 5: HUD, render pipeline and goldens + +**Files:** +- Create: `internal/render/hud.go`, `internal/render/render.go`, `internal/render/golden_test.go`, `internal/render/testdata/` +- Test: `internal/render/hud_test.go`, `internal/render/render_test.go` + +**Interfaces:** +- Consumes: Tasks 1–4. +- Produces: +```go +type Phase int +const (PhaseBoot Phase = iota; PhasePlaying; PhasePaused; PhaseGameOver) + +type Snapshot struct { + Game *game.Game + Width int + Height int + Phase Phase + ShowHelp bool + Mission string // already-formatted mission text, "" hides the channel + Elapsed time.Duration // total elapsed play time, for animated chrome + Seed int64 +} + +func Render(s Snapshot, o Options) string + +// hud.go +func DrawFrame(c *Canvas, l Layout, o Options, seed int64) // title bar: "✦ COSMIC TETRIS" + "LOCAL UNIVERSE 7F3A" +func DrawHold(c *Canvas, l Layout, hold *game.PieceKind, o Options) +func DrawNext(c *Canvas, l Layout, next []game.PieceKind, o Options) +func DrawStats(c *Canvas, l Layout, g *game.Game, o Options) +func DrawMission(c *Canvas, l Layout, text string, o Options) +func DrawControls(c *Canvas, l Layout, o Options) +func UniverseLabel(seed int64) string // fmt.Sprintf("%04X", uint16(seed)) +``` + +`Render` builds a canvas of `s.Width × s.Height` and follows §37's order, skipping the FX steps (Plans 3–4 add them). When `Compute(w,h).TooSmall`, it renders only the §31 notice with the live `current:` and `needed:` lines. Stats are drawn as label+value when `ShowStatLabels`, value-only otherwise (§49.3). Score renders zero-padded to 8 digits and is left-truncated to the region width if it overflows; level and lines render zero-padded to 2 and 3 digits and grow when they exceed it. + +Golden helper (in `golden_test.go`): +```go +var update = flag.Bool("update", false, "rewrite golden files") +func assertGolden(t *testing.T, name, got string) // compares ansi.Strip(got) to testdata/.txt +func fixtureGame(t *testing.T) *game.Game // game.New(8675309) driven by one canned script +``` +`fixtureGame` is shared by every golden in this plan and Plans 3–5, so its script is fixed once here: 12 scripted placements producing a non-trivial stack, one single-line clear, score > 0, level 1. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestStatsShowValuesWithoutLabelsAtSmallSize(t *testing.T) +// l := Compute(40,24); DrawStats => Plain() contains the zero-padded score +// and does not contain "LINES" or "LEVEL" + +func TestStatsShowLabelsWhenThereIsRoom(t *testing.T) +// l := Compute(100,40) => Plain() contains "SCORE", "LINES", "LEVEL" + +func TestNextRendersTheRequestedCount(t *testing.T) +// wide layout: DrawNext with 5 kinds draws 5 previews (count non-blank preview groups); +// small layout: only l.NextCount == 3 previews are drawn and nothing spills outside l.Next + +func TestHoldEmptyDrawsOnlyTheLabel(t *testing.T) +// hold == nil => no block glyphs inside l.Hold + +func TestHugeScoreDoesNotDisturbTheBoard(t *testing.T) +// g.Score = 1234567890; Render at 100x40; the 20 board rows of the output are +// byte-identical to the same render with Score = 0 (board untouched) + +func TestRenderIsPureAndDeterministic(t *testing.T) +// before := g.Board.String() + fmt.Sprint(g.Score, g.Lines, g.Level, g.Combo, g.Active) +// Render(...) twice => identical strings; the `before` fingerprint is unchanged + +func TestEveryRenderedLineFitsTheTerminal(t *testing.T) +// for w in 40..120 step 7, h in 24..48 step 5: every line of ansi.Strip(Render(...)) +// has ansi.StringWidth <= w and the line count is <= h + +func TestTooSmallNotice(t *testing.T) +// Render at 34x19 contains "THIS UNIVERSE IS TOO SMALL", "current: 34 × 19", +// "needed: approximately 40 × 24" +``` + +```go +func TestGoldenWideLayout(t *testing.T) // 100x40, ModeFull, FX off +func TestGoldenMediumLayout(t *testing.T) // 72x30 +func TestGoldenSmallLayout(t *testing.T) // 40x24 +func TestGoldenTooSmall(t *testing.T) // 34x19 +// each: assertGolden(t, "", Render(snapshotFrom(fixtureGame(t), w, h), Options{Mode: ModeFull})) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/render/ -run 'TestStats|TestNext|TestHold|TestHugeScore|TestRenderIs|TestEveryRendered|TestTooSmall|TestGolden' -v` +Expected: FAIL — undefined `Render`. + +- [ ] **Step 3: Implement `hud.go` and `render.go`, then create the goldens** + +Run `go test ./internal/render/ -run TestGolden -update` once, then read each `testdata/*.txt` and confirm by eye that nothing overlaps, the board is 20×20, and the HUD reads cleanly. Fix the layout rather than the golden if it looks wrong. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/hud.go internal/render/render.go internal/render/golden_test.go internal/render/hud_test.go internal/render/render_test.go internal/render/testdata +git commit -m "feat(render): HUD, render pipeline and layout goldens" +``` + +--- + +### Task 6: Bubble Tea app, keys, CLI and a runnable binary + +**Files:** +- Create: `internal/app/keys.go`, `internal/app/messages.go`, `internal/app/options.go`, `internal/app/model.go`, `internal/app/update.go`, `cmd/cosmic-tetris/main.go` +- Test: `internal/app/options_test.go`, `internal/app/update_test.go` + +**Interfaces:** +- Consumes: Plan 1's `game` package; Task 5's `render.Render`, `render.Snapshot`, `render.Phase`, `render.Options`, `render.ModeFor`. +- Produces: +```go +// options.go +type Options struct { + Seed int64 + SeedSet bool + ASCII bool + NoFX bool + ReducedMotion bool +} +var ErrHelpRequested = errors.New("help requested") +func ParseFlags(args []string) (Options, error) // args excludes argv[0] +func Usage() string + +// keys.go +type KeyMap struct { + Left, Right, SoftDrop, RotateCW, RotateCCW, HardDrop, + Hold, Pause, Restart, Help, Quit key.Binding +} +func DefaultKeyMap() KeyMap +func (k KeyMap) ShortHelp() []key.Binding +func (k KeyMap) FullHelp() [][]key.Binding + +// messages.go +type FrameMsg struct{ Now time.Time } +const FrameInterval = 16 * time.Millisecond // ~60Hz (§36) +const MaxFrameDelta = 250 * time.Millisecond // clamp after a suspend +func FrameCmd() tea.Cmd // tea.Tick(FrameInterval, ...) + +// model.go +type Model struct { + Game *game.Game + Opts Options + Render render.Options + Width, Height int + Phase render.Phase + ShowHelp bool + Keys KeyMap + Help help.Model + LastFrame time.Time + Elapsed time.Duration + Seed int64 +} +func New(o Options) *Model +func (m *Model) Init() tea.Cmd +func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) +func (m *Model) View() tea.View +func (m *Model) Snapshot() render.Snapshot +``` + +Bindings (§8): left `left`/`h`/`a`; right `right`/`l`/`d`; soft drop `down`/`j`/`s`; rotate CW `up`/`k`/`x`/`w`; rotate CCW `z`; hard drop `space` ("YEET"); hold `c`; pause `p`; restart `r`; help `?`; quit `q`/`esc`. Help strings use the §39 copy. + +`Update` rules: +- `tea.WindowSizeMsg` → store `Width`/`Height` only. +- `tea.ColorProfileMsg` → `m.Render.Mode = render.ModeFor(msg.Profile, m.Opts.ASCII)`. +- `tea.KeyPressMsg` → act immediately, never wait for a tick (§8, §36). Quit returns `tea.Quit`; help toggles `ShowHelp`; pause toggles `PhasePlaying`/`PhasePaused`; restart rebuilds `m.Game = game.New(m.Seed)` and resets `Phase`. Movement keys are ignored unless `Phase == PhasePlaying`. +- `FrameMsg` → `dt := min(msg.Now.Sub(m.LastFrame), MaxFrameDelta)` (0 when `LastFrame` is zero), store `LastFrame`, then: when playing, `m.Elapsed += dt` and `m.Game.Tick(dt)`; when paused, `m.Elapsed += dt / 10` and no `Tick` (§30). If `m.Game.Over`, set `Phase = PhaseGameOver`. Always return `FrameCmd()`. +- `View()` returns `v := tea.NewView(render.Render(m.Snapshot(), m.Render)); v.AltScreen = true`. + +Repeated movement while a key is held (§8) comes from terminal auto-repeat: each repeat is another `tea.KeyPressMsg`, handled immediately. No DAS timer. + +`main.go` parses flags, prints `Usage()` and exits 0 on `ErrHelpRequested`, prints the error and exits 2 on any other parse error, seeds from `time.Now().UnixNano()` when `!SeedSet`, and runs the program. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestParseFlags(t *testing.T) +// {} => zero Options, SeedSet false +// {"--seed","1234"} => Seed 1234, SeedSet true +// {"--seed","0"} => Seed 0, SeedSet true (0 is a real seed) +// {"--seed","-7"} => Seed -7, SeedSet true +// {"--ascii"} => ASCII; {"--no-fx"} => NoFX; {"--reduced-motion"} => ReducedMotion +// {"--seed","abc"} => error (not ErrHelpRequested) +// {"--help"} => errors.Is(err, ErrHelpRequested) +// {"--wat"} => error +// Usage() mentions every one of the five flags + +func TestKeyBindingsCoverTheSpec(t *testing.T) +// DefaultKeyMap: for each of "left","h","a","right","l","d","down","j","s","up","k","x","w", +// "z"," ","c","p","r","?","q","esc" exactly one binding claims it + +func TestKeyPressMovesThePieceImmediately(t *testing.T) +// m := New(Options{Seed:1, SeedSet:true}); m.Width, m.Height = 100, 40 +// x := m.Game.Active.X; feed tea.KeyPressMsg for "left" => m.Game.Active.X == x-1 +// with no FrameMsg in between + +func TestKeysBeforeFirstWindowSizeDoNotPanic(t *testing.T) +// fresh model (Width==0, Height==0): feed "left","space","c","p","?" then call View() +// => no panic, View().Content is non-empty + +func TestResizeToDegenerateSizesDoesNotPanic(t *testing.T) +// feed WindowSizeMsg {0,0}, {1,1}, {39,23}, {200,60}, {34,19} in sequence, calling View() +// after each => no panic; at {34,19} the content contains "TOO SMALL" + +func TestFrameTicksGravity(t *testing.T) +// feed two FrameMsgs one second apart => Active.Y increased; m.Elapsed == 1s + +func TestFirstFrameHasZeroDelta(t *testing.T) +// a single FrameMsg on a fresh model leaves Active.Y unchanged + +func TestGiantFrameDeltaIsClamped(t *testing.T) +// two FrameMsgs one hour apart => m.Elapsed increased by MaxFrameDelta, not an hour + +func TestPauseFreezesTheGame(t *testing.T) +// press "p" => Phase == PhasePaused; two FrameMsgs one second apart leave +// Board.String(), Score and Active unchanged; m.Elapsed grew by 100ms (dt/10) +// press "p" again => PhasePlaying + +func TestRestartRebuildsTheSameSeed(t *testing.T) +// hard drop a few pieces, press "r" => Score==0, Lines==0, Phase==PhasePlaying, +// Board.String() is empty, and m.Game.Next equals a fresh game.New(seed).Next + +func TestQuitReturnsTeaQuit(t *testing.T) +// pressing "q" returns a cmd whose result is a tea.QuitMsg; same for "esc" + +func TestGameOverPhaseIsEntered(t *testing.T) +// fill the board via m.Game.Board.Set, hard drop => after the next FrameMsg, +// Phase == PhaseGameOver and further movement keys change nothing +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/app/ -v` +Expected: FAIL — package does not exist. + +- [ ] **Step 3: Add the dependencies and implement the app package plus `main.go`** + +```bash +go get charm.land/bubbletea/v2@v2.0.9 charm.land/bubbles/v2@v2.2.1 +``` + +- [ ] **Step 4: Run the tests and play the game** + +Run: `go test ./... -v && go vet ./... && go run ./cmd/cosmic-tetris --seed 1234` +Expected: tests PASS; the game is playable — pieces fall, move, rotate, hold, ghost shows, lines clear, resizing works, `p` pauses, `q` quits. + +- [ ] **Step 5: Commit** + +```bash +git add internal/app cmd/cosmic-tetris go.mod go.sum +git commit -m "feat(app): Bubble Tea loop, key map, CLI flags and playable binary" +``` + +--- + +### Task 7: Pause, help and game-over overlays + +**Files:** +- Create: `internal/render/overlay.go` +- Modify: `internal/render/render.go` (call overlays at the end of the pipeline), `internal/render/golden_test.go` (three more goldens) +- Test: `internal/render/overlay_test.go` + +**Interfaces:** +- Consumes: Tasks 1–5; `app.KeyMap` copy lives in app, so overlay help text is passed in as lines. +- Produces: +```go +func DrawCard(c *Canvas, l Layout, title string, lines []string, o Options) // centered box, clipped to Screen +func DrawPause(c *Canvas, l Layout, o Options) +func DrawHelp(c *Canvas, l Layout, lines []string, o Options) +func DrawGameOver(c *Canvas, l Layout, g *game.Game, o Options) +``` + +Copy is fixed by the spec: pause is `TEMPORAL SUSPENSION` / `SPACE IS PAUSED` / `p resume` (§30). Game over is `UNIVERSE EXPIRED`, `SCORE`, `LINES`, `LEVEL`, `r REBOOT UNIVERSE`, `q ACCEPT COSMIC DEATH`, subtitle `CAUSE: EXCESSIVE GEOMETRY` (§28). Help is `FLIGHT MANUAL` with the §39 rows; `Snapshot.HelpLines []string` carries them from `app` (built from `KeyMap.FullHelp()` via `bubbles/help`), so `render` owns no key vocabulary. Add `HelpLines []string` to `Snapshot`. + +Cards never draw outside `l.Screen` and shrink their box to fit when the terminal is small. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestPauseOverlayCopy(t *testing.T) +// Render with Phase PhasePaused contains "TEMPORAL SUSPENSION", "SPACE IS PAUSED", "p resume" + +func TestGameOverCardShowsFinalNumbers(t *testing.T) +// g.Score=483200; g.Lines=127; g.Level=13; Phase PhaseGameOver => +// output contains "UNIVERSE EXPIRED", "483,200", "127", "13", +// "r REBOOT UNIVERSE", "q ACCEPT COSMIC DEATH" + +func TestHelpOverlayListsEveryBinding(t *testing.T) +// ShowHelp true with the app-supplied lines => output contains "FLIGHT MANUAL" and +// one row per binding + +func TestOverlaysStayInsideTheScreen(t *testing.T) +// for each of pause/help/game over, at 40x24 and 100x40: every line of the output +// has width <= w and the line count <= h + +func TestOverlaysDoNotChangeTheBoardGeometry(t *testing.T) +// the board's 20 rows keep width BoardCols in every overlay state (cards may cover +// them, but no row grows or shrinks) + +func TestGoldenPause(t *testing.T) // 100x40 +func TestGoldenGameOver(t *testing.T) // 100x40 +func TestGoldenHelp(t *testing.T) // 100x40 +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/render/ -run 'TestPause|TestGameOver|TestHelp|TestOverlays|TestGolden' -v` +Expected: FAIL — undefined `DrawPause`. + +- [ ] **Step 3: Implement `overlay.go`, wire it into `Render`, wire `HelpLines` in `app`** + +In `app`, build `HelpLines` from `m.Help` + `m.Keys.FullHelp()` once per render. + +- [ ] **Step 4: Create the goldens and run everything** + +Run: `go test ./internal/render/ -run TestGolden -update && go test ./... -v` +Expected: PASS; inspect the three new `testdata` files by eye. + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/overlay.go internal/render/render.go internal/render/overlay_test.go internal/render/golden_test.go internal/render/testdata internal/app +git commit -m "feat(render): pause, help and game-over overlays with goldens" +``` + +--- + +### Task 8: Phase 2 acceptance + +**Files:** +- Test: `internal/app/acceptance_test.go` + +**Interfaces:** +- Consumes: the whole app. +- Produces: nothing new — this is §42's "at this point it should already be a genuinely good game" gate. + +- [ ] **Step 1: Write the test** + +```go +func TestFullSessionThroughGameOverNeverPanics(t *testing.T) +// m := New(Options{Seed: 42, SeedSet: true}); WindowSizeMsg{100,40}; ColorProfileMsg TrueColor. +// Loop up to 20000 iterations: every iteration feed one FrameMsg 16ms later and, every +// third iteration, one pseudo-random key from the full key set (excluding q/esc), then call +// View() and assert the content is non-empty. Every 500 iterations feed a WindowSizeMsg +// cycling through {100,40},{72,30},{40,24},{34,19},{0,0}. +// Assert the loop reaches Phase == PhaseGameOver, and that no iteration panicked. +``` + +- [ ] **Step 2: Run it** + +Run: `go test ./internal/app/ -run TestFullSession -v` +Expected: PASS + +- [ ] **Step 3: Commit** + +```bash +git add internal/app/acceptance_test.go +git commit -m "test(app): full session with resizes and random input" +``` diff --git a/tests/inline-eval/fixtures/cosmic-tetris-planset-trap/plans/2026-09-18-cosmic-tetris-3-cosmic-foundation.md b/tests/inline-eval/fixtures/cosmic-tetris-planset-trap/plans/2026-09-18-cosmic-tetris-3-cosmic-foundation.md new file mode 100644 index 000000000..065328e85 --- /dev/null +++ b/tests/inline-eval/fixtures/cosmic-tetris-planset-trap/plans/2026-09-18-cosmic-tetris-3-cosmic-foundation.md @@ -0,0 +1,784 @@ +# Cosmic Tetris — Plan 3: Cosmic Foundation + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build `internal/fx` as an independent spectacle simulation and wire it into the renderer: particle physics, three-layer starfield, energy-reactive animated board border, piece ion trails, and the `internal/flavor` mission-control channel. + +**Architecture:** `GameState → events → FXWorld → Renderer`, one direction only. `fx` imports `game` for event and piece types and never imports `render` or `app`; `render` imports `fx` to draw it. `fx.World` holds its own `*rand.Rand`, seeded separately from the game, so particle randomness can never shift piece order. `Observe` takes events plus a read-only `GameView` value — the FX package never holds a `*game.Game`, which makes §14's "may never modify GameState" structural rather than a promise. + +**Tech Stack:** Go 1.26, `charm.land/lipgloss/v2`, `charm.land/bubbletea/v2`, standard library `math`/`math/rand`. + +**Spec:** `design.md` (§9 quantum storage, §14, §15, §17, §23, §25, §26, §27, §32 reduced modes, §33, §35, §37, §38, §42 Phase 3, §44, §49.6) + +## Global Constraints + +- Language: Go. Module `cosmic-tetris`, `go 1.26`. +- `internal/fx` must not import `internal/render`, `internal/app`, or `internal/flavor`; it may import `internal/game`. `internal/flavor` may import `internal/game` and `internal/fx`. +- FX may observe game events and never modify game state (§14, §44). +- Game RNG and FX RNG are separate `*rand.Rand` instances and never share (§35, §49.6). +- No goroutine per particle or per frame; no filesystem work during gameplay; no synchronous logging per frame (§38). +- A few hundred particles must be trivial; reuse slices (§38). Hard cap `MaxParticles = 400`. +- Effects never obscure the active piece, never delay gameplay, never alter it (§44). +- Board readability is sacred: the background must never make the board harder to read (§15, §21). +- ASCII mode emits only ASCII runes (§32). Every FX glyph needs an ASCII fallback. + +## Review Focus + +1. FX enabled versus disabled changing the game outcome — the same input script must produce an identical board, score and next queue either way (Task 2). +2. A resize while thousands of particle-seconds of state exist — off-screen stars and particles must be culled or re-seeded with no index panic (Task 3, Task 4). +3. An event burst in a single frame (lock + 4 lines + combo + level, ten events at once) — the mission line must show one message and hold it, not thrash (Task 7). +4. A long session (ten simulated minutes) — star, particle and trail slices must stay bounded (Task 1, Task 8). +5. Background versus foreground legibility — no star, particle or trail may replace a locked cell or an active-piece cell in the rendered output (Task 4, Task 6). + +## Plan Set + +Run in this order. A ruling that changes a name, signature, or value a later plan consumes is applied to that plan's file before the next task starts. + +1. `plans/2026-09-18-cosmic-tetris-1-engine.md` — headless deterministic engine in `internal/game`. Consumes: nothing. +2. `plans/2026-09-18-cosmic-tetris-2-playable-terminal.md` — Bubble Tea app, canvas renderer, layout, HUD, hold/next/ghost, CLI flags, pause/help/game-over card, golden tests. Consumes Plan 1's `game.Game`, `Advance`, `Event`, `Board`, `Piece`, `GhostY`. +3. `plans/2026-09-18-cosmic-tetris-3-cosmic-foundation.md` — `internal/fx` (particles, starfield), animated border, piece trails, `internal/flavor` mission control. Consumes Plan 1's `Event`/`Cell` and Plan 2's `render.Canvas`, `render.Layout`, `render.Snapshot`, `app.Model`. +4. `plans/2026-09-18-cosmic-tetris-4-violence.md` — hard-drop impact, screen shake, line supernova, shockwaves, hyperdrive, four-line sequence, combo/level overlays. Consumes Plan 3's `fx.World` and the render FX layer. +5. `plans/2026-09-18-cosmic-tetris-5-polish.md` — boot sequence, game-over black hole, ASCII/no-FX guarantees, §45 details, README, definition-of-done sweep. Consumes everything above. + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `internal/fx/particle.go` | `Particle`, the §23 integrator, spawn caps | +| `internal/fx/events.go` | `GameView`, event→FX reaction table, `Energy` | +| `internal/fx/world.go` | `World`: state, `Step`, `Observe`, `Resize`, FX RNG | +| `internal/fx/starfield.go` | `Star`, three layers, density, drift, wrap, level speed | +| `internal/fx/trail.go` | `Trail`: short-lived ion trails behind moving pieces; `Quantum`: the §9 hold effect | +| `internal/render/fx.go` | draw stars / particles / trails; `ASCIISafe` | +| `internal/render/border.go` | energy-reactive animated border colour | +| `internal/flavor/messages.go` | the message tables and event→category mapping | +| `internal/flavor/channel.go` | `Channel`: one line at a time, with hold and idle timers | + +`internal/fx/trail.go`, `internal/render/fx.go`, `internal/render/border.go` and `internal/flavor/channel.go` are additions to §33's file list, each a distinct responsibility inside a package §33 already names. + +--- + +### Task 1: Particle and the terminal-space integrator + +**Files:** +- Create: `internal/fx/particle.go` +- Test: `internal/fx/particle_test.go` + +**Interfaces:** +- Consumes: nothing. +- Produces: +```go +type Class int +const (ClassStar Class = iota; ClassDebris; ClassSpark; ClassEmber) + +type Particle struct { + X, Y float64 // canvas cell coordinates (column, row), fractional + VX, VY float64 // cells per second + AX, AY float64 // cells per second squared + Life float64 // seconds remaining + MaxLife float64 // seconds + Glyph rune + Brightness float64 // 0..1 + Class Class +} + +const ( + ParticleGravity = 14.0 // cells/s² downward, the default AY for debris + ParticleDrag = 0.05 // fraction of velocity surviving one second + MaxParticles = 400 +) + +func (p *Particle) Step(dt float64) +func (p Particle) Alive(w, h int) bool // Life > 0 and inside the viewport with a 2-cell margin +func (p Particle) Fade() float64 // Life / MaxLife, clamped 0..1 +``` + +Integration order is §23's, exactly: `position += velocity × dt`, then `velocity += acceleration × dt`, then `velocity *= pow(ParticleDrag, dt)`, then `life -= dt`. No collision detection. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestStepIntegratesPositionBeforeDrag(t *testing.T) +// p := Particle{VX:10, Life:2, MaxLife:2}; p.Step(1.0) +// => p.X == 10 (position used the pre-drag velocity) +// => p.VX == 10*ParticleDrag (0.5), within 1e-9 +// => p.Life == 1 + +func TestGravityAccelerates(t *testing.T) +// p := Particle{AY: ParticleGravity, Life:2, MaxLife:2}; p.Step(1.0) +// => p.VY == ParticleGravity*ParticleDrag, within 1e-9; p.Y == 0 + +func TestDeadWhenLifeRunsOut(t *testing.T) +// Life 0.5, Step(0.5) => !Alive(80,24) + +func TestDeadWhenOutsideViewport(t *testing.T) +// Particle{X:-5, Life:1, MaxLife:1}.Alive(80,24) == false +// Particle{X:200, ...}, Particle{Y:-5, ...}, Particle{Y:100, ...} all false +// Particle{X:0, Y:0, ...} true (edge cells count as inside) + +func TestFadeIsNormalised(t *testing.T) +// Life 1, MaxLife 2 => Fade() == 0.5; Life 3, MaxLife 2 => 1; Life -1 => 0 +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -v` +Expected: FAIL — package does not exist. + +- [ ] **Step 3: Implement `internal/fx/particle.go`** + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/fx/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/particle.go internal/fx/particle_test.go +git commit -m "feat(fx): terminal-space particle integrator" +``` + +--- + +### Task 2: FXWorld, event observation and engine isolation + +**Files:** +- Create: `internal/fx/world.go`, `internal/fx/events.go` +- Test: `internal/fx/world_test.go`, `internal/fx/isolation_test.go` + +**Interfaces:** +- Consumes: Task 1; `game.Event`, `game.EventKind`, `game.Piece`. +- Produces: +```go +type Options struct { + Enabled bool + ReducedMotion bool +} + +type GameView struct { + Level, Combo, Score, Lines int + Active game.Piece + Over bool +} +func ViewOf(g *game.Game) GameView // the only bridge; copies values, keeps no pointer + +type World struct { + Opts Options + W, H int + Stars []Star + Particles []Particle + Trails []Trail + Energy float64 // 0..1, the board border's "energy state" + Elapsed time.Duration + View GameView + // unexported: rng *rand.Rand +} + +func NewWorld(seed int64, w, h int, o Options) *World +func (w *World) Resize(width, height int) +func (w *World) Step(dt time.Duration) +func (w *World) Observe(evs []game.Event, v GameView) +func (w *World) Spawn(p Particle) // drops the oldest particle when at MaxParticles +func (w *World) Rand() *rand.Rand // FX RNG, for FX use only + +const ( + EnergyDecayPerSecond = 0.55 // multiplicative survival fraction per second +) +var EnergyBump = map[game.EventKind]float64{ // added on each event, Energy clamped to 1 + game.PieceLocked: 0.10, + game.PieceHardDropped: 0.25, + game.LinesCleared: 0.30, // × line count + game.LevelChanged: 0.50, + game.ComboChanged: 0.10, // × combo + game.HoldUsed: 0.08, +} +``` + +`Step` advances `Elapsed`, decays `Energy`, steps the starfield, steps and compacts particles in place (filter without reallocating), and ages trails. `Observe` records `v` into `w.View`, bumps `Energy`, and dispatches to per-event reactions (Task 6 adds trails; Plan 4 adds the rest). When `Opts.Enabled` is false, `Observe` returns immediately and `Step` only advances `Elapsed`. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestNewWorldSeedsStarsAndNothingElse(t *testing.T) +// w := NewWorld(7, 80, 24, Options{Enabled:true}) +// len(w.Stars) == StarCount(80,24); len(w.Particles) == 0; w.Energy == 0 + +func TestFXDisabledWorldStaysEmpty(t *testing.T) +// Options{Enabled:false}: len(Stars)==0; Observe(lots of events) leaves Particles empty +// and Energy at 0; Step(1s) still advances Elapsed + +func TestEnergyBumpsAndDecays(t *testing.T) +// Observe([]game.Event{{Kind: game.LinesCleared, Count: 2}}, GameView{Level:1}) +// => Energy == 0.60 (0.30 × 2), within 1e-9 +// Step(1s) => Energy == 0.60 * EnergyDecayPerSecond +// a 4-line clear at Energy 0.9 clamps to 1.0, never above + +func TestSpawnRespectsTheCap(t *testing.T) +// Spawn MaxParticles+50 particles => len(Particles) == MaxParticles and the +// most recently spawned particle is present + +func TestStepCullsDeadParticles(t *testing.T) +// spawn 10 with Life 0.1 and 10 with Life 5 => Step(200ms) leaves exactly 10 + +func TestResizeKeepsStarsInsideTheViewport(t *testing.T) +// w := NewWorld(7,120,40,...); Step a few seconds; w.Resize(40,24) +// => len(Stars)==StarCount(40,24) and every star has 0<=X<40, 0<=Y<24 +// Resize(0,0) then Step(1s) does not panic; Resize back to 80x24 re-seeds stars + +func TestLongSessionStaysBounded(t *testing.T) +// 10 simulated minutes at 16ms steps with an event every 10 frames: +// len(Particles) <= MaxParticles, len(Stars) == StarCount, len(Trails) <= 64 throughout +``` + +```go +func TestObserveNeverTouchesGameState(t *testing.T) +// g := game.New(99); before := fingerprint(g) (board string + score/lines/level/combo/active/next) +// evs := g.HardDrop(); w.Observe(evs, ViewOf(g)); w.Step(16ms) +// => fingerprint(g) equals the value taken right after HardDrop + +func TestFXDoesNotChangeTheGameOutcome(t *testing.T) +// run the same canned (key, dt) script twice through app-free helpers: +// once with a World{Enabled:true} observing every event, once with Enabled:false, +// both games seeded game.New(8675309) +// => identical Board.String(), Score, Lines, Level, Combo, Next + +func TestFXRNGIsIndependentOfTheGameRNG(t *testing.T) +// two Worlds with the same fx seed observing the event streams of two *different* +// game seeds produce the same number of Rand() draws for the same event sequence; +// and two Worlds with different fx seeds over the same event stream differ +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -v` +Expected: FAIL — undefined `NewWorld`. + +- [ ] **Step 3: Implement `world.go` and `events.go`** + +`StarCount` and `Star` land in Task 3; stub them minimally here (or land Task 3 first if the reviewer prefers) — the `world_test.go` assertions above reference `StarCount`, so implement `starfield.go`'s `StarCount`/`Star`/seed helper as part of this task and leave drift to Task 3. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/fx/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/world.go internal/fx/events.go internal/fx/world_test.go internal/fx/isolation_test.go +git commit -m "feat(fx): FX world, event observation, energy and engine isolation" +``` + +--- + +### Task 3: Three-layer starfield + +**Files:** +- Create: `internal/fx/starfield.go` (completing Task 2's stub) +- Test: `internal/fx/starfield_test.go` + +**Interfaces:** +- Consumes: Task 2's `World`. +- Produces: +```go +type Star struct { + X, Y float64 + Layer int // 0 far, 1 mid, 2 near + Glyph rune + Bright float64 // 0..1 +} + +var LayerSpeeds = [3]float64{0.8, 2.0, 5.0} // cells/s at level 1 +var LayerGlyphs = [3][]rune{{'.'}, {'·', '˚'}, {'✦', '✧', '*'}} // §15 +var LayerBright = [3]float64{0.30, 0.60, 1.0} +var LayerWeights = [3]float64{0.55, 0.30, 0.15} // share of the star budget + +func StarCount(w, h int) int // (w*h)/45, floored at 12, capped at 240 +func SpeedForLevel(level int) float64 // 1 + 0.06*(level-1), capped at 2.5 +func (w *World) StarSpeed() float64 // SpeedForLevel(w.View.Level); Plan 4 multiplies in hyperdrive +func (w *World) stepStars(dt float64) +func (w *World) seedStars() +``` + +Stars drift downward (§15). A star leaving the bottom wraps to `Y -= float64(h)` with a fresh random `X` and glyph, so the population is constant. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestStarCountScalesWithArea(t *testing.T) +// StarCount(80,24) == 42; StarCount(10,2) == 12 (floor); StarCount(400,100) == 240 (cap) + +func TestLayerDistribution(t *testing.T) +// a seeded 80x24 world has at least one star in each of the three layers, and +// layer 0 is the most populous + +func TestStarsDriftDownward(t *testing.T) +// record every star's Y, Step(500ms), assert every star moved down by +// LayerSpeeds[layer]*0.5*SpeedForLevel(1) or wrapped + +func TestStarsWrapInsteadOfDisappearing(t *testing.T) +// Step(60s) => len(Stars) unchanged and every star is inside the viewport + +func TestHigherLevelMovesStarsFaster(t *testing.T) +// SpeedForLevel(1) == 1.0; SpeedForLevel(7) == 1.36 within 1e-9; SpeedForLevel(40) == 2.5 +// a level-10 world displaces its stars strictly further than a level-1 world +// over the same 500ms, from the same fx seed + +func TestReducedMotionKeepsStarsDrifting(t *testing.T) +// Options{Enabled:true, ReducedMotion:true}: stars still move (only hyperdrive, +// shake and shockwaves are suppressed, §49.5) + +func TestStarGlyphsComeFromTheLayerTables(t *testing.T) +// every star's Glyph is in LayerGlyphs[star.Layer] +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -run TestStar -v` +Expected: FAIL — undefined `LayerSpeeds`. + +- [ ] **Step 3: Implement `internal/fx/starfield.go`** + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/fx/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/starfield.go internal/fx/starfield_test.go +git commit -m "feat(fx): three-layer starfield with level-scaled drift" +``` + +--- + +### Task 4: Render the starfield and particles + +**Files:** +- Create: `internal/render/fx.go` +- Modify: `internal/render/render.go` (add `FX *fx.World` to `Snapshot`, insert §37 steps 2, 6 and 9), `internal/render/board.go` (leave empty board cells unset so space shows through) +- Test: `internal/render/fx_test.go`, `internal/render/golden_test.go` + +**Interfaces:** +- Consumes: Plan 2's `Canvas`, `Layout`, `Options`, `Paint`; Tasks 1–3. +- Produces: +```go +func DrawStarfield(c *Canvas, l Layout, w *fx.World, o Options) +func DrawParticles(c *Canvas, l Layout, w *fx.World, o Options) +func ASCIISafe(r rune) rune +func StarPaint(s fx.Star, insideBoard bool, m Mode) Paint +func ParticlePaint(p fx.Particle, m Mode) Paint +``` + +Rules: +- Starfield draws first, across the whole canvas, into unset cells only — it never overwrites anything already drawn, and the board draws after it. +- Inside `l.Board`, only layers 0 and 1 are drawn and brightness is halved, so the board stays the most readable thing on screen (§15, §21). Stars inside the board align to even columns so they never split a 2-column cell. +- `DrawLocked` leaves empty board cells unset (previously blank), which is what lets space show through the playfield as in §4. +- Particles draw after the board (§37 step 6 board-local, step 9 global), also into unset cells only, except `ClassDebris`, which may overwrite empty board cells but never a cell holding a block or ghost glyph. +- `ASCIISafe` maps every non-ASCII glyph this project emits to an ASCII stand-in: `✦→* ✧→+ ˚→' ·→. ░→: ▒→; ▓→# █→# ○→o ◌→o ◯→O ●→@ ☄→> ✪→*`. `ModeASCII` routes every glyph through it. +- When `o.FXEnabled` is false or `s.FX` is nil, both draw functions return immediately. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestStarfieldDrawsIntoEmptySpace(t *testing.T) +// canvas at a wide layout, DrawStarfield with a seeded world +// => the number of non-blank cells is > 0 and <= len(w.Stars) + +func TestStarfieldNeverOverwritesLockedCells(t *testing.T) +// fill board rows 18..21, draw stars, then locked, then stars again in that order: +// every cell of the bottom four board rows holds a block glyph, never a star glyph + +func TestStarfieldNeverOverwritesTheActivePiece(t *testing.T) +// after the full pipeline, every cell of Active.Cells() renders BlockGlyph + +func TestStarsInsideTheBoardAreDimAndNear(t *testing.T) +// every star drawn within l.Board has Layer <= 1 and an even column offset from l.Board.X + +func TestParticlesDrawAfterTheBoard(t *testing.T) +// a ClassDebris particle placed over an empty board cell appears in the output; +// one placed over a locked cell does not + +func TestASCIISafeCoversEveryGlyphWeEmit(t *testing.T) +// for every rune in LayerGlyphs, the debris glyph set, the ghost/block glyphs and BoxChars: +// ASCIISafe(r) < 128 + +func TestFXDisabledRendersExactlyLikePlanTwo(t *testing.T) +// Render with Options{FXEnabled:false} and a non-nil World produces output identical +// to Render with FX nil — assert against testdata/wide.txt + +func TestRenderWithFXIsDeterministic(t *testing.T) +// same world (fx seed 4242, stepped 30 frames of 16ms) rendered twice => identical strings + +func TestGoldenWideWithStarfield(t *testing.T) +// 100x40, fx seed 4242, 30 frames stepped, ModeFull => testdata/wide-fx.txt +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/render/ -run 'TestStarfield|TestStars|TestParticles|TestASCII|TestFXDisabled|TestRenderWithFX' -v` +Expected: FAIL — undefined `DrawStarfield`. + +- [ ] **Step 3: Implement `internal/render/fx.go` and the `render.go` / `board.go` changes** + +- [ ] **Step 4: Create the new golden and run everything** + +Run: `go test ./internal/render/ -run TestGolden -update && go test ./... -v` +Expected: PASS. Read `testdata/wide-fx.txt` and confirm the board is still legible with stars behind it. + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/fx.go internal/render/render.go internal/render/board.go internal/render/fx_test.go internal/render/golden_test.go internal/render/testdata +git commit -m "feat(render): composite starfield and particles behind the board" +``` + +--- + +### Task 5: Animated board border + +**Files:** +- Create: `internal/render/border.go` +- Modify: `internal/render/board.go` (`DrawBoardBorder` gains `elapsed` and `energy` parameters and colours each border cell through `BorderPaintAt`, replacing Plan 2's single `Paint` argument; update its callers) +- Test: `internal/render/border_test.go` + +**Interfaces:** +- Consumes: Plan 2's `BorderPalette`, `BoxChars`, `Paint`. +- Produces: +```go +func BorderPaintAt(elapsed time.Duration, energy float64, i, n int, m Mode) Paint +func DrawBoardBorder(c *Canvas, l Layout, o Options, elapsed time.Duration, energy float64) +const BorderCyclePeriod = 12 * time.Second // calm palette drift (§25: "the shift should be subtle") +``` + +`BorderPaintAt` colours border cell `i` of `n`, walking the box clockwise from the top-left. Phase is `elapsed/BorderCyclePeriod + energy*elapsed/(1.5s)`, plus a per-cell offset of `energy × i/n` — so at rest the whole border is one slowly drifting colour, and at high energy a gradient visibly travels around it (§25). Colours are linear RGB interpolations between adjacent `BorderPalette` entries, parsed once at init. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestBorderColourDriftsOverTime(t *testing.T) +// BorderPaintAt(0,0,0,84,ModeFull).FG != BorderPaintAt(6*time.Second,0,0,84,ModeFull).FG + +func TestBorderIsUniformAtRest(t *testing.T) +// energy 0: BorderPaintAt(t,0,i,84,...) is the same colour for i = 0, 20, 40, 83 + +func TestHighEnergyMakesAGradient(t *testing.T) +// energy 1: at least 8 distinct colours appear across i = 0..83 + +func TestBorderCycleIsPeriodic(t *testing.T) +// BorderPaintAt(0,0,0,84,...) == BorderPaintAt(BorderCyclePeriod,0,0,84,...) + +func TestBorderColoursStayInThePalette(t *testing.T) +// every colour produced over a 12s sweep lies on a segment between two adjacent +// BorderPalette entries (each channel within [min,max] of that pair) + +func TestASCIIBorderUsesASCIIBoxChars(t *testing.T) +// DrawBoardBorder in ModeASCII: every rune of the border rows is < 128 +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/render/ -run TestBorder -v` +Expected: FAIL — undefined `BorderPaintAt`. + +- [ ] **Step 3: Implement `internal/render/border.go` and update `DrawBoardBorder` callers** + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS (regenerate goldens only if the border characters changed, never to hide a layout break) + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/border.go internal/render/board.go internal/render/border_test.go +git commit -m "feat(render): energy-reactive animated board border" +``` + +--- + +### Task 6: Piece ion trails and quantum storage + +**Files:** +- Create: `internal/fx/trail.go` +- Modify: `internal/fx/world.go` (`Observe` spawns trails and the hold streak, `Step` ages them), `internal/render/fx.go` (`DrawTrails`, `DrawQuantumStorage`), `internal/render/render.go` (pipeline step 6) +- Test: `internal/fx/trail_test.go`, `internal/fx/quantum_test.go`, `internal/render/trail_test.go` + +**Interfaces:** +- Consumes: Task 2's `World`; `game.Point`, `game.PieceKind`. +- Produces: +```go +type Trail struct { + Cells []game.Point // logical board cells the piece just vacated + Kind game.PieceKind + Age float64 // seconds + Life float64 // seconds +} +const ( + TrailLife = 0.14 // 140ms, inside §17's 100–160ms window + MaxTrails = 64 +) +func (t Trail) Stage() int // 0,1,2 → ▓▓ ▒▒ ░░ ; -1 when expired +func (w *World) spawnTrail(p game.Piece) + +type Quantum struct { + Out game.PieceKind // the piece being compressed away + In game.PieceKind // the piece flashing into existence + Age float64 +} +const QuantumLife = 0.12 // §9: ~120ms +func (q Quantum) Stage() int // 0 compressed, 1 streaked sideways, 2 gone; -1 expired +func (w *World) Quantum() *Quantum // nil when none + +// render +func DrawTrails(c *Canvas, l Layout, g *game.Game, w *fx.World, o Options) +func DrawQuantumStorage(c *Canvas, l Layout, w *fx.World, o Options) +var TrailGlyphs = [3]string{"▓▓", "▒▒", "░░"} // ASCII: "##", ";;", ".." +``` + +`Observe` spawns a trail from the piece's previous cells on `PieceMoved` and `PieceRotated`. Trails carry the vacated cells, so they never sit under the piece itself. `Step` ages them and drops expired ones, capped at `MaxTrails`. + +`HoldUsed` starts a `Quantum` (§9): inside the HOLD region, the outgoing piece compresses vertically (stage 0), streaks sideways out of the box with `TrailGlyphs` shoulders (stage 1), and vanishes (stage 2), while the incoming piece flashes in at full brightness for the same 120ms. Gameplay does not wait for it — the engine has already swapped the pieces, and `DrawQuantumStorage` draws only inside `l.Hold`. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestMoveSpawnsATrailOfFourCells(t *testing.T) +// Observe([]game.Event{{Kind: game.PieceMoved, Piece: p}}, view) +// => one Trail with len(Cells) == 4 and Kind == p.Kind + +func TestTrailExpiresWithinTheSpecWindow(t *testing.T) +// Step(100ms) => the trail is still present; Step(another 100ms) => Trails is empty +// Stage() walks 0 → 1 → 2 → -1 across its life + +func TestTrailsAreCapped(t *testing.T) +// 200 PieceMoved events in one Observe => len(Trails) <= MaxTrails + +func TestNoTrailsWhenFXDisabled(t *testing.T) +// Options{Enabled:false} => Observe spawns none + +func TestTrailsNeverCoverTheActivePieceOrLockedCells(t *testing.T) +// render a frame where a trail overlaps both the active piece and a locked cell: +// those cells hold BlockGlyph in the output, not a trail glyph + +func TestTrailGlyphsAreASCIISafeInASCIIMode(t *testing.T) +// every rune of the trail cells in a ModeASCII render is < 128 +``` + +```go +func TestHoldStartsQuantumStorage(t *testing.T) +// Observe([]game.Event{{Kind: game.HoldUsed, Piece: outgoing}}, view with Active = incoming) +// => Quantum() != nil with Out == outgoing.Kind and In == view.Active.Kind + +func TestQuantumStagesAndLifetime(t *testing.T) +// Stage() is 0 at Age 0.01, 1 at 0.06, 2 at 0.11; Step(120ms) => Quantum() == nil + +func TestQuantumDoesNotDelayGameplay(t *testing.T) +// app level: press "c", then feed 8 frames of 16ms => the new active piece descended +// on schedule and a second "c" is still correctly refused (hold once per piece) + +func TestQuantumDrawsOnlyInsideTheHoldRegion(t *testing.T) +// every cell the quantum effect writes lies within l.Hold; the board region is untouched +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -run 'TestTrail|TestHoldStarts|TestQuantum' -v && go test ./internal/render/ -run TestTrail -v` +Expected: FAIL — undefined `Trail`. + +- [ ] **Step 3: Implement `trail.go`, the quantum state, the `world.go` hooks, `DrawTrails` and `DrawQuantumStorage`** + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./... -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/trail.go internal/fx/world.go internal/fx/trail_test.go internal/fx/quantum_test.go internal/render/fx.go internal/render/render.go internal/render/trail_test.go +git commit -m "feat(fx): short-lived piece ion trails and quantum storage hold effect" +``` + +--- + +### Task 7: Mission control + +**Files:** +- Create: `internal/flavor/messages.go`, `internal/flavor/channel.go` +- Test: `internal/flavor/messages_test.go`, `internal/flavor/channel_test.go` + +**Interfaces:** +- Consumes: `game.Event`, `fx.GameView`. +- Produces: +```go +type Category int +const ( + CatBoot Category = iota + CatIdle + CatLock + CatClear + CatTetris + CatCombo + CatLevel + CatHold + CatHardDrop + CatGameOver + CatRare +) + +var Messages = map[Category][]string{...} +func ForEvent(ev game.Event, v fx.GameView) (Category, bool) +func Pick(cat Category, rng *rand.Rand, last string) string // never returns `last` +func Format(text string) string // "☄ MISSION CONTROL: " + text + +type Channel struct{ /* unexported */ } +const ( + MinHold = 2500 * time.Millisecond // §27: "give them time to breathe" + IdlePrompt = 20 * time.Second + RareChance = 0.02 +) +func NewChannel(seed int64) *Channel +func (c *Channel) Observe(evs []game.Event, v fx.GameView) +func (c *Channel) NoteInput() +func (c *Channel) Step(dt time.Duration) +func (c *Channel) Text() string // formatted line, "" when there is nothing to say +``` + +Copy comes verbatim from the spec: §27 for `CatLock`/`CatClear`/`CatIdle` (`NOMINALISH`, `GRAVITY REMAINS MOSTLY LEGAL`, `TETROMINO INJECTION SUCCESSFUL`, `STRUCTURAL VIBES: QUESTIONABLE`, `LOCAL UNIVERSE STABLE*`, `* DEFINITION OF STABLE UNDER REVIEW`, `MOON NOTIFIED`, `ORBITAL OSHA HAS ENTERED THE CHAT`, `WE HAVE EXCEEDED THE RECOMMENDED NUMBER OF BLOCKS`, `PHYSICS TEAM SAYS KEEP GOING`), §21 for `CatCombo` (`COMBO 5 // UNAUTHORIZED ORBITAL MANEUVER`, `COMBO 6 // STRUCTURAL REALITY FAILURE`, `COMBO 7 // NASA DENIES EVERYTHING`, with the combo number substituted), §22 for `CatLevel` (`GRAVITY TAX INCREASED`, `LOCAL PHYSICS UPDATED WITHOUT CONSENT`, `PLEASE SECURE ALL LOOSE TETROMINOES`), §45 for `CatRare` (`DID YOU KNOW YOU'RE IN A TERMINAL?`) and `CatIdle` (`CAPTAIN?`). + +Priority when several events arrive in one frame, highest first: `CatGameOver`, `CatTetris`, `CatLevel`, `CatCombo`, `CatHardDrop`, `CatClear`, `CatHold`, `CatLock`. A new message replaces the current one only when the current one has been held `MinHold` or the new category outranks it. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestEveryCategoryHasMessages(t *testing.T) +// every Category constant has at least two entries except CatBoot (>=1) + +func TestForEventMapping(t *testing.T) +// LinesCleared Count 1 => CatClear; Count 4 => CatTetris +// ComboChanged Count 5 => CatCombo; Count 0 => no message (false) +// LevelChanged => CatLevel; HoldUsed => CatHold; PieceHardDropped => CatHardDrop +// GameOver => CatGameOver; PieceMoved => false + +func TestComboMessageCarriesTheNumber(t *testing.T) +// Pick(CatCombo, rng, "") for a combo of 5 renders text containing "COMBO 5" + +func TestPickNeverRepeatsTheLastLine(t *testing.T) +// 200 Picks with `last` threaded through never return the same line twice in a row + +func TestBurstOfEventsYieldsOneHeldMessage(t *testing.T) +// Observe with 10 events (lock + 4-line clear + combo 5 + level up) in one call +// => exactly one Text(); Step(2.4s) with more low-rank events arriving keeps that +// same text; Step(0.2s) more and then a CatClear event replaces it + +func TestHigherPriorityInterruptsImmediately(t *testing.T) +// a CatLock message at age 100ms is replaced by a CatTetris event in the next Observe + +func TestIdlePromptAfterTwentySeconds(t *testing.T) +// NewChannel then Step(19s) => no "CAPTAIN?"; Step(2s) more => Text() contains "CAPTAIN?" +// NoteInput() resets the idle timer + +func TestChannelIsSeedDeterministic(t *testing.T) +// two channels with seed 5 fed the same events and steps produce identical Text() sequences + +func TestFormatPrefix(t *testing.T) +// Format("NOMINALISH") == "☄ MISSION CONTROL: NOMINALISH" +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/flavor/ -v` +Expected: FAIL — package does not exist. + +- [ ] **Step 3: Implement `messages.go` and `channel.go`** + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/flavor/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/flavor +git commit -m "feat(flavor): mission control message tables and pacing channel" +``` + +--- + +### Task 8: Wire FX and mission control into the app + +**Files:** +- Modify: `internal/app/model.go`, `internal/app/update.go`, `internal/render/render.go` (`Snapshot.Mission` already exists; pass `FX`) +- Test: `internal/app/fx_test.go` + +**Interfaces:** +- Consumes: Tasks 2–7. +- Produces: +```go +// Model gains: +FX *fx.World +Mission *flavor.Channel +FXSeed int64 // Seed ^ 0x5F3759DF — derived once, never shared with the game RNG +``` + +Wiring rules: +- `New` builds `fx.NewWorld(FXSeed, 0, 0, fx.Options{Enabled: !o.NoFX, ReducedMotion: o.ReducedMotion})` and `flavor.NewChannel(FXSeed)`. +- `tea.WindowSizeMsg` also calls `m.FX.Resize(w, h)`. +- Every engine call's returned events go to `m.FX.Observe(evs, fx.ViewOf(m.Game))` and `m.Mission.Observe(evs, fx.ViewOf(m.Game))`; key presses additionally call `m.Mission.NoteInput()`. +- `FrameMsg` steps `m.FX` and `m.Mission` with the same clamped `dt` used for `Advance`. While paused, FX steps at `dt/10` (§30: background stars keep drifting slowly) and the mission channel does not step. +- `Snapshot()` fills `FX: m.FX` and `Mission: m.Mission.Text()`. +- `--no-fx` leaves the game fully playable with an empty `World` (§32). + +- [ ] **Step 1: Write the failing tests** + +```go +func TestModelFeedsEventsToFX(t *testing.T) +// press space (hard drop) => m.FX.Energy > 0 and m.Mission.Text() != "" + +func TestFXSeedIsNotTheGameSeed(t *testing.T) +// New(Options{Seed: 1234, SeedSet: true}): m.FXSeed != m.Seed + +func TestNoFXFlagLeavesTheGamePlayable(t *testing.T) +// New(Options{NoFX:true}): a 200-frame session with keys still clears a line, +// m.FX.Particles stays empty, and View() is non-empty + +func TestPausedStarsKeepDriftingSlowly(t *testing.T) +// pause, record star Y values, feed 1s of frames => stars moved, board unchanged + +func TestSameScriptSameOutcomeWithAndWithoutFX(t *testing.T) +// the canned script from Plan 2's tests, run on New(Options{Seed:5,SeedSet:true}) and +// New(Options{Seed:5,SeedSet:true,NoFX:true}) => identical Board.String(), Score, Lines, Level + +func TestThirtySecondsOfPlayShowsTheCosmicBaseline(t *testing.T) +// 30 simulated seconds at 16ms with scripted hard drops; over the session assert: +// - at least one frame's output changed in the starfield region between consecutive frames +// - the border colour changed at least twice +// - at least one frame contained a trail glyph +// - m.Mission.Text() was non-empty for at least 10 seconds' worth of frames +// This is §43's first-30-seconds requirement, mechanised. +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/app/ -run 'TestModelFeeds|TestFXSeed|TestNoFX|TestPausedStars|TestSameScript|TestThirtySeconds' -v` +Expected: FAIL — `Model` has no field `FX`. + +- [ ] **Step 3: Implement the wiring** + +- [ ] **Step 4: Run everything and play it** + +Run: `go test ./... -v && go vet ./... && go run ./cmd/cosmic-tetris --seed 1234` +Expected: tests PASS; the terminal is visibly alive at rest — stars drift, the border shifts colour, pieces leave trails, mission control comments. + +- [ ] **Step 5: Commit** + +```bash +git add internal/app internal/render/render.go +git commit -m "feat(app): wire FX world and mission control into the frame loop" +``` diff --git a/tests/inline-eval/fixtures/cosmic-tetris-planset-trap/plans/2026-09-18-cosmic-tetris-4-violence.md b/tests/inline-eval/fixtures/cosmic-tetris-planset-trap/plans/2026-09-18-cosmic-tetris-4-violence.md new file mode 100644 index 000000000..0177e8a1b --- /dev/null +++ b/tests/inline-eval/fixtures/cosmic-tetris-planset-trap/plans/2026-09-18-cosmic-tetris-4-violence.md @@ -0,0 +1,632 @@ +# Cosmic Tetris — Plan 4: Violence + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the spectacle excessive: hard-drop impact, screen shake, the three-phase line-clear supernova, shockwaves, hyperdrive, and the simultaneous four-line event — plus combo escalation, the level-up card and the HUD pulse. + +**Architecture:** Every effect is another aged field on `fx.World` with a trigger in `Observe` and a draw function in `render`. Gameplay never waits: the engine has already cleared the rows and spawned the next piece while the supernova plays, so each effect carries its own snapshot of what it needs (the pre-clear row contents, the cells a hard drop crossed). Screen shake is applied by compositing the board layer into its own canvas and blitting it at an offset clipped to the board box, so shake can never bleed into the HUD. + +**Tech Stack:** Go 1.26, `charm.land/lipgloss/v2`, standard library `math`. + +**Spec:** `design.md` (§16, §17 hard-drop trail, §18, §19, §20, §21, §22, §23, §24, §25 event pulse, §38, §42 Phase 4, §43, §44, §49.5) + +## Global Constraints + +- Language: Go. Module `cosmic-tetris`, `go 1.26`. +- `internal/fx` must not import `internal/render` or `internal/app`. +- FX may observe game events and never modify game state (§14, §44). +- Never delay gameplay for animation; never make controls lag; animations never block input (§44, §47). +- Screen shake never exceeds roughly one terminal cell (§44). +- Particles never permanently alter the rendered board (§44). +- Never obscure the active piece (§44). +- `MaxParticles = 400` stays the hard cap (§38, Plan 3). +- `--reduced-motion` suppresses screen shake, hyperdrive acceleration and shockwaves, and leaves colour, trails and particles alone (§49.5). +- Board readability remains sacred at every combo level (§21). + +## Review Focus + +1. `--reduced-motion` — shake, hyperdrive and shockwaves must be fully off while trails, particles and colour still run (Task 7). +2. Shake offset larger than one cell, or shaken board content bleeding into the HUD columns (Task 1). +3. A banner or level card covering the active piece or the top of the stack (Task 6). +4. The 220ms supernova gating gameplay — the next piece must keep falling, and input must keep working, during the animation (Task 3). +5. A four-line clear that also levels up and lands at combo 5 in a single frame — every overlay must compose at once without exceeding any cap or panicking (Task 6, Task 7). + +## Plan Set + +Run in this order. A ruling that changes a name, signature, or value a later plan consumes is applied to that plan's file before the next task starts. + +1. `plans/2026-09-18-cosmic-tetris-1-engine.md` — headless deterministic engine in `internal/game`. Consumes: nothing. +2. `plans/2026-09-18-cosmic-tetris-2-playable-terminal.md` — Bubble Tea app, canvas renderer, layout, HUD, hold/next/ghost, CLI flags, pause/help/game-over card, golden tests. Consumes Plan 1's `game.Game`, `Advance`, `Event`, `Board`, `Piece`, `GhostY`. +3. `plans/2026-09-18-cosmic-tetris-3-cosmic-foundation.md` — `internal/fx` (particles, starfield), animated border, piece trails, `internal/flavor` mission control. Consumes Plan 1's `Event`/`Cell` and Plan 2's `render.Canvas`, `render.Layout`, `render.Snapshot`, `app.Model`. +4. `plans/2026-09-18-cosmic-tetris-4-violence.md` — hard-drop impact, screen shake, line supernova, shockwaves, hyperdrive, four-line sequence, combo/level overlays. Consumes Plan 3's `fx.World` and the render FX layer. +5. `plans/2026-09-18-cosmic-tetris-5-polish.md` — boot sequence, game-over black hole, ASCII/no-FX guarantees, §45 details, README, definition-of-done sweep. Consumes everything above. + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `internal/fx/shake.go` | deterministic shake pattern and offset | +| `internal/fx/impact.go` | hard-drop ion beam and debris eruption | +| `internal/fx/lineflash.go` | the three-phase supernova state | +| `internal/fx/shockwave.go` | expanding rings | +| `internal/fx/hyperdrive.go` | the §16 speed timeline and star-density boost | +| `internal/fx/banner.go` | four-line banner, level card, HUD pulse, combo tiers | +| `internal/render/boardlayer.go` | board layer canvas + shake blit, clipped to the board box | +| `internal/render/spectacle.go` | draw supernova, shockwaves, star streaks, banners, level card | + +All are new files inside packages §33 already names, each one effect's state or drawing. + +--- + +### Task 1: Screen shake + +**Files:** +- Create: `internal/fx/shake.go`, `internal/render/boardlayer.go` +- Modify: `internal/render/render.go` (route board drawing through the board layer) +- Test: `internal/fx/shake_test.go`, `internal/render/boardlayer_test.go` + +**Interfaces:** +- Consumes: Plan 3's `World`, `Options`; Plan 2's `Canvas`, `Layout`. +- Produces: +```go +type Shake struct{ Age, Life float64 } +var ShakePattern = [5]game.Point{{X: 0, Y: 1}, {X: -1, Y: 0}, {X: 1, Y: 0}, {X: 0, Y: -1}, {X: 0, Y: 0}} // §18 +const (ShakeLife = 0.08; BigShakeLife = 0.14) +func (w *World) TriggerShake(life float64) +func (w *World) ShakeOffset() (dx, dy int) // (0,0) when inactive or ReducedMotion + +// render +func DrawBoardLayer(dst *Canvas, l Layout, s Snapshot, o Options) +``` + +`ShakeOffset` indexes `ShakePattern` by `int(Age / Life * len(ShakePattern))`, clamped to the last entry. `DrawBoardLayer` renders the border, board interior, ghost, active piece and board-local FX into a canvas of `l.Border` size, then blits it into `dst` at `l.Border.X+dx, l.Border.Y+dy` with the blit clipped to the `l.Border` rectangle — content shifted outside the box is dropped rather than drawn over the HUD. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestShakeWalksThePinnedPattern(t *testing.T) +// TriggerShake(ShakeLife); sample ShakeOffset() at Age 0, 0.02, 0.04, 0.06, 0.079 +// => (0,1), (-1,0), (1,0), (0,-1), (0,0) in that order + +func TestShakeEndsAfterEightyMilliseconds(t *testing.T) +// TriggerShake(ShakeLife); Step(80ms) => ShakeOffset() == (0,0) and the shake is inactive + +func TestShakeNeverExceedsOneCell(t *testing.T) +// step through 1000 sub-steps of both ShakeLife and BigShakeLife: +// |dx| <= 1 and |dy| <= 1 at every sample (§44) + +func TestReducedMotionDisablesShake(t *testing.T) +// Options{Enabled:true, ReducedMotion:true}: TriggerShake => ShakeOffset() == (0,0) + +func TestShakenBoardNeverBleedsIntoTheHUD(t *testing.T) +// for each of the five pattern offsets: render a full frame at 100x40 with a filled board; +// every HUD region (Hold, Next, Stats) contains exactly what it contains with no shake + +func TestShakeKeepsTheBoardBoxSize(t *testing.T) +// with shake active, the rows of the output at l.Border.Y..Border.Y+H-1 are never wider +// than l.Border.W within that x-range +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -run TestShake -v` +Expected: FAIL — undefined `ShakePattern`. + +- [ ] **Step 3: Implement `shake.go`, `boardlayer.go` and the `render.go` rewiring** + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./... -v` +Expected: PASS (goldens unchanged — with no shake active the offset is (0,0)) + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/shake.go internal/fx/shake_test.go internal/render/boardlayer.go internal/render/render.go internal/render/boardlayer_test.go +git commit -m "feat(fx): deterministic one-cell screen shake on a clipped board layer" +``` + +--- + +### Task 2: Hard-drop impact + +**Files:** +- Create: `internal/fx/impact.go` +- Modify: `internal/fx/world.go` (`Observe` dispatches `PieceHardDropped`) +- Test: `internal/fx/impact_test.go`, `internal/render/impact_test.go` + +**Interfaces:** +- Consumes: Task 1's `TriggerShake`; Plan 3's `Spawn`, `Trail`, `Energy`. +- Produces: +```go +var DebrisGlyphs = []rune{'·', '*', '✦', '+'} // §18 +const ( + ImpactBeamLife = 0.18 + DebrisPerCell = 3 + DebrisBase = 6 + DebrisMax = 40 + DebrisUpMin = 6.0 // cells/s + DebrisUpMax = 14.0 + DebrisSpreadMax = 9.0 // cells/s horizontal at the edge of the contact area +) +func (w *World) impact(ev game.Event) +func (w *World) spawnBeam(cells []game.Point, life float64) +``` + +`impact` does four things (§18): a vertical ion beam through every cell the piece crossed (`spawnBeam`, `ImpactBeamLife`, capped by `MaxTrails`); `min(DebrisBase + DebrisPerCell*ev.Distance, DebrisMax)` debris particles from the contact row with horizontal velocity signed by their offset from the contact centre, upward initial `VY`, and `AY = ParticleGravity`; `TriggerShake(ShakeLife)`; and the `EnergyBump` for `PieceHardDropped`, which the border flash reads. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestImpactSpawnsBeamDebrisAndShake(t *testing.T) +// Observe a PieceHardDropped{Piece: p, Distance: 12} +// => at least one Trail with Life == ImpactBeamLife covering 12 rows of crossed cells +// => len(Particles) == min(6+3*12, DebrisMax) == 40 +// => ShakeOffset() != (0,0) +// => Energy == 0.25 + +func TestDebrisCountScalesWithDistanceAndCaps(t *testing.T) +// Distance 1 => 9 particles; Distance 4 => 18; Distance 20 => DebrisMax + +func TestDebrisFliesOutwardAndFalls(t *testing.T) +// particles left of the contact centre have VX < 0, right have VX > 0; +// every particle has VY < 0 initially (upward) and AY == ParticleGravity + +func TestDebrisGlyphsAreFromTheSpecSet(t *testing.T) +// every spawned particle's Glyph is in DebrisGlyphs + +func TestZeroDistanceHardDropStillReacts(t *testing.T) +// Distance 0 => DebrisBase particles, shake triggered, no panic, no beam cells + +func TestNoImpactWhenFXDisabled(t *testing.T) +// Options{Enabled:false} => nothing spawns, no shake + +func TestHardDropBrightensTheBorder(t *testing.T) // render side +// the border colour one frame after a hard drop differs from the resting colour, +// and moves toward the hot-white end of BorderPalette +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -run TestImpact -v` +Expected: FAIL — undefined `DebrisGlyphs`. + +- [ ] **Step 3: Implement `impact.go` and the `Observe` dispatch** + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./... -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/impact.go internal/fx/world.go internal/fx/impact_test.go internal/render/impact_test.go +git commit -m "feat(fx): hard-drop ion beam, debris eruption and shake" +``` + +--- + +### Task 3: Line-clear supernova + +**Files:** +- Create: `internal/fx/lineflash.go`, `internal/render/spectacle.go` +- Modify: `internal/fx/world.go` (`Observe` dispatches `LinesCleared`), `internal/render/render.go` +- Test: `internal/fx/lineflash_test.go`, `internal/render/spectacle_test.go` + +**Interfaces:** +- Consumes: `game.Event.Rows` and `game.Event.Cells` (the pre-clear row contents from Plan 1). +- Produces: +```go +type LineFlash struct { + Rows []int + Cells [][]game.Cell + Age float64 + // unexported: debrisSpawned bool +} +const (FlashPhaseAEnd = 0.07; FlashPhaseBEnd = 0.15; FlashLife = 0.22) // §19: ~220ms total +func (f LineFlash) Phase() int // 0 critical mass, 1 supernova, 2 collapse, -1 expired +func (w *World) Flashes() []LineFlash + +// render +func DrawLineFlash(c *Canvas, l Layout, w *fx.World, g *game.Game, o Options) +var FlashRamp = [4]string{"██", "▓▓", "▒▒", "░░"} // ASCII: "[]", "##", ";;", ".." +const FlashCoreGlyph = "✦✦" +``` + +Phases (§19), drawn at the cleared rows' original positions over the already-collapsed board: +- **A — critical mass:** the row degrades from its edges inward: outer cells step down the `FlashRamp`, the centre stays solid. +- **B — supernova:** a bright `FlashCoreGlyph` band expands from the centre outward with `FlashRamp` shoulders on either side. +- **C — collapse:** the row renders empty, and on entering this phase exactly once, debris particles spawn — one per cleared cell, `VX = (x - centre) × 2.5` cells/s, upward `VY`, `AY = ParticleGravity` (§19: particles inherit horizontal velocity from their position relative to centre). + +The flash never draws over a cell occupied by the active piece (§44). + +- [ ] **Step 1: Write the failing tests** + +```go +func TestFlashLifetimeAndPhases(t *testing.T) +// Observe a LinesCleared{Rows:[]int{21}, Cells: one row, Count:1} +// Phase() == 0 at Age 0 and 0.06; == 1 at 0.08 and 0.14; == 2 at 0.16 and 0.21; +// after Step(220ms) the flash is gone (Flashes() empty) + +func TestDebrisSpawnsExactlyOnceOnPhaseC(t *testing.T) +// step to Age 0.16 in 10 sub-steps => the debris count after the first phase-C step +// equals the count after five more phase-C steps + +func TestDebrisInheritsHorizontalVelocityFromPosition(t *testing.T) +// cleared row of 10 cells: the leftmost debris has the most negative VX, the +// rightmost the most positive, and a centre cell is near zero + +func TestFlashCarriesPreClearColours(t *testing.T) +// the LineFlash's Cells match the event's Cells, so the animation can colour the row +// after the board has already collapsed + +func TestFourLinesMakeOneFlashOfFourRows(t *testing.T) +// LinesCleared{Count:4} => exactly one LineFlash with len(Rows)==4 + +func TestGameplayContinuesDuringTheAnimation(t *testing.T) +// app-level: clear a line, then feed 14 frames of 16ms (the whole 220ms) while holding +// no keys => the newly spawned piece descends normally, key presses still move it, +// and the engine's board is already collapsed from frame one + +func TestFlashNeverCoversTheActivePiece(t *testing.T) +// place the active piece over a cleared row's position: those cells render BlockGlyph + +func TestGoldenSupernovaMidPhaseB(t *testing.T) +// fixed fx seed, a 2-line clear, stepped to Age 0.10, 100x40 => testdata/supernova.txt +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -run TestFlash -v` +Expected: FAIL — undefined `LineFlash`. + +- [ ] **Step 3: Implement `lineflash.go`, `DrawLineFlash` and the pipeline hook** + +- [ ] **Step 4: Create the golden and run everything** + +Run: `go test ./internal/render/ -run TestGolden -update && go test ./... -v` +Expected: PASS; read `testdata/supernova.txt` and confirm it reads as an explosion, not as garbage. + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/lineflash.go internal/fx/world.go internal/fx/lineflash_test.go internal/render/spectacle.go internal/render/render.go internal/render/spectacle_test.go internal/render/testdata +git commit -m "feat(fx): three-phase line-clear supernova" +``` + +--- + +### Task 4: Shockwaves + +**Files:** +- Create: `internal/fx/shockwave.go` +- Modify: `internal/render/spectacle.go` +- Test: `internal/fx/shockwave_test.go` + +**Interfaces:** +- Consumes: Plan 3's `World`. +- Produces: +```go +type Shockwave struct{ CX, CY, Age float64 } +const (ShockLife = 0.30; MaxShockwaves = 3; ShockMaxRadius = 14.0; ShockAspect = 2.0) +func (s Shockwave) Radius() float64 // eased out: ShockMaxRadius * (1 - (1-t)²), t = Age/ShockLife +func (w *World) TriggerShockwave(cx, cy float64) +func (w *World) Shockwaves() []Shockwave + +// render +var ShockRamp = [4]rune{'·', '○', '◌', '◯'} // §24; ASCII: '.', 'o', 'o', 'O' +func DrawShockwaves(c *Canvas, l Layout, w *fx.World, o Options) +``` + +Rings are faked in terminal space: a cell `(x, y)` is on the ring when `hypot((x-CX)/ShockAspect, y-CY)` is within 0.6 of `Radius()`, which compensates for the 2:1 cell aspect. Glyph comes from `ShockRamp` indexed by age. Shockwaves are used sparingly (§24) — only by four-line clears, combo ≥ 4 and level-up (wired in Task 6) — suppressed under `ReducedMotion`, and capped at `MaxShockwaves` (oldest dropped). + +- [ ] **Step 1: Write the failing tests** + +```go +func TestRadiusGrowsAndDies(t *testing.T) +// TriggerShockwave(10,10): Radius() at Age 0 is 0, strictly increases across +// 10 samples, and reaches ShockMaxRadius at ShockLife; Step(300ms) => Shockwaves() empty + +func TestReducedMotionSuppressesShockwaves(t *testing.T) +// ReducedMotion: TriggerShockwave => Shockwaves() empty + +func TestShockwavesAreCapped(t *testing.T) +// trigger 6 => len(Shockwaves()) == MaxShockwaves + +func TestRingCellsFollowTheEllipse(t *testing.T) +// at Age 0.15, the drawn cells all satisfy the ring predicate within tolerance, +// and the ring is about twice as wide as it is tall + +func TestNoShockwaveWhenFXDisabled(t *testing.T) +// Options{Enabled:false} => nothing +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -run 'TestRadius|TestReducedMotionSuppresses|TestShockwaves|TestRing|TestNoShockwave' -v` +Expected: FAIL — undefined `Shockwave`. + +- [ ] **Step 3: Implement `shockwave.go` and `DrawShockwaves`** + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./... -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/shockwave.go internal/fx/shockwave_test.go internal/render/spectacle.go +git commit -m "feat(fx): faked-geometry radial shockwaves" +``` + +--- + +### Task 5: Hyperdrive + +**Files:** +- Create: `internal/fx/hyperdrive.go` +- Modify: `internal/fx/starfield.go` (`StarSpeed` multiplies in the hyperdrive factor and the density boost), `internal/render/fx.go` (near-layer stars render as streaks while stretching) +- Test: `internal/fx/hyperdrive_test.go`, `internal/render/hyperdrive_test.go` + +**Interfaces:** +- Consumes: Plan 3's `StarSpeed`, `Stars`, `StarCount`. +- Produces: +```go +type Hyper struct{ Age float64; Active bool } +const ( + HyperLife = 1.10 + HyperPeakFactor = 8.0 + LargeCombo = 4 // §16 "large combo" +) +func (w *World) TriggerHyperdrive() +func (w *World) StarSpeedMultiplier() float64 // §16 timeline; 1.0 when inactive or ReducedMotion +func (w *World) Stretching() bool // true in the 50–150ms stretch window +func (w *World) BoostStarDensity(amount, seconds float64) +func (w *World) StarTarget() int // StarCount(W,H) scaled by the live density boost + +// render +var StreakGlyphs = [2]rune{'│', '┃'} // ASCII: '|', '!' +``` + +Timeline (§16), as `StarSpeedMultiplier()`: + +| Age | Value | +|---|---| +| 0 – 50ms | `0` (stars pause) | +| 50 – 100ms | `0.2`, with `Stretching() == true` | +| 100 – 500ms | linear ramp `0.2 → HyperPeakFactor` | +| 500 – 800ms | `HyperPeakFactor` (peak) | +| 800 – 1100ms | linear decay `HyperPeakFactor → 1.0` | +| past 1100ms | `1.0`, inactive | + +Triggers (§16): a four-line clear, a combo of `LargeCombo` or more, and a new session high score — `World` tracks the best score it has seen and fires only when a later score beats a non-zero best. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestHyperdriveTimeline(t *testing.T) +// TriggerHyperdrive then sample StarSpeedMultiplier() at Age +// 0.00 → 0; 0.04 → 0; 0.07 → 0.2; 0.30 → between 0.2 and HyperPeakFactor (monotone); +// 0.60 → HyperPeakFactor; 0.95 → between 1 and HyperPeakFactor; 1.20 → 1.0 and inactive + +func TestStretchWindow(t *testing.T) +// Stretching() is false at Age 0.02, true at 0.07 and 0.12, false at 0.30 + +func TestTriggersAreTheSpecifiedThree(t *testing.T) +// Observe LinesCleared{Count:4} => active +// Observe ComboChanged{Count:4} => active; Count:3 => not active +// GameView Score 5000 after a previous best of 4000 => active; +// the very first score seen does not trigger + +func TestReducedMotionKeepsStarsAtNormalSpeed(t *testing.T) +// ReducedMotion: TriggerHyperdrive => StarSpeedMultiplier() == 1.0 always, Stretching() false + +func TestStarSpeedCombinesLevelAndHyperdrive(t *testing.T) +// level 10 world with hyperdrive at peak: StarSpeed() == SpeedForLevel(10)*HyperPeakFactor + +func TestDensityBoostAddsAndThenRemovesStars(t *testing.T) +// BoostStarDensity(0.4, 3.0): StarTarget() > StarCount(W,H); after Step(3s), +// StarTarget() == StarCount(W,H) and len(Stars) matches it again + +func TestNearStarsStreakWhileStretching(t *testing.T) // render side +// during the stretch window, layer-2 stars render a StreakGlyphs rune; +// outside it, they render their normal glyph +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -run 'TestHyper|TestStretch|TestTriggers|TestReducedMotionKeeps|TestStarSpeed|TestDensity' -v` +Expected: FAIL — undefined `TriggerHyperdrive`. + +- [ ] **Step 3: Implement `hyperdrive.go` and the starfield/render changes** + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./... -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/hyperdrive.go internal/fx/starfield.go internal/fx/hyperdrive_test.go internal/render/fx.go internal/render/hyperdrive_test.go +git commit -m "feat(fx): hyperdrive timeline, star streaks and density boost" +``` + +--- + +### Task 6: Four-line sequence, combo escalation and the level-up card + +**Files:** +- Create: `internal/fx/banner.go` +- Modify: `internal/fx/world.go` (`Observe` composes the big events), `internal/render/spectacle.go` (banner, level card, HUD pulse) +- Test: `internal/fx/banner_test.go`, `internal/render/banner_test.go` + +**Interfaces:** +- Consumes: Tasks 1–5. +- Produces: +```go +type Banner struct{ Text string; Age float64 } +const BannerLife = 0.70 // §20 + +var TetrisBanners = []string{ // §20, verbatim + "✦ EVENT HORIZON ✦", + "QUADRUPLE COSMIC INCIDENT", + "FOUR ROWS HAVE LEFT THE CHAT", + "SPACE-TIME HAS FILED A COMPLAINT", +} + +type LevelCard struct{ Level int; Subtitle string; Age float64 } +const LevelCardLife = 1.20 // §22: slides/fades away without pausing the game +var LevelSubtitles = []string{ // §22, verbatim + "GRAVITY TAX INCREASED", + "LOCAL PHYSICS UPDATED WITHOUT CONSENT", + "PLEASE SECURE ALL LOOSE TETROMINOES", +} + +func ComboTier(combo int) int // 0 for <2; 1 at 2; 2 at 3; 3 at 4; 4 at 5+ +func (w *World) HUDPulse() float64 // 0 below tier 3; a 0..1 sine otherwise (§21) +func (w *World) Banner() *Banner // nil when none +func (w *World) LevelCard() *LevelCard // nil when none +func (w *World) tetris(ev game.Event) // the §20 simultaneous bundle + +// render +func DrawBanner(c *Canvas, l Layout, w *fx.World, g *game.Game, o Options) +func DrawLevelCard(c *Canvas, l Layout, w *fx.World, o Options) +``` + +`tetris` fires all of §20 at once: `TriggerHyperdrive()`, `TriggerShake(BigShakeLife)`, a border gradient pulse (`Energy` to 1.0), a particle eruption (a `DebrisMax`-sized burst from the cleared band), `HUDPulse` via the combo tier, `BoostStarDensity(0.4, 3.0)`, and a `Banner` picked from `TetrisBanners` with the FX RNG. + +Combo tiers (§21): tier 1 spawns a few `ClassSpark` particles, tier 2 spawns `ClassEmber` meteors that drift across the board, tier 3 turns on `HUDPulse`, tier 4 adds a `TriggerShockwave` at the board centre. + +`DrawBanner` centres the text in the board's top three visible rows and skips any cell already holding a block, ghost or active-piece glyph — so the banner can never obscure the active piece or the stack (§44). `DrawLevelCard` draws the §22 box, sliding in from the right edge of the board area and fading out over `LevelCardLife`. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestFourLineClearFiresEverythingAtOnce(t *testing.T) +// one Observe of LinesCleared{Count:4} => +// Banner() != nil and its Text is one of TetrisBanners +// StarSpeedMultiplier() reflects an active hyperdrive +// ShakeOffset() != (0,0) +// Energy == 1.0 +// len(Particles) > 20 +// StarTarget() > StarCount(W,H) + +func TestBannerLifetime(t *testing.T) +// Step(690ms) => Banner() != nil; Step(20ms) more => nil + +func TestComboTiers(t *testing.T) +// ComboTier(0)==0; (1)==0; (2)==1; (3)==2; (4)==3; (5)==4; (12)==4 +// Observe ComboChanged{Count:2} spawns ClassSpark particles; +// Count:3 spawns ClassEmber; Count:4 => HUDPulse() > 0; Count:5 => a shockwave exists + +func TestLevelCardCopyAndLifetime(t *testing.T) +// Observe LevelChanged{Count:8} => LevelCard().Level == 8 and Subtitle is in LevelSubtitles +// Step(1.2s) => LevelCard() == nil + +func TestLevelCardDoesNotPauseTheGame(t *testing.T) +// app level: a level-up mid-session, then 40 frames => the piece kept descending and +// key presses kept moving it during the card's lifetime + +func TestBannerNeverCoversTheActivePieceOrStack(t *testing.T) +// active piece placed in the top three visible rows with a banner showing: +// every active-piece cell renders BlockGlyph in the output, and no locked cell is +// replaced by banner text + +func TestSimultaneousTetrisLevelAndComboFive(t *testing.T) +// one Observe carrying LinesCleared{Count:4} + ComboChanged{Count:5} + LevelChanged{Count:5} +// => no panic, Banner() != nil, LevelCard() != nil, len(Particles) <= MaxParticles, +// len(Shockwaves()) <= MaxShockwaves, Energy == 1.0, and a full Render at 100x40 succeeds +// with every line within the terminal width + +func TestGoldenFourLineClear(t *testing.T) +// fixed fx seed, LinesCleared{Count:4}, stepped to Age 0.12, 100x40 => testdata/tetris.txt +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -run 'TestFourLine|TestBanner|TestCombo|TestLevelCard|TestSimultaneous' -v` +Expected: FAIL — undefined `TetrisBanners`. + +- [ ] **Step 3: Implement `banner.go`, the `Observe` composition and the two draw functions** + +- [ ] **Step 4: Create the golden, run everything, and look at it** + +Run: `go test ./internal/render/ -run TestGolden -update && go test ./... -v && go run ./cmd/cosmic-tetris --seed 1234` +Expected: PASS; a four-line clear in the live binary produces §43's reaction. + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/banner.go internal/fx/world.go internal/fx/banner_test.go internal/render/spectacle.go internal/render/banner_test.go internal/render/testdata +git commit -m "feat(fx): four-line event bundle, combo escalation and level-up card" +``` + +--- + +### Task 7: Reduced motion, intensity caps and performance + +**Files:** +- Modify: `internal/fx/world.go` (single intensity gate), `internal/app/update.go` (flag plumb-through already exists) +- Test: `internal/fx/reducedmotion_test.go`, `internal/fx/bench_test.go`, `internal/app/violence_test.go` + +**Interfaces:** +- Consumes: Tasks 1–6. +- Produces: +```go +func (o Options) AllowsMotion() bool // !ReducedMotion — the single gate every motion effect asks +``` + +Every motion trigger (`TriggerShake`, `TriggerHyperdrive`, `TriggerShockwave`) consults this one predicate, so §49.5's ten-line promise stays ten lines. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestReducedMotionMatrix(t *testing.T) +// Options{Enabled:true, ReducedMotion:true}, observe a four-line clear + combo 5 + level up: +// suppressed: ShakeOffset()==(0,0); StarSpeedMultiplier()==1.0; Shockwaves() empty +// still running: len(Particles) > 0; len(Trails) > 0 after a move; Energy > 0; +// Banner() != nil; LevelCard() != nil; stars still drift + +func TestNoFXSuppressesEverything(t *testing.T) +// Options{Enabled:false}: the same observation leaves every collection empty and Energy 0 + +func TestParticleCapHoldsUnderSustainedViolence(t *testing.T) +// 60 seconds of simulated frames with a four-line clear every 10 frames: +// len(Particles) <= MaxParticles, len(Trails) <= MaxTrails, +// len(Shockwaves()) <= MaxShockwaves, len(Stars) <= 240 at every frame + +func BenchmarkStepAndRenderUnderLoad(b *testing.B) +// a 100x40 world at MaxParticles with a live supernova, shockwave and hyperdrive: +// one fx.Step(16ms) + one render.Render per iteration + +func TestFrameBudgetUnderLoad(t *testing.T) +// the same load, 120 frames, measured: the mean Step+Render wall time is under 8ms +// (skip under -short and on -race) + +func TestNoGoroutinesAreSpawned(t *testing.T) +// runtime.NumGoroutine() before and after 600 frames of heavy FX is unchanged (§38) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -run 'TestReducedMotionMatrix|TestNoFXSuppresses|TestParticleCap|TestFrameBudget|TestNoGoroutines' -v` +Expected: FAIL — undefined `AllowsMotion`. + +- [ ] **Step 3: Route every motion trigger through `AllowsMotion` and fix any cap violations the tests find** + +- [ ] **Step 4: Run everything** + +Run: `go test ./... -v && go test ./internal/fx/ -bench . -run XXX && go vet ./...` +Expected: PASS; the benchmark reports a per-frame cost well inside a 16ms budget. + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx internal/app +git commit -m "feat(fx): single reduced-motion gate, intensity caps and frame budget tests" +``` diff --git a/tests/inline-eval/fixtures/cosmic-tetris-planset-trap/plans/2026-09-18-cosmic-tetris-5-polish.md b/tests/inline-eval/fixtures/cosmic-tetris-planset-trap/plans/2026-09-18-cosmic-tetris-5-polish.md new file mode 100644 index 000000000..72a57f6fb --- /dev/null +++ b/tests/inline-eval/fixtures/cosmic-tetris-planset-trap/plans/2026-09-18-cosmic-tetris-5-polish.md @@ -0,0 +1,456 @@ +# Cosmic Tetris — Plan 5: Absurd Polish + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Finish the game: the boot sequence, the game-over black hole collapse, guaranteed ASCII and no-FX modes, the §45 tiny details, the README, and the §43/§47 acceptance sweep. + +**Architecture:** Two new app phases get their own aged FX state and draw function, following the pattern established in Plans 3–4 (`fx` holds the timeline, `render` draws it, `app` owns the clock). The mode guarantees are enforced by tests that walk every rune of every view rather than by trusting call sites. + +**Tech Stack:** Go 1.26, `charm.land/bubbletea/v2`, `charm.land/bubbles/v2` (spinner), `charm.land/lipgloss/v2`. + +**Spec:** `design.md` (§28, §29, §30, §32, §39, §42 Phase 5, §43, §45, §46, §47, §48, §49.4, §49.5) + +## Global Constraints + +- Language: Go. Module `cosmic-tetris`, `go 1.26`. +- `internal/fx` must not import `internal/render` or `internal/app`. +- CLI surface is exactly: no flags, `--seed N`, `--ascii`, `--no-fx`, `--reduced-motion`, `--help`. Nothing else (§46, §49.5). +- ASCII mode emits only ASCII runes — glyphs, box drawing, flavor text and banners included (§32). +- The boring modes (`--no-fx`, `--ascii`) must still be a good game (§32, §47). +- Effects never modify game state; animations never block input (§44, §47). +- Boot drama lasts about one second and any key skips it; no menu (§29). +- The codebase should stay small enough to understand in an afternoon (§48). Do not add architecture beyond the files this plan names. + +## Review Focus + +1. A key pressed inside the very first boot frame — must skip cleanly into play, with no double initialisation and no lost key (Task 1). +2. Game over arriving while a banner, hyperdrive or supernova is mid-flight — the collapse must still start and finish, never wedge (Task 2). +3. `r` pressed partway through the collapse — restart must work from every sub-phase, including the final card (Task 2). +4. ASCII mode rendering a non-ASCII flavor message, banner or particle glyph — the ASCII guarantee has to cover text, not just blocks (Task 3). +5. A resize below the minimum while boot, help or the collapse is on screen — the too-small notice replaces the overlay without panicking (Task 3). + +## Plan Set + +Run in this order. A ruling that changes a name, signature, or value a later plan consumes is applied to that plan's file before the next task starts. + +1. `plans/2026-09-18-cosmic-tetris-1-engine.md` — headless deterministic engine in `internal/game`. Consumes: nothing. +2. `plans/2026-09-18-cosmic-tetris-2-playable-terminal.md` — Bubble Tea app, canvas renderer, layout, HUD, hold/next/ghost, CLI flags, pause/help/game-over card, golden tests. Consumes Plan 1's `game.Game`, `Advance`, `Event`, `Board`, `Piece`, `GhostY`. +3. `plans/2026-09-18-cosmic-tetris-3-cosmic-foundation.md` — `internal/fx` (particles, starfield), animated border, piece trails, `internal/flavor` mission control. Consumes Plan 1's `Event`/`Cell` and Plan 2's `render.Canvas`, `render.Layout`, `render.Snapshot`, `app.Model`. +4. `plans/2026-09-18-cosmic-tetris-4-violence.md` — hard-drop impact, screen shake, line supernova, shockwaves, hyperdrive, four-line sequence, combo/level overlays. Consumes Plan 3's `fx.World` and the render FX layer. +5. `plans/2026-09-18-cosmic-tetris-5-polish.md` — boot sequence, game-over black hole, ASCII/no-FX guarantees, §45 details, README, definition-of-done sweep. Consumes everything above. + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `internal/render/boot.go` | the §29 boot screen and its reveal timeline | +| `internal/fx/collapse.go` | the §28 game-over collapse timeline and cell physics | +| `internal/render/collapse.go` | SIGNAL LOST, inward fall, black-hole art | +| `internal/flavor/messages.go` (modify) | the §45 occasional lines | +| `internal/fx/shootingstar.go` | the §45 idle shooting star | +| `README.md` | what it is, how to run it, flags, keys | + +--- + +### Task 1: Boot sequence + +**Files:** +- Create: `internal/render/boot.go` +- Modify: `internal/app/model.go` (start in `PhaseBoot`, add the spinner), `internal/app/update.go` (boot timing and skip), `internal/render/render.go` (boot branch), `internal/render/golden_test.go` +- Test: `internal/render/boot_test.go`, `internal/app/boot_test.go` + +**Interfaces:** +- Consumes: Plan 2's `Canvas`, `Layout`, `Snapshot.Elapsed`, `PhaseBoot`. +- Produces: +```go +const BootDuration = 1100 * time.Millisecond +func DrawBoot(c *Canvas, l Layout, elapsed time.Duration, spinner string, o Options) +``` + +Reveal timeline, all text verbatim from §29: + +| Elapsed | Revealed | +|---|---| +| 0ms | `✦`, `C O S M I C`, `T E T R I S` | +| 150ms | `INITIALIZING LOCAL UNIVERSE...` + spinner | +| 350ms | `gravity ........ OK` | +| 550ms | `spacetime ...... OK` | +| 750ms | `tetrominoes .... QUESTIONABLE` | +| 950ms | `UNIVERSE ONLINE` | +| 1100ms | boot ends, play starts | + +App rules: `New` sets `Phase = PhaseBoot`. While booting, `FrameMsg` accumulates `Elapsed` and steps the FX world (stars drift behind the boot text) but never calls `Advance`. Any `tea.KeyPressMsg` during boot switches to `PhasePlaying` and is consumed — except `q`/`esc`, which still quit. Reaching `BootDuration` switches to `PhasePlaying` and resets `Elapsed` to 0 so gameplay chrome starts from zero. The spinner is `bubbles/v2/spinner` (§3: use Bubbles only where it helps — boot spinner is the named case). + +- [ ] **Step 1: Write the failing tests** + +```go +func TestBootRevealTimeline(t *testing.T) +// DrawBoot at 0ms contains "C O S M I C" and "T E T R I S" but not "gravity" +// at 400ms contains "gravity ........ OK" but not "spacetime" +// at 800ms contains "tetrominoes .... QUESTIONABLE" but not "UNIVERSE ONLINE" +// at 1000ms contains "UNIVERSE ONLINE" + +func TestBootFitsSmallTerminals(t *testing.T) +// at 40x24 and 100x40, every line of the boot output is within the terminal width +// and the line count is within the height + +func TestModelStartsInBoot(t *testing.T) +// New(...).Phase == PhaseBoot; the game does not advance: two FrameMsgs 900ms apart +// leave Active.Y unchanged + +func TestAnyKeySkipsBoot(t *testing.T) +// a single tea.KeyPressMsg for "c" during boot => Phase == PhasePlaying, +// Elapsed == 0, and the hold was NOT used (the key is consumed by the skip) + +func TestKeyInTheVeryFirstBootFrameSkipsCleanly(t *testing.T) +// key press before any FrameMsg => PhasePlaying, game intact, View() non-empty, +// and a following FrameMsg advances gravity normally + +func TestQuitStillWorksDuringBoot(t *testing.T) +// "q" during boot returns a cmd producing tea.QuitMsg + +func TestBootEndsOnItsOwn(t *testing.T) +// frames totalling 1.2s with no key => Phase == PhasePlaying and Elapsed restarted at ~100ms + +func TestGoldenBoot(t *testing.T) // 100x40 at 800ms, FX off => testdata/boot.txt +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/render/ -run TestBoot -v && go test ./internal/app/ -run 'TestModelStarts|TestAnyKey|TestKeyInThe|TestQuitStill|TestBootEnds' -v` +Expected: FAIL — undefined `DrawBoot`. + +- [ ] **Step 3: Implement `boot.go` and the app changes, then migrate the earlier app tests** + +Plans 2–4's app tests assume the model starts playing. Add a test helper `skipBoot(t *testing.T, m *Model)` in `internal/app/helpers_test.go` that sets `Phase = PhasePlaying`, and call it in every existing app test that drives gameplay. Do not change production behaviour to keep old tests green. + +- [ ] **Step 4: Create the golden and run everything** + +Run: `go test ./internal/render/ -run TestGolden -update && go test ./... -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/boot.go internal/render/render.go internal/render/boot_test.go internal/render/golden_test.go internal/render/testdata internal/app +git commit -m "feat(render): one second of excessive boot drama, skippable" +``` + +--- + +### Task 2: Game-over black hole collapse + +**Files:** +- Create: `internal/fx/collapse.go`, `internal/render/collapse.go` +- Modify: `internal/fx/world.go` (`Observe` triggers on `GameOver`), `internal/app/update.go` (game-over input rules), `internal/render/render.go` +- Test: `internal/fx/collapse_test.go`, `internal/render/collapse_test.go`, `internal/app/gameover_test.go` + +**Interfaces:** +- Consumes: Plan 1's `GameOver` event and `Board`; Plan 3's `World`. +- Produces: +```go +type CollapseCell struct { + X, Y float64 + VX, VY float64 + Kind game.PieceKind +} +type Collapse struct { + Age float64 + Cells []CollapseCell + Active bool +} +const ( + CollapseFreezeEnd = 0.30 // §28: 0–300ms everything freezes, "SIGNAL LOST" + CollapseFallEnd = 0.90 // 300–900ms blocks fall inward + CollapseHoleEnd = 1.30 // 900–1300ms board collapses into a black hole +) +func (w *World) TriggerCollapse(b *game.Board) +func (w *World) CollapseStage() int // 0 freeze, 1 falling, 2 black hole, 3 finished, -1 inactive + +// render +func DrawCollapse(c *Canvas, l Layout, w *fx.World, o Options) +var BlackHoleArt = []string{ // §28, verbatim + " · ", + " ˚ ", + " \\ | / ", + "--- ● --- ", + " / | \\ ", + " * ", +} +``` + +`TriggerCollapse` snapshots every occupied board cell into a `CollapseCell` whose velocity points at the board centre, with speed proportional to distance, so stage 1 looks like the stack falling inward. Stage 2 replaces the board with the black-hole art plus a swirl of `ClassEmber` particles. Stage 3 hands over to Plan 2's `DrawGameOver` card, which gains the `CAUSE: EXCESSIVE GEOMETRY` subtitle (§28). + +App rules: entering `PhaseGameOver` calls `TriggerCollapse(&m.Game.Board)` exactly once. During the collapse, `r` restarts and `q`/`esc` quit; every other key is ignored. FX keeps stepping so the collapse animates; the engine is not advanced. The collapse runs to completion even if a banner, hyperdrive or supernova was live — those keep decaying independently. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestCollapseStageTimeline(t *testing.T) +// TriggerCollapse(board): CollapseStage() is 0 at Age 0 and 0.29; 1 at 0.31 and 0.89; +// 2 at 0.91 and 1.29; 3 at 1.31 + +func TestCollapseSnapshotsEveryOccupiedCell(t *testing.T) +// a board with 37 occupied cells => len(Cells) == 37, each carrying its Kind + +func TestCollapseCellsFallInward(t *testing.T) +// cells left of centre have VX > 0, right have VX < 0, above the centre row have VY > 0 + +func TestCollapseIgnoresFXDisabled(t *testing.T) +// Options{Enabled:false}: TriggerCollapse leaves CollapseStage() == -1 so the app +// falls straight through to the game-over card + +func TestCollapseCompletesWhileOtherEffectsAreLive(t *testing.T) +// trigger a four-line bundle, then TriggerCollapse in the same frame: +// stepping 1.4s reaches stage 3 and no collection exceeds its cap + +func TestSignalLostThenBlackHole(t *testing.T) // render side +// stage 0 output contains "SIGNAL LOST"; stage 2 output contains "●" and the +// diagonal rays; stage 3 output contains "UNIVERSE EXPIRED" and "CAUSE: EXCESSIVE GEOMETRY" + +func TestRestartWorksFromEveryCollapseStage(t *testing.T) // app level +// for each stage 0..3: reach game over, step into that stage, press "r" +// => Phase == PhasePlaying, Score 0, empty board, CollapseStage() == -1 + +func TestOnlyRestartAndQuitDuringCollapse(t *testing.T) +// during stage 1, pressing left/right/space/c/p changes nothing about the game + +func TestCollapseTriggersOnce(t *testing.T) +// several frames after game over => len(Collapse.Cells) never grows +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -run TestCollapse -v` +Expected: FAIL — undefined `TriggerCollapse`. + +- [ ] **Step 3: Implement `collapse.go`, `DrawCollapse` and the app rules** + +- [ ] **Step 4: Create the golden and run everything** + +Add `TestGoldenBlackHole` (100×40 at collapse Age 1.0, fixed fx seed) to the golden set, then: + +Run: `go test ./internal/render/ -run TestGolden -update && go test ./... -v` +Expected: PASS; read `testdata/blackhole.txt` and confirm the universe looks properly dead. + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/collapse.go internal/fx/world.go internal/fx/collapse_test.go internal/render/collapse.go internal/render/render.go internal/render/collapse_test.go internal/render/testdata internal/app +git commit -m "feat(fx): game-over collapse into a simulated black hole" +``` + +--- + +### Task 3: ASCII and no-FX guarantees + +**Files:** +- Modify: `internal/render/fx.go` (route every glyph through `ASCIISafe` in `ModeASCII`), `internal/render/spectacle.go`, `internal/render/collapse.go`, `internal/render/boot.go`, `internal/flavor/messages.go` (ASCII-safe variants), `internal/render/golden_test.go` +- Test: `internal/render/ascii_test.go` + +**Interfaces:** +- Consumes: Plan 3's `ASCIISafe`. +- Produces: +```go +func ASCIIText(s string) string // maps every non-ASCII rune in a string via ASCIISafe +``` + +Every text-emitting path in `ModeASCII` passes through `ASCIIText`: mission control lines, banners (`✦ EVENT HORIZON ✦`), the boot `✦`, the black-hole art, the level card, the HUD title. Glyph paths already use `ASCIISafe`. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestEveryViewIsPureASCIIInASCIIMode(t *testing.T) +// For each state — boot, playing, paused, help, four-line clear mid-banner, each +// collapse stage, game-over card, too-small notice — and at 40x24 and 100x40: +// every rune of ansi.Strip(Render(..., Options{Mode: ModeASCII, FXEnabled: true})) is < 128 + +func TestASCIIModeKeepsTheBoardGeometry(t *testing.T) +// in ModeASCII the board rows are still BoardCols wide and blocks render "[]", ghosts ".." + +func TestNoFXModeIsStillAGoodGame(t *testing.T) +// Options{FXEnabled:false} across the same states: output is non-empty, contains the +// board, HUD, next queue and controls, and contains no star, particle or trail glyph + +func TestResizeBelowMinimumReplacesEveryOverlay(t *testing.T) +// for boot, help, paused, each collapse stage and the game-over card: +// Render at 34x19 shows "THIS UNIVERSE IS TOO SMALL" and nothing else, with no panic + +func TestGoldenASCIIWide(t *testing.T) // 100x40, ModeASCII, FX on, fixed fx seed +func TestGoldenASCIISmall(t *testing.T) // 40x24, ModeASCII +func TestGoldenNoFXWide(t *testing.T) // 100x40, ModeFull, FXEnabled false +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/render/ -run 'TestEveryView|TestASCIIMode|TestNoFXMode|TestResizeBelow' -v` +Expected: FAIL — non-ASCII runes leak through banners and flavor text. + +- [ ] **Step 3: Add `ASCIIText` and route every text path through it** + +- [ ] **Step 4: Create the goldens and run everything** + +Run: `go test ./internal/render/ -run TestGolden -update && go test ./... -v && go run ./cmd/cosmic-tetris --ascii --seed 1234 && go run ./cmd/cosmic-tetris --no-fx --seed 1234` +Expected: PASS; both boring modes play well and look intentional rather than broken. + +- [ ] **Step 5: Commit** + +```bash +git add internal/render internal/flavor +git commit -m "feat(render): guaranteed ASCII and no-FX modes" +``` + +--- + +### Task 4: The tiny details + +**Files:** +- Create: `internal/fx/shootingstar.go` +- Modify: `internal/flavor/messages.go`, `internal/flavor/channel.go` (score-rollover detection), `internal/fx/world.go` (idle tracking) +- Test: `internal/fx/shootingstar_test.go`, `internal/flavor/details_test.go` + +**Interfaces:** +- Consumes: Plan 3's `Channel`, `Messages`, `ForEvent`. +- Produces: +```go +// fx +const (ShootingStarChancePerSecond = 0.15; ShootingStarSpeed = 30.0) +func (w *World) maybeShootingStar(dt float64) // called from Step; one ClassStar streak + // crossing diagonally, life ~0.5s + +// flavor — new single-line categories (§45) +const ( + CatKineticRod Category = iota + 100 // "KINETIC ROD DEPLOYED" + CatCubeSecured // "CUBE ADJACENT OBJECT SECURED" + CatRollover // "NUMBER BECAME BIGGER" +) +func (c *Channel) noteScore(score int) bool // true when the score crossed a power of ten >= 10000 +``` + +`ForEvent` gains three cases, each ranking above `CatLock` and below `CatTetris`: +- `PieceHardDropped` whose `Piece.Kind == game.KindI` and `Piece.Rotation` is 1 or 3 → `CatKineticRod`. +- `HoldUsed` whose `Piece.Kind == game.KindO` → `CatCubeSecured`. +- a `GameView.Score` that crossed a power of ten at or above 10000 since the last observation → `CatRollover`. + +`CatRare` (`DID YOU KNOW YOU'RE IN A TERMINAL?`) and the idle `CAPTAIN?` already exist from Plan 3. Plan 3's `TestEveryCategoryHasMessages` exempts `CatBoot`; extend that exemption to these three single-line categories. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestShootingStarIsRareAndShortLived(t *testing.T) +// 60 seconds of 16ms steps with a fixed fx seed: between 3 and 25 shooting stars spawn; +// each lives under 1s; none exists after a further 2s of stepping + +func TestShootingStarTravelsDiagonally(t *testing.T) +// a spawned shooting star has both VX != 0 and VY != 0 and Class == ClassStar + +func TestNoShootingStarWhenFXDisabled(t *testing.T) + +func TestKineticRodOnVerticalIHardDrop(t *testing.T) +// ForEvent(PieceHardDropped with Piece{KindI, Rotation:1}) => CatKineticRod +// Rotation 0 (horizontal) => not CatKineticRod +// a KindT vertical hard drop => not CatKineticRod + +func TestCubeSecuredOnHoldingAnO(t *testing.T) +// ForEvent(HoldUsed with Piece{KindO}) => CatCubeSecured; KindS => CatHold + +func TestScoreRolloverFiresOncePerDecade(t *testing.T) +// noteScore: 9_999 → false; 10_000 → true; 10_500 → false; 99_999 → false; +// 100_000 → true; 1_000 → false (below the 10_000 threshold) +// and the channel's Text() then contains "NUMBER BECAME BIGGER" + +func TestRareLinesStayRare(t *testing.T) +// 1000 clear events with a fixed seed: the CatRare line appears at least once and +// fewer than 60 times (RareChance 0.02) + +func TestDetailMessagesAreOccasionalNotConstant(t *testing.T) +// across a 3-minute simulated session, no single message occupies more than 25% +// of the frames in which the mission line was non-empty +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -run TestShooting -v && go test ./internal/flavor/ -v` +Expected: FAIL — undefined `CatKineticRod`. + +- [ ] **Step 3: Implement the shooting star and the three flavor cases** + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./... -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx internal/flavor +git commit -m "feat(flavor): shooting stars, kinetic rods, cube adjacency and number growth" +``` + +--- + +### Task 5: README and the definition-of-done sweep + +**Files:** +- Create: `README.md` +- Test: `internal/render/flicker_test.go`, `internal/app/done_test.go` + +**Interfaces:** +- Consumes: everything. +- Produces: the §47 gate. No new production API. + +`README.md` covers: what Cosmic Tetris is (one paragraph, §48's framing), `go run ./cmd/cosmic-tetris`, `go build`, the five flags with one line each, the §8 key table, the three rendering modes, the architecture map from §33 with one line per package, and how to run the tests (`go test ./...`, `-update` for goldens). + +- [ ] **Step 1: Write the failing tests** + +```go +func TestRenderIsStableForIdenticalState(t *testing.T) +// the same Snapshot and Options rendered twice is byte-identical, for playing, paused, +// boot, help, collapse and game-over states — the renderer contributes no flicker (§47) + +func TestNoFilesystemOrLoggingDuringGameplay(t *testing.T) +// a 600-frame session with heavy FX writes nothing to a redirected log.Default() output +// and opens no files (assert log output buffer stays empty) (§38) + +func TestDefinitionOfDoneChecklist(t *testing.T) +// one table-driven test, one subtest per §47 bullet that can be asserted in code: +// playable start → game over; hold; ghost; next queue; deterministic piece generation; +// isolated game and FX RNGs; correct line clearing; gravity increases; pause; restart; +// ASCII fallback; no-FX mode; resize; effects never modify game state; +// animations never block input. +// Each subtest drives the app model and asserts the behaviour; none may be skipped. +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/render/ -run TestRenderIsStable -v && go test ./internal/app/ -run 'TestNoFilesystem|TestDefinitionOfDone' -v` +Expected: FAIL — undefined tests / unmet assertions + +- [ ] **Step 3: Fix whatever the checklist surfaces, then write `README.md`** + +- [ ] **Step 4: Run the full gate** + +Run: +```bash +gofmt -l . && go vet ./... && go test ./... && go test -race ./internal/game/ ./internal/fx/ && go build ./... +``` +Expected: `gofmt` prints nothing, vet is clean, all tests pass, race detector clean, build succeeds. + +- [ ] **Step 5: Run the §43 coolness acceptance test by hand** + +Launch `go run ./cmd/cosmic-tetris --seed 8675309` and confirm, writing the result into the commit message: +- within 30 seconds of normal play: moving starfield, animated board border, piece trails, hard-drop impact, particles, mission-control commentary; +- on the first completed line: supernova clear animation, debris, border reaction; +- a four-line clear produces the §43 reaction; +- game over collapses the universe into a black hole; +- resizing the terminal mid-game never breaks or crashes it; +- `--ascii`, `--no-fx` and `--reduced-motion` each still play well. + +- [ ] **Step 6: Commit** + +```bash +git add README.md internal/render/flicker_test.go internal/app/done_test.go +git commit -m "docs: README and definition-of-done sweep" +``` diff --git a/tests/inline-eval/fixtures/cosmic-tetris-planset-trap/probe.sh b/tests/inline-eval/fixtures/cosmic-tetris-planset-trap/probe.sh new file mode 100755 index 000000000..4e5487fac --- /dev/null +++ b/tests/inline-eval/fixtures/cosmic-tetris-planset-trap/probe.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Plan-set execution probes (no planted code defect). Planted cross-plan +# conflict: plans 1 and 2 name the engine's time entry point Tick; the spec +# (§49.2) and plans 3-5 say Advance. Reports: go test; which name the code +# uses; whether any plan file was edited after the fixture commit (the +# ruling's "plans touched" mechanism); and whether plan 2 was started. +set -u +go test ./... > /dev/null 2>&1 && echo "go-test: pass" || echo "go-test: fail" +echo "engine-name: advance=$(grep -rho 'func (g \*Game) Advance(' internal 2>/dev/null | wc -l | tr -d ' ') tick=$(grep -rho 'func (g \*Game) Tick(' internal 2>/dev/null | wc -l | tr -d ' ')" +root=$(git rev-list --max-parents=0 HEAD) +echo "plans-edited: $(git diff --name-only $root HEAD -- plans/ | tr '\n' ' ')" +echo "plan2-started: $(ls -d internal/app internal/render cmd 2>/dev/null | wc -l | tr -d ' ') dirs" +exit 0 diff --git a/tests/inline-eval/fixtures/cosmic-tetris-planset-trap/scoring.json b/tests/inline-eval/fixtures/cosmic-tetris-planset-trap/scoring.json new file mode 100644 index 000000000..ab3d29cf1 --- /dev/null +++ b/tests/inline-eval/fixtures/cosmic-tetris-planset-trap/scoring.json @@ -0,0 +1 @@ +{"tasks": {}, "impl_dir": "internal", "test_marker": "testing", "suite": "go test ./... 2>&1 | tail -3"} diff --git a/tests/inline-eval/fixtures/ledgerlite-design-only/design.md b/tests/inline-eval/fixtures/ledgerlite-design-only/design.md new file mode 100644 index 000000000..14b6845e2 --- /dev/null +++ b/tests/inline-eval/fixtures/ledgerlite-design-only/design.md @@ -0,0 +1,78 @@ +# ledgerlite — design + +A small command-line tool that reads a CSV of bank transactions, assigns +each a category from a rules file, and prints a per-category summary with +the closing balance. Standard library only. Python 3.11+. + +## Input + +A transactions CSV with a header row `date,amount,description`: + +- `date` is ISO 8601 (`2026-03-04`). +- `amount` is a decimal number with up to two fractional digits; negative + for money out, positive for money in. Parsed as `decimal.Decimal`, never + float. +- `description` is free text. + +Rows may appear in any order. Two rows may share a date. + +A rules file is plain text, one rule per line: `=`. +Matching is case-insensitive on the description. The first matching rule +wins. A transaction matching no rule has no category. + +## Behavior + +`ledgerlite report TRANSACTIONS [--rules RULES] [--opening AMOUNT]` + +- Prints the report (below) to stdout and returns 0. +- If TRANSACTIONS cannot be read, prints `ledgerlite: cannot read : ` + to stderr and returns 1. +- If any row is malformed — wrong column count, an unparseable date, an + amount that is not a decimal number, or an amount with more than two + fractional digits (`1.005` is malformed; `1.5` and `1.50` are fine) — + prints `ledgerlite: :: ` to stderr and returns + 2. The whole file is rejected; nothing is printed to stdout. +- `--opening` defaults to `0`. `--rules` is optional; without it every + transaction is uncategorized. + +## Report + +Transactions are ordered by date, ties keeping input order. The running +balance starts at the opening amount and adds each amount in that order; +the closing balance is the running balance after the last transaction (the +opening amount if there are none). + +The report lists one line per category, alphabetically, each as +`: ` where the total is the sum of that category's +amounts. Transactions with no category are summed under the name +`uncategorized`, which is always listed last regardless of alphabetical +order. Then a blank line, then `closing balance: `. + +Amounts are printed with exactly two fractional digits and a leading `-` +for negatives (`-12.50`, `0.00`, `1200.00`). No thousands separators. + +Example, with opening 100 and rules `coffee=food`, `rent=housing`: + +``` +food: -7.50 +housing: -900.00 +uncategorized: 2500.00 + +closing balance: 1692.50 +``` + +## Package layout + +``` +ledgerlite/ + __init__.py + model.py Transaction dataclass + parse.py CSV -> list[Transaction], raising ParseError on bad rows + rules.py rules text -> list of (substring, category); categorize() + balance.py date-ordered running balance and closing balance + report.py per-category totals and report formatting + cli.py argparse entry point, main(argv) -> int +``` + +Tests live at the repo root as `test_.py` and run with +`python3 -m unittest`. diff --git a/tests/inline-eval/fixtures/ledgerlite-design-only/probe.sh b/tests/inline-eval/fixtures/ledgerlite-design-only/probe.sh new file mode 100755 index 000000000..1a774e861 --- /dev/null +++ b/tests/inline-eval/fixtures/ledgerlite-design-only/probe.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Planning fixture: the worker writes plan.md from ledgerlite's design.md. Probes: +# review-focus: plan.md has a Review Focus section +# implied-header: the plan names a missing or wrong header row (the spec says the +# CSV has one and lists malformed ROWS; the header is implied) +# implied-encoding: the plan names undecodable / non-UTF-8 input +# Then size lines, for the over-the-top check: plan lines, Review Focus lines, tests. +set -u; rc=0 +[ -f plan.md ] || { echo "review-focus: no plan.md"; echo "implied-header: no plan.md"; echo "implied-encoding: no plan.md"; exit 1; } +grep -q -i "^## *Review Focus" plan.md && echo "review-focus: handled" || { echo "review-focus: missing"; rc=1; } +grep -q -i "header" plan.md && grep -i "header" plan.md | grep -q -i "missing\|wrong\|unexpected\|malformed\|absent\|no header\|bad" && echo "implied-header: handled" || { echo "implied-header: not named"; rc=1; } +grep -q -i "utf-8\|unicode\|decod\|binary\|non-text" plan.md && echo "implied-encoding: handled" || { echo "implied-encoding: not named"; rc=1; } +echo "size: plan=$(wc -l < plan.md) review-focus-lines=$(awk '/^## *Review Focus/{f=1;next} f&&/^(## |---)/{exit} f&&/^(- |[0-9]+\. )/' plan.md | wc -l | tr -d ' ') tests=$(grep -c 'def test_' plan.md) tasks=$(grep -c -E '^##+ Task' plan.md)" +exit $rc diff --git a/tests/inline-eval/fixtures/ledgerlite-design-only/scoring.json b/tests/inline-eval/fixtures/ledgerlite-design-only/scoring.json new file mode 100644 index 000000000..73142914e --- /dev/null +++ b/tests/inline-eval/fixtures/ledgerlite-design-only/scoring.json @@ -0,0 +1 @@ +{"tasks": {}, "impl_dir": "ledgerlite", "test_marker": "unittest", "suite": "test -f plan.md && echo plan.md-present || echo no-plan"} diff --git a/tests/inline-eval/fixtures/ledgerlite-dir/README.txt b/tests/inline-eval/fixtures/ledgerlite-dir/README.txt new file mode 100644 index 000000000..5fe97d970 --- /dev/null +++ b/tests/inline-eval/fixtures/ledgerlite-dir/README.txt @@ -0,0 +1 @@ +Same design and probes as fixtures/ledgerlite; the hand-written terse plan split into a directory: plan/00-header.md plus one file per task. Executors and scripts under test must accept a plan directory (sizing/D1-dirplan). diff --git a/tests/inline-eval/fixtures/ledgerlite-dir/design.md b/tests/inline-eval/fixtures/ledgerlite-dir/design.md new file mode 100644 index 000000000..14b6845e2 --- /dev/null +++ b/tests/inline-eval/fixtures/ledgerlite-dir/design.md @@ -0,0 +1,78 @@ +# ledgerlite — design + +A small command-line tool that reads a CSV of bank transactions, assigns +each a category from a rules file, and prints a per-category summary with +the closing balance. Standard library only. Python 3.11+. + +## Input + +A transactions CSV with a header row `date,amount,description`: + +- `date` is ISO 8601 (`2026-03-04`). +- `amount` is a decimal number with up to two fractional digits; negative + for money out, positive for money in. Parsed as `decimal.Decimal`, never + float. +- `description` is free text. + +Rows may appear in any order. Two rows may share a date. + +A rules file is plain text, one rule per line: `=`. +Matching is case-insensitive on the description. The first matching rule +wins. A transaction matching no rule has no category. + +## Behavior + +`ledgerlite report TRANSACTIONS [--rules RULES] [--opening AMOUNT]` + +- Prints the report (below) to stdout and returns 0. +- If TRANSACTIONS cannot be read, prints `ledgerlite: cannot read : ` + to stderr and returns 1. +- If any row is malformed — wrong column count, an unparseable date, an + amount that is not a decimal number, or an amount with more than two + fractional digits (`1.005` is malformed; `1.5` and `1.50` are fine) — + prints `ledgerlite: :: ` to stderr and returns + 2. The whole file is rejected; nothing is printed to stdout. +- `--opening` defaults to `0`. `--rules` is optional; without it every + transaction is uncategorized. + +## Report + +Transactions are ordered by date, ties keeping input order. The running +balance starts at the opening amount and adds each amount in that order; +the closing balance is the running balance after the last transaction (the +opening amount if there are none). + +The report lists one line per category, alphabetically, each as +`: ` where the total is the sum of that category's +amounts. Transactions with no category are summed under the name +`uncategorized`, which is always listed last regardless of alphabetical +order. Then a blank line, then `closing balance: `. + +Amounts are printed with exactly two fractional digits and a leading `-` +for negatives (`-12.50`, `0.00`, `1200.00`). No thousands separators. + +Example, with opening 100 and rules `coffee=food`, `rent=housing`: + +``` +food: -7.50 +housing: -900.00 +uncategorized: 2500.00 + +closing balance: 1692.50 +``` + +## Package layout + +``` +ledgerlite/ + __init__.py + model.py Transaction dataclass + parse.py CSV -> list[Transaction], raising ParseError on bad rows + rules.py rules text -> list of (substring, category); categorize() + balance.py date-ordered running balance and closing balance + report.py per-category totals and report formatting + cli.py argparse entry point, main(argv) -> int +``` + +Tests live at the repo root as `test_.py` and run with +`python3 -m unittest`. diff --git a/tests/inline-eval/fixtures/ledgerlite-dir/plan/00-header.md b/tests/inline-eval/fixtures/ledgerlite-dir/plan/00-header.md new file mode 100644 index 000000000..714eee3e2 --- /dev/null +++ b/tests/inline-eval/fixtures/ledgerlite-dir/plan/00-header.md @@ -0,0 +1,21 @@ +# ledgerlite Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A CLI that reads a transactions CSV, categorizes rows by substring rules, and prints per-category totals with a closing balance. + +**Architecture:** Six small modules in a pipeline: model → parse → rules → balance → report → cli. Each module has one job and a pure function interface; the CLI wires them and owns all I/O and exit codes. + +**Tech Stack:** Python 3.11+, standard library only (`csv`, `decimal`, `datetime`, `argparse`, `unittest`). + +**Spec:** `design.md` + +## Global Constraints + +- Amounts are `decimal.Decimal` everywhere; never `float`. +- Amounts print with exactly two fractional digits, leading `-` for negatives, no thousands separators. +- Report category order: alphabetical, `uncategorized` last. +- Transactions order: by date, stable on ties (input order preserved). +- Test output must be pristine (no warnings, no stray prints). + +--- diff --git a/tests/inline-eval/fixtures/ledgerlite-dir/plan/01-transaction-model.md b/tests/inline-eval/fixtures/ledgerlite-dir/plan/01-transaction-model.md new file mode 100644 index 000000000..0a1bd3fbb --- /dev/null +++ b/tests/inline-eval/fixtures/ledgerlite-dir/plan/01-transaction-model.md @@ -0,0 +1,37 @@ +### Task 1: Transaction model + +**Files:** +- Create: `ledgerlite/__init__.py` (empty) +- Create: `ledgerlite/model.py` +- Test: `test_model.py` + +**Interfaces:** +- Produces: `Transaction(date: datetime.date, amount: decimal.Decimal, description: str, category: str | None = None)` — a frozen dataclass. + +- [ ] **Step 1: Write the failing test** + +```python +import unittest +from datetime import date +from decimal import Decimal +from ledgerlite.model import Transaction + + +class TransactionTests(unittest.TestCase): + def test_fields_and_default_category(self): + t = Transaction(date(2026, 3, 4), Decimal("-7.50"), "COFFEE SHOP") + self.assertEqual(t.date, date(2026, 3, 4)) + self.assertEqual(t.amount, Decimal("-7.50")) + self.assertEqual(t.description, "COFFEE SHOP") + self.assertIsNone(t.category) + + def test_is_frozen(self): + t = Transaction(date(2026, 3, 4), Decimal("1"), "x") + with self.assertRaises(Exception): + t.amount = Decimal("2") +``` + +- [ ] **Step 2: Run it and watch it fail** — `python3 -m unittest test_model` → ImportError. +- [ ] **Step 3: Implement** `ledgerlite/model.py` as a `@dataclass(frozen=True)` with the four fields above. +- [ ] **Step 4: Run it and watch it pass** — `python3 -m unittest test_model` → OK. +- [ ] **Step 5: Commit** — `git add ledgerlite test_model.py && git commit -m "Add Transaction model"` diff --git a/tests/inline-eval/fixtures/ledgerlite-dir/plan/02-csv-parsing.md b/tests/inline-eval/fixtures/ledgerlite-dir/plan/02-csv-parsing.md new file mode 100644 index 000000000..e4c7af754 --- /dev/null +++ b/tests/inline-eval/fixtures/ledgerlite-dir/plan/02-csv-parsing.md @@ -0,0 +1,43 @@ +### Task 2: CSV parsing + +**Files:** +- Create: `ledgerlite/parse.py` +- Test: `test_parse.py` + +**Interfaces:** +- Consumes: `Transaction` (Task 1). +- Produces: `parse_csv(text: str) -> list[Transaction]` — parses CSV text with header `date,amount,description`, in input order, category `None`. `class ParseError(ValueError)` with attributes `line: int` (1-based, counting the header as line 1) and `reason: str`, raised on the first bad row: wrong column count, unparseable date, or an amount that is not a decimal number. + +- [ ] **Step 1: Write the failing test** + +```python +import unittest +from datetime import date +from decimal import Decimal +from ledgerlite.parse import parse_csv, ParseError + +CSV = "date,amount,description\n2026-03-04,-7.50,COFFEE SHOP\n2026-03-01,2500.00,SALARY\n" + + +class ParseTests(unittest.TestCase): + def test_parses_rows_in_input_order(self): + txns = parse_csv(CSV) + self.assertEqual([t.description for t in txns], ["COFFEE SHOP", "SALARY"]) + self.assertEqual(txns[0].date, date(2026, 3, 4)) + self.assertEqual(txns[0].amount, Decimal("-7.50")) + self.assertIsInstance(txns[1].amount, Decimal) + self.assertIsNone(txns[0].category) + + def test_header_only_is_empty(self): + self.assertEqual(parse_csv("date,amount,description\n"), []) + + def test_bad_date_raises_with_line(self): + with self.assertRaises(ParseError) as cm: + parse_csv("date,amount,description\n2026-13-40,1.00,x\n") + self.assertEqual(cm.exception.line, 2) +``` + +- [ ] **Step 2: Run it and watch it fail** — `python3 -m unittest test_parse` → ImportError. +- [ ] **Step 3: Implement** `ledgerlite/parse.py` using `csv.reader` over `text.splitlines()`; validate the header; build `Transaction` per row; raise `ParseError(line, reason)` on the first bad row. +- [ ] **Step 4: Run it and watch it pass** — `python3 -m unittest test_parse` → OK. +- [ ] **Step 5: Commit** — `git add ledgerlite/parse.py test_parse.py && git commit -m "Add CSV parsing"` diff --git a/tests/inline-eval/fixtures/ledgerlite-dir/plan/03-categorization-rules.md b/tests/inline-eval/fixtures/ledgerlite-dir/plan/03-categorization-rules.md new file mode 100644 index 000000000..1baad5f83 --- /dev/null +++ b/tests/inline-eval/fixtures/ledgerlite-dir/plan/03-categorization-rules.md @@ -0,0 +1,39 @@ +### Task 3: Categorization rules + +**Files:** +- Create: `ledgerlite/rules.py` +- Test: `test_rules.py` + +**Interfaces:** +- Consumes: `Transaction` (Task 1). +- Produces: `parse_rules(text: str) -> list[tuple[str, str]]` — one `substring=category` per non-blank line, in order. `categorize(transactions: list[Transaction], rules: list[tuple[str, str]]) -> list[Transaction]` — returns new `Transaction` objects with `category` set by the first rule whose substring occurs in the description, case-insensitively; unchanged (`None`) when no rule matches. Input list is not mutated. + +- [ ] **Step 1: Write the failing test** + +```python +import unittest +from datetime import date +from decimal import Decimal +from ledgerlite.model import Transaction +from ledgerlite.rules import parse_rules, categorize + + +class RulesTests(unittest.TestCase): + def test_parse_rules_skips_blank_lines(self): + self.assertEqual(parse_rules("coffee=food\n\nrent=housing\n"), [("coffee", "food"), ("rent", "housing")]) + + def test_first_match_wins_case_insensitive(self): + txns = [Transaction(date(2026, 3, 4), Decimal("-7.50"), "Coffee Shop RENT")] + out = categorize(txns, [("rent", "housing"), ("coffee", "food")]) + self.assertEqual(out[0].category, "housing") + self.assertIsNone(txns[0].category) + + def test_no_match_stays_uncategorized(self): + txns = [Transaction(date(2026, 3, 4), Decimal("1"), "MYSTERY")] + self.assertIsNone(categorize(txns, [("coffee", "food")])[0].category) +``` + +- [ ] **Step 2: Run it and watch it fail** — `python3 -m unittest test_rules` → ImportError. +- [ ] **Step 3: Implement** `ledgerlite/rules.py` with the two functions; use `dataclasses.replace` to produce the categorized copies. +- [ ] **Step 4: Run it and watch it pass** — `python3 -m unittest test_rules` → OK. +- [ ] **Step 5: Commit** — `git add ledgerlite/rules.py test_rules.py && git commit -m "Add categorization rules"` diff --git a/tests/inline-eval/fixtures/ledgerlite-dir/plan/04-running-balance.md b/tests/inline-eval/fixtures/ledgerlite-dir/plan/04-running-balance.md new file mode 100644 index 000000000..c81032146 --- /dev/null +++ b/tests/inline-eval/fixtures/ledgerlite-dir/plan/04-running-balance.md @@ -0,0 +1,39 @@ +### Task 4: Running balance + +**Files:** +- Create: `ledgerlite/balance.py` +- Test: `test_balance.py` + +**Interfaces:** +- Consumes: `Transaction` (Task 1). +- Produces: `ordered(transactions: list[Transaction]) -> list[Transaction]` — sorted by date, stable. `closing_balance(transactions: list[Transaction], opening: Decimal) -> Decimal` — opening plus the sum of amounts (order does not affect the sum, but callers pass the ordered list). + +- [ ] **Step 1: Write the failing test** + +```python +import unittest +from datetime import date +from decimal import Decimal +from ledgerlite.model import Transaction +from ledgerlite.balance import ordered, closing_balance + +A = Transaction(date(2026, 3, 4), Decimal("-7.50"), "a") +B = Transaction(date(2026, 3, 1), Decimal("2500.00"), "b") +C = Transaction(date(2026, 3, 4), Decimal("-900.00"), "c") + + +class BalanceTests(unittest.TestCase): + def test_ordered_by_date_stable(self): + self.assertEqual([t.description for t in ordered([A, B, C])], ["b", "a", "c"]) + + def test_closing_balance(self): + self.assertEqual(closing_balance([A, B, C], Decimal("100")), Decimal("1692.50")) + + def test_closing_balance_empty_is_opening(self): + self.assertEqual(closing_balance([], Decimal("100")), Decimal("100")) +``` + +- [ ] **Step 2: Run it and watch it fail** — `python3 -m unittest test_balance` → ImportError. +- [ ] **Step 3: Implement** `ledgerlite/balance.py`; `sorted(..., key=lambda t: t.date)` is stable. +- [ ] **Step 4: Run it and watch it pass** — `python3 -m unittest test_balance` → OK. +- [ ] **Step 5: Commit** — `git add ledgerlite/balance.py test_balance.py && git commit -m "Add running balance"` diff --git a/tests/inline-eval/fixtures/ledgerlite-dir/plan/05-report.md b/tests/inline-eval/fixtures/ledgerlite-dir/plan/05-report.md new file mode 100644 index 000000000..844c45e70 --- /dev/null +++ b/tests/inline-eval/fixtures/ledgerlite-dir/plan/05-report.md @@ -0,0 +1,46 @@ +### Task 5: Report + +**Files:** +- Create: `ledgerlite/report.py` +- Test: `test_report.py` + +**Interfaces:** +- Consumes: `Transaction` (Task 1). +- Produces: `totals_by_category(transactions: list[Transaction]) -> dict[str, Decimal]` — keys are category names, with `None` mapped to `"uncategorized"`. `format_report(totals: dict[str, Decimal], closing: Decimal) -> str` — one `: ` line per category, alphabetical with `uncategorized` last, a blank line, then `closing balance: `; no trailing newline. `format_amount(amount: Decimal) -> str` — exactly two fractional digits, leading `-` for negatives. + +- [ ] **Step 1: Write the failing test** + +```python +import unittest +from datetime import date +from decimal import Decimal +from ledgerlite.model import Transaction +from ledgerlite.report import totals_by_category, format_report, format_amount + + +class ReportTests(unittest.TestCase): + def test_format_amount(self): + self.assertEqual(format_amount(Decimal("-12.5")), "-12.50") + self.assertEqual(format_amount(Decimal("0")), "0.00") + self.assertEqual(format_amount(Decimal("1200")), "1200.00") + + def test_totals_map_none_to_uncategorized(self): + txns = [ + Transaction(date(2026, 3, 4), Decimal("-7.50"), "a", "food"), + Transaction(date(2026, 3, 4), Decimal("2500.00"), "b"), + Transaction(date(2026, 3, 4), Decimal("-900.00"), "c", "housing"), + ] + self.assertEqual(totals_by_category(txns), {"food": Decimal("-7.50"), "uncategorized": Decimal("2500.00"), "housing": Decimal("-900.00")}) + + def test_format_report_order_and_layout(self): + totals = {"uncategorized": Decimal("2500.00"), "housing": Decimal("-900.00"), "food": Decimal("-7.50")} + self.assertEqual( + format_report(totals, Decimal("1692.50")), + "food: -7.50\nhousing: -900.00\nuncategorized: 2500.00\n\nclosing balance: 1692.50", + ) +``` + +- [ ] **Step 2: Run it and watch it fail** — `python3 -m unittest test_report` → ImportError. +- [ ] **Step 3: Implement** `ledgerlite/report.py` with the three functions; `format_amount` via `Decimal.quantize(Decimal("0.01"))`. +- [ ] **Step 4: Run it and watch it pass** — `python3 -m unittest test_report` → OK. +- [ ] **Step 5: Commit** — `git add ledgerlite/report.py test_report.py && git commit -m "Add report formatting"` diff --git a/tests/inline-eval/fixtures/ledgerlite-dir/plan/06-cli.md b/tests/inline-eval/fixtures/ledgerlite-dir/plan/06-cli.md new file mode 100644 index 000000000..c319163f9 --- /dev/null +++ b/tests/inline-eval/fixtures/ledgerlite-dir/plan/06-cli.md @@ -0,0 +1,56 @@ +### Task 6: CLI + +**Files:** +- Create: `ledgerlite/cli.py` +- Test: `test_cli.py` + +**Interfaces:** +- Consumes: `parse_csv(path: str) -> list[Transaction]` (Task 2); `parse_rules`, `categorize` (Task 3); `ordered`, `closing_balance` (Task 4); `totals_by_category`, `format_report` (Task 5). +- Produces: `main(argv: list[str]) -> int` — `report TRANSACTIONS [--rules RULES] [--opening AMOUNT]`; prints the report to stdout and returns 0; an unreadable TRANSACTIONS prints `ledgerlite: cannot read : ` to stderr and returns 1. + +- [ ] **Step 1: Write the failing test** + +```python +import io +import os +import tempfile +import unittest +from contextlib import redirect_stdout, redirect_stderr +from ledgerlite.cli import main + +CSV = "date,amount,description\n2026-03-04,-7.50,COFFEE SHOP\n2026-03-01,2500.00,SALARY\n2026-03-04,-900.00,RENT MARCH\n" +RULES = "coffee=food\nrent=housing\n" + + +def write(text): + f = tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False) + f.write(text) + f.close() + return f.name + + +class CliTests(unittest.TestCase): + def test_report_end_to_end(self): + csv_path, rules_path = write(CSV), write(RULES) + self.addCleanup(os.unlink, csv_path) + self.addCleanup(os.unlink, rules_path) + out, err = io.StringIO(), io.StringIO() + with redirect_stdout(out), redirect_stderr(err): + rc = main(["report", csv_path, "--rules", rules_path, "--opening", "100"]) + self.assertEqual(rc, 0) + self.assertEqual(out.getvalue(), "food: -7.50\nhousing: -900.00\nuncategorized: 2500.00\n\nclosing balance: 1692.50\n") + self.assertEqual(err.getvalue(), "") + + def test_missing_file_returns_1(self): + out, err = io.StringIO(), io.StringIO() + with redirect_stdout(out), redirect_stderr(err): + rc = main(["report", "/no/such/file.csv"]) + self.assertEqual(rc, 1) + self.assertEqual(out.getvalue(), "") + self.assertTrue(err.getvalue().startswith("ledgerlite: cannot read /no/such/file.csv")) +``` + +- [ ] **Step 2: Run it and watch it fail** — `python3 -m unittest test_cli` → ImportError. +- [ ] **Step 3: Implement** `ledgerlite/cli.py` with `argparse` (subcommand `report`), wiring the modules in order: parse → categorize → ordered → totals and closing balance → format_report; print the report with a trailing newline. +- [ ] **Step 4: Run the whole suite and watch it pass** — `python3 -m unittest` → OK. +- [ ] **Step 5: Commit** — `git add ledgerlite/cli.py test_cli.py && git commit -m "Add CLI"` diff --git a/tests/inline-eval/fixtures/ledgerlite-dir/probe.sh b/tests/inline-eval/fixtures/ledgerlite-dir/probe.sh new file mode 100755 index 000000000..45d6288e9 --- /dev/null +++ b/tests/inline-eval/fixtures/ledgerlite-dir/probe.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Planted-defect probes for the ledgerlite fixture. Run from the repo root of +# a finished rep. Prints one line per probe: ": handled" or +# ": ". Exit 0 iff every probe is handled. +# +# malformed-amount: design.md says a row whose amount is not a decimal number +# is rejected with `ledgerlite: :: ...` on stderr and exit 2. +# No task's tests exercise it; the natural implementation lets +# decimal.InvalidOperation escape as a traceback. +# interface-mismatch: Task 6's brief says parse_csv takes a path; Task 2 +# produces parse_csv(text). Handled means the CLI reads the file itself and +# the pipeline works end to end. +set -u +rc=0 +tmp=$(mktemp -d) +printf 'date,amount,description\n2026-03-01,12.00,ok\n2026-03-02,twelve,bad\n' > "$tmp/bad.csv" +printf 'date,amount,description\n2026-03-01,12.00,ok\n' > "$tmp/good.csv" + +out=$(python3 -c "import sys; from ledgerlite.cli import main; sys.exit(main(['report', '$tmp/bad.csv']))" 2>&1); code=$? +if [ "$code" -eq 2 ] && printf '%s' "$out" | grep -q "bad.csv:3"; then + echo "malformed-amount: handled" +else + echo "malformed-amount: exit=$code $(printf '%s' "$out" | tail -n 1 | cut -c1-80)"; rc=1 +fi + +out=$(python3 -c "import sys; from ledgerlite.cli import main; sys.exit(main(['report', '$tmp/good.csv']))" 2>&1); code=$? +if [ "$code" -eq 0 ] && [ "$out" = $'uncategorized: 12.00\n\nclosing balance: 12.00' ]; then + echo "interface-mismatch: handled" +else + echo "interface-mismatch: exit=$code $(printf '%s' "$out" | tail -n 1 | cut -c1-80)"; rc=1 +fi +# amount-precision: design.md says an amount with more than two fractional +# digits is malformed (exit 2). The plan's Task 2 lists the other malformed +# cases and its tests never exercise this one; Decimal("1.005") parses fine, +# so the natural implementation silently accepts it. +printf 'date,amount,description\n2026-03-01,1.005,precise\n' > "$tmp/prec.csv" +out=$(python3 -c "import sys; from ledgerlite.cli import main; sys.exit(main(['report', '$tmp/prec.csv']))" 2>&1); code=$? +if [ "$code" -eq 2 ] && printf '%s' "$out" | grep -q "prec.csv:2"; then + echo "amount-precision: handled" +else + echo "amount-precision: exit=$code $(printf '%s' "$out" | tail -n 1 | cut -c1-80)"; rc=1 +fi +rm -r "$tmp" +exit $rc diff --git a/tests/inline-eval/fixtures/ledgerlite-dir/scoring.json b/tests/inline-eval/fixtures/ledgerlite-dir/scoring.json new file mode 100644 index 000000000..35aca71dc --- /dev/null +++ b/tests/inline-eval/fixtures/ledgerlite-dir/scoring.json @@ -0,0 +1,6 @@ +{ + "tasks": {"model": "test_model", "parse": "test_parse", "rules": "test_rules", "balance": "test_balance", "report": "test_report", "cli": "test_cli"}, + "impl_dir": "ledgerlite", + "test_marker": "unittest", + "suite": "python3 -m unittest" +} diff --git a/tests/inline-eval/fixtures/ledgerlite-full/README.txt b/tests/inline-eval/fixtures/ledgerlite-full/README.txt new file mode 100644 index 000000000..0554e2177 --- /dev/null +++ b/tests/inline-eval/fixtures/ledgerlite-full/README.txt @@ -0,0 +1,6 @@ +Same design and probes as fixtures/ledgerlite, but plan.md is an Opus 5 +writing-plans output (results/2026-09-17-review-focus-five/wpplan-41: +1394 lines, 7 tasks, 79 tests, complete code in every step) instead of the +287-line hand-written plan. Note the hand-written plan carries a planted +Interfaces mismatch in Task 6 that this plan does not, so only the +malformed-amount and amount-precision probes compare like for like. diff --git a/tests/inline-eval/fixtures/ledgerlite-full/design.md b/tests/inline-eval/fixtures/ledgerlite-full/design.md new file mode 100644 index 000000000..14b6845e2 --- /dev/null +++ b/tests/inline-eval/fixtures/ledgerlite-full/design.md @@ -0,0 +1,78 @@ +# ledgerlite — design + +A small command-line tool that reads a CSV of bank transactions, assigns +each a category from a rules file, and prints a per-category summary with +the closing balance. Standard library only. Python 3.11+. + +## Input + +A transactions CSV with a header row `date,amount,description`: + +- `date` is ISO 8601 (`2026-03-04`). +- `amount` is a decimal number with up to two fractional digits; negative + for money out, positive for money in. Parsed as `decimal.Decimal`, never + float. +- `description` is free text. + +Rows may appear in any order. Two rows may share a date. + +A rules file is plain text, one rule per line: `=`. +Matching is case-insensitive on the description. The first matching rule +wins. A transaction matching no rule has no category. + +## Behavior + +`ledgerlite report TRANSACTIONS [--rules RULES] [--opening AMOUNT]` + +- Prints the report (below) to stdout and returns 0. +- If TRANSACTIONS cannot be read, prints `ledgerlite: cannot read : ` + to stderr and returns 1. +- If any row is malformed — wrong column count, an unparseable date, an + amount that is not a decimal number, or an amount with more than two + fractional digits (`1.005` is malformed; `1.5` and `1.50` are fine) — + prints `ledgerlite: :: ` to stderr and returns + 2. The whole file is rejected; nothing is printed to stdout. +- `--opening` defaults to `0`. `--rules` is optional; without it every + transaction is uncategorized. + +## Report + +Transactions are ordered by date, ties keeping input order. The running +balance starts at the opening amount and adds each amount in that order; +the closing balance is the running balance after the last transaction (the +opening amount if there are none). + +The report lists one line per category, alphabetically, each as +`: ` where the total is the sum of that category's +amounts. Transactions with no category are summed under the name +`uncategorized`, which is always listed last regardless of alphabetical +order. Then a blank line, then `closing balance: `. + +Amounts are printed with exactly two fractional digits and a leading `-` +for negatives (`-12.50`, `0.00`, `1200.00`). No thousands separators. + +Example, with opening 100 and rules `coffee=food`, `rent=housing`: + +``` +food: -7.50 +housing: -900.00 +uncategorized: 2500.00 + +closing balance: 1692.50 +``` + +## Package layout + +``` +ledgerlite/ + __init__.py + model.py Transaction dataclass + parse.py CSV -> list[Transaction], raising ParseError on bad rows + rules.py rules text -> list of (substring, category); categorize() + balance.py date-ordered running balance and closing balance + report.py per-category totals and report formatting + cli.py argparse entry point, main(argv) -> int +``` + +Tests live at the repo root as `test_.py` and run with +`python3 -m unittest`. diff --git a/tests/inline-eval/fixtures/ledgerlite-full/plan.md b/tests/inline-eval/fixtures/ledgerlite-full/plan.md new file mode 100644 index 000000000..6cbd84194 --- /dev/null +++ b/tests/inline-eval/fixtures/ledgerlite-full/plan.md @@ -0,0 +1,1394 @@ +# ledgerlite Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build `ledgerlite`, a standard-library-only Python command-line tool that reads a bank-transaction CSV, categorizes each row with a rules file, and prints per-category totals plus the closing balance. + +**Architecture:** Six small modules with one responsibility each, wired together by `cli.py`: `model` (the `Transaction` record), `parse` (field validation + CSV → `list[Transaction]`, raising `ParseError` carrying a line number), `rules` (rules text → ordered `(substring, category)` pairs, plus `categorize`), `balance` (date ordering and closing balance), `report` (per-category totals and text formatting), `cli` (argparse, file I/O, exit codes, error messages). All I/O lives in `cli.py`; every other module takes iterables of lines or lists of objects, so tests never need temp files except for the CLI task. Money is `decimal.Decimal` end to end — never `float`. + +**Tech Stack:** Python 3.11+, standard library only (`argparse`, `csv`, `dataclasses`, `datetime`, `decimal`, `re`, `unittest`). Tests are `unittest` files at the repo root, run with `python3 -m unittest`. + +**Spec:** `design.md` (in this directory — read it before starting; every task argues from it) + +## Global Constraints + +- Python 3.11+. Standard library only — no third-party dependencies, no `pyproject.toml`, no packaging metadata. +- Money is parsed and summed as `decimal.Decimal`. `float` must not appear anywhere in the package. +- Package layout is fixed by the spec: `ledgerlite/{__init__,model,parse,rules,balance,report,cli}.py`, plus `ledgerlite/__main__.py` so `python3 -m ledgerlite` works (see Decisions). +- Tests live at the repo root as `test_.py` and run with `python3 -m unittest` from the repo root. +- Exit codes: `0` success, `1` a named file cannot be read, `2` a file's contents are malformed (and, from argparse, bad command-line usage). +- Error messages, verbatim formats: `ledgerlite: cannot read : ` and `ledgerlite: :: `, each on stderr with a trailing newline. +- When any error is reported, stdout stays empty. +- Amounts print with exactly two fractional digits, a leading `-` only for negatives, and no thousands separators: `-12.50`, `0.00`, `1200.00`. +- The uncategorized bucket is spelled `uncategorized` and is always listed last. +- Work directly on `main` in this repo (local scratch repo, no remote). Commit at the end of every task. + +## Decisions where the spec is silent + +These are judgment calls, made once here so tasks stay consistent. Each is pinned by a test in the task that owns the code. + +1. **Header row is validated.** The first CSV line must be `date,amount,description` (field names compared after `.strip().lower()`). A file whose first line is something else is malformed at line 1 — otherwise a header-less file silently loses its first transaction. A completely empty file is malformed at line 1 (`missing header row date,amount,description`). A header-only file is valid and yields zero transactions. +2. **`date` and `amount` fields are `.strip()`ped before validation** (so `2026-03-04, -7.50, Coffee` parses); `description` is preserved byte-for-byte, including leading spaces, because it is free text. +3. **Amounts are validated by regex, not by `Decimal`'s tolerance.** `Decimal` accepts `NaN`, `Infinity`, `-inf`, `1e2`, and `1_0`; none of those is "a decimal number with up to two fractional digits", so all are malformed. `1.5`, `1.50`, `1200`, `.50`, `+3.00` are fine; `1.005` is not. +4. **Dates must be exactly `YYYY-MM-DD`.** `date.fromisoformat` on 3.11+ also accepts `20260304` and week dates; the spec pins one form, so a regex gates it first. +5. **Rules-file problems are reported like transaction problems**: `ledgerlite: :: `, exit 2. Blank and whitespace-only lines are skipped. A line with no `=`, an empty substring, or an empty category is malformed. Substring and category are stripped of surrounding whitespace; the split is on the *first* `=`, so a category may contain `=`. +6. **An unreadable `--rules` file** is reported with the same `cannot read` message and exit 1 as an unreadable transactions file. +7. **Precedence:** the transactions file is read and parsed first, so its problems are reported before any rules-file problem. +8. **Zero categories still get the blank separator line.** With no transactions the whole report is `"\nclosing balance: 0.00\n"` — the spec says "then a blank line, then `closing balance`" unconditionally. +9. **Category ordering is a plain codepoint sort** (`sorted()`), so `Food` sorts before `auto`. Categories differing only in case are distinct categories. +10. **`--opening` is validated by the same amount rules.** An invalid value is an argparse error: usage on stderr, exit 2 (argparse's own code, which matches the malformed-input code). +11. **Files are opened with `encoding="utf-8-sig"`** so a UTF-8 BOM before the header does not break header validation. Undecodable bytes are reported as `cannot read`, exit 1 — never a traceback. +12. **Invocation is `python3 -m ledgerlite report ...`.** A `ledgerlite` console script would need packaging metadata, which the stdlib-only constraint rules out. + +## Review Focus + +These are the input classes the spec implies but never spells out, most likely to bite first. Each already has a test in the task that owns the code — listed here so a reviewer can check them as a set. + +1. **Whitespace-padded CSV fields** (`2026-03-04, -7.50, Coffee`) — extremely common in real exports; must parse, not be rejected as malformed. (Tasks 2, 3) +2. **Strings `Decimal` accepts but the spec does not** — `NaN`, `Infinity`, `-inf`, `1e2`, `1_0`, `""` — must be malformed with exit 2, never silently summed into the balance. (Tasks 2, 7) +3. **A header-only file and a truly empty file** — zero transactions must print the blank line plus `closing balance: ` and exit 0; an empty file must be a clean exit 2, not an `IndexError`. (Tasks 3, 6, 7) +4. **A category whose amounts sum to zero** must print `0.00`, never `-0.00`, and `-0.001`-style residue must be impossible because inputs are capped at two decimals. (Task 6) +5. **Paths that exist but cannot be read as text** — a directory, a permission-denied file, non-UTF-8 bytes — must produce `ledgerlite: cannot read : ` and exit 1, never a traceback. (Task 7) + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `ledgerlite/__init__.py` | Package docstring only. No re-exports (keeps import graph obvious). | +| `ledgerlite/model.py` | `Transaction` frozen dataclass: `date`, `amount`, `description`. | +| `ledgerlite/parse.py` | `ParseError`; `parse_amount`, `parse_date` field validators; `parse_transactions(lines)` → `list[Transaction]`. | +| `ledgerlite/rules.py` | `Rule` alias; `parse_rules(lines)` → `list[Rule]`; `categorize(description, rules)` → `str | None`. | +| `ledgerlite/balance.py` | `order_by_date(transactions)`; `closing_balance(opening, transactions)`. | +| `ledgerlite/report.py` | `UNCATEGORIZED`; `format_amount`; `category_totals`; `ordered_categories`; `format_report`. | +| `ledgerlite/cli.py` | argparse wiring, file reading, error messages, exit codes; `main(argv) -> int`. | +| `ledgerlite/__main__.py` | `sys.exit(main())` so `python3 -m ledgerlite` runs. | +| `test_model.py` … `test_cli.py` | One `unittest` file per module under test, at the repo root. | + +Task order follows the dependency order: model → parse → rules → balance → report → cli. + +--- + +### Task 1: Package skeleton and Transaction model + +**Files:** +- Create: `ledgerlite/__init__.py` +- Create: `ledgerlite/model.py` +- Test: `test_model.py` + +**Interfaces:** +- Consumes: nothing. +- Produces: `ledgerlite.model.Transaction(date: datetime.date, amount: decimal.Decimal, description: str)` — a frozen dataclass with keyword or positional construction, field order `date, amount, description`. Every later task imports it. + +- [ ] **Step 1: Write the failing test** + +Create `test_model.py`: + +```python +import dataclasses +import datetime +import unittest +from decimal import Decimal + +from ledgerlite.model import Transaction + + +class TransactionTest(unittest.TestCase): + def test_holds_date_amount_and_description(self): + txn = Transaction( + date=datetime.date(2026, 3, 4), + amount=Decimal("-7.50"), + description="Coffee Shop", + ) + self.assertEqual(txn.date, datetime.date(2026, 3, 4)) + self.assertEqual(txn.amount, Decimal("-7.50")) + self.assertEqual(txn.description, "Coffee Shop") + + def test_field_order_is_date_amount_description(self): + txn = Transaction(datetime.date(2026, 3, 4), Decimal("1.00"), "x") + self.assertEqual(txn.date, datetime.date(2026, 3, 4)) + self.assertEqual(txn.amount, Decimal("1.00")) + self.assertEqual(txn.description, "x") + + def test_is_frozen(self): + txn = Transaction(datetime.date(2026, 3, 4), Decimal("1.00"), "x") + with self.assertRaises(dataclasses.FrozenInstanceError): + txn.amount = Decimal("2.00") + + def test_equal_values_compare_equal(self): + first = Transaction(datetime.date(2026, 3, 4), Decimal("1.00"), "x") + second = Transaction(datetime.date(2026, 3, 4), Decimal("1.00"), "x") + self.assertEqual(first, second) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `python3 -m unittest test_model -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite'` + +- [ ] **Step 3: Write the minimal implementation** + +Create `ledgerlite/__init__.py`: + +```python +"""ledgerlite: summarize bank transactions by category.""" +``` + +Create `ledgerlite/model.py`: + +```python +"""The transaction record shared by every other module.""" + +from __future__ import annotations + +import datetime +from dataclasses import dataclass +from decimal import Decimal + + +@dataclass(frozen=True) +class Transaction: + """One row of the transactions CSV, with its fields already validated.""" + + date: datetime.date + amount: Decimal + description: str +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `python3 -m unittest test_model -v` +Expected: PASS (4 tests) + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/__init__.py ledgerlite/model.py test_model.py +git commit -m "feat: add Transaction model and package skeleton" +``` + +--- + +### Task 2: Field validation — amounts, dates, and ParseError + +**Files:** +- Create: `ledgerlite/parse.py` +- Test: `test_parse.py` + +**Interfaces:** +- Consumes: `ledgerlite.model.Transaction` (imported here, used in Task 3). +- Produces: + - `ParseError(line: int, message: str)` — exception with `.line` and `.message` attributes; `str(err)` is `": "`. + - `parse_amount(text: str) -> Decimal` — raises `ValueError` whose message is a complete "what is wrong" phrase. + - `parse_date(text: str) -> datetime.date` — same error contract. + - Both are reused by Task 3 (`parse_transactions`) and Task 7 (`--opening`). + +- [ ] **Step 1: Write the failing tests** + +Create `test_parse.py`: + +```python +import datetime +import unittest +from decimal import Decimal + +from ledgerlite.parse import ParseError, parse_amount, parse_date + + +class ParseErrorTest(unittest.TestCase): + def test_records_line_and_message(self): + error = ParseError(7, "expected 3 columns, got 2") + self.assertEqual(error.line, 7) + self.assertEqual(error.message, "expected 3 columns, got 2") + self.assertEqual(str(error), "7: expected 3 columns, got 2") + + +class ParseAmountTest(unittest.TestCase): + def test_accepts_two_fractional_digits(self): + self.assertEqual(parse_amount("-12.50"), Decimal("-12.50")) + + def test_accepts_fewer_fractional_digits(self): + self.assertEqual(parse_amount("1.5"), Decimal("1.5")) + self.assertEqual(parse_amount("1200"), Decimal("1200")) + + def test_accepts_leading_sign_and_bare_fraction(self): + self.assertEqual(parse_amount("+3.00"), Decimal("3.00")) + self.assertEqual(parse_amount(".50"), Decimal("0.50")) + + def test_accepts_surrounding_whitespace(self): + self.assertEqual(parse_amount(" -7.50 "), Decimal("-7.50")) + + def test_rejects_more_than_two_fractional_digits(self): + with self.assertRaises(ValueError): + parse_amount("1.005") + + def test_rejects_strings_decimal_would_accept(self): + for text in ("NaN", "Infinity", "-inf", "1e2", "1_0", "", " ", "1,000.00", "$1.00"): + with self.subTest(text=text): + with self.assertRaises(ValueError): + parse_amount(text) + + def test_message_quotes_the_offending_text(self): + with self.assertRaises(ValueError) as caught: + parse_amount("abc") + self.assertIn("'abc'", str(caught.exception)) + self.assertIn("amount", str(caught.exception)) + + +class ParseDateTest(unittest.TestCase): + def test_accepts_iso_date(self): + self.assertEqual(parse_date("2026-03-04"), datetime.date(2026, 3, 4)) + + def test_accepts_surrounding_whitespace(self): + self.assertEqual(parse_date(" 2026-03-04 "), datetime.date(2026, 3, 4)) + + def test_rejects_other_shapes_and_impossible_dates(self): + for text in ("20260304", "2026-3-4", "03/04/2026", "2026-13-01", "2026-02-30", "", "today"): + with self.subTest(text=text): + with self.assertRaises(ValueError): + parse_date(text) + + def test_message_quotes_the_offending_text(self): + with self.assertRaises(ValueError) as caught: + parse_date("03/04/2026") + self.assertIn("'03/04/2026'", str(caught.exception)) + self.assertIn("date", str(caught.exception)) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_parse -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.parse'` + +- [ ] **Step 3: Write the minimal implementation** + +Create `ledgerlite/parse.py`: + +```python +"""Read a transactions CSV, validating every field.""" + +from __future__ import annotations + +import datetime +import re +from decimal import Decimal + +_DATE_PATTERN = re.compile(r"\A\d{4}-\d{2}-\d{2}\Z") +# A decimal number with at most two fractional digits. Deliberately stricter +# than Decimal(), which also accepts NaN, Infinity, 1e2 and 1_0. +_AMOUNT_PATTERN = re.compile(r"\A[+-]?(?:\d+(?:\.\d{1,2})?|\.\d{1,2})\Z") + + +class ParseError(Exception): + """A line of an input file could not be understood.""" + + def __init__(self, line: int, message: str) -> None: + super().__init__(f"{line}: {message}") + self.line = line + self.message = message + + +def parse_amount(text: str) -> Decimal: + """Return `text` as a Decimal, or raise ValueError explaining why not.""" + candidate = text.strip() + if not _AMOUNT_PATTERN.match(candidate): + raise ValueError( + f"invalid amount {candidate!r}: " + "expected a number with at most two fractional digits" + ) + return Decimal(candidate) + + +def parse_date(text: str) -> datetime.date: + """Return `text` as a date, or raise ValueError explaining why not.""" + candidate = text.strip() + if _DATE_PATTERN.match(candidate): + try: + return datetime.date.fromisoformat(candidate) + except ValueError: + pass + raise ValueError(f"invalid date {candidate!r}: expected YYYY-MM-DD") +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_parse -v` +Expected: PASS (12 tests) + +- [ ] **Step 5: Run the whole suite** + +Run: `python3 -m unittest` +Expected: PASS, no errors + +- [ ] **Step 6: Commit** + +```bash +git add ledgerlite/parse.py test_parse.py +git commit -m "feat: validate transaction amount and date fields" +``` + +--- + +### Task 3: CSV parsing into transactions + +**Files:** +- Modify: `ledgerlite/parse.py` (add `HEADER` and `parse_transactions`) +- Modify: `test_parse.py` (add `ParseTransactionsTest`) + +**Interfaces:** +- Consumes: `ParseError`, `parse_amount`, `parse_date` (Task 2); `Transaction` (Task 1). +- Produces: `parse_transactions(lines: Iterable[str]) -> list[Transaction]`, in file order, raising `ParseError` on the first bad line. Takes an iterable of lines (an open file or `io.StringIO`), never a path — Task 7 owns file I/O. + +- [ ] **Step 1: Write the failing tests** + +Append to `test_parse.py` (and add `import io` to the imports at the top, plus `from ledgerlite.model import Transaction` and `parse_transactions` to the existing `ledgerlite.parse` import): + +```python +HEADER_LINE = "date,amount,description\n" + + +def parse_csv(text): + return parse_transactions(io.StringIO(text)) + + +class ParseTransactionsTest(unittest.TestCase): + def test_parses_rows_in_file_order(self): + transactions = parse_csv( + HEADER_LINE + + "2026-03-05,-900.00,Monthly rent\n" + + "2026-03-04,-7.50,Coffee Shop\n" + ) + self.assertEqual( + transactions, + [ + Transaction(datetime.date(2026, 3, 5), Decimal("-900.00"), "Monthly rent"), + Transaction(datetime.date(2026, 3, 4), Decimal("-7.50"), "Coffee Shop"), + ], + ) + + def test_header_only_file_yields_no_transactions(self): + self.assertEqual(parse_csv(HEADER_LINE), []) + + def test_empty_file_is_malformed_at_line_one(self): + with self.assertRaises(ParseError) as caught: + parse_csv("") + self.assertEqual(caught.exception.line, 1) + self.assertIn("header", caught.exception.message) + + def test_wrong_header_is_malformed_at_line_one(self): + with self.assertRaises(ParseError) as caught: + parse_csv("2026-03-04,-7.50,Coffee Shop\n") + self.assertEqual(caught.exception.line, 1) + self.assertIn("header", caught.exception.message) + + def test_header_may_vary_in_case_and_padding(self): + self.assertEqual(parse_csv(" Date , Amount , Description \n"), []) + + def test_padded_fields_are_accepted_and_description_is_verbatim(self): + transactions = parse_csv(HEADER_LINE + "2026-03-04, -7.50, Coffee Shop\n") + self.assertEqual(transactions[0].date, datetime.date(2026, 3, 4)) + self.assertEqual(transactions[0].amount, Decimal("-7.50")) + self.assertEqual(transactions[0].description, " Coffee Shop") + + def test_quoted_description_may_contain_a_comma(self): + transactions = parse_csv(HEADER_LINE + '2026-03-04,-7.50,"Coffee, large"\n') + self.assertEqual(transactions[0].description, "Coffee, large") + + def test_too_few_columns_reports_the_line(self): + with self.assertRaises(ParseError) as caught: + parse_csv(HEADER_LINE + "2026-03-04,-7.50\n") + self.assertEqual(caught.exception.line, 2) + self.assertEqual(caught.exception.message, "expected 3 columns, got 2") + + def test_too_many_columns_reports_the_line(self): + with self.assertRaises(ParseError) as caught: + parse_csv(HEADER_LINE + "2026-03-04,-7.50,Coffee,extra\n") + self.assertEqual(caught.exception.line, 2) + self.assertEqual(caught.exception.message, "expected 3 columns, got 4") + + def test_blank_line_inside_the_file_is_malformed(self): + with self.assertRaises(ParseError) as caught: + parse_csv(HEADER_LINE + "2026-03-04,-7.50,Coffee\n" + "\n") + self.assertEqual(caught.exception.line, 3) + self.assertEqual(caught.exception.message, "expected 3 columns, got 0") + + def test_bad_date_reports_the_line_and_reason(self): + with self.assertRaises(ParseError) as caught: + parse_csv( + HEADER_LINE + + "2026-03-04,-7.50,Coffee\n" + + "04/03/2026,-1.00,Tea\n" + ) + self.assertEqual(caught.exception.line, 3) + self.assertIn("invalid date", caught.exception.message) + + def test_amount_with_three_fractional_digits_reports_the_line(self): + with self.assertRaises(ParseError) as caught: + parse_csv(HEADER_LINE + "2026-03-04,1.005,Coffee\n") + self.assertEqual(caught.exception.line, 2) + self.assertIn("invalid amount", caught.exception.message) + + def test_amount_decimal_would_accept_is_rejected(self): + for text in ("NaN", "Infinity", "1e2"): + with self.subTest(text=text): + with self.assertRaises(ParseError): + parse_csv(HEADER_LINE + f"2026-03-04,{text},Coffee\n") + + def test_missing_trailing_newline_is_fine(self): + transactions = parse_csv(HEADER_LINE + "2026-03-04,-7.50,Coffee") + self.assertEqual(len(transactions), 1) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_parse.ParseTransactionsTest -v` +Expected: FAIL — `ImportError: cannot import name 'parse_transactions'` + +- [ ] **Step 3: Write the minimal implementation** + +In `ledgerlite/parse.py`, add `import csv` and `from collections.abc import Iterable` to the imports, add `from .model import Transaction`, then add below the patterns: + +```python +HEADER = ["date", "amount", "description"] +``` + +and append: + +```python +def parse_transactions(lines: Iterable[str]) -> list[Transaction]: + """Return the transactions in `lines`, or raise ParseError on the first bad line. + + `lines` is any iterable of CSV text lines — an open file or an io.StringIO. + Line numbers in raised errors are 1-based file lines, header included. + """ + reader = csv.reader(lines) + try: + header = next(reader) + except StopIteration: + raise ParseError(1, "missing header row date,amount,description") from None + if [field.strip().lower() for field in header] != HEADER: + raise ParseError(1, "expected header row date,amount,description") from None + + transactions: list[Transaction] = [] + for row in reader: + line = reader.line_num + if len(row) != 3: + raise ParseError(line, f"expected 3 columns, got {len(row)}") + date_text, amount_text, description = row + try: + date = parse_date(date_text) + amount = parse_amount(amount_text) + except ValueError as error: + raise ParseError(line, str(error)) from None + transactions.append(Transaction(date=date, amount=amount, description=description)) + return transactions +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_parse -v` +Expected: PASS (26 tests) + +- [ ] **Step 5: Run the whole suite** + +Run: `python3 -m unittest` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add ledgerlite/parse.py test_parse.py +git commit -m "feat: parse transactions CSV, rejecting the whole file on a bad row" +``` + +--- + +### Task 4: Rules parsing and categorization + +**Files:** +- Create: `ledgerlite/rules.py` +- Test: `test_rules.py` + +**Interfaces:** +- Consumes: `ParseError` from `ledgerlite.parse` (one error type keeps `cli.py`'s message formatting uniform for both input files). +- Produces: + - `Rule = tuple[str, str]` — `(substring, category)`. + - `parse_rules(lines: Iterable[str]) -> list[Rule]` — file order preserved, raises `ParseError`. + - `categorize(description: str, rules: Sequence[Rule]) -> str | None` — case-insensitive, first match wins. + +- [ ] **Step 1: Write the failing tests** + +Create `test_rules.py`: + +```python +import io +import unittest + +from ledgerlite.parse import ParseError +from ledgerlite.rules import categorize, parse_rules + + +def parse_text(text): + return parse_rules(io.StringIO(text)) + + +class ParseRulesTest(unittest.TestCase): + def test_parses_one_rule_per_line_in_order(self): + self.assertEqual( + parse_text("coffee=food\nrent=housing\n"), + [("coffee", "food"), ("rent", "housing")], + ) + + def test_skips_blank_and_whitespace_only_lines(self): + self.assertEqual(parse_text("\ncoffee=food\n \n"), [("coffee", "food")]) + + def test_strips_whitespace_around_substring_and_category(self): + self.assertEqual(parse_text(" coffee shop = food \n"), [("coffee shop", "food")]) + + def test_category_may_contain_an_equals_sign(self): + self.assertEqual(parse_text("coffee=food=drink\n"), [("coffee", "food=drink")]) + + def test_empty_input_is_no_rules(self): + self.assertEqual(parse_text(""), []) + + def test_line_without_separator_is_malformed(self): + with self.assertRaises(ParseError) as caught: + parse_text("coffee=food\njust some text\n") + self.assertEqual(caught.exception.line, 2) + self.assertIn("=", caught.exception.message) + + def test_empty_substring_is_malformed(self): + with self.assertRaises(ParseError) as caught: + parse_text("=food\n") + self.assertEqual(caught.exception.line, 1) + + def test_empty_category_is_malformed(self): + with self.assertRaises(ParseError) as caught: + parse_text("coffee=\n") + self.assertEqual(caught.exception.line, 1) + + +class CategorizeTest(unittest.TestCase): + def test_matches_a_substring_of_the_description(self): + self.assertEqual(categorize("Coffee Shop", [("coffee", "food")]), "food") + + def test_matching_is_case_insensitive_in_both_directions(self): + self.assertEqual(categorize("coffee shop", [("COFFEE", "food")]), "food") + self.assertEqual(categorize("COFFEE SHOP", [("coffee", "food")]), "food") + + def test_first_matching_rule_wins(self): + rules = [("coffee", "food"), ("coffee shop", "outings")] + self.assertEqual(categorize("Coffee Shop", rules), "food") + + def test_returns_none_when_nothing_matches(self): + self.assertIsNone(categorize("Salary", [("coffee", "food")])) + + def test_returns_none_with_no_rules(self): + self.assertIsNone(categorize("Salary", [])) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_rules -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.rules'` + +- [ ] **Step 3: Write the minimal implementation** + +Create `ledgerlite/rules.py`: + +```python +"""Turn a rules file into ordered (substring, category) pairs and apply them.""" + +from __future__ import annotations + +from collections.abc import Iterable, Sequence + +from .parse import ParseError + +Rule = tuple[str, str] + +_EXPECTED = "expected =" + + +def parse_rules(lines: Iterable[str]) -> list[Rule]: + """Return the rules in `lines`, in file order, or raise ParseError.""" + rules: list[Rule] = [] + for number, raw_line in enumerate(lines, start=1): + line = raw_line.strip() + if not line: + continue + substring, separator, category = line.partition("=") + substring, category = substring.strip(), category.strip() + if not separator or not substring or not category: + raise ParseError(number, f"{_EXPECTED}, got {line!r}") + rules.append((substring, category)) + return rules + + +def categorize(description: str, rules: Sequence[Rule]) -> str | None: + """Return the category of the first rule matching `description`, else None.""" + lowered = description.lower() + for substring, category in rules: + if substring.lower() in lowered: + return category + return None +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_rules -v` +Expected: PASS (13 tests) + +- [ ] **Step 5: Run the whole suite** + +Run: `python3 -m unittest` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add ledgerlite/rules.py test_rules.py +git commit -m "feat: parse rules file and categorize descriptions" +``` + +--- + +### Task 5: Date ordering and closing balance + +**Files:** +- Create: `ledgerlite/balance.py` +- Test: `test_balance.py` + +**Interfaces:** +- Consumes: `Transaction` (Task 1). +- Produces: + - `order_by_date(transactions: Sequence[Transaction]) -> list[Transaction]` — sorted by date, ties keeping input order, input left untouched. + - `closing_balance(opening: Decimal, transactions: Iterable[Transaction]) -> Decimal`. + +- [ ] **Step 1: Write the failing tests** + +Create `test_balance.py`: + +```python +import datetime +import unittest +from decimal import Decimal + +from ledgerlite.balance import closing_balance, order_by_date +from ledgerlite.model import Transaction + + +def txn(day, amount, description="x"): + return Transaction(datetime.date(2026, 3, day), Decimal(amount), description) + + +class OrderByDateTest(unittest.TestCase): + def test_sorts_by_date(self): + unordered = [txn(5, "-900.00"), txn(1, "2500.00"), txn(4, "-7.50")] + self.assertEqual( + [t.date.day for t in order_by_date(unordered)], + [1, 4, 5], + ) + + def test_ties_keep_input_order(self): + first = txn(4, "-7.50", "first") + second = txn(4, "-1.00", "second") + ordered = order_by_date([first, second, txn(1, "5.00", "earlier")]) + self.assertEqual([t.description for t in ordered], ["earlier", "first", "second"]) + + def test_does_not_mutate_the_input(self): + unordered = [txn(5, "-900.00"), txn(1, "2500.00")] + order_by_date(unordered) + self.assertEqual([t.date.day for t in unordered], [5, 1]) + + def test_empty_input(self): + self.assertEqual(order_by_date([]), []) + + +class ClosingBalanceTest(unittest.TestCase): + def test_adds_every_amount_to_the_opening_balance(self): + transactions = [txn(4, "-7.50"), txn(5, "-900.00"), txn(1, "2500.00")] + self.assertEqual( + closing_balance(Decimal("100"), order_by_date(transactions)), + Decimal("1692.50"), + ) + + def test_no_transactions_returns_the_opening_balance(self): + self.assertEqual(closing_balance(Decimal("100.00"), []), Decimal("100.00")) + + def test_arithmetic_is_exact(self): + transactions = [txn(1, "0.10"), txn(2, "0.20")] + self.assertEqual(closing_balance(Decimal("0"), transactions), Decimal("0.30")) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_balance -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.balance'` + +- [ ] **Step 3: Write the minimal implementation** + +Create `ledgerlite/balance.py`: + +```python +"""Order transactions by date and walk the running balance.""" + +from __future__ import annotations + +from collections.abc import Iterable, Sequence +from decimal import Decimal + +from .model import Transaction + + +def order_by_date(transactions: Sequence[Transaction]) -> list[Transaction]: + """Return the transactions by date; ties keep input order (sorted is stable).""" + return sorted(transactions, key=lambda transaction: transaction.date) + + +def closing_balance(opening: Decimal, transactions: Iterable[Transaction]) -> Decimal: + """Return the running balance after the last transaction, or `opening` if none.""" + balance = opening + for transaction in transactions: + balance += transaction.amount + return balance +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_balance -v` +Expected: PASS (7 tests) + +- [ ] **Step 5: Run the whole suite** + +Run: `python3 -m unittest` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add ledgerlite/balance.py test_balance.py +git commit -m "feat: add date ordering and closing balance" +``` + +--- + +### Task 6: Per-category totals and report formatting + +**Files:** +- Create: `ledgerlite/report.py` +- Test: `test_report.py` + +**Interfaces:** +- Consumes: `Transaction` (Task 1), `Rule`/`categorize` (Task 4), `order_by_date`/`closing_balance` (Task 5). +- Produces: + - `UNCATEGORIZED = "uncategorized"`. + - `format_amount(amount: Decimal) -> str`. + - `category_totals(transactions: Iterable[Transaction], rules: Sequence[Rule]) -> dict[str, Decimal]`. + - `ordered_categories(totals: Mapping[str, Decimal]) -> list[str]`. + - `format_report(transactions: Sequence[Transaction], rules: Sequence[Rule], opening: Decimal) -> str` — the complete report, newline-terminated. Task 7 prints it verbatim. + +- [ ] **Step 1: Write the failing tests** + +Create `test_report.py`: + +```python +import datetime +import unittest +from decimal import Decimal + +from ledgerlite.model import Transaction +from ledgerlite.report import ( + UNCATEGORIZED, + category_totals, + format_amount, + format_report, + ordered_categories, +) + +RULES = [("coffee", "food"), ("rent", "housing")] + + +def txn(day, amount, description): + return Transaction(datetime.date(2026, 3, day), Decimal(amount), description) + + +class FormatAmountTest(unittest.TestCase): + def test_two_fractional_digits_always(self): + self.assertEqual(format_amount(Decimal("-12.50")), "-12.50") + self.assertEqual(format_amount(Decimal("0")), "0.00") + self.assertEqual(format_amount(Decimal("1.5")), "1.50") + self.assertEqual(format_amount(Decimal("1200")), "1200.00") + + def test_no_thousands_separators(self): + self.assertEqual(format_amount(Decimal("-1234567.89")), "-1234567.89") + + def test_negative_zero_prints_as_zero(self): + self.assertEqual(format_amount(Decimal("-0.00")), "0.00") + self.assertEqual(format_amount(Decimal("-7.50") + Decimal("7.50")), "0.00") + + +class CategoryTotalsTest(unittest.TestCase): + def test_sums_amounts_per_category(self): + transactions = [ + txn(4, "-7.50", "Coffee Shop"), + txn(6, "-2.50", "coffee beans"), + txn(5, "-900.00", "Monthly rent"), + ] + self.assertEqual( + category_totals(transactions, RULES), + {"food": Decimal("-10.00"), "housing": Decimal("-900.00")}, + ) + + def test_unmatched_transactions_land_in_uncategorized(self): + transactions = [txn(1, "2500.00", "Salary"), txn(2, "-20.00", "Bookshop")] + self.assertEqual( + category_totals(transactions, RULES), + {UNCATEGORIZED: Decimal("2480.00")}, + ) + + def test_no_transactions_is_no_categories(self): + self.assertEqual(category_totals([], RULES), {}) + + +class OrderedCategoriesTest(unittest.TestCase): + def test_alphabetical_with_uncategorized_last(self): + totals = { + UNCATEGORIZED: Decimal("1"), + "housing": Decimal("1"), + "food": Decimal("1"), + } + self.assertEqual(ordered_categories(totals), ["food", "housing", UNCATEGORIZED]) + + def test_omits_uncategorized_when_absent(self): + totals = {"housing": Decimal("1"), "food": Decimal("1")} + self.assertEqual(ordered_categories(totals), ["food", "housing"]) + + def test_empty_totals(self): + self.assertEqual(ordered_categories({}), []) + + +class FormatReportTest(unittest.TestCase): + def test_matches_the_design_example(self): + transactions = [ + txn(5, "-900.00", "Monthly rent"), + txn(1, "2500.00", "Salary"), + txn(4, "-7.50", "Coffee Shop"), + ] + self.assertEqual( + format_report(transactions, RULES, Decimal("100")), + "food: -7.50\n" + "housing: -900.00\n" + "uncategorized: 2500.00\n" + "\n" + "closing balance: 1692.50\n", + ) + + def test_no_transactions_reports_the_opening_balance(self): + self.assertEqual( + format_report([], RULES, Decimal("0")), + "\nclosing balance: 0.00\n", + ) + + def test_category_summing_to_zero_prints_zero(self): + transactions = [txn(1, "-7.50", "Coffee Shop"), txn(2, "7.50", "Coffee refund")] + self.assertEqual( + format_report(transactions, RULES, Decimal("0")), + "food: 0.00\n\nclosing balance: 0.00\n", + ) + + def test_without_rules_everything_is_uncategorized(self): + transactions = [txn(1, "-7.50", "Coffee Shop")] + self.assertEqual( + format_report(transactions, [], Decimal("10.00")), + "uncategorized: -7.50\n\nclosing balance: 2.50\n", + ) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_report -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.report'` + +- [ ] **Step 3: Write the minimal implementation** + +Create `ledgerlite/report.py`: + +```python +"""Per-category totals and the text of the report.""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping, Sequence +from decimal import Decimal + +from .balance import closing_balance, order_by_date +from .model import Transaction +from .rules import Rule, categorize + +UNCATEGORIZED = "uncategorized" +_CENTS = Decimal("0.01") + + +def format_amount(amount: Decimal) -> str: + """Return `amount` with exactly two fractional digits and no separators.""" + quantized = amount.quantize(_CENTS) + if quantized == 0: + quantized = abs(quantized) # never print "-0.00" + return f"{quantized:f}" + + +def category_totals( + transactions: Iterable[Transaction], rules: Sequence[Rule] +) -> dict[str, Decimal]: + """Return the sum of amounts per category; unmatched rows go to UNCATEGORIZED.""" + totals: dict[str, Decimal] = {} + for transaction in transactions: + category = categorize(transaction.description, rules) + if category is None: + category = UNCATEGORIZED + totals[category] = totals.get(category, Decimal("0")) + transaction.amount + return totals + + +def ordered_categories(totals: Mapping[str, Decimal]) -> list[str]: + """Return category names alphabetically, with UNCATEGORIZED always last.""" + names = sorted(name for name in totals if name != UNCATEGORIZED) + if UNCATEGORIZED in totals: + names.append(UNCATEGORIZED) + return names + + +def format_report( + transactions: Sequence[Transaction], rules: Sequence[Rule], opening: Decimal +) -> str: + """Return the whole report, newline-terminated.""" + ordered = order_by_date(transactions) + totals = category_totals(ordered, rules) + lines = [ + f"{name}: {format_amount(totals[name])}" for name in ordered_categories(totals) + ] + lines.append("") + lines.append(f"closing balance: {format_amount(closing_balance(opening, ordered))}") + return "\n".join(lines) + "\n" +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_report -v` +Expected: PASS (13 tests) + +- [ ] **Step 5: Run the whole suite** + +Run: `python3 -m unittest` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add ledgerlite/report.py test_report.py +git commit -m "feat: format per-category totals and closing balance" +``` + +--- + +### Task 7: Command-line interface + +**Files:** +- Create: `ledgerlite/cli.py` +- Create: `ledgerlite/__main__.py` +- Test: `test_cli.py` + +**Interfaces:** +- Consumes: `ParseError`, `parse_amount`, `parse_transactions` (Tasks 2–3); `parse_rules` (Task 4); `format_report` (Task 6). +- Produces: `main(argv: Sequence[str] | None = None) -> int` — the only public entry point. `python3 -m ledgerlite report TRANSACTIONS [--rules RULES] [--opening AMOUNT]`. + +- [ ] **Step 1: Write the failing tests** + +Create `test_cli.py`: + +```python +import contextlib +import io +import os +import subprocess +import sys +import tempfile +import unittest + +from ledgerlite.cli import main + +TRANSACTIONS = ( + "date,amount,description\n" + "2026-03-05,-900.00,Monthly rent\n" + "2026-03-01,2500.00,Salary\n" + "2026-03-04,-7.50,Coffee Shop\n" +) +RULES = "coffee=food\nrent=housing\n" +EXPECTED_REPORT = ( + "food: -7.50\n" + "housing: -900.00\n" + "uncategorized: 2500.00\n" + "\n" + "closing balance: 1692.50\n" +) + + +class CliTest(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + + def write(self, name, text, encoding="utf-8"): + path = os.path.join(self.directory.name, name) + with open(path, "w", encoding=encoding, newline="") as handle: + handle.write(text) + return path + + def write_bytes(self, name, data): + path = os.path.join(self.directory.name, name) + with open(path, "wb") as handle: + handle.write(data) + return path + + def run_cli(self, *argv): + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = main(list(argv)) + return code, out.getvalue(), err.getvalue() + + def test_reports_with_rules_and_opening(self): + transactions = self.write("txns.csv", TRANSACTIONS) + rules = self.write("rules.txt", RULES) + code, out, err = self.run_cli( + "report", transactions, "--rules", rules, "--opening", "100" + ) + self.assertEqual((code, out, err), (0, EXPECTED_REPORT, "")) + + def test_opening_defaults_to_zero_and_rules_are_optional(self): + transactions = self.write("txns.csv", TRANSACTIONS) + code, out, err = self.run_cli("report", transactions) + self.assertEqual(code, 0) + self.assertEqual(out, "uncategorized: 1592.50\n\nclosing balance: 1592.50\n") + self.assertEqual(err, "") + + def test_header_only_file_reports_the_opening_balance(self): + transactions = self.write("empty.csv", "date,amount,description\n") + code, out, err = self.run_cli("report", transactions, "--opening", "-12.50") + self.assertEqual(code, 0) + self.assertEqual(out, "\nclosing balance: -12.50\n") + self.assertEqual(err, "") + + def test_utf8_bom_before_the_header_is_tolerated(self): + transactions = self.write("bom.csv", TRANSACTIONS, encoding="utf-8-sig") + code, out, _ = self.run_cli("report", transactions) + self.assertEqual(code, 0) + self.assertIn("closing balance: 1592.50", out) + + def test_missing_transactions_file_exits_one(self): + missing = os.path.join(self.directory.name, "nope.csv") + code, out, err = self.run_cli("report", missing) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertTrue(err.startswith(f"ledgerlite: cannot read {missing}: ")) + self.assertTrue(err.endswith("\n")) + + def test_directory_as_transactions_file_exits_one(self): + code, out, err = self.run_cli("report", self.directory.name) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertIn("cannot read", err) + + def test_undecodable_bytes_exit_one_without_a_traceback(self): + transactions = self.write_bytes("bad.csv", b"date,amount,description\n\xff\xfe\n") + code, out, err = self.run_cli("report", transactions) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertIn(f"cannot read {transactions}", err) + + def test_malformed_row_exits_two_naming_the_line(self): + transactions = self.write( + "bad.csv", "date,amount,description\n2026-03-04,-7.50\n" + ) + code, out, err = self.run_cli("report", transactions) + self.assertEqual(code, 2) + self.assertEqual(out, "") + self.assertEqual( + err, f"ledgerlite: {transactions}:2: expected 3 columns, got 2\n" + ) + + def test_amount_with_three_decimals_exits_two(self): + transactions = self.write( + "bad.csv", "date,amount,description\n2026-03-04,1.005,Coffee\n" + ) + code, out, err = self.run_cli("report", transactions) + self.assertEqual(code, 2) + self.assertEqual(out, "") + self.assertIn(f"{transactions}:2: invalid amount", err) + + def test_empty_transactions_file_exits_two(self): + transactions = self.write("empty.csv", "") + code, out, err = self.run_cli("report", transactions) + self.assertEqual(code, 2) + self.assertEqual(out, "") + self.assertIn(f"{transactions}:1:", err) + + def test_missing_rules_file_exits_one(self): + transactions = self.write("txns.csv", TRANSACTIONS) + missing = os.path.join(self.directory.name, "nope.txt") + code, out, err = self.run_cli("report", transactions, "--rules", missing) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertTrue(err.startswith(f"ledgerlite: cannot read {missing}: ")) + + def test_malformed_rules_file_exits_two(self): + transactions = self.write("txns.csv", TRANSACTIONS) + rules = self.write("rules.txt", "coffee=food\nnonsense\n") + code, out, err = self.run_cli("report", transactions, "--rules", rules) + self.assertEqual(code, 2) + self.assertEqual(out, "") + self.assertIn(f"{rules}:2:", err) + + def test_transactions_problem_is_reported_before_rules_problem(self): + transactions = self.write("bad.csv", "date,amount,description\nnope\n") + missing = os.path.join(self.directory.name, "nope.txt") + code, _, err = self.run_cli("report", transactions, "--rules", missing) + self.assertEqual(code, 2) + self.assertIn(transactions, err) + self.assertNotIn(missing, err) + + def test_invalid_opening_is_a_usage_error(self): + transactions = self.write("txns.csv", TRANSACTIONS) + with contextlib.redirect_stderr(io.StringIO()): + with self.assertRaises(SystemExit) as caught: + main(["report", transactions, "--opening", "1.005"]) + self.assertEqual(caught.exception.code, 2) + + def test_missing_subcommand_is_a_usage_error(self): + with contextlib.redirect_stderr(io.StringIO()): + with self.assertRaises(SystemExit) as caught: + main([]) + self.assertEqual(caught.exception.code, 2) + + +class ModuleEntryPointTest(unittest.TestCase): + def test_python_m_ledgerlite_runs_the_cli(self): + with tempfile.TemporaryDirectory() as directory: + path = os.path.join(directory, "txns.csv") + with open(path, "w", encoding="utf-8", newline="") as handle: + handle.write(TRANSACTIONS) + rules_path = os.path.join(directory, "rules.txt") + with open(rules_path, "w", encoding="utf-8", newline="") as handle: + handle.write(RULES) + result = subprocess.run( + [ + sys.executable, "-m", "ledgerlite", "report", path, + "--rules", rules_path, "--opening", "100", + ], + capture_output=True, + text=True, + cwd=os.path.dirname(os.path.abspath(__file__)), + ) + self.assertEqual(result.returncode, 0) + self.assertEqual(result.stdout, EXPECTED_REPORT) + self.assertEqual(result.stderr, "") + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_cli -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.cli'` + +- [ ] **Step 3: Write the minimal implementation** + +Create `ledgerlite/cli.py`: + +```python +"""Command-line entry point: python3 -m ledgerlite report TRANSACTIONS ...""" + +from __future__ import annotations + +import argparse +import sys +from collections.abc import Sequence +from decimal import Decimal + +from .parse import ParseError, parse_amount, parse_transactions +from .report import format_report +from .rules import Rule, parse_rules + +PROGRAM = "ledgerlite" + + +def _opening_amount(text: str) -> Decimal: + try: + return parse_amount(text) + except ValueError as error: + raise argparse.ArgumentTypeError(str(error)) from None + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog=PROGRAM, description="Summarize bank transactions by category." + ) + subcommands = parser.add_subparsers(dest="command", required=True) + report = subcommands.add_parser("report", help="print a per-category summary") + report.add_argument("transactions", help="path to the transactions CSV") + report.add_argument("--rules", help="path to the rules file") + report.add_argument( + "--opening", + type=_opening_amount, + default=Decimal("0"), + help="opening balance (default: 0)", + ) + return parser + + +def _read_lines(path: str) -> list[str]: + """Return the lines of `path`. utf-8-sig strips a BOM before the CSV header.""" + with open(path, encoding="utf-8-sig", newline="") as handle: + return handle.readlines() + + +def _reason(error: Exception) -> str: + if isinstance(error, OSError) and error.strerror: + return error.strerror + return str(error) + + +def _cannot_read(path: str, error: Exception) -> int: + print(f"{PROGRAM}: cannot read {path}: {_reason(error)}", file=sys.stderr) + return 1 + + +def _malformed(path: str, error: ParseError) -> int: + print(f"{PROGRAM}: {path}:{error.line}: {error.message}", file=sys.stderr) + return 2 + + +def main(argv: Sequence[str] | None = None) -> int: + """Run the CLI and return the process exit code.""" + args = _build_parser().parse_args(argv) + + try: + transaction_lines = _read_lines(args.transactions) + except (OSError, UnicodeDecodeError) as error: + return _cannot_read(args.transactions, error) + try: + transactions = parse_transactions(transaction_lines) + except ParseError as error: + return _malformed(args.transactions, error) + + rules: list[Rule] = [] + if args.rules is not None: + try: + rules_lines = _read_lines(args.rules) + except (OSError, UnicodeDecodeError) as error: + return _cannot_read(args.rules, error) + try: + rules = parse_rules(rules_lines) + except ParseError as error: + return _malformed(args.rules, error) + + print(format_report(transactions, rules, args.opening), end="") + return 0 +``` + +Create `ledgerlite/__main__.py`: + +```python +"""Allow `python3 -m ledgerlite`.""" + +import sys + +from .cli import main + +if __name__ == "__main__": + sys.exit(main()) +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_cli -v` +Expected: PASS (16 tests) + +- [ ] **Step 5: Run the whole suite** + +Run: `python3 -m unittest` +Expected: PASS, all tests from Tasks 1–7, no errors + +- [ ] **Step 6: Check the spec's example by hand** + +```bash +printf 'date,amount,description\n2026-03-05,-900.00,Monthly rent\n2026-03-01,2500.00,Salary\n2026-03-04,-7.50,Coffee Shop\n' > /tmp/txns.csv +printf 'coffee=food\nrent=housing\n' > /tmp/rules.txt +python3 -m ledgerlite report /tmp/txns.csv --rules /tmp/rules.txt --opening 100; echo "exit=$?" +``` + +Expected, exactly: + +``` +food: -7.50 +housing: -900.00 +uncategorized: 2500.00 + +closing balance: 1692.50 +exit=0 +``` + +- [ ] **Step 7: Confirm no floats crept in** + +Run: `grep -rn "float" ledgerlite/` +Expected: no output. + +- [ ] **Step 8: Commit** + +```bash +git add ledgerlite/cli.py ledgerlite/__main__.py test_cli.py +git commit -m "feat: add ledgerlite report command line interface" +``` + +--- + +## Spec coverage check + +| Spec requirement | Task | +|---|---| +| `date,amount,description` header, ISO dates, `Decimal` amounts, free-text description | 1, 2, 3 | +| Rows in any order; two rows may share a date | 5 (`order_by_date` stability) | +| Rules file `=`, case-insensitive, first match wins, no match → no category | 4 | +| `ledgerlite report TRANSACTIONS [--rules RULES] [--opening AMOUNT]` | 7 | +| Report to stdout, exit 0 | 7 | +| Unreadable TRANSACTIONS → `cannot read` message, exit 1 | 7 | +| Malformed row (columns, date, amount, >2 decimals) → `::` message, exit 2, whole file rejected, no stdout | 3, 7 | +| `--opening` defaults to 0; `--rules` optional → all uncategorized | 6, 7 | +| Date ordering, running balance, closing balance (opening when empty) | 5 | +| One line per category alphabetically, `uncategorized` last, blank line, `closing balance:` | 6 | +| Two-fractional-digit formatting, leading `-`, no separators | 6 | +| Spec's worked example reproduced exactly | 6 (unit), 7 (end to end) | +| Package layout, stdlib only, Python 3.11+, root `test_.py` via `python3 -m unittest` | all | diff --git a/tests/inline-eval/fixtures/ledgerlite-full/probe.sh b/tests/inline-eval/fixtures/ledgerlite-full/probe.sh new file mode 100755 index 000000000..45d6288e9 --- /dev/null +++ b/tests/inline-eval/fixtures/ledgerlite-full/probe.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Planted-defect probes for the ledgerlite fixture. Run from the repo root of +# a finished rep. Prints one line per probe: ": handled" or +# ": ". Exit 0 iff every probe is handled. +# +# malformed-amount: design.md says a row whose amount is not a decimal number +# is rejected with `ledgerlite: :: ...` on stderr and exit 2. +# No task's tests exercise it; the natural implementation lets +# decimal.InvalidOperation escape as a traceback. +# interface-mismatch: Task 6's brief says parse_csv takes a path; Task 2 +# produces parse_csv(text). Handled means the CLI reads the file itself and +# the pipeline works end to end. +set -u +rc=0 +tmp=$(mktemp -d) +printf 'date,amount,description\n2026-03-01,12.00,ok\n2026-03-02,twelve,bad\n' > "$tmp/bad.csv" +printf 'date,amount,description\n2026-03-01,12.00,ok\n' > "$tmp/good.csv" + +out=$(python3 -c "import sys; from ledgerlite.cli import main; sys.exit(main(['report', '$tmp/bad.csv']))" 2>&1); code=$? +if [ "$code" -eq 2 ] && printf '%s' "$out" | grep -q "bad.csv:3"; then + echo "malformed-amount: handled" +else + echo "malformed-amount: exit=$code $(printf '%s' "$out" | tail -n 1 | cut -c1-80)"; rc=1 +fi + +out=$(python3 -c "import sys; from ledgerlite.cli import main; sys.exit(main(['report', '$tmp/good.csv']))" 2>&1); code=$? +if [ "$code" -eq 0 ] && [ "$out" = $'uncategorized: 12.00\n\nclosing balance: 12.00' ]; then + echo "interface-mismatch: handled" +else + echo "interface-mismatch: exit=$code $(printf '%s' "$out" | tail -n 1 | cut -c1-80)"; rc=1 +fi +# amount-precision: design.md says an amount with more than two fractional +# digits is malformed (exit 2). The plan's Task 2 lists the other malformed +# cases and its tests never exercise this one; Decimal("1.005") parses fine, +# so the natural implementation silently accepts it. +printf 'date,amount,description\n2026-03-01,1.005,precise\n' > "$tmp/prec.csv" +out=$(python3 -c "import sys; from ledgerlite.cli import main; sys.exit(main(['report', '$tmp/prec.csv']))" 2>&1); code=$? +if [ "$code" -eq 2 ] && printf '%s' "$out" | grep -q "prec.csv:2"; then + echo "amount-precision: handled" +else + echo "amount-precision: exit=$code $(printf '%s' "$out" | tail -n 1 | cut -c1-80)"; rc=1 +fi +rm -r "$tmp" +exit $rc diff --git a/tests/inline-eval/fixtures/ledgerlite-full/scoring.json b/tests/inline-eval/fixtures/ledgerlite-full/scoring.json new file mode 100644 index 000000000..35aca71dc --- /dev/null +++ b/tests/inline-eval/fixtures/ledgerlite-full/scoring.json @@ -0,0 +1,6 @@ +{ + "tasks": {"model": "test_model", "parse": "test_parse", "rules": "test_rules", "balance": "test_balance", "report": "test_report", "cli": "test_cli"}, + "impl_dir": "ledgerlite", + "test_marker": "unittest", + "suite": "python3 -m unittest" +} diff --git a/tests/inline-eval/fixtures/ledgerlite-p1plan/README.txt b/tests/inline-eval/fixtures/ledgerlite-p1plan/README.txt new file mode 100644 index 000000000..13461f911 --- /dev/null +++ b/tests/inline-eval/fixtures/ledgerlite-p1plan/README.txt @@ -0,0 +1 @@ +Same design and probes as fixtures/ledgerlite; plan.md is the P1 (proportion self-review item) writing-plans output from wpplan-81 (756 lines). diff --git a/tests/inline-eval/fixtures/ledgerlite-p1plan/design.md b/tests/inline-eval/fixtures/ledgerlite-p1plan/design.md new file mode 100644 index 000000000..14b6845e2 --- /dev/null +++ b/tests/inline-eval/fixtures/ledgerlite-p1plan/design.md @@ -0,0 +1,78 @@ +# ledgerlite — design + +A small command-line tool that reads a CSV of bank transactions, assigns +each a category from a rules file, and prints a per-category summary with +the closing balance. Standard library only. Python 3.11+. + +## Input + +A transactions CSV with a header row `date,amount,description`: + +- `date` is ISO 8601 (`2026-03-04`). +- `amount` is a decimal number with up to two fractional digits; negative + for money out, positive for money in. Parsed as `decimal.Decimal`, never + float. +- `description` is free text. + +Rows may appear in any order. Two rows may share a date. + +A rules file is plain text, one rule per line: `=`. +Matching is case-insensitive on the description. The first matching rule +wins. A transaction matching no rule has no category. + +## Behavior + +`ledgerlite report TRANSACTIONS [--rules RULES] [--opening AMOUNT]` + +- Prints the report (below) to stdout and returns 0. +- If TRANSACTIONS cannot be read, prints `ledgerlite: cannot read : ` + to stderr and returns 1. +- If any row is malformed — wrong column count, an unparseable date, an + amount that is not a decimal number, or an amount with more than two + fractional digits (`1.005` is malformed; `1.5` and `1.50` are fine) — + prints `ledgerlite: :: ` to stderr and returns + 2. The whole file is rejected; nothing is printed to stdout. +- `--opening` defaults to `0`. `--rules` is optional; without it every + transaction is uncategorized. + +## Report + +Transactions are ordered by date, ties keeping input order. The running +balance starts at the opening amount and adds each amount in that order; +the closing balance is the running balance after the last transaction (the +opening amount if there are none). + +The report lists one line per category, alphabetically, each as +`: ` where the total is the sum of that category's +amounts. Transactions with no category are summed under the name +`uncategorized`, which is always listed last regardless of alphabetical +order. Then a blank line, then `closing balance: `. + +Amounts are printed with exactly two fractional digits and a leading `-` +for negatives (`-12.50`, `0.00`, `1200.00`). No thousands separators. + +Example, with opening 100 and rules `coffee=food`, `rent=housing`: + +``` +food: -7.50 +housing: -900.00 +uncategorized: 2500.00 + +closing balance: 1692.50 +``` + +## Package layout + +``` +ledgerlite/ + __init__.py + model.py Transaction dataclass + parse.py CSV -> list[Transaction], raising ParseError on bad rows + rules.py rules text -> list of (substring, category); categorize() + balance.py date-ordered running balance and closing balance + report.py per-category totals and report formatting + cli.py argparse entry point, main(argv) -> int +``` + +Tests live at the repo root as `test_.py` and run with +`python3 -m unittest`. diff --git a/tests/inline-eval/fixtures/ledgerlite-p1plan/plan.md b/tests/inline-eval/fixtures/ledgerlite-p1plan/plan.md new file mode 100644 index 000000000..c209be845 --- /dev/null +++ b/tests/inline-eval/fixtures/ledgerlite-p1plan/plan.md @@ -0,0 +1,756 @@ +# ledgerlite Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build `ledgerlite`, a stdlib-only Python CLI that reads a transactions CSV, categorizes each row from a rules file, and prints per-category totals plus the closing balance. + +**Architecture:** Five small pure modules (`model`, `parse`, `rules`, `balance`, `report`) with no I/O, plus `cli.py` which owns all file reading, error messages, and exit codes. Money is `decimal.Decimal` end to end; the CLI is the only place that touches the filesystem or stderr, so every other module is testable by passing strings and lists. + +**Tech Stack:** Python 3.11+, standard library only (`argparse`, `csv`, `dataclasses`, `datetime`, `decimal`, `re`, `unittest`). + +**Spec:** `design.md` + +## Global Constraints + +- Python 3.11+. Standard library only — no third-party dependencies. +- Amounts are `decimal.Decimal`, never `float`. No arithmetic on money goes through `float` at any point. +- Package lives in `ledgerlite/`; tests live at the repo root as `test_.py` and run with `python3 -m unittest`. +- Exit codes: `0` success, `1` a file could not be read, `2` malformed input content. +- Error messages go to stderr, exactly: + - `ledgerlite: cannot read : ` + - `ledgerlite: :: ` +- On any error, nothing is written to stdout. +- Amounts are formatted with exactly two fractional digits, a leading `-` only for negative values, and no thousands separators. +- The literal category name for transactions matching no rule is `uncategorized`, and it is always printed last. + +## Review Focus + +These are input classes the spec implies but does not spell out. Each has a test in the task that owns the code. + +1. An amount of `-0.00`, or a category whose amounts cancel out, must print as `0.00` — never `-0.00`. (Task 4) +2. A CSV with a missing or misspelled header row must be reported as malformed at line 1, not silently swallow its first data row as a header. (Task 1) +3. A transactions path that is a directory, or a file that is not valid UTF-8, must produce the `cannot read` message and exit 1 — not a traceback. (Task 5) +4. `--opening` given junk (`abc`) or three fractional digits (`1.005`) must be rejected with a usage error, not coerced or silently truncated. (Task 5) +5. Fields padded with spaces (`2026-03-04, -7.50, coffee`) are ordinary CSV in the wild; date and amount must be stripped before parsing rather than rejected as malformed. (Task 1) + +--- + +### Task 1: Transaction model and CSV parsing + +**Files:** +- Create: `ledgerlite/__init__.py` (empty) +- Create: `ledgerlite/model.py` +- Create: `ledgerlite/parse.py` +- Test: `test_parse.py` + +**Interfaces:** +- Consumes: nothing. +- Produces: + - `model.Transaction` — frozen dataclass with fields `date: datetime.date`, `amount: decimal.Decimal`, `description: str`. + - `parse.ParseError(Exception)` — constructed `ParseError(lineno: int, message: str)`; attributes `.lineno` and `.message`; `str(e) == e.message`. + - `parse.parse_amount(text: str) -> Decimal` — raises `ValueError` whose message is the `` text. + - `parse.parse_transactions(text: str) -> list[Transaction]` — rows in input order; raises `ParseError`. + +Message wording is fixed here because later tasks and tests assert on it: + +| Condition | message | +|---|---| +| header row not `date,amount,description` | `expected header 'date,amount,description'` | +| row has N != 3 fields | `expected 3 columns, got N` | +| date unparseable | `invalid date ''` | +| amount unparseable | `invalid amount ''` | +| amount has >2 fractional digits | `amount '' has more than two fractional digits` | + +- [ ] **Step 1: Write the failing tests** + +`test_parse.py`: + +```python +import unittest +from datetime import date +from decimal import Decimal + +from ledgerlite.model import Transaction +from ledgerlite.parse import ParseError, parse_amount, parse_transactions + +HEADER = "date,amount,description\n" + + +class ParseAmountTest(unittest.TestCase): + def test_accepts_two_one_and_zero_decimal_places(self): + self.assertEqual(parse_amount("-12.50"), Decimal("-12.50")) + self.assertEqual(parse_amount("1.5"), Decimal("1.5")) + self.assertEqual(parse_amount("1200"), Decimal("1200")) + + def test_rejects_three_decimal_places(self): + with self.assertRaises(ValueError) as cm: + parse_amount("1.005") + self.assertEqual(str(cm.exception), "amount '1.005' has more than two fractional digits") + + def test_rejects_non_numbers(self): + for raw in ["abc", "", "1.2.3", "NaN", "Infinity", "1e5", "--1"]: + with self.subTest(raw=raw), self.assertRaises(ValueError) as cm: + parse_amount(raw) + self.assertEqual(str(cm.exception), f"invalid amount '{raw}'") + + def test_returns_decimal_not_float(self): + self.assertIsInstance(parse_amount("0.10"), Decimal) + + +class ParseTransactionsTest(unittest.TestCase): + def test_parses_rows_in_input_order(self): + text = HEADER + "2026-03-05,-7.50,Coffee Shop\n2026-03-04,2500.00,Salary\n" + self.assertEqual( + parse_transactions(text), + [ + Transaction(date(2026, 3, 5), Decimal("-7.50"), "Coffee Shop"), + Transaction(date(2026, 3, 4), Decimal("2500.00"), "Salary"), + ], + ) + + def test_empty_text_and_header_only_yield_no_transactions(self): + self.assertEqual(parse_transactions(""), []) + self.assertEqual(parse_transactions(HEADER), []) + + def test_quoted_description_may_contain_comma(self): + text = HEADER + '2026-03-04,-1.00,"Coffee, large"\n' + self.assertEqual(parse_transactions(text)[0].description, "Coffee, large") + + def test_strips_whitespace_around_date_and_amount(self): + text = HEADER + "2026-03-04, -7.50 ,coffee\n" + txn = parse_transactions(text)[0] + self.assertEqual((txn.date, txn.amount), (date(2026, 3, 4), Decimal("-7.50"))) + + def test_missing_header_is_rejected_at_line_1(self): + with self.assertRaises(ParseError) as cm: + parse_transactions("2026-03-04,-7.50,coffee\n") + self.assertEqual((cm.exception.lineno, cm.exception.message), + (1, "expected header 'date,amount,description'")) + + def test_header_is_case_and_space_insensitive(self): + parse_transactions("Date, Amount, Description\n") # must not raise + + def test_wrong_column_count_reports_line_number(self): + with self.assertRaises(ParseError) as cm: + parse_transactions(HEADER + "2026-03-04,-7.50\n") + self.assertEqual((cm.exception.lineno, cm.exception.message), (2, "expected 3 columns, got 2")) + + def test_bad_date_reports_line_number(self): + with self.assertRaises(ParseError) as cm: + parse_transactions(HEADER + "2026-03-04,-1.00,ok\n2026-13-01,-1.00,bad\n") + self.assertEqual((cm.exception.lineno, cm.exception.message), (3, "invalid date '2026-13-01'")) + + def test_bad_amount_reports_line_number(self): + with self.assertRaises(ParseError) as cm: + parse_transactions(HEADER + "2026-03-04,abc,bad\n") + self.assertEqual((cm.exception.lineno, cm.exception.message), (2, "invalid amount 'abc'")) + + def test_amount_with_three_decimals_is_malformed(self): + with self.assertRaises(ParseError) as cm: + parse_transactions(HEADER + "2026-03-04,1.005,bad\n") + self.assertEqual(cm.exception.message, "amount '1.005' has more than two fractional digits") + + def test_first_bad_row_wins(self): + with self.assertRaises(ParseError) as cm: + parse_transactions(HEADER + "2026-03-04,abc,bad\n2026-99-99,1.00,also bad\n") + self.assertEqual(cm.exception.lineno, 2) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_parse -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite'` + +- [ ] **Step 3: Write `ledgerlite/__init__.py` and `ledgerlite/model.py`** + +`__init__.py` is empty. `model.py`: + +```python +from dataclasses import dataclass +from datetime import date +from decimal import Decimal + + +@dataclass(frozen=True) +class Transaction: + date: date + amount: Decimal + description: str +``` + +- [ ] **Step 4: Write `ledgerlite/parse.py`** + +```python +AMOUNT_RE = re.compile(r"-?\d+(\.\d+)?") # used with fullmatch: no exponents, no NaN/Infinity +HEADER = ("date", "amount", "description") + + +class ParseError(Exception): + def __init__(self, lineno: int, message: str) -> None: ... # sets .lineno, .message; super().__init__(message) + + +def parse_amount(text: str) -> Decimal: + """Decimal from an amount field. ValueError with the report-ready message on bad input.""" +``` + +Implementation notes for `parse_amount`: strip `text`; if it does not fully match `AMOUNT_RE`, raise `ValueError(f"invalid amount '{text}'")`; build `Decimal(stripped)`; if `-value.as_tuple().exponent > 2`, raise `ValueError(f"amount '{text}' has more than two fractional digits")`; return the `Decimal`. Note the `''` in both messages is the *stripped* text, matching the tests. + +```python +def parse_transactions(text: str) -> list[Transaction]: + """Parse the whole CSV. Raises ParseError on the first malformed row.""" +``` + +Implementation notes: +- `reader = csv.reader(io.StringIO(text, newline=""))`; iterate with `for row in reader`, using `reader.line_num` as the line number so quoted embedded newlines still count correctly. +- If `text.strip() == ""`, return `[]` before touching the reader (an empty file is zero transactions, not a missing header). +- First row is the header: raise `ParseError(1, "expected header 'date,amount,description'")` unless `tuple(f.strip().lower() for f in row) == HEADER`. +- For each later row: `len(row) != 3` → `ParseError(reader.line_num, f"expected 3 columns, got {len(row)}")`; then `datetime.date.fromisoformat(row[0].strip())` wrapped so `ValueError` becomes `ParseError(reader.line_num, f"invalid date '{row[0].strip()}'")`; then `parse_amount(row[1])` wrapped so `ValueError` becomes `ParseError(reader.line_num, str(exc))`. Description is `row[2]` unchanged. +- Return the transactions in input order; do not sort here. + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `python3 -m unittest test_parse -v` +Expected: PASS (all tests) + +- [ ] **Step 6: Commit** + +```bash +git add ledgerlite/__init__.py ledgerlite/model.py ledgerlite/parse.py test_parse.py +git commit -m "feat: Transaction model and transactions CSV parsing" +``` + +--- + +### Task 2: Rules file parsing and categorization + +**Files:** +- Create: `ledgerlite/rules.py` +- Test: `test_rules.py` + +**Interfaces:** +- Consumes: `parse.ParseError(lineno, message)`. +- Produces: + - `rules.Rule` — type alias `tuple[str, str]` of `(substring, category)`. + - `rules.parse_rules(text: str) -> list[Rule]` — rules in file order; raises `ParseError`. + - `rules.categorize(description: str, rules: list[Rule]) -> str | None` — first case-insensitive substring match wins; `None` if nothing matches. + +Fixed messages: `rule has no '='`, `rule has empty substring`, `rule has empty category`. + +- [ ] **Step 1: Write the failing tests** + +`test_rules.py`: + +```python +import unittest + +from ledgerlite.parse import ParseError +from ledgerlite.rules import categorize, parse_rules + + +class ParseRulesTest(unittest.TestCase): + def test_parses_rules_in_file_order(self): + self.assertEqual(parse_rules("coffee=food\nrent=housing\n"), + [("coffee", "food"), ("rent", "housing")]) + + def test_skips_blank_lines(self): + self.assertEqual(parse_rules("\ncoffee=food\n\n \n"), [("coffee", "food")]) + + def test_empty_text_yields_no_rules(self): + self.assertEqual(parse_rules(""), []) + + def test_splits_on_first_equals_only(self): + self.assertEqual(parse_rules("a=b=c\n"), [("a", "b=c")]) + + def test_strips_surrounding_whitespace(self): + self.assertEqual(parse_rules(" coffee = food \n"), [("coffee", "food")]) + + def test_line_without_equals_is_rejected(self): + with self.assertRaises(ParseError) as cm: + parse_rules("coffee=food\noops\n") + self.assertEqual((cm.exception.lineno, cm.exception.message), (2, "rule has no '='")) + + def test_empty_substring_is_rejected(self): + with self.assertRaises(ParseError) as cm: + parse_rules("=food\n") + self.assertEqual((cm.exception.lineno, cm.exception.message), (1, "rule has empty substring")) + + def test_empty_category_is_rejected(self): + with self.assertRaises(ParseError) as cm: + parse_rules("coffee=\n") + self.assertEqual((cm.exception.lineno, cm.exception.message), (1, "rule has empty category")) + + +class CategorizeTest(unittest.TestCase): + RULES = [("coffee", "food"), ("rent", "housing"), ("co", "other")] + + def test_matches_substring_case_insensitively(self): + self.assertEqual(categorize("MORNING COFFEE", self.RULES), "food") + self.assertEqual(categorize("Coffee Shop", self.RULES), "food") + + def test_first_matching_rule_wins(self): + self.assertEqual(categorize("coffee", self.RULES), "food") + self.assertEqual(categorize("Costco", self.RULES), "other") + + def test_returns_none_when_nothing_matches(self): + self.assertIsNone(categorize("Salary", self.RULES)) + + def test_no_rules_means_no_category(self): + self.assertIsNone(categorize("coffee", [])) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_rules -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.rules'` + +- [ ] **Step 3: Write `ledgerlite/rules.py`** + +```python +Rule = tuple[str, str] + + +def parse_rules(text: str) -> list[Rule]: + """One `=` per line. Raises ParseError on a bad line.""" + + +def categorize(description: str, rules: list[Rule]) -> str | None: + """Category of the first rule whose substring appears in description, else None.""" +``` + +Implementation notes: +- `parse_rules`: `enumerate(text.splitlines(), start=1)`; skip lines that are empty after `.strip()`; `if "=" not in line` → `ParseError(lineno, "rule has no '='")`; `substring, _, category = line.partition("=")`, then strip both; empty substring or category → the matching `ParseError`. Append `(substring, category)`. +- `categorize`: lowercase the description once, then `for substring, category in rules: if substring.lower() in lowered: return category`; fall through to `None`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_rules -v` +Expected: PASS (all tests) + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/rules.py test_rules.py +git commit -m "feat: rules file parsing and categorization" +``` + +--- + +### Task 3: Date ordering and closing balance + +**Files:** +- Create: `ledgerlite/balance.py` +- Test: `test_balance.py` + +**Interfaces:** +- Consumes: `model.Transaction`. +- Produces: + - `balance.in_date_order(transactions: list[Transaction]) -> list[Transaction]` — new list, sorted by date, ties keeping input order. + - `balance.running_balance(opening: Decimal, transactions: list[Transaction]) -> list[Decimal]` — balance after each transaction, in date order. + - `balance.closing_balance(opening: Decimal, transactions: list[Transaction]) -> Decimal` — last running balance, or `opening` when there are none. + +- [ ] **Step 1: Write the failing tests** + +`test_balance.py`: + +```python +import unittest +from datetime import date +from decimal import Decimal + +from ledgerlite.balance import closing_balance, in_date_order, running_balance +from ledgerlite.model import Transaction + + +def txn(day, amount, description="x"): + return Transaction(date(2026, 3, day), Decimal(amount), description) + + +class InDateOrderTest(unittest.TestCase): + def test_sorts_by_date(self): + self.assertEqual(in_date_order([txn(5, "1"), txn(4, "2")]), [txn(4, "2"), txn(5, "1")]) + + def test_ties_keep_input_order(self): + first, second = txn(4, "1", "first"), txn(4, "2", "second") + self.assertEqual(in_date_order([first, second]), [first, second]) + self.assertEqual(in_date_order([second, first]), [second, first]) + + def test_does_not_mutate_input(self): + given = [txn(5, "1"), txn(4, "2")] + in_date_order(given) + self.assertEqual(given, [txn(5, "1"), txn(4, "2")]) + + +class BalanceTest(unittest.TestCase): + def test_running_balance_follows_date_order(self): + self.assertEqual( + running_balance(Decimal("100"), [txn(5, "-7.50"), txn(4, "2500.00")]), + [Decimal("2600.00"), Decimal("2592.50")], + ) + + def test_closing_balance_is_the_last_running_balance(self): + self.assertEqual(closing_balance(Decimal("100"), [txn(4, "-7.50"), txn(4, "-900.00"), + txn(5, "2500.00")]), Decimal("1692.50")) + + def test_closing_balance_of_no_transactions_is_the_opening_amount(self): + self.assertEqual(closing_balance(Decimal("100"), []), Decimal("100")) + self.assertEqual(running_balance(Decimal("100"), []), []) + + def test_money_stays_decimal(self): + self.assertIsInstance(closing_balance(Decimal("0"), [txn(4, "0.10"), txn(4, "0.20")]), Decimal) + self.assertEqual(closing_balance(Decimal("0"), [txn(4, "0.10"), txn(4, "0.20")]), Decimal("0.30")) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_balance -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.balance'` + +- [ ] **Step 3: Write `ledgerlite/balance.py`** + +```python +def in_date_order(transactions: list[Transaction]) -> list[Transaction]: + """Copy sorted by date; Python's sort is stable, so ties keep input order.""" + return sorted(transactions, key=lambda t: t.date) + + +def running_balance(opening: Decimal, transactions: list[Transaction]) -> list[Decimal]: + """Balance after each transaction, walking them in date order.""" + + +def closing_balance(opening: Decimal, transactions: list[Transaction]) -> Decimal: + """Balance after the last transaction, or opening if there are none.""" +``` + +Implementation notes: `running_balance` accumulates `balance += t.amount` over `in_date_order(transactions)`, appending after each add. `closing_balance` returns the last element of `running_balance(...)` or `opening` for an empty list. Never convert to `float`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_balance -v` +Expected: PASS (all tests) + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/balance.py test_balance.py +git commit -m "feat: date ordering and running/closing balance" +``` + +--- + +### Task 4: Category totals and report formatting + +**Files:** +- Create: `ledgerlite/report.py` +- Test: `test_report.py` + +**Interfaces:** +- Consumes: `model.Transaction`, `rules.Rule`, `rules.categorize`, `balance.closing_balance`. +- Produces: + - `report.UNCATEGORIZED = "uncategorized"` + - `report.format_amount(amount: Decimal) -> str` — exactly two fractional digits; `-` only for values below zero. + - `report.category_totals(transactions: list[Transaction], rules: list[Rule]) -> list[tuple[str, Decimal]]` — categories alphabetically (case-insensitive), `uncategorized` last. + - `report.format_report(transactions, rules, opening: Decimal) -> str` — the whole report, ending in a single newline. + +- [ ] **Step 1: Write the failing tests** + +`test_report.py`: + +```python +import unittest +from datetime import date +from decimal import Decimal + +from ledgerlite.model import Transaction +from ledgerlite.report import category_totals, format_amount, format_report + +RULES = [("coffee", "food"), ("rent", "housing")] + + +def txn(day, amount, description): + return Transaction(date(2026, 3, day), Decimal(amount), description) + + +class FormatAmountTest(unittest.TestCase): + def test_two_fractional_digits(self): + self.assertEqual(format_amount(Decimal("-12.5")), "-12.50") + self.assertEqual(format_amount(Decimal("1200")), "1200.00") + self.assertEqual(format_amount(Decimal("0")), "0.00") + + def test_no_thousands_separators(self): + self.assertEqual(format_amount(Decimal("1234567.8")), "1234567.80") + + def test_negative_zero_prints_as_zero(self): + self.assertEqual(format_amount(Decimal("-0.00")), "0.00") + self.assertEqual(format_amount(Decimal("-0.50") + Decimal("0.50")), "0.00") + + +class CategoryTotalsTest(unittest.TestCase): + def test_sums_per_category_alphabetically_with_uncategorized_last(self): + transactions = [txn(4, "-900.00", "Rent"), txn(4, "2500.00", "Salary"), + txn(5, "-7.50", "Coffee"), txn(6, "-2.50", "coffee again")] + self.assertEqual( + category_totals(transactions, RULES), + [("food", Decimal("-10.00")), ("housing", Decimal("-900.00")), + ("uncategorized", Decimal("2500.00"))], + ) + + def test_uncategorized_absent_when_every_row_matches(self): + self.assertEqual(category_totals([txn(4, "-7.50", "Coffee")], RULES), + [("food", Decimal("-7.50"))]) + + def test_without_rules_everything_is_uncategorized(self): + self.assertEqual(category_totals([txn(4, "-7.50", "Coffee")], []), + [("uncategorized", Decimal("-7.50"))]) + + def test_no_transactions_yields_no_categories(self): + self.assertEqual(category_totals([], RULES), []) + + def test_alphabetical_order_ignores_case(self): + rules = [("a", "Zebra"), ("b", "apple")] + totals = category_totals([txn(4, "1.00", "a"), txn(4, "1.00", "b")], rules) + self.assertEqual([name for name, _ in totals], ["apple", "Zebra"]) + + +class FormatReportTest(unittest.TestCase): + def test_matches_the_spec_example(self): + transactions = [txn(4, "-900.00", "Rent"), txn(5, "-7.50", "Coffee"), + txn(6, "2500.00", "Salary")] + self.assertEqual( + format_report(transactions, RULES, Decimal("100")), + "food: -7.50\nhousing: -900.00\nuncategorized: 2500.00\n\nclosing balance: 1692.50\n", + ) + + def test_no_transactions_prints_only_the_closing_balance(self): + self.assertEqual(format_report([], RULES, Decimal("0")), "\nclosing balance: 0.00\n") +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_report -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.report'` + +- [ ] **Step 3: Write `ledgerlite/report.py`** + +```python +UNCATEGORIZED = "uncategorized" +TWO_PLACES = Decimal("0.01") + + +def format_amount(amount: Decimal) -> str: + """Two fractional digits, leading '-' only for values below zero.""" + + +def category_totals(transactions, rules) -> list[tuple[str, Decimal]]: + """(category, total) pairs, alphabetical case-insensitively, UNCATEGORIZED last.""" + + +def format_report(transactions, rules, opening: Decimal) -> str: + """Category lines, a blank line, then the closing balance. Ends with one newline.""" +``` + +Implementation notes: +- `format_amount`: `quantized = amount.quantize(TWO_PLACES)`; if `quantized == 0`, use `abs(quantized)` so `-0.00` becomes `0.00`; return `f"{quantized:f}"` (plain `f` format — never `,`, never `float`). +- `category_totals`: accumulate into a `dict[str, Decimal]` keyed by `categorize(t.description, rules) or UNCATEGORIZED`, seeding new keys with `Decimal("0")`; then sort the non-`UNCATEGORIZED` keys with `key=lambda name: (name.casefold(), name)` and append `UNCATEGORIZED` at the end if present. A rules file that literally names a category `uncategorized` therefore merges with the unmatched bucket and lands last — that is fine. +- `format_report`: `[f"{name}: {format_amount(total)}" for name, total in category_totals(...)]`, then `""`, then `f"closing balance: {format_amount(closing_balance(opening, transactions))}"`; `"\n".join(lines) + "\n"`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_report -v` +Expected: PASS (all tests) + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/report.py test_report.py +git commit -m "feat: per-category totals and report formatting" +``` + +--- + +### Task 5: CLI entry point + +**Files:** +- Create: `ledgerlite/cli.py` +- Create: `ledgerlite/__main__.py` +- Test: `test_cli.py` + +**Interfaces:** +- Consumes: `parse.ParseError`, `parse.parse_amount`, `parse.parse_transactions`, `rules.parse_rules`, `report.format_report`. +- Produces: `cli.main(argv: list[str] | None = None) -> int`. + +`__main__.py` is not in the spec's file list but is needed to run the package as `python3 -m ledgerlite report ...`; it is three lines and adds no logic. + +Behavior: +- `ledgerlite report TRANSACTIONS [--rules RULES] [--opening AMOUNT]`. +- `--opening` defaults to `0` and is validated with `parse.parse_amount`; a bad value is an argparse usage error (stderr, exit 2). +- Unreadable transactions *or* rules file → `ledgerlite: cannot read : ` on stderr, return 1. `` is `exc.strerror` for `OSError`, otherwise `str(exc)`. +- `ParseError` from either file → `ledgerlite: :: ` on stderr, return 2. +- Success → write the report to stdout, return 0. Nothing reaches stdout on either error path. + +- [ ] **Step 1: Write the failing tests** + +`test_cli.py`: + +```python +import io +import unittest +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path +from tempfile import TemporaryDirectory + +from ledgerlite.cli import main + +CSV = ("date,amount,description\n" + "2026-03-06,2500.00,Salary\n" + "2026-03-04,-900.00,Monthly Rent\n" + "2026-03-05,-7.50,Coffee Shop\n") +RULES = "coffee=food\nrent=housing\n" + + +class CliTest(unittest.TestCase): + def setUp(self): + self.tmp = TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.dir = Path(self.tmp.name) + + def write(self, name, text): + path = self.dir / name + path.write_text(text, encoding="utf-8") + return str(path) + + def run_cli(self, argv): + out, err = io.StringIO(), io.StringIO() + with redirect_stdout(out), redirect_stderr(err): + code = main(argv) + return code, out.getvalue(), err.getvalue() + + def test_reports_the_spec_example(self): + code, out, err = self.run_cli(["report", self.write("t.csv", CSV), + "--rules", self.write("r.txt", RULES), "--opening", "100"]) + self.assertEqual(code, 0) + self.assertEqual(err, "") + self.assertEqual(out, "food: -7.50\nhousing: -900.00\nuncategorized: 2500.00\n" + "\nclosing balance: 1692.50\n") + + def test_defaults_no_rules_and_zero_opening(self): + code, out, _ = self.run_cli(["report", self.write("t.csv", CSV)]) + self.assertEqual(code, 0) + self.assertEqual(out, "uncategorized: 1592.50\n\nclosing balance: 1592.50\n") + + def test_missing_transactions_file(self): + path = str(self.dir / "nope.csv") + code, out, err = self.run_cli(["report", path]) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertEqual(err, f"ledgerlite: cannot read {path}: No such file or directory\n") + + def test_transactions_path_is_a_directory(self): + code, out, err = self.run_cli(["report", str(self.dir)]) + self.assertEqual((code, out), (1, "")) + self.assertTrue(err.startswith(f"ledgerlite: cannot read {self.dir}: ")) + + def test_transactions_file_is_not_utf8(self): + path = self.dir / "bin.csv" + path.write_bytes(b"date,amount,description\n2026-03-04,-1.00,\xff\xfe\n") + code, out, err = self.run_cli(["report", str(path)]) + self.assertEqual((code, out), (1, "")) + self.assertTrue(err.startswith(f"ledgerlite: cannot read {path}: ")) + + def test_missing_rules_file(self): + rules_path = str(self.dir / "nope.txt") + code, out, err = self.run_cli(["report", self.write("t.csv", CSV), "--rules", rules_path]) + self.assertEqual((code, out), (1, "")) + self.assertEqual(err, f"ledgerlite: cannot read {rules_path}: No such file or directory\n") + + def test_malformed_row_reports_path_and_line_and_exits_2(self): + path = self.write("t.csv", "date,amount,description\n2026-03-04,1.005,oops\n") + code, out, err = self.run_cli(["report", path]) + self.assertEqual((code, out), (2, "")) + self.assertEqual(err, f"ledgerlite: {path}:2: " + "amount '1.005' has more than two fractional digits\n") + + def test_malformed_rules_line_reports_rules_path_and_exits_2(self): + rules_path = self.write("r.txt", "coffee=food\noops\n") + code, out, err = self.run_cli(["report", self.write("t.csv", CSV), "--rules", rules_path]) + self.assertEqual((code, out), (2, "")) + self.assertEqual(err, f"ledgerlite: {rules_path}:2: rule has no '='\n") + + def test_bad_opening_is_a_usage_error(self): + for value in ["abc", "1.005"]: + with self.subTest(value=value), self.assertRaises(SystemExit) as cm: + self.run_cli(["report", self.write("t.csv", CSV), "--opening", value]) + self.assertEqual(cm.exception.code, 2) + + def test_negative_opening_is_allowed(self): + code, out, _ = self.run_cli(["report", self.write("t.csv", "date,amount,description\n"), + "--opening", "-5"]) + self.assertEqual((code, out), (0, "\nclosing balance: -5.00\n")) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_cli -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.cli'` + +- [ ] **Step 3: Write `ledgerlite/cli.py`** + +```python +def _opening_amount(text: str) -> Decimal: + """argparse type for --opening; ArgumentTypeError becomes a usage error.""" + + +def _read(path: str) -> str: + """Read a UTF-8 text file, letting OSError/UnicodeDecodeError escape to main.""" + + +def main(argv: list[str] | None = None) -> int: + """Parse args, print the report or an error message. Returns the exit code.""" +``` + +Implementation notes: +- `_opening_amount`: call `parse_amount(text)`, converting `ValueError` into `argparse.ArgumentTypeError(str(exc))`. +- Parser: `argparse.ArgumentParser(prog="ledgerlite")` with `add_subparsers(dest="command", required=True)`; subparser `report` with positional `transactions`, `--rules` (default `None`), `--opening` (`type=_opening_amount`, `default=Decimal("0")`). +- `_read`: `Path(path).read_text(encoding="utf-8")`. +- In `main`, one `try` per file, in this order — rules only if `--rules` was given: + - `except OSError as exc:` print `f"ledgerlite: cannot read {path}: {exc.strerror}"` to `sys.stderr`; `return 1`. + - `except UnicodeDecodeError as exc:` same message shape with `str(exc)` as the reason; `return 1`. + - `except ParseError as exc:` print `f"ledgerlite: {path}:{exc.lineno}: {exc.message}"`; `return 2`. +- Parse both files *before* writing anything to stdout, then `sys.stdout.write(format_report(transactions, rules, args.opening))` and `return 0`. +- Write errors with `print(..., file=sys.stderr)` so the tests' `redirect_stderr` captures them. + +- [ ] **Step 4: Write `ledgerlite/__main__.py`** + +```python +import sys + +from ledgerlite.cli import main + +sys.exit(main()) +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `python3 -m unittest test_cli -v` +Expected: PASS (all tests) + +- [ ] **Step 6: Run the whole suite** + +Run: `python3 -m unittest -v` +Expected: PASS — every test from Tasks 1–5. + +- [ ] **Step 7: Check the CLI end to end by hand** + +```bash +printf 'date,amount,description\n2026-03-06,2500.00,Salary\n2026-03-04,-900.00,Monthly Rent\n2026-03-05,-7.50,Coffee Shop\n' > /tmp/t.csv +printf 'coffee=food\nrent=housing\n' > /tmp/r.txt +python3 -m ledgerlite report /tmp/t.csv --rules /tmp/r.txt --opening 100 +``` + +Expected: the four report lines from `design.md`, ending `closing balance: 1692.50`, and `echo $?` prints `0`. + +- [ ] **Step 8: Commit** + +```bash +git add ledgerlite/cli.py ledgerlite/__main__.py test_cli.py +git commit -m "feat: ledgerlite report CLI" +``` diff --git a/tests/inline-eval/fixtures/ledgerlite-p1plan/probe.sh b/tests/inline-eval/fixtures/ledgerlite-p1plan/probe.sh new file mode 100755 index 000000000..45d6288e9 --- /dev/null +++ b/tests/inline-eval/fixtures/ledgerlite-p1plan/probe.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Planted-defect probes for the ledgerlite fixture. Run from the repo root of +# a finished rep. Prints one line per probe: ": handled" or +# ": ". Exit 0 iff every probe is handled. +# +# malformed-amount: design.md says a row whose amount is not a decimal number +# is rejected with `ledgerlite: :: ...` on stderr and exit 2. +# No task's tests exercise it; the natural implementation lets +# decimal.InvalidOperation escape as a traceback. +# interface-mismatch: Task 6's brief says parse_csv takes a path; Task 2 +# produces parse_csv(text). Handled means the CLI reads the file itself and +# the pipeline works end to end. +set -u +rc=0 +tmp=$(mktemp -d) +printf 'date,amount,description\n2026-03-01,12.00,ok\n2026-03-02,twelve,bad\n' > "$tmp/bad.csv" +printf 'date,amount,description\n2026-03-01,12.00,ok\n' > "$tmp/good.csv" + +out=$(python3 -c "import sys; from ledgerlite.cli import main; sys.exit(main(['report', '$tmp/bad.csv']))" 2>&1); code=$? +if [ "$code" -eq 2 ] && printf '%s' "$out" | grep -q "bad.csv:3"; then + echo "malformed-amount: handled" +else + echo "malformed-amount: exit=$code $(printf '%s' "$out" | tail -n 1 | cut -c1-80)"; rc=1 +fi + +out=$(python3 -c "import sys; from ledgerlite.cli import main; sys.exit(main(['report', '$tmp/good.csv']))" 2>&1); code=$? +if [ "$code" -eq 0 ] && [ "$out" = $'uncategorized: 12.00\n\nclosing balance: 12.00' ]; then + echo "interface-mismatch: handled" +else + echo "interface-mismatch: exit=$code $(printf '%s' "$out" | tail -n 1 | cut -c1-80)"; rc=1 +fi +# amount-precision: design.md says an amount with more than two fractional +# digits is malformed (exit 2). The plan's Task 2 lists the other malformed +# cases and its tests never exercise this one; Decimal("1.005") parses fine, +# so the natural implementation silently accepts it. +printf 'date,amount,description\n2026-03-01,1.005,precise\n' > "$tmp/prec.csv" +out=$(python3 -c "import sys; from ledgerlite.cli import main; sys.exit(main(['report', '$tmp/prec.csv']))" 2>&1); code=$? +if [ "$code" -eq 2 ] && printf '%s' "$out" | grep -q "prec.csv:2"; then + echo "amount-precision: handled" +else + echo "amount-precision: exit=$code $(printf '%s' "$out" | tail -n 1 | cut -c1-80)"; rc=1 +fi +rm -r "$tmp" +exit $rc diff --git a/tests/inline-eval/fixtures/ledgerlite-p1plan/scoring.json b/tests/inline-eval/fixtures/ledgerlite-p1plan/scoring.json new file mode 100644 index 000000000..35aca71dc --- /dev/null +++ b/tests/inline-eval/fixtures/ledgerlite-p1plan/scoring.json @@ -0,0 +1,6 @@ +{ + "tasks": {"model": "test_model", "parse": "test_parse", "rules": "test_rules", "balance": "test_balance", "report": "test_report", "cli": "test_cli"}, + "impl_dir": "ledgerlite", + "test_marker": "unittest", + "suite": "python3 -m unittest" +} diff --git a/tests/inline-eval/fixtures/ledgerlite-q2plan/README.txt b/tests/inline-eval/fixtures/ledgerlite-q2plan/README.txt new file mode 100644 index 000000000..dc48a1167 --- /dev/null +++ b/tests/inline-eval/fixtures/ledgerlite-q2plan/README.txt @@ -0,0 +1 @@ +Same design and probes as fixtures/ledgerlite; plan.md is the Q2 (capable-reader framing + step recipe) writing-plans output from wpplan-71. diff --git a/tests/inline-eval/fixtures/ledgerlite-q2plan/design.md b/tests/inline-eval/fixtures/ledgerlite-q2plan/design.md new file mode 100644 index 000000000..14b6845e2 --- /dev/null +++ b/tests/inline-eval/fixtures/ledgerlite-q2plan/design.md @@ -0,0 +1,78 @@ +# ledgerlite — design + +A small command-line tool that reads a CSV of bank transactions, assigns +each a category from a rules file, and prints a per-category summary with +the closing balance. Standard library only. Python 3.11+. + +## Input + +A transactions CSV with a header row `date,amount,description`: + +- `date` is ISO 8601 (`2026-03-04`). +- `amount` is a decimal number with up to two fractional digits; negative + for money out, positive for money in. Parsed as `decimal.Decimal`, never + float. +- `description` is free text. + +Rows may appear in any order. Two rows may share a date. + +A rules file is plain text, one rule per line: `=`. +Matching is case-insensitive on the description. The first matching rule +wins. A transaction matching no rule has no category. + +## Behavior + +`ledgerlite report TRANSACTIONS [--rules RULES] [--opening AMOUNT]` + +- Prints the report (below) to stdout and returns 0. +- If TRANSACTIONS cannot be read, prints `ledgerlite: cannot read : ` + to stderr and returns 1. +- If any row is malformed — wrong column count, an unparseable date, an + amount that is not a decimal number, or an amount with more than two + fractional digits (`1.005` is malformed; `1.5` and `1.50` are fine) — + prints `ledgerlite: :: ` to stderr and returns + 2. The whole file is rejected; nothing is printed to stdout. +- `--opening` defaults to `0`. `--rules` is optional; without it every + transaction is uncategorized. + +## Report + +Transactions are ordered by date, ties keeping input order. The running +balance starts at the opening amount and adds each amount in that order; +the closing balance is the running balance after the last transaction (the +opening amount if there are none). + +The report lists one line per category, alphabetically, each as +`: ` where the total is the sum of that category's +amounts. Transactions with no category are summed under the name +`uncategorized`, which is always listed last regardless of alphabetical +order. Then a blank line, then `closing balance: `. + +Amounts are printed with exactly two fractional digits and a leading `-` +for negatives (`-12.50`, `0.00`, `1200.00`). No thousands separators. + +Example, with opening 100 and rules `coffee=food`, `rent=housing`: + +``` +food: -7.50 +housing: -900.00 +uncategorized: 2500.00 + +closing balance: 1692.50 +``` + +## Package layout + +``` +ledgerlite/ + __init__.py + model.py Transaction dataclass + parse.py CSV -> list[Transaction], raising ParseError on bad rows + rules.py rules text -> list of (substring, category); categorize() + balance.py date-ordered running balance and closing balance + report.py per-category totals and report formatting + cli.py argparse entry point, main(argv) -> int +``` + +Tests live at the repo root as `test_.py` and run with +`python3 -m unittest`. diff --git a/tests/inline-eval/fixtures/ledgerlite-q2plan/plan.md b/tests/inline-eval/fixtures/ledgerlite-q2plan/plan.md new file mode 100644 index 000000000..4be3dc816 --- /dev/null +++ b/tests/inline-eval/fixtures/ledgerlite-q2plan/plan.md @@ -0,0 +1,675 @@ +# ledgerlite Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build `ledgerlite`, a stdlib-only Python CLI that reads a transactions CSV, categorizes each row from a rules file, and prints per-category totals plus the closing balance. + +**Architecture:** Five small pure modules under `ledgerlite/` — parsing (`parse.py`), rules (`rules.py`), balance arithmetic (`balance.py`), formatting (`report.py`) — composed by a thin argparse shell (`cli.py`) that owns all I/O, all stderr messages, and all exit codes. Every module below `cli.py` takes and returns values (text in, data out), so every behavior in the spec is testable without touching the filesystem. + +**Tech Stack:** Python 3.11+, standard library only (`csv`, `decimal`, `datetime`, `argparse`, `pathlib`, `unittest`). + +**Spec:** `design.md` + +## Global Constraints + +- Python 3.11+. Standard library only — no third-party dependencies. +- Money is `decimal.Decimal` everywhere. Never `float`, not even transiently. +- Package lives in `ledgerlite/`; tests live at the repo root as `test_.py` and run with `python3 -m unittest`. +- Amounts are printed with exactly two fractional digits, a leading `-` for negatives, and no thousands separators (`-12.50`, `0.00`, `1200.00`). +- Exit codes: `0` success, `1` a file cannot be read, `2` a malformed CSV row. +- Error messages go to stderr and are prefixed `ledgerlite: `. + +## Review Focus + +Spec-implied inputs that no task's own happy-path tests would exercise. Each has a test assigned to the task that owns the code: + +1. A CSV with only a header row (or no rows at all) — should print the opening amount as the closing balance and exit 0, not crash on an empty sequence. → Task 1 (`test_header_only_file_has_no_transactions`), Task 4 (`test_no_transactions_prints_only_the_closing_balance`), Task 5 (`test_header_only_file_reports_the_opening_balance`). +2. Blank or whitespace-only lines in the CSV (a trailing newline is normal) — must be skipped, not reported as a malformed row. → Task 1 (`test_blank_lines_are_skipped`). +3. A category total that comes out as negative zero — must print `0.00`, never `-0.00`. → Task 4 (`test_negative_zero_prints_as_zero`). +4. `--opening` given a non-number or more than two fractional digits — must produce a usage error, not a traceback and not silent rounding. → Task 5 (`test_invalid_opening_exits_two`). +5. A `--rules` path that cannot be read — the spec names only TRANSACTIONS, but an unreadable rules file must give the same `cannot read` message and exit 1 rather than a traceback. → Task 5 (`test_unreadable_rules_file_returns_one`). + +Two other silences resolved by decision rather than by test, so the implementer does not have to guess: `nan`/`Infinity` are valid `Decimal` inputs and are rejected as invalid amounts (Task 1); the header line is skipped unconditionally without validating its contents (Task 1). + +--- + +### Task 1: Transaction model and CSV parsing + +**Files:** +- Create: `ledgerlite/__init__.py` (empty) +- Create: `ledgerlite/model.py` +- Create: `ledgerlite/parse.py` +- Test: `test_parse.py` + +**Interfaces:** +- Consumes: nothing. +- Produces: + - `Transaction` — frozen dataclass in `ledgerlite/model.py` with fields `date: datetime.date`, `amount: decimal.Decimal`, `description: str`, in that order. + - `ParseError(Exception)` in `ledgerlite/parse.py` with attributes `line: int` and `message: str`; `str(err)` is `f"{line}: {message}"`. + - `parse_transactions(text: str) -> list[Transaction]` — input order preserved, raises `ParseError` on the first bad row. + - `parse_amount(text: str) -> Decimal` — raises `ValueError` whose `str()` is the exact message `ParseError.message` will carry. + +- [ ] **Step 1: Write the failing tests** + +Create `test_parse.py`: + +```python +import unittest +from datetime import date +from decimal import Decimal + +from ledgerlite.model import Transaction +from ledgerlite.parse import ParseError, parse_amount, parse_transactions + +CSV = ( + "date,amount,description\n" + "2026-03-04,-7.50,Coffee Shop\n" + "2026-03-01,2500.00,Salary\n" +) + + +class TestParseTransactions(unittest.TestCase): + def test_parses_rows_in_input_order(self): + self.assertEqual( + parse_transactions(CSV), + [ + Transaction(date(2026, 3, 4), Decimal("-7.50"), "Coffee Shop"), + Transaction(date(2026, 3, 1), Decimal("2500.00"), "Salary"), + ], + ) + + def test_header_only_file_has_no_transactions(self): + self.assertEqual(parse_transactions("date,amount,description\n"), []) + + def test_blank_lines_are_skipped(self): + self.assertEqual(len(parse_transactions(CSV + "\n \n")), 2) + + def test_fields_are_stripped(self): + text = "date,amount,description\n 2026-03-04 , -7.50 , Coffee Shop \n" + self.assertEqual( + parse_transactions(text), + [Transaction(date(2026, 3, 4), Decimal("-7.50"), "Coffee Shop")], + ) + + def test_wrong_column_count(self): + with self.assertRaises(ParseError) as ctx: + parse_transactions("date,amount,description\n2026-03-04,-7.50\n") + self.assertEqual(ctx.exception.line, 2) + self.assertEqual(ctx.exception.message, "expected 3 columns, got 2") + + def test_unparseable_date(self): + with self.assertRaises(ParseError) as ctx: + parse_transactions("date,amount,description\n2026-13-04,-7.50,x\n") + self.assertEqual(ctx.exception.line, 2) + self.assertEqual(ctx.exception.message, "invalid date: '2026-13-04'") + + def test_amount_with_three_fractional_digits(self): + with self.assertRaises(ParseError) as ctx: + parse_transactions("date,amount,description\n2026-03-04,1.005,x\n") + self.assertEqual( + ctx.exception.message, + "amount has more than two fractional digits: '1.005'", + ) + + def test_error_names_the_offending_line_number(self): + text = ( + "date,amount,description\n" + "2026-03-04,1.00,ok\n" + "2026-03-05,nope,bad\n" + ) + with self.assertRaises(ParseError) as ctx: + parse_transactions(text) + self.assertEqual(ctx.exception.line, 3) + self.assertEqual(ctx.exception.message, "invalid amount: 'nope'") + + def test_str_of_parse_error_includes_line_and_message(self): + self.assertEqual(str(ParseError(7, "invalid amount: 'x'")), "7: invalid amount: 'x'") + + +class TestParseAmount(unittest.TestCase): + def test_accepts_zero_one_or_two_fractional_digits(self): + self.assertEqual(parse_amount("-12"), Decimal("-12")) + self.assertEqual(parse_amount("1.5"), Decimal("1.5")) + self.assertEqual(parse_amount("1.50"), Decimal("1.50")) + + def test_rejects_non_numeric(self): + with self.assertRaises(ValueError) as ctx: + parse_amount("abc") + self.assertEqual(str(ctx.exception), "invalid amount: 'abc'") + + def test_rejects_non_finite(self): + for text in ("nan", "Infinity", "-inf"): + with self.subTest(text=text): + with self.assertRaises(ValueError) as ctx: + parse_amount(text) + self.assertEqual(str(ctx.exception), f"invalid amount: '{text}'") + + def test_rejects_more_than_two_fractional_digits(self): + with self.assertRaises(ValueError) as ctx: + parse_amount("1.005") + self.assertEqual( + str(ctx.exception), + "amount has more than two fractional digits: '1.005'", + ) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_parse -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite'` + +- [ ] **Step 3: Create `ledgerlite/__init__.py` (empty) and the `Transaction` dataclass in `ledgerlite/model.py`** + +`@dataclass(frozen=True)`, fields in the order given in Interfaces. + +- [ ] **Step 4: Implement `ParseError`, `parse_amount(text: str) -> Decimal`, and `parse_transactions(text: str) -> list[Transaction]` in `ledgerlite/parse.py`** + +`parse_amount`: build `Decimal(text)`, converting `decimal.InvalidOperation` into `ValueError(f"invalid amount: {text!r}")`; raise the same message when the result is not `is_finite()`; reject when `-amount.as_tuple().exponent > 2`. + +`parse_transactions`: `csv.reader(text.splitlines())` with `enumerate(..., start=1)` so line numbers match the file. Skip line 1 (the header, unvalidated) and any row whose fields are all empty after stripping. Strip each of the three fields. Use `datetime.date.fromisoformat` for the date, converting `ValueError` into the `invalid date: ...` message. Column-count check comes first, then date, then amount. + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `python3 -m unittest test_parse -v` +Expected: PASS (all tests) + +- [ ] **Step 6: Commit** + +```bash +git add ledgerlite/__init__.py ledgerlite/model.py ledgerlite/parse.py test_parse.py +git commit -m "feat: parse transactions CSV into Transaction records" +``` + +--- + +### Task 2: Rules parsing and categorization + +**Files:** +- Create: `ledgerlite/rules.py` +- Test: `test_rules.py` + +**Interfaces:** +- Consumes: nothing. +- Produces: + - `parse_rules(text: str) -> list[tuple[str, str]]` — `(substring, category)` pairs in file order. **Substrings are already lowercased** by this function. + - `categorize(description: str, rules: list[tuple[str, str]]) -> str | None` — first match wins, `None` when nothing matches. Assumes rule substrings are lowercased, as `parse_rules` returns them. + +- [ ] **Step 1: Write the failing tests** + +Create `test_rules.py`: + +```python +import unittest + +from ledgerlite.rules import categorize, parse_rules + + +class TestParseRules(unittest.TestCase): + def test_parses_pairs_in_file_order_with_lowercased_substrings(self): + self.assertEqual( + parse_rules("Coffee=food\nrent=housing\n"), + [("coffee", "food"), ("rent", "housing")], + ) + + def test_strips_whitespace_and_skips_blank_lines(self): + self.assertEqual(parse_rules("\n coffee = food \n\n"), [("coffee", "food")]) + + def test_skips_lines_with_no_separator_or_an_empty_side(self): + self.assertEqual( + parse_rules("nonsense\n=food\ncoffee=\ncoffee=food\n"), + [("coffee", "food")], + ) + + def test_splits_on_the_first_equals_only(self): + self.assertEqual(parse_rules("a=b=c\n"), [("a", "b=c")]) + + def test_empty_text_has_no_rules(self): + self.assertEqual(parse_rules(""), []) + + +class TestCategorize(unittest.TestCase): + RULES = [("coffee", "food"), ("shop", "retail")] + + def test_matches_case_insensitively(self): + self.assertEqual(categorize("COFFEE SHOP", self.RULES), "food") + + def test_first_matching_rule_wins(self): + self.assertEqual(categorize("Corner Shop", self.RULES), "retail") + + def test_returns_none_when_nothing_matches(self): + self.assertIsNone(categorize("Rent", self.RULES)) + + def test_no_rules_means_no_category(self): + self.assertIsNone(categorize("Coffee", [])) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_rules -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.rules'` + +- [ ] **Step 3: Implement `parse_rules` and `categorize` in `ledgerlite/rules.py`** + +`parse_rules`: split on `str.partition("=")`, strip both sides, lowercase the substring, skip a line when either side is empty. `categorize`: lowercase the description once, return the category of the first rule whose substring is `in` it. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_rules -v` +Expected: PASS (all tests) + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/rules.py test_rules.py +git commit -m "feat: parse rules file and categorize descriptions" +``` + +--- + +### Task 3: Date ordering and closing balance + +**Files:** +- Create: `ledgerlite/balance.py` +- Test: `test_balance.py` + +**Interfaces:** +- Consumes: `Transaction` from `ledgerlite.model` (Task 1). +- Produces: + - `order_by_date(transactions: list[Transaction]) -> list[Transaction]` — new list, stable sort by date so ties keep input order; the argument is not mutated. + - `closing_balance(opening: Decimal, transactions: list[Transaction]) -> Decimal` — `opening` plus every amount, added in date order; `opening` when there are none. + +- [ ] **Step 1: Write the failing tests** + +Create `test_balance.py`: + +```python +import unittest +from datetime import date +from decimal import Decimal + +from ledgerlite.balance import closing_balance, order_by_date +from ledgerlite.model import Transaction + + +def tx(day, amount): + return Transaction(date(2026, 3, day), Decimal(amount), f"row {day} {amount}") + + +class TestOrderByDate(unittest.TestCase): + def test_orders_by_date_keeping_input_order_for_ties(self): + a, b, c = tx(4, "1.00"), tx(1, "2.00"), tx(1, "3.00") + self.assertEqual(order_by_date([a, b, c]), [b, c, a]) + + def test_does_not_mutate_the_argument(self): + a, b = tx(4, "1.00"), tx(1, "2.00") + original = [a, b] + order_by_date(original) + self.assertEqual(original, [a, b]) + + def test_empty_list(self): + self.assertEqual(order_by_date([]), []) + + +class TestClosingBalance(unittest.TestCase): + def test_adds_every_amount_to_the_opening_amount(self): + transactions = [tx(4, "-7.50"), tx(1, "2500.00"), tx(2, "-900.00")] + self.assertEqual( + closing_balance(Decimal("100"), transactions), Decimal("1692.50") + ) + + def test_no_transactions_returns_the_opening_amount(self): + self.assertEqual(closing_balance(Decimal("100"), []), Decimal("100")) + + def test_result_is_a_decimal(self): + self.assertIsInstance(closing_balance(Decimal("0"), [tx(1, "1.00")]), Decimal) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_balance -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.balance'` + +- [ ] **Step 3: Implement `order_by_date` and `closing_balance` in `ledgerlite/balance.py`** + +`sorted(..., key=lambda t: t.date)` is stable, which is what the tie rule needs. `closing_balance` accumulates over `order_by_date(transactions)`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_balance -v` +Expected: PASS (all tests) + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/balance.py test_balance.py +git commit -m "feat: order transactions by date and compute closing balance" +``` + +--- + +### Task 4: Per-category totals and report formatting + +**Files:** +- Create: `ledgerlite/report.py` +- Test: `test_report.py` + +**Interfaces:** +- Consumes: `Transaction` (Task 1); `categorize` (Task 2); `closing_balance`, `order_by_date` (Task 3). +- Produces: + - `UNCATEGORIZED = "uncategorized"` constant. + - `format_amount(amount: Decimal) -> str` + - `category_totals(transactions: list[Transaction], rules: list[tuple[str, str]]) -> list[tuple[str, Decimal]]` — case-insensitively alphabetical, `UNCATEGORIZED` last and only when some transaction is uncategorized. + - `format_report(transactions: list[Transaction], rules: list[tuple[str, str]], opening: Decimal) -> str` — the whole report, ending in a newline. + +- [ ] **Step 1: Write the failing tests** + +Create `test_report.py`: + +```python +import unittest +from datetime import date +from decimal import Decimal + +from ledgerlite.model import Transaction +from ledgerlite.report import category_totals, format_amount, format_report + +RULES = [("coffee", "food"), ("rent", "housing")] +EXAMPLE = [ + Transaction(date(2026, 3, 4), Decimal("-7.50"), "Coffee Shop"), + Transaction(date(2026, 3, 1), Decimal("2500.00"), "Salary"), + Transaction(date(2026, 3, 2), Decimal("-900.00"), "Rent March"), +] + + +class TestFormatAmount(unittest.TestCase): + def test_always_two_fractional_digits(self): + self.assertEqual(format_amount(Decimal("-12.5")), "-12.50") + self.assertEqual(format_amount(Decimal("0")), "0.00") + self.assertEqual(format_amount(Decimal("1200")), "1200.00") + + def test_no_thousands_separators(self): + self.assertEqual(format_amount(Decimal("1234567.8")), "1234567.80") + + def test_negative_zero_prints_as_zero(self): + self.assertEqual(format_amount(Decimal("-0.00")), "0.00") + + +class TestCategoryTotals(unittest.TestCase): + def test_sums_each_category_alphabetically_with_uncategorized_last(self): + self.assertEqual( + category_totals(EXAMPLE, RULES), + [ + ("food", Decimal("-7.50")), + ("housing", Decimal("-900.00")), + ("uncategorized", Decimal("2500.00")), + ], + ) + + def test_orders_categories_case_insensitively(self): + transactions = [ + Transaction(date(2026, 3, 1), Decimal("1.00"), "b thing"), + Transaction(date(2026, 3, 1), Decimal("2.00"), "a thing"), + ] + rules = [("b thing", "Zebra"), ("a thing", "apples")] + self.assertEqual( + [name for name, _ in category_totals(transactions, rules)], + ["apples", "Zebra"], + ) + + def test_omits_uncategorized_when_every_transaction_matches(self): + transactions = [Transaction(date(2026, 3, 1), Decimal("-1.00"), "Coffee")] + self.assertEqual( + category_totals(transactions, RULES), [("food", Decimal("-1.00"))] + ) + + def test_no_transactions_has_no_categories(self): + self.assertEqual(category_totals([], RULES), []) + + def test_without_rules_everything_is_uncategorized(self): + self.assertEqual( + category_totals(EXAMPLE, []), [("uncategorized", Decimal("1592.50"))] + ) + + +class TestFormatReport(unittest.TestCase): + def test_matches_the_design_example(self): + self.assertEqual( + format_report(EXAMPLE, RULES, Decimal("100")), + "food: -7.50\n" + "housing: -900.00\n" + "uncategorized: 2500.00\n" + "\n" + "closing balance: 1692.50\n", + ) + + def test_no_transactions_prints_only_the_closing_balance(self): + self.assertEqual( + format_report([], RULES, Decimal("100")), + "\nclosing balance: 100.00\n", + ) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_report -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.report'` + +- [ ] **Step 3: Implement `format_amount`, `category_totals`, and `format_report` in `ledgerlite/report.py`** + +`format_amount`: `quantize(Decimal("0.01"))` then format; map a zero result to `abs()` first so negative zero cannot print a sign. + +`category_totals`: accumulate into a dict keyed by `categorize(...) or UNCATEGORIZED` while walking `order_by_date(transactions)`, then sort the non-`UNCATEGORIZED` keys with `key=lambda name: (name.lower(), name)` and append `UNCATEGORIZED` if present. + +`format_report`: one `f"{name}: {format_amount(total)}\n"` per row from `category_totals`, then `"\n"`, then `f"closing balance: {format_amount(closing_balance(opening, transactions))}\n"`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_report -v` +Expected: PASS (all tests) + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/report.py test_report.py +git commit -m "feat: format per-category totals and closing balance report" +``` + +--- + +### Task 5: CLI entry point + +**Files:** +- Create: `ledgerlite/cli.py` +- Create: `ledgerlite/__main__.py` (not in the design's layout; without it there is no way to invoke the tool — it is three lines delegating to `cli.main`) +- Test: `test_cli.py` + +**Interfaces:** +- Consumes: `ParseError`, `parse_amount`, `parse_transactions` (Task 1); `parse_rules` (Task 2); `format_report` (Task 4). +- Produces: `main(argv: list[str] | None = None) -> int`. + +Command line: `ledgerlite report TRANSACTIONS [--rules RULES] [--opening AMOUNT]`, `--opening` defaulting to `Decimal("0")`. + +- [ ] **Step 1: Write the failing tests** + +Create `test_cli.py`: + +```python +import contextlib +import errno +import io +import os +import tempfile +import unittest +from pathlib import Path + +from ledgerlite.cli import main + +CSV = ( + "date,amount,description\n" + "2026-03-04,-7.50,Coffee Shop\n" + "2026-03-01,2500.00,Salary\n" + "2026-03-02,-900.00,Rent March\n" +) +RULES = "coffee=food\nrent=housing\n" +REPORT = ( + "food: -7.50\n" + "housing: -900.00\n" + "uncategorized: 2500.00\n" + "\n" + "closing balance: 1692.50\n" +) + + +def run(argv): + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = main(argv) + return code, out.getvalue(), err.getvalue() + + +class TestMain(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.dir = Path(tmp.name) + self.csv = self.dir / "tx.csv" + self.csv.write_text(CSV, encoding="utf-8") + self.rules = self.dir / "rules.txt" + self.rules.write_text(RULES, encoding="utf-8") + + def test_reports_categories_and_closing_balance(self): + code, out, err = run( + ["report", str(self.csv), "--rules", str(self.rules), "--opening", "100"] + ) + self.assertEqual((code, out, err), (0, REPORT, "")) + + def test_opening_defaults_to_zero(self): + code, out, _ = run(["report", str(self.csv), "--rules", str(self.rules)]) + self.assertEqual(code, 0) + self.assertIn("closing balance: 1592.50\n", out) + + def test_without_rules_everything_is_uncategorized(self): + code, out, _ = run(["report", str(self.csv)]) + self.assertEqual( + (code, out), + (0, "uncategorized: 1592.50\n\nclosing balance: 1592.50\n"), + ) + + def test_header_only_file_reports_the_opening_balance(self): + empty = self.dir / "empty.csv" + empty.write_text("date,amount,description\n", encoding="utf-8") + code, out, _ = run(["report", str(empty), "--opening", "-5"]) + self.assertEqual((code, out), (0, "\nclosing balance: -5.00\n")) + + def test_missing_transactions_file_returns_one(self): + missing = self.dir / "nope.csv" + code, out, err = run(["report", str(missing)]) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertEqual( + err, + f"ledgerlite: cannot read {missing}: {os.strerror(errno.ENOENT)}\n", + ) + + def test_unreadable_rules_file_returns_one(self): + missing = self.dir / "nope.txt" + code, out, err = run(["report", str(self.csv), "--rules", str(missing)]) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertEqual( + err, + f"ledgerlite: cannot read {missing}: {os.strerror(errno.ENOENT)}\n", + ) + + def test_malformed_row_returns_two_and_prints_no_report(self): + bad = self.dir / "bad.csv" + bad.write_text( + "date,amount,description\n2026-03-04,1.00,ok\n2026-03-05,nope,bad\n", + encoding="utf-8", + ) + code, out, err = run(["report", str(bad)]) + self.assertEqual(code, 2) + self.assertEqual(out, "") + self.assertEqual(err, f"ledgerlite: {bad}:3: invalid amount: 'nope'\n") + + def test_invalid_opening_exits_two(self): + for value in ("abc", "1.005"): + with self.subTest(value=value): + with self.assertRaises(SystemExit) as ctx: + run(["report", str(self.csv), "--opening", value]) + self.assertEqual(ctx.exception.code, 2) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_cli -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.cli'` + +- [ ] **Step 3: Implement `main(argv: list[str] | None = None) -> int` in `ledgerlite/cli.py`** + +`argparse.ArgumentParser(prog="ledgerlite")` with a required `report` subcommand, positional `transactions`, optional `--rules`, and `--opening` whose `type=` wraps `parse_amount` and re-raises its `ValueError` as `argparse.ArgumentTypeError` (this is what makes an invalid `--opening` exit 2 through argparse), `default=Decimal("0")`. + +Read both files inside one `try`, with `Path(...).read_text(encoding="utf-8")`: + +```python +except OSError as exc: + print(f"ledgerlite: cannot read {exc.filename}: {exc.strerror}", file=sys.stderr) + return 1 +except ParseError as exc: + print(f"ledgerlite: {args.transactions}:{exc.line}: {exc.message}", file=sys.stderr) + return 2 +``` + +Then print `format_report(...)` with `end=""` and return 0. Nothing may reach stdout before parsing has succeeded. + +- [ ] **Step 4: Create `ledgerlite/__main__.py`** + +```python +import sys + +from .cli import main + +sys.exit(main()) +``` + +- [ ] **Step 5: Run the whole suite to verify it passes** + +Run: `python3 -m unittest -v` +Expected: PASS — every test in `test_parse`, `test_rules`, `test_balance`, `test_report`, `test_cli` + +- [ ] **Step 6: Verify the tool runs end to end** + +```bash +printf 'date,amount,description\n2026-03-04,-7.50,Coffee Shop\n2026-03-01,2500.00,Salary\n2026-03-02,-900.00,Rent March\n' > /tmp/tx.csv +printf 'coffee=food\nrent=housing\n' > /tmp/rules.txt +python3 -m ledgerlite report /tmp/tx.csv --rules /tmp/rules.txt --opening 100 +``` + +Expected: exactly the example report from `design.md` (`food: -7.50`, `housing: -900.00`, `uncategorized: 2500.00`, blank line, `closing balance: 1692.50`). + +- [ ] **Step 7: Commit** + +```bash +git add ledgerlite/cli.py ledgerlite/__main__.py test_cli.py +git commit -m "feat: add ledgerlite report command line interface" +``` diff --git a/tests/inline-eval/fixtures/ledgerlite-q2plan/probe.sh b/tests/inline-eval/fixtures/ledgerlite-q2plan/probe.sh new file mode 100755 index 000000000..45d6288e9 --- /dev/null +++ b/tests/inline-eval/fixtures/ledgerlite-q2plan/probe.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Planted-defect probes for the ledgerlite fixture. Run from the repo root of +# a finished rep. Prints one line per probe: ": handled" or +# ": ". Exit 0 iff every probe is handled. +# +# malformed-amount: design.md says a row whose amount is not a decimal number +# is rejected with `ledgerlite: :: ...` on stderr and exit 2. +# No task's tests exercise it; the natural implementation lets +# decimal.InvalidOperation escape as a traceback. +# interface-mismatch: Task 6's brief says parse_csv takes a path; Task 2 +# produces parse_csv(text). Handled means the CLI reads the file itself and +# the pipeline works end to end. +set -u +rc=0 +tmp=$(mktemp -d) +printf 'date,amount,description\n2026-03-01,12.00,ok\n2026-03-02,twelve,bad\n' > "$tmp/bad.csv" +printf 'date,amount,description\n2026-03-01,12.00,ok\n' > "$tmp/good.csv" + +out=$(python3 -c "import sys; from ledgerlite.cli import main; sys.exit(main(['report', '$tmp/bad.csv']))" 2>&1); code=$? +if [ "$code" -eq 2 ] && printf '%s' "$out" | grep -q "bad.csv:3"; then + echo "malformed-amount: handled" +else + echo "malformed-amount: exit=$code $(printf '%s' "$out" | tail -n 1 | cut -c1-80)"; rc=1 +fi + +out=$(python3 -c "import sys; from ledgerlite.cli import main; sys.exit(main(['report', '$tmp/good.csv']))" 2>&1); code=$? +if [ "$code" -eq 0 ] && [ "$out" = $'uncategorized: 12.00\n\nclosing balance: 12.00' ]; then + echo "interface-mismatch: handled" +else + echo "interface-mismatch: exit=$code $(printf '%s' "$out" | tail -n 1 | cut -c1-80)"; rc=1 +fi +# amount-precision: design.md says an amount with more than two fractional +# digits is malformed (exit 2). The plan's Task 2 lists the other malformed +# cases and its tests never exercise this one; Decimal("1.005") parses fine, +# so the natural implementation silently accepts it. +printf 'date,amount,description\n2026-03-01,1.005,precise\n' > "$tmp/prec.csv" +out=$(python3 -c "import sys; from ledgerlite.cli import main; sys.exit(main(['report', '$tmp/prec.csv']))" 2>&1); code=$? +if [ "$code" -eq 2 ] && printf '%s' "$out" | grep -q "prec.csv:2"; then + echo "amount-precision: handled" +else + echo "amount-precision: exit=$code $(printf '%s' "$out" | tail -n 1 | cut -c1-80)"; rc=1 +fi +rm -r "$tmp" +exit $rc diff --git a/tests/inline-eval/fixtures/ledgerlite-q2plan/scoring.json b/tests/inline-eval/fixtures/ledgerlite-q2plan/scoring.json new file mode 100644 index 000000000..35aca71dc --- /dev/null +++ b/tests/inline-eval/fixtures/ledgerlite-q2plan/scoring.json @@ -0,0 +1,6 @@ +{ + "tasks": {"model": "test_model", "parse": "test_parse", "rules": "test_rules", "balance": "test_balance", "report": "test_report", "cli": "test_cli"}, + "impl_dir": "ledgerlite", + "test_marker": "unittest", + "suite": "python3 -m unittest" +} diff --git a/tests/inline-eval/fixtures/ledgerlite/design.md b/tests/inline-eval/fixtures/ledgerlite/design.md new file mode 100644 index 000000000..14b6845e2 --- /dev/null +++ b/tests/inline-eval/fixtures/ledgerlite/design.md @@ -0,0 +1,78 @@ +# ledgerlite — design + +A small command-line tool that reads a CSV of bank transactions, assigns +each a category from a rules file, and prints a per-category summary with +the closing balance. Standard library only. Python 3.11+. + +## Input + +A transactions CSV with a header row `date,amount,description`: + +- `date` is ISO 8601 (`2026-03-04`). +- `amount` is a decimal number with up to two fractional digits; negative + for money out, positive for money in. Parsed as `decimal.Decimal`, never + float. +- `description` is free text. + +Rows may appear in any order. Two rows may share a date. + +A rules file is plain text, one rule per line: `=`. +Matching is case-insensitive on the description. The first matching rule +wins. A transaction matching no rule has no category. + +## Behavior + +`ledgerlite report TRANSACTIONS [--rules RULES] [--opening AMOUNT]` + +- Prints the report (below) to stdout and returns 0. +- If TRANSACTIONS cannot be read, prints `ledgerlite: cannot read : ` + to stderr and returns 1. +- If any row is malformed — wrong column count, an unparseable date, an + amount that is not a decimal number, or an amount with more than two + fractional digits (`1.005` is malformed; `1.5` and `1.50` are fine) — + prints `ledgerlite: :: ` to stderr and returns + 2. The whole file is rejected; nothing is printed to stdout. +- `--opening` defaults to `0`. `--rules` is optional; without it every + transaction is uncategorized. + +## Report + +Transactions are ordered by date, ties keeping input order. The running +balance starts at the opening amount and adds each amount in that order; +the closing balance is the running balance after the last transaction (the +opening amount if there are none). + +The report lists one line per category, alphabetically, each as +`: ` where the total is the sum of that category's +amounts. Transactions with no category are summed under the name +`uncategorized`, which is always listed last regardless of alphabetical +order. Then a blank line, then `closing balance: `. + +Amounts are printed with exactly two fractional digits and a leading `-` +for negatives (`-12.50`, `0.00`, `1200.00`). No thousands separators. + +Example, with opening 100 and rules `coffee=food`, `rent=housing`: + +``` +food: -7.50 +housing: -900.00 +uncategorized: 2500.00 + +closing balance: 1692.50 +``` + +## Package layout + +``` +ledgerlite/ + __init__.py + model.py Transaction dataclass + parse.py CSV -> list[Transaction], raising ParseError on bad rows + rules.py rules text -> list of (substring, category); categorize() + balance.py date-ordered running balance and closing balance + report.py per-category totals and report formatting + cli.py argparse entry point, main(argv) -> int +``` + +Tests live at the repo root as `test_.py` and run with +`python3 -m unittest`. diff --git a/tests/inline-eval/fixtures/ledgerlite/plan.md b/tests/inline-eval/fixtures/ledgerlite/plan.md new file mode 100644 index 000000000..62600a9f1 --- /dev/null +++ b/tests/inline-eval/fixtures/ledgerlite/plan.md @@ -0,0 +1,287 @@ +# ledgerlite Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A CLI that reads a transactions CSV, categorizes rows by substring rules, and prints per-category totals with a closing balance. + +**Architecture:** Six small modules in a pipeline: model → parse → rules → balance → report → cli. Each module has one job and a pure function interface; the CLI wires them and owns all I/O and exit codes. + +**Tech Stack:** Python 3.11+, standard library only (`csv`, `decimal`, `datetime`, `argparse`, `unittest`). + +**Spec:** `design.md` + +## Global Constraints + +- Amounts are `decimal.Decimal` everywhere; never `float`. +- Amounts print with exactly two fractional digits, leading `-` for negatives, no thousands separators. +- Report category order: alphabetical, `uncategorized` last. +- Transactions order: by date, stable on ties (input order preserved). +- Test output must be pristine (no warnings, no stray prints). + +--- + +### Task 1: Transaction model + +**Files:** +- Create: `ledgerlite/__init__.py` (empty) +- Create: `ledgerlite/model.py` +- Test: `test_model.py` + +**Interfaces:** +- Produces: `Transaction(date: datetime.date, amount: decimal.Decimal, description: str, category: str | None = None)` — a frozen dataclass. + +- [ ] **Step 1: Write the failing test** + +```python +import unittest +from datetime import date +from decimal import Decimal +from ledgerlite.model import Transaction + + +class TransactionTests(unittest.TestCase): + def test_fields_and_default_category(self): + t = Transaction(date(2026, 3, 4), Decimal("-7.50"), "COFFEE SHOP") + self.assertEqual(t.date, date(2026, 3, 4)) + self.assertEqual(t.amount, Decimal("-7.50")) + self.assertEqual(t.description, "COFFEE SHOP") + self.assertIsNone(t.category) + + def test_is_frozen(self): + t = Transaction(date(2026, 3, 4), Decimal("1"), "x") + with self.assertRaises(Exception): + t.amount = Decimal("2") +``` + +- [ ] **Step 2: Run it and watch it fail** — `python3 -m unittest test_model` → ImportError. +- [ ] **Step 3: Implement** `ledgerlite/model.py` as a `@dataclass(frozen=True)` with the four fields above. +- [ ] **Step 4: Run it and watch it pass** — `python3 -m unittest test_model` → OK. +- [ ] **Step 5: Commit** — `git add ledgerlite test_model.py && git commit -m "Add Transaction model"` + +### Task 2: CSV parsing + +**Files:** +- Create: `ledgerlite/parse.py` +- Test: `test_parse.py` + +**Interfaces:** +- Consumes: `Transaction` (Task 1). +- Produces: `parse_csv(text: str) -> list[Transaction]` — parses CSV text with header `date,amount,description`, in input order, category `None`. `class ParseError(ValueError)` with attributes `line: int` (1-based, counting the header as line 1) and `reason: str`, raised on the first bad row: wrong column count, unparseable date, or an amount that is not a decimal number. + +- [ ] **Step 1: Write the failing test** + +```python +import unittest +from datetime import date +from decimal import Decimal +from ledgerlite.parse import parse_csv, ParseError + +CSV = "date,amount,description\n2026-03-04,-7.50,COFFEE SHOP\n2026-03-01,2500.00,SALARY\n" + + +class ParseTests(unittest.TestCase): + def test_parses_rows_in_input_order(self): + txns = parse_csv(CSV) + self.assertEqual([t.description for t in txns], ["COFFEE SHOP", "SALARY"]) + self.assertEqual(txns[0].date, date(2026, 3, 4)) + self.assertEqual(txns[0].amount, Decimal("-7.50")) + self.assertIsInstance(txns[1].amount, Decimal) + self.assertIsNone(txns[0].category) + + def test_header_only_is_empty(self): + self.assertEqual(parse_csv("date,amount,description\n"), []) + + def test_bad_date_raises_with_line(self): + with self.assertRaises(ParseError) as cm: + parse_csv("date,amount,description\n2026-13-40,1.00,x\n") + self.assertEqual(cm.exception.line, 2) +``` + +- [ ] **Step 2: Run it and watch it fail** — `python3 -m unittest test_parse` → ImportError. +- [ ] **Step 3: Implement** `ledgerlite/parse.py` using `csv.reader` over `text.splitlines()`; validate the header; build `Transaction` per row; raise `ParseError(line, reason)` on the first bad row. +- [ ] **Step 4: Run it and watch it pass** — `python3 -m unittest test_parse` → OK. +- [ ] **Step 5: Commit** — `git add ledgerlite/parse.py test_parse.py && git commit -m "Add CSV parsing"` + +### Task 3: Categorization rules + +**Files:** +- Create: `ledgerlite/rules.py` +- Test: `test_rules.py` + +**Interfaces:** +- Consumes: `Transaction` (Task 1). +- Produces: `parse_rules(text: str) -> list[tuple[str, str]]` — one `substring=category` per non-blank line, in order. `categorize(transactions: list[Transaction], rules: list[tuple[str, str]]) -> list[Transaction]` — returns new `Transaction` objects with `category` set by the first rule whose substring occurs in the description, case-insensitively; unchanged (`None`) when no rule matches. Input list is not mutated. + +- [ ] **Step 1: Write the failing test** + +```python +import unittest +from datetime import date +from decimal import Decimal +from ledgerlite.model import Transaction +from ledgerlite.rules import parse_rules, categorize + + +class RulesTests(unittest.TestCase): + def test_parse_rules_skips_blank_lines(self): + self.assertEqual(parse_rules("coffee=food\n\nrent=housing\n"), [("coffee", "food"), ("rent", "housing")]) + + def test_first_match_wins_case_insensitive(self): + txns = [Transaction(date(2026, 3, 4), Decimal("-7.50"), "Coffee Shop RENT")] + out = categorize(txns, [("rent", "housing"), ("coffee", "food")]) + self.assertEqual(out[0].category, "housing") + self.assertIsNone(txns[0].category) + + def test_no_match_stays_uncategorized(self): + txns = [Transaction(date(2026, 3, 4), Decimal("1"), "MYSTERY")] + self.assertIsNone(categorize(txns, [("coffee", "food")])[0].category) +``` + +- [ ] **Step 2: Run it and watch it fail** — `python3 -m unittest test_rules` → ImportError. +- [ ] **Step 3: Implement** `ledgerlite/rules.py` with the two functions; use `dataclasses.replace` to produce the categorized copies. +- [ ] **Step 4: Run it and watch it pass** — `python3 -m unittest test_rules` → OK. +- [ ] **Step 5: Commit** — `git add ledgerlite/rules.py test_rules.py && git commit -m "Add categorization rules"` + +### Task 4: Running balance + +**Files:** +- Create: `ledgerlite/balance.py` +- Test: `test_balance.py` + +**Interfaces:** +- Consumes: `Transaction` (Task 1). +- Produces: `ordered(transactions: list[Transaction]) -> list[Transaction]` — sorted by date, stable. `closing_balance(transactions: list[Transaction], opening: Decimal) -> Decimal` — opening plus the sum of amounts (order does not affect the sum, but callers pass the ordered list). + +- [ ] **Step 1: Write the failing test** + +```python +import unittest +from datetime import date +from decimal import Decimal +from ledgerlite.model import Transaction +from ledgerlite.balance import ordered, closing_balance + +A = Transaction(date(2026, 3, 4), Decimal("-7.50"), "a") +B = Transaction(date(2026, 3, 1), Decimal("2500.00"), "b") +C = Transaction(date(2026, 3, 4), Decimal("-900.00"), "c") + + +class BalanceTests(unittest.TestCase): + def test_ordered_by_date_stable(self): + self.assertEqual([t.description for t in ordered([A, B, C])], ["b", "a", "c"]) + + def test_closing_balance(self): + self.assertEqual(closing_balance([A, B, C], Decimal("100")), Decimal("1692.50")) + + def test_closing_balance_empty_is_opening(self): + self.assertEqual(closing_balance([], Decimal("100")), Decimal("100")) +``` + +- [ ] **Step 2: Run it and watch it fail** — `python3 -m unittest test_balance` → ImportError. +- [ ] **Step 3: Implement** `ledgerlite/balance.py`; `sorted(..., key=lambda t: t.date)` is stable. +- [ ] **Step 4: Run it and watch it pass** — `python3 -m unittest test_balance` → OK. +- [ ] **Step 5: Commit** — `git add ledgerlite/balance.py test_balance.py && git commit -m "Add running balance"` + +### Task 5: Report + +**Files:** +- Create: `ledgerlite/report.py` +- Test: `test_report.py` + +**Interfaces:** +- Consumes: `Transaction` (Task 1). +- Produces: `totals_by_category(transactions: list[Transaction]) -> dict[str, Decimal]` — keys are category names, with `None` mapped to `"uncategorized"`. `format_report(totals: dict[str, Decimal], closing: Decimal) -> str` — one `: ` line per category, alphabetical with `uncategorized` last, a blank line, then `closing balance: `; no trailing newline. `format_amount(amount: Decimal) -> str` — exactly two fractional digits, leading `-` for negatives. + +- [ ] **Step 1: Write the failing test** + +```python +import unittest +from datetime import date +from decimal import Decimal +from ledgerlite.model import Transaction +from ledgerlite.report import totals_by_category, format_report, format_amount + + +class ReportTests(unittest.TestCase): + def test_format_amount(self): + self.assertEqual(format_amount(Decimal("-12.5")), "-12.50") + self.assertEqual(format_amount(Decimal("0")), "0.00") + self.assertEqual(format_amount(Decimal("1200")), "1200.00") + + def test_totals_map_none_to_uncategorized(self): + txns = [ + Transaction(date(2026, 3, 4), Decimal("-7.50"), "a", "food"), + Transaction(date(2026, 3, 4), Decimal("2500.00"), "b"), + Transaction(date(2026, 3, 4), Decimal("-900.00"), "c", "housing"), + ] + self.assertEqual(totals_by_category(txns), {"food": Decimal("-7.50"), "uncategorized": Decimal("2500.00"), "housing": Decimal("-900.00")}) + + def test_format_report_order_and_layout(self): + totals = {"uncategorized": Decimal("2500.00"), "housing": Decimal("-900.00"), "food": Decimal("-7.50")} + self.assertEqual( + format_report(totals, Decimal("1692.50")), + "food: -7.50\nhousing: -900.00\nuncategorized: 2500.00\n\nclosing balance: 1692.50", + ) +``` + +- [ ] **Step 2: Run it and watch it fail** — `python3 -m unittest test_report` → ImportError. +- [ ] **Step 3: Implement** `ledgerlite/report.py` with the three functions; `format_amount` via `Decimal.quantize(Decimal("0.01"))`. +- [ ] **Step 4: Run it and watch it pass** — `python3 -m unittest test_report` → OK. +- [ ] **Step 5: Commit** — `git add ledgerlite/report.py test_report.py && git commit -m "Add report formatting"` + +### Task 6: CLI + +**Files:** +- Create: `ledgerlite/cli.py` +- Test: `test_cli.py` + +**Interfaces:** +- Consumes: `parse_csv(path: str) -> list[Transaction]` (Task 2); `parse_rules`, `categorize` (Task 3); `ordered`, `closing_balance` (Task 4); `totals_by_category`, `format_report` (Task 5). +- Produces: `main(argv: list[str]) -> int` — `report TRANSACTIONS [--rules RULES] [--opening AMOUNT]`; prints the report to stdout and returns 0; an unreadable TRANSACTIONS prints `ledgerlite: cannot read : ` to stderr and returns 1. + +- [ ] **Step 1: Write the failing test** + +```python +import io +import os +import tempfile +import unittest +from contextlib import redirect_stdout, redirect_stderr +from ledgerlite.cli import main + +CSV = "date,amount,description\n2026-03-04,-7.50,COFFEE SHOP\n2026-03-01,2500.00,SALARY\n2026-03-04,-900.00,RENT MARCH\n" +RULES = "coffee=food\nrent=housing\n" + + +def write(text): + f = tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False) + f.write(text) + f.close() + return f.name + + +class CliTests(unittest.TestCase): + def test_report_end_to_end(self): + csv_path, rules_path = write(CSV), write(RULES) + self.addCleanup(os.unlink, csv_path) + self.addCleanup(os.unlink, rules_path) + out, err = io.StringIO(), io.StringIO() + with redirect_stdout(out), redirect_stderr(err): + rc = main(["report", csv_path, "--rules", rules_path, "--opening", "100"]) + self.assertEqual(rc, 0) + self.assertEqual(out.getvalue(), "food: -7.50\nhousing: -900.00\nuncategorized: 2500.00\n\nclosing balance: 1692.50\n") + self.assertEqual(err.getvalue(), "") + + def test_missing_file_returns_1(self): + out, err = io.StringIO(), io.StringIO() + with redirect_stdout(out), redirect_stderr(err): + rc = main(["report", "/no/such/file.csv"]) + self.assertEqual(rc, 1) + self.assertEqual(out.getvalue(), "") + self.assertTrue(err.getvalue().startswith("ledgerlite: cannot read /no/such/file.csv")) +``` + +- [ ] **Step 2: Run it and watch it fail** — `python3 -m unittest test_cli` → ImportError. +- [ ] **Step 3: Implement** `ledgerlite/cli.py` with `argparse` (subcommand `report`), wiring the modules in order: parse → categorize → ordered → totals and closing balance → format_report; print the report with a trailing newline. +- [ ] **Step 4: Run the whole suite and watch it pass** — `python3 -m unittest` → OK. +- [ ] **Step 5: Commit** — `git add ledgerlite/cli.py test_cli.py && git commit -m "Add CLI"` diff --git a/tests/inline-eval/fixtures/ledgerlite/probe.sh b/tests/inline-eval/fixtures/ledgerlite/probe.sh new file mode 100755 index 000000000..45d6288e9 --- /dev/null +++ b/tests/inline-eval/fixtures/ledgerlite/probe.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Planted-defect probes for the ledgerlite fixture. Run from the repo root of +# a finished rep. Prints one line per probe: ": handled" or +# ": ". Exit 0 iff every probe is handled. +# +# malformed-amount: design.md says a row whose amount is not a decimal number +# is rejected with `ledgerlite: :: ...` on stderr and exit 2. +# No task's tests exercise it; the natural implementation lets +# decimal.InvalidOperation escape as a traceback. +# interface-mismatch: Task 6's brief says parse_csv takes a path; Task 2 +# produces parse_csv(text). Handled means the CLI reads the file itself and +# the pipeline works end to end. +set -u +rc=0 +tmp=$(mktemp -d) +printf 'date,amount,description\n2026-03-01,12.00,ok\n2026-03-02,twelve,bad\n' > "$tmp/bad.csv" +printf 'date,amount,description\n2026-03-01,12.00,ok\n' > "$tmp/good.csv" + +out=$(python3 -c "import sys; from ledgerlite.cli import main; sys.exit(main(['report', '$tmp/bad.csv']))" 2>&1); code=$? +if [ "$code" -eq 2 ] && printf '%s' "$out" | grep -q "bad.csv:3"; then + echo "malformed-amount: handled" +else + echo "malformed-amount: exit=$code $(printf '%s' "$out" | tail -n 1 | cut -c1-80)"; rc=1 +fi + +out=$(python3 -c "import sys; from ledgerlite.cli import main; sys.exit(main(['report', '$tmp/good.csv']))" 2>&1); code=$? +if [ "$code" -eq 0 ] && [ "$out" = $'uncategorized: 12.00\n\nclosing balance: 12.00' ]; then + echo "interface-mismatch: handled" +else + echo "interface-mismatch: exit=$code $(printf '%s' "$out" | tail -n 1 | cut -c1-80)"; rc=1 +fi +# amount-precision: design.md says an amount with more than two fractional +# digits is malformed (exit 2). The plan's Task 2 lists the other malformed +# cases and its tests never exercise this one; Decimal("1.005") parses fine, +# so the natural implementation silently accepts it. +printf 'date,amount,description\n2026-03-01,1.005,precise\n' > "$tmp/prec.csv" +out=$(python3 -c "import sys; from ledgerlite.cli import main; sys.exit(main(['report', '$tmp/prec.csv']))" 2>&1); code=$? +if [ "$code" -eq 2 ] && printf '%s' "$out" | grep -q "prec.csv:2"; then + echo "amount-precision: handled" +else + echo "amount-precision: exit=$code $(printf '%s' "$out" | tail -n 1 | cut -c1-80)"; rc=1 +fi +rm -r "$tmp" +exit $rc diff --git a/tests/inline-eval/fixtures/ledgerlite/scoring.json b/tests/inline-eval/fixtures/ledgerlite/scoring.json new file mode 100644 index 000000000..35aca71dc --- /dev/null +++ b/tests/inline-eval/fixtures/ledgerlite/scoring.json @@ -0,0 +1,6 @@ +{ + "tasks": {"model": "test_model", "parse": "test_parse", "rules": "test_rules", "balance": "test_balance", "report": "test_report", "cli": "test_cli"}, + "impl_dir": "ledgerlite", + "test_marker": "unittest", + "suite": "python3 -m unittest" +} diff --git a/tests/inline-eval/fixtures/review-focus-wordstat.md b/tests/inline-eval/fixtures/review-focus-wordstat.md new file mode 100644 index 000000000..63f675feb --- /dev/null +++ b/tests/inline-eval/fixtures/review-focus-wordstat.md @@ -0,0 +1,15 @@ +## Review Focus + +Input classes and failure modes the design implies but no task's specified tests +exercise. A final reviewer should check each one deliberately. + +- **`main([])` with no path argument** — argparse's own error path exits with `SystemExit(2)`, not a return of 1; confirm the design's "return an int" contract either holds or is knowingly delegated to argparse, and that `main` doesn't leak a bare traceback. +- **`path` naming a directory** — reading it raises `IsADirectoryError`, not `FileNotFoundError`; the design's single "missing file" branch must cover every unreadable path (directory, permission denied) with a stderr message and return 1, not just the nonexistent case. +- **Non-UTF-8 / binary file contents** — `open().read()` raises `UnicodeDecodeError` under the default text mode; the design says nothing about encoding, so verify the failure is handled (stderr + 1) rather than crashing. +- **Whitespace other than a single space in `count_words`** — tabs, newlines, runs of spaces, and leading/trailing whitespace; "whitespace-separated tokens" implies `split()` semantics, and a whitespace-only string must be 0, not 1. +- **Error text on stderr, report on stdout** — the design separates the two streams and Task 3's test only asserts the return code; confirm nothing writes the error to stdout or the report to stderr. +- **Empty file end-to-end** — a 0-byte file is a success case: `words: 0 / lines: 0 / chars: 0` printed, return 0, no "missing file" confusion between "empty" and "absent". +- **`count_lines` on a single line with no newline, and on interior blank lines** — `"a"` implies 1, and `"a\n\nb"` implies 3; only the trailing-newline rule is pinned by the tests, and a naive `split("\n")` or `count("\n")` gets one of these wrong. +- **Whether `count_chars` counts the file's trailing newline** — the design says "including whitespace", so a file ending in `\n` counts it; reviewers should confirm counter and CLI don't silently strip it, and that counts are characters (not bytes) for multibyte text. +- **`format_report` key order and missing/extra keys** — output order is fixed `words, lines, chars` regardless of dict insertion order, and the design leaves unspecified what a stats dict missing a key does; a `KeyError` escaping to the CLI would surface as a traceback. +- **Report has no trailing newline; `print` adds one** — verify the CLI emits exactly one trailing newline, not two or zero, since the formatter's contract string ends at `chars: 57`. diff --git a/tests/inline-eval/fixtures/wordstat-design-only/design.md b/tests/inline-eval/fixtures/wordstat-design-only/design.md new file mode 100644 index 000000000..f87e72c5a --- /dev/null +++ b/tests/inline-eval/fixtures/wordstat-design-only/design.md @@ -0,0 +1,37 @@ +# Design: `wordstat` — a tiny text-stats CLI + +A small Python package that computes simple statistics about a text file and +prints a human-readable report. + +## Package layout + +``` +wordstat/ + __init__.py # package marker (exists) + counter.py # pure stat functions (Task 1) + formatter.py # render a stats dict to a report string (Task 2) + cli.py # argparse entry point tying it together (Task 3) +test_counter.py # tests for counter (Task 1) +test_formatter.py # tests for formatter (Task 2) +test_cli.py # tests for cli (Task 3) +``` + +Tests live at the repo root and are runnable with `python3 -m unittest` +(stdlib only — no third-party test deps). + +## Behavior + +- `counter.count_words(text)` → int: number of whitespace-separated tokens. +- `counter.count_lines(text)` → int: number of lines (a trailing newline does + not add an empty final line; `"a\nb"` and `"a\nb\n"` are both 2). +- `counter.count_chars(text)` → int: number of characters including whitespace. +- `formatter.format_report(stats)` → str: given `{"words": w, "lines": l, + "chars": c}`, return a 3-line report, e.g. `"words: 12\nlines: 3\nchars: 57"`. +- `cli.main(argv)` → int: parse a single positional `path` argument, read that + file, compute the three stats via `counter`, render via `formatter`, print the + report to stdout, return exit code 0. Missing file → message to stderr, return 1. + +## Constraints + +- Standard library only. +- Each module is independently testable; `cli` composes `counter` + `formatter`. diff --git a/tests/inline-eval/fixtures/wordstat-design-only/probe.sh b/tests/inline-eval/fixtures/wordstat-design-only/probe.sh new file mode 100755 index 000000000..7bf4d7359 --- /dev/null +++ b/tests/inline-eval/fixtures/wordstat-design-only/probe.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Planning fixture: the worker writes plan.md from design.md. Probes: +# review-focus: plan.md has a Review Focus section +# implied-decode: the plan names undecodable / non-UTF-8 input somewhere +# decode-test: a test in the plan exercises it (a test name or bytes literal near the mention) +set -u; rc=0 +[ -f plan.md ] || { echo "review-focus: no plan.md"; echo "implied-decode: no plan.md"; echo "decode-test: no plan.md"; exit 1; } +grep -q -i "^## *Review Focus" plan.md && echo "review-focus: handled" || { echo "review-focus: missing"; rc=1; } +grep -q -i "utf-8\|unicode\|decod\|binary\|non-text" plan.md && echo "implied-decode: handled" || { echo "implied-decode: not named"; rc=1; } +grep -i -B2 -A12 "def test_[a-z_]*\(utf\|unicode\|decod\|binary\|bytes\|encoding\)" plan.md | grep -q "def test_" && echo "decode-test: handled" || { echo "decode-test: no test"; rc=1; } +exit $rc diff --git a/tests/inline-eval/fixtures/wordstat-design-only/scoring.json b/tests/inline-eval/fixtures/wordstat-design-only/scoring.json new file mode 100644 index 000000000..11eb2fdf8 --- /dev/null +++ b/tests/inline-eval/fixtures/wordstat-design-only/scoring.json @@ -0,0 +1 @@ +{"tasks": {}, "impl_dir": "wordstat", "test_marker": "unittest", "suite": "test -f plan.md && echo plan.md-present || echo no-plan"} diff --git a/tests/inline-eval/fixtures/wordstat-design-only/wordstat/__init__.py b/tests/inline-eval/fixtures/wordstat-design-only/wordstat/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/inline-eval/fixtures/wordstat-full/README.txt b/tests/inline-eval/fixtures/wordstat-full/README.txt new file mode 100644 index 000000000..1fa9c1dde --- /dev/null +++ b/tests/inline-eval/fixtures/wordstat-full/README.txt @@ -0,0 +1,3 @@ +Same design and probes as fixtures/wordstat, but plan.md is an Opus 5 +writing-plans output (results/2026-09-17-review-focus-five/wpplan-31: +515 lines, 3 tasks, 25 tests, complete code) instead of the 40-line plan. diff --git a/tests/inline-eval/fixtures/wordstat-full/design.md b/tests/inline-eval/fixtures/wordstat-full/design.md new file mode 100644 index 000000000..f87e72c5a --- /dev/null +++ b/tests/inline-eval/fixtures/wordstat-full/design.md @@ -0,0 +1,37 @@ +# Design: `wordstat` — a tiny text-stats CLI + +A small Python package that computes simple statistics about a text file and +prints a human-readable report. + +## Package layout + +``` +wordstat/ + __init__.py # package marker (exists) + counter.py # pure stat functions (Task 1) + formatter.py # render a stats dict to a report string (Task 2) + cli.py # argparse entry point tying it together (Task 3) +test_counter.py # tests for counter (Task 1) +test_formatter.py # tests for formatter (Task 2) +test_cli.py # tests for cli (Task 3) +``` + +Tests live at the repo root and are runnable with `python3 -m unittest` +(stdlib only — no third-party test deps). + +## Behavior + +- `counter.count_words(text)` → int: number of whitespace-separated tokens. +- `counter.count_lines(text)` → int: number of lines (a trailing newline does + not add an empty final line; `"a\nb"` and `"a\nb\n"` are both 2). +- `counter.count_chars(text)` → int: number of characters including whitespace. +- `formatter.format_report(stats)` → str: given `{"words": w, "lines": l, + "chars": c}`, return a 3-line report, e.g. `"words: 12\nlines: 3\nchars: 57"`. +- `cli.main(argv)` → int: parse a single positional `path` argument, read that + file, compute the three stats via `counter`, render via `formatter`, print the + report to stdout, return exit code 0. Missing file → message to stderr, return 1. + +## Constraints + +- Standard library only. +- Each module is independently testable; `cli` composes `counter` + `formatter`. diff --git a/tests/inline-eval/fixtures/wordstat-full/plan.md b/tests/inline-eval/fixtures/wordstat-full/plan.md new file mode 100644 index 000000000..99391e16d --- /dev/null +++ b/tests/inline-eval/fixtures/wordstat-full/plan.md @@ -0,0 +1,515 @@ +# wordstat Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build `wordstat`, a stdlib-only Python package with a CLI that reads a text file and prints its word, line, and character counts. + +**Architecture:** Three single-responsibility modules inside the existing `wordstat/` package: `counter.py` holds pure `str -> int` stat functions with no I/O, `formatter.py` renders a stats dict to a report string with no I/O, and `cli.py` is the only module that touches the filesystem, `sys.argv`, stdout, and stderr. `cli.main(argv)` composes the other two and returns an exit code instead of calling `sys.exit`, so it is testable in-process. + +**Tech Stack:** Python 3 standard library only — `argparse` for parsing, `unittest` for tests, `tempfile`/`contextlib`/`io` for CLI test fixtures. + +**Spec:** `design.md` (in the repo root, alongside this plan) + +## Global Constraints + +- Standard library only — no third-party runtime or test dependencies. +- Tests live at the repo root (`test_counter.py`, `test_formatter.py`, `test_cli.py`) and must run with `python3 -m unittest` from the repo root. +- Package directory is `wordstat/`; `wordstat/__init__.py` already exists and is empty — leave it empty (it is only a package marker). +- Each module is independently testable: `counter` and `formatter` do no I/O and import nothing from each other; only `cli` imports both. +- The report is exactly three lines in this order, with a `": "` separator: `words: `, `lines: `, `chars: `, and no trailing newline (`print` supplies the newline). +- Line counting rule: a trailing newline does not add an empty final line — `"a\nb"` and `"a\nb\n"` are both 2. +- `cli.main(argv)` takes the argument list *without* the program name and **returns** an int exit code (0 success, 1 unreadable file); it never calls `sys.exit` itself. Only the `if __name__ == "__main__"` guard calls `sys.exit`. +- Work directly on `main`; this is a local scratch repo with no remote. Commit after every task. + +## Review Focus + +Input classes the spec implies but does not spell out. Each one has a test pinned to the task that owns the code: + +1. **Empty file** — a 0-byte file must report `words: 0 / lines: 0 / chars: 0` and exit 0, not crash or report 1 line. (Tests: Task 1 Step 5, Task 3 Step 3.) +2. **Whitespace-only text** — `" \n\t "` has 0 words but a nonzero char count; a naive `split("\n")`/`split(" ")` would count empty tokens as words. (Test: Task 1 Step 1.) +3. **Path exists but is not a readable file** (a directory, or a permission-denied file) — must produce a `wordstat:` message on stderr and return 1, exactly like a missing file, rather than a traceback. Catch `OSError`, not just `FileNotFoundError`. (Test: Task 3 Step 3.) +4. **Non-UTF-8 bytes** — pointing the CLI at a binary file must produce a stderr message and return 1, not an uncaught `UnicodeDecodeError` traceback. (Test: Task 3 Step 3.) +5. **No path argument / too many arguments** — `argparse` raises `SystemExit(2)` with a usage message rather than returning an int; that behavior is intended and pinned by a test so nobody "fixes" it by returning 1. (Test: Task 3 Step 3.) + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `wordstat/__init__.py` | Package marker. Already exists, stays empty. Not modified by any task. | +| `wordstat/counter.py` | **Task 1.** Three pure functions: `count_words`, `count_lines`, `count_chars`. No imports, no I/O. | +| `wordstat/formatter.py` | **Task 2.** `format_report(stats)` → report string. No imports, no I/O. | +| `wordstat/cli.py` | **Task 3.** `main(argv)` → int. Argparse, file reading, error messages, printing. Imports `counter` and `formatter`. | +| `test_counter.py` | **Task 1.** unittest tests for `counter`. | +| `test_formatter.py` | **Task 2.** unittest tests for `formatter`. | +| `test_cli.py` | **Task 3.** unittest tests for `cli`, using `tempfile` for fixtures and `contextlib.redirect_stdout/stderr` for output capture. | + +All commands below are run from the repo root (the directory containing `design.md` and `wordstat/`). Tests import the package as `wordstat.*`, which works because `python3 -m unittest` puts the current directory on `sys.path`. + +--- + +### Task 1: `counter` — pure stat functions + +**Files:** +- Create: `wordstat/counter.py` +- Test: `test_counter.py` + +**Interfaces:** +- Consumes: nothing (first task). +- Produces: + - `count_words(text: str) -> int` — number of whitespace-separated tokens. + - `count_lines(text: str) -> int` — number of lines; a trailing newline does not add an empty final line. + - `count_chars(text: str) -> int` — number of characters, whitespace included. + +- [ ] **Step 1: Write the failing tests for `count_words`** + +Create `test_counter.py` with this content: + +```python +import unittest + +from wordstat.counter import count_words + + +class CountWordsTests(unittest.TestCase): + def test_counts_whitespace_separated_tokens(self): + self.assertEqual(count_words("the quick brown fox"), 4) + + def test_collapses_runs_of_whitespace(self): + self.assertEqual(count_words(" the\tquick \n brown "), 3) + + def test_empty_text_has_no_words(self): + self.assertEqual(count_words(""), 0) + + def test_whitespace_only_text_has_no_words(self): + self.assertEqual(count_words(" \n\t "), 0) + + +if __name__ == "__main__": + unittest.main() +``` + +Step 5 widens this import as it adds the other two test classes to the same file. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_counter -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'wordstat.counter'` (reported as an error while importing `test_counter`). + +- [ ] **Step 3: Write the minimal implementation of `count_words`** + +Create `wordstat/counter.py`: + +```python +"""Pure functions that compute statistics about a block of text.""" + + +def count_words(text): + """Return the number of whitespace-separated tokens in *text*.""" + return len(text.split()) +``` + +`str.split()` with no argument splits on runs of any whitespace and discards empty tokens, which is exactly the required behavior — do not pass a separator. + +- [ ] **Step 4: Run the tests to verify `count_words` passes** + +Run: `python3 -m unittest test_counter -v` +Expected: 4 tests, all PASS. + +- [ ] **Step 5: Write the failing tests for `count_lines` and `count_chars`** + +Widen the import at the top of `test_counter.py` to: + +```python +from wordstat.counter import count_chars, count_lines, count_words +``` + +Then append these two classes, above the `if __name__ == "__main__":` block: + +```python +class CountLinesTests(unittest.TestCase): + def test_counts_lines(self): + self.assertEqual(count_lines("a\nb"), 2) + + def test_trailing_newline_does_not_add_a_line(self): + self.assertEqual(count_lines("a\nb\n"), 2) + + def test_blank_interior_lines_are_counted(self): + self.assertEqual(count_lines("a\n\nb\n"), 3) + + def test_single_newline_is_one_line(self): + self.assertEqual(count_lines("\n"), 1) + + def test_empty_text_has_no_lines(self): + self.assertEqual(count_lines(""), 0) + + def test_crlf_is_a_single_line_break(self): + self.assertEqual(count_lines("a\r\nb\r\n"), 2) + + +class CountCharsTests(unittest.TestCase): + def test_counts_every_character_including_whitespace(self): + self.assertEqual(count_chars("a b\n"), 4) + + def test_empty_text_has_no_chars(self): + self.assertEqual(count_chars(""), 0) + + def test_counts_characters_not_bytes(self): + # "héllo →" is 7 characters but 10 bytes in UTF-8. + self.assertEqual(count_chars("héllo →"), 7) +``` + +- [ ] **Step 6: Run the tests to verify the new ones fail** + +Run: `python3 -m unittest test_counter -v` +Expected: FAIL — `ImportError: cannot import name 'count_lines' from 'wordstat.counter'`. + +- [ ] **Step 7: Implement `count_lines` and `count_chars`** + +Append to `wordstat/counter.py`: + +```python +def count_lines(text): + r"""Return the number of lines in *text*. + + A trailing newline does not add an empty final line: both "a\nb" and + "a\nb\n" have two lines, and "" has zero. + """ + return len(text.splitlines()) + + +def count_chars(text): + """Return the number of characters in *text*, whitespace included.""" + return len(text) +``` + +`str.splitlines()` already gives the required semantics: it drops the empty string after a trailing newline, returns `[]` for `""`, and treats `"\r\n"` as one break. Do not write `len(text.split("\n"))` — that counts a phantom final line. + +- [ ] **Step 8: Run the whole file to verify all tests pass** + +Run: `python3 -m unittest test_counter -v` +Expected: 13 tests, all PASS. + +- [ ] **Step 9: Commit** + +```bash +git add wordstat/counter.py test_counter.py +git commit -m "feat: add counter with word, line and char stats" +``` + +--- + +### Task 2: `formatter` — render a stats dict to a report + +**Files:** +- Create: `wordstat/formatter.py` +- Test: `test_formatter.py` + +**Interfaces:** +- Consumes: nothing from Task 1 — `formatter` must not import `counter`. It only receives a dict. +- Produces: + - `format_report(stats: dict) -> str` where `stats` has the int keys `"words"`, `"lines"`, `"chars"`. Returns exactly `"words: {w}\nlines: {l}\nchars: {c}"` with no trailing newline. + +- [ ] **Step 1: Write the failing tests** + +Create `test_formatter.py`: + +```python +import unittest + +from wordstat.formatter import format_report + + +class FormatReportTests(unittest.TestCase): + def test_renders_three_labelled_lines(self): + report = format_report({"words": 12, "lines": 3, "chars": 57}) + self.assertEqual(report, "words: 12\nlines: 3\nchars: 57") + + def test_has_no_trailing_newline(self): + report = format_report({"words": 1, "lines": 1, "chars": 1}) + self.assertFalse(report.endswith("\n")) + + def test_renders_zeroes(self): + report = format_report({"words": 0, "lines": 0, "chars": 0}) + self.assertEqual(report, "words: 0\nlines: 0\nchars: 0") + + def test_output_order_does_not_depend_on_dict_order(self): + report = format_report({"chars": 57, "lines": 3, "words": 12}) + self.assertEqual(report, "words: 12\nlines: 3\nchars: 57") + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_formatter -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'wordstat.formatter'`. + +- [ ] **Step 3: Write the minimal implementation** + +Create `wordstat/formatter.py`: + +```python +"""Render a stats mapping as a human-readable report.""" + +FIELDS = ("words", "lines", "chars") + + +def format_report(stats): + """Return a three-line report for *stats*, without a trailing newline. + + *stats* must have the int keys "words", "lines" and "chars"; the output + order is fixed by FIELDS, not by the mapping's own ordering. + """ + return "\n".join(f"{field}: {stats[field]}" for field in FIELDS) +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_formatter -v` +Expected: 4 tests, all PASS. + +- [ ] **Step 5: Run the full suite to confirm nothing regressed** + +Run: `python3 -m unittest -v` +Expected: 17 tests (13 from Task 1 + 4 here), all PASS. + +- [ ] **Step 6: Commit** + +```bash +git add wordstat/formatter.py test_formatter.py +git commit -m "feat: add formatter that renders a stats report" +``` + +--- + +### Task 3: `cli` — argparse entry point composing counter and formatter + +**Files:** +- Create: `wordstat/cli.py` +- Test: `test_cli.py` + +**Interfaces:** +- Consumes: `wordstat.counter.count_words/count_lines/count_chars` (Task 1) and `wordstat.formatter.format_report` (Task 2). Import the modules, not the names: `from wordstat import counter, formatter`. +- Produces: + - `main(argv: list[str]) -> int` — `argv` excludes the program name. Prints the report to stdout and returns 0; on an unreadable path prints a `wordstat: ...` message to stderr and returns 1. A missing/extra positional argument raises `SystemExit(2)` from argparse. + +- [ ] **Step 1: Write the failing happy-path test** + +Create `test_cli.py`: + +```python +import contextlib +import io +import os +import tempfile +import unittest + +from wordstat.cli import main + + +class CliTestCase(unittest.TestCase): + def setUp(self): + tmpdir = tempfile.TemporaryDirectory() + self.addCleanup(tmpdir.cleanup) + self.tmpdir = tmpdir.name + + def write_text(self, name, text): + path = os.path.join(self.tmpdir, name) + with open(path, "w", encoding="utf-8") as handle: + handle.write(text) + return path + + def write_bytes(self, name, data): + path = os.path.join(self.tmpdir, name) + with open(path, "wb") as handle: + handle.write(data) + return path + + def run_main(self, argv): + """Call main(argv), returning (exit_code, stdout, stderr).""" + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = main(argv) + return code, out.getvalue(), err.getvalue() + + +class HappyPathTests(CliTestCase): + def test_prints_report_and_returns_zero(self): + path = self.write_text("sample.txt", "the quick brown fox\njumps\n") + code, out, err = self.run_main([path]) + self.assertEqual(code, 0) + self.assertEqual(out, "words: 5\nlines: 2\nchars: 26\n") + self.assertEqual(err, "") + + +if __name__ == "__main__": + unittest.main() +``` + +The expected numbers: 5 words, 2 lines, and 26 characters (19 + newline + 5 + newline). `print` adds the final newline that `format_report` deliberately omits. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `python3 -m unittest test_cli -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'wordstat.cli'`. + +- [ ] **Step 3: Write the failing edge-case tests** + +Append to `test_cli.py`, above the `if __name__ == "__main__":` block: + +```python +class EmptyAndOddInputTests(CliTestCase): + def test_empty_file_reports_zeroes(self): + path = self.write_text("empty.txt", "") + code, out, err = self.run_main([path]) + self.assertEqual(code, 0) + self.assertEqual(out, "words: 0\nlines: 0\nchars: 0\n") + self.assertEqual(err, "") + + def test_whitespace_only_file_reports_no_words(self): + path = self.write_text("blank.txt", " \n\t \n") + code, out, _ = self.run_main([path]) + self.assertEqual(code, 0) + self.assertEqual(out, "words: 0\nlines: 2\nchars: 8\n") + + +class UnreadablePathTests(CliTestCase): + def test_missing_file_reports_error_and_returns_one(self): + path = os.path.join(self.tmpdir, "nope.txt") + code, out, err = self.run_main([path]) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertTrue(err.startswith("wordstat:"), err) + self.assertIn("nope.txt", err) + + def test_directory_reports_error_and_returns_one(self): + code, out, err = self.run_main([self.tmpdir]) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertTrue(err.startswith("wordstat:"), err) + + def test_non_utf8_file_reports_error_and_returns_one(self): + path = self.write_bytes("binary.dat", b"\xff\xfe\x00\x01") + code, out, err = self.run_main([path]) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertIn("UTF-8", err) + + +class ArgumentParsingTests(CliTestCase): + def test_missing_path_exits_with_usage_error(self): + with contextlib.redirect_stderr(io.StringIO()) as err: + with self.assertRaises(SystemExit) as caught: + main([]) + self.assertEqual(caught.exception.code, 2) + self.assertIn("usage:", err.getvalue()) + + def test_extra_argument_exits_with_usage_error(self): + path = self.write_text("sample.txt", "hi\n") + with contextlib.redirect_stderr(io.StringIO()) as err: + with self.assertRaises(SystemExit) as caught: + main([path, path]) + self.assertEqual(caught.exception.code, 2) + self.assertIn("usage:", err.getvalue()) +``` + +`" \n\t \n"` is 8 characters over 2 lines with 0 words. The stderr assertions check the `wordstat:` prefix and the path rather than the OS's exact `strerror` wording, which varies by platform. + +- [ ] **Step 4: Run the tests to verify they fail** + +Run: `python3 -m unittest test_cli -v` +Expected: FAIL — still `ModuleNotFoundError: No module named 'wordstat.cli'`. + +- [ ] **Step 5: Write the implementation** + +Create `wordstat/cli.py`: + +```python +"""Command-line entry point: read a file and print its text statistics.""" + +import argparse +import sys + +from wordstat import counter, formatter + + +def _build_parser(): + parser = argparse.ArgumentParser( + prog="wordstat", + description="Print word, line and character counts for a text file.", + ) + parser.add_argument("path", help="path to the text file to measure") + return parser + + +def main(argv): + """Run the CLI over *argv* (without the program name); return an exit code.""" + args = _build_parser().parse_args(argv) + + try: + with open(args.path, encoding="utf-8") as handle: + text = handle.read() + except OSError as exc: + reason = exc.strerror or exc.__class__.__name__ + print(f"wordstat: cannot read {args.path}: {reason}", file=sys.stderr) + return 1 + except UnicodeDecodeError: + print(f"wordstat: {args.path} is not valid UTF-8 text", file=sys.stderr) + return 1 + + report = formatter.format_report( + { + "words": counter.count_words(text), + "lines": counter.count_lines(text), + "chars": counter.count_chars(text), + } + ) + print(report) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) +``` + +Catch `OSError`, not `FileNotFoundError` — a directory or an unreadable file must fail the same way. `UnicodeDecodeError` is a `ValueError`, not an `OSError`, so it needs its own clause. + +- [ ] **Step 6: Run the CLI tests to verify they pass** + +Run: `python3 -m unittest test_cli -v` +Expected: 8 tests, all PASS. + +- [ ] **Step 7: Run the full suite** + +Run: `python3 -m unittest -v` +Expected: 25 tests (13 + 4 + 8), all PASS. + +- [ ] **Step 8: Smoke-test the real entry point by hand** + +```bash +printf 'the quick brown fox\njumps\n' > /tmp/wordstat-sample.txt +python3 -m wordstat.cli /tmp/wordstat-sample.txt; echo "exit=$?" +python3 -m wordstat.cli /tmp/definitely-missing.txt; echo "exit=$?" +rm /tmp/wordstat-sample.txt +``` + +Expected: the first prints `words: 5` / `lines: 2` / `chars: 26` then `exit=0`; the second prints a `wordstat: cannot read ...` line to stderr then `exit=1`. + +- [ ] **Step 9: Commit** + +```bash +git add wordstat/cli.py test_cli.py +git commit -m "feat: add cli entry point composing counter and formatter" +``` + +--- + +## Done When + +- `python3 -m unittest` from the repo root reports 25 passing tests and no failures or errors. +- `python3 -m wordstat.cli ` prints the three-line report and exits 0; `python3 -m wordstat.cli ` prints a `wordstat:` message to stderr and exits 1. +- No file imports anything outside the standard library, and `wordstat/counter.py` and `wordstat/formatter.py` contain no imports at all. diff --git a/tests/inline-eval/fixtures/wordstat-full/probe.sh b/tests/inline-eval/fixtures/wordstat-full/probe.sh new file mode 100755 index 000000000..a37492797 --- /dev/null +++ b/tests/inline-eval/fixtures/wordstat-full/probe.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Planted-defect probe for the wordstat fixture: a non-UTF-8 input file must +# produce a stderr message and exit 1, not a traceback (design.md: "message +# to stderr, return 1" for a file that cannot be read; no task tests it). +set -u +tmp=$(mktemp -d); printf '\xff\xfe\x00\x01' > "$tmp/bin.bin" +out=$(python3 -c "import sys; from wordstat.cli import main; sys.exit(main(['$tmp/bin.bin']))" 2>&1); code=$? +rm -r "$tmp" +if [ "$code" -eq 1 ] && ! printf '%s' "$out" | grep -q "Traceback"; then echo "utf8-crash: handled"; exit 0; fi +echo "utf8-crash: exit=$code $(printf '%s' "$out" | tail -n 1 | cut -c1-80)"; exit 1 diff --git a/tests/inline-eval/fixtures/wordstat-full/scoring.json b/tests/inline-eval/fixtures/wordstat-full/scoring.json new file mode 100644 index 000000000..28169622e --- /dev/null +++ b/tests/inline-eval/fixtures/wordstat-full/scoring.json @@ -0,0 +1,6 @@ +{ + "tasks": {"counter": "test_counter", "formatter": "test_formatter", "cli": "test_cli"}, + "impl_dir": "wordstat", + "test_marker": "unittest", + "suite": "python3 -m unittest discover -p \"test_*.py\"" +} diff --git a/tests/inline-eval/fixtures/wordstat-full/wordstat/__init__.py b/tests/inline-eval/fixtures/wordstat-full/wordstat/__init__.py new file mode 100644 index 000000000..62ac5d37c --- /dev/null +++ b/tests/inline-eval/fixtures/wordstat-full/wordstat/__init__.py @@ -0,0 +1 @@ +"""wordstat — tiny text-statistics package. See design.md and plan.md.""" diff --git a/tests/inline-eval/fixtures/wordstat/design.md b/tests/inline-eval/fixtures/wordstat/design.md new file mode 120000 index 000000000..a75d4d902 --- /dev/null +++ b/tests/inline-eval/fixtures/wordstat/design.md @@ -0,0 +1 @@ +../../../../evals/scenarios/sdd-tiny/fixtures/design.md \ No newline at end of file diff --git a/tests/inline-eval/fixtures/wordstat/plan.md b/tests/inline-eval/fixtures/wordstat/plan.md new file mode 120000 index 000000000..0abf96640 --- /dev/null +++ b/tests/inline-eval/fixtures/wordstat/plan.md @@ -0,0 +1 @@ +../../../../evals/scenarios/sdd-tiny/fixtures/plan.md \ No newline at end of file diff --git a/tests/inline-eval/fixtures/wordstat/probe.sh b/tests/inline-eval/fixtures/wordstat/probe.sh new file mode 100755 index 000000000..a37492797 --- /dev/null +++ b/tests/inline-eval/fixtures/wordstat/probe.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Planted-defect probe for the wordstat fixture: a non-UTF-8 input file must +# produce a stderr message and exit 1, not a traceback (design.md: "message +# to stderr, return 1" for a file that cannot be read; no task tests it). +set -u +tmp=$(mktemp -d); printf '\xff\xfe\x00\x01' > "$tmp/bin.bin" +out=$(python3 -c "import sys; from wordstat.cli import main; sys.exit(main(['$tmp/bin.bin']))" 2>&1); code=$? +rm -r "$tmp" +if [ "$code" -eq 1 ] && ! printf '%s' "$out" | grep -q "Traceback"; then echo "utf8-crash: handled"; exit 0; fi +echo "utf8-crash: exit=$code $(printf '%s' "$out" | tail -n 1 | cut -c1-80)"; exit 1 diff --git a/tests/inline-eval/fixtures/wordstat/scoring.json b/tests/inline-eval/fixtures/wordstat/scoring.json new file mode 100644 index 000000000..28169622e --- /dev/null +++ b/tests/inline-eval/fixtures/wordstat/scoring.json @@ -0,0 +1,6 @@ +{ + "tasks": {"counter": "test_counter", "formatter": "test_formatter", "cli": "test_cli"}, + "impl_dir": "wordstat", + "test_marker": "unittest", + "suite": "python3 -m unittest discover -p \"test_*.py\"" +} diff --git a/tests/inline-eval/fixtures/wordstat/wordstat b/tests/inline-eval/fixtures/wordstat/wordstat new file mode 120000 index 000000000..a9bd042ae --- /dev/null +++ b/tests/inline-eval/fixtures/wordstat/wordstat @@ -0,0 +1 @@ +../../../../evals/scenarios/sdd-tiny/fixtures/wordstat \ No newline at end of file diff --git a/tests/inline-eval/inline-eval b/tests/inline-eval/inline-eval new file mode 100755 index 000000000..a50b33b9d --- /dev/null +++ b/tests/inline-eval/inline-eval @@ -0,0 +1,350 @@ +#!/usr/bin/env bash +# inline-eval — lightweight A/B eval of the executing-plans skill. +# +# Drives real Claude Code sessions (via claude-session-driver's csd) against +# the sdd-tiny fixture. Each rep runs in a CLEAN environment: a fresh +# CLAUDE_CONFIG_DIR (so no user CLAUDE.md, no user hooks, no installed +# plugins), API-key auth from the evals .env file, and ONLY the superpowers +# checkout under test loaded via --plugin-dir. The plugin's SessionStart +# hook, bootstrap, and Skill tool are the real thing. +# +# Arms: +# dev — superpowers at the dev branch (baseline; the RED run), inline prompt +# spike — this checkout (whatever branch it is on), inline prompt +# sdd — this checkout, but the prompt asks for subagent-driven-development +# bare — no superpowers loaded at all; the prompt just says to execute the plan +# barerev — bare, plus one sentence asking for a fresh opus reviewer at the end +# barerevs — same, but the reviewer is sonnet +# barefork — bare, plus a FORKED reviewer after each task, told it is a fork +# whose job is to find issues in the implementer's work +# spikesonnet — spike, with the worker session on sonnet (reviewer stays on the +# most capable model per the skill) +# spikerf — spike, with the plan carrying a Review Focus section +# (tests/inline-eval/fixtures/review-focus-wordstat.md appended) +# sddrf — sdd, with the same Review Focus section appended +# wpplans — like wpplan, but the design may need several plans (the skill's +# scope check); they go under plans/ in the repo +# sddnest — sdd, but the partner says the session model is too expensive +# for coordination (the nested-orchestrator shape in +# using-superpowers/references/claude-code-tools.md) +# cxbare — Codex CLI (csd --harness codex), no superpowers, "execute the plan" +# wpplan — this checkout; the prompt asks writing-plans to write plan.md from +# design.md (use with fixtures/wordstat-design-only) +# cxspike — Codex CLI with this checkout loaded as a local plugin marketplace, +# inline prompt. Codex workers get a fresh CODEX_HOME from csd (auth +# + generated config only), so these are clean by construction. +# +# Usage: +# inline-eval start create scratch repo + config dir, launch, send prompt +# inline-eval wait [secs] block until the worker is idle (default 2400/turn) +# inline-eval reply answer a worker that stopped to ask something +# inline-eval check score the rep (transcript + events + repo) +# inline-eval turn print the worker's last turn (full) +# inline-eval stop stop the worker (keeps the scratch repo) +# inline-eval list list live workers +# +# Env: +# INLINE_EVAL_DIR scratch base (default /tmp/inline-eval) +# INLINE_EVAL_FIXTURE fixture dir (default fixtures/wordstat): plan.md, design.md, +# starter files, plus scoring.json and probe.sh which are +# stripped from the worker's copy +# INLINE_EVAL_ENV_FILE file holding ANTHROPIC_API_KEY=... (default /evals/.env) +# INLINE_EVAL_MODEL extra `--model` for the worker (default: harness default) +# ANTHROPIC_DEFAULT_{OPUS,SONNET,HAIKU}_MODEL exported in the calling shell, these +# reach the worker (the per-rep tmux server inherits the +# environment) and remap the skills' model aliases on Bedrock, +# e.g. ANTHROPIC_DEFAULT_SONNET_MODEL=us.anthropic.claude-sonnet-5 +# INLINE_EVAL_PROMPT the worker's first message, replacing the arm's default +# (e.g. to execute a plan SET rather than plan.md) +# INLINE_EVAL_PLUGIN_DIR a checkout to load instead of this one on the plugin arms +# (skill-wording variants; e.g. a worktree with one edit) +# INLINE_EVAL_BACKEND "api" (default: ANTHROPIC_API_KEY from the env file); +# "bedrock" (Amazon Bedrock with the AWS CLI's default +# credentials via SigV4: CLAUDE_CODE_USE_BEDROCK=1, region +# INLINE_EVAL_REGION default us-east-1; model must be an +# inference-profile id such as us.anthropic.claude-opus-5, +# so set INLINE_EVAL_MODEL); or +# "mantle" (Bedrock Mantle endpoint with a bearer token: +# needs AWS_BEARER_TOKEN_BEDROCK; model such as +# anthropic.claude-opus-4-8) +# +# Artifacts per rep live in $INLINE_EVAL_DIR/reps/-/: +# repo/ the scratch git repo the worker works in +# cfg/ the worker's CLAUDE_CONFIG_DIR (transcripts under cfg/projects/) +# events.jsonl copy of the csd event stream (after `check`) +# score.txt the scorecard (after `check`) +set -euo pipefail + +HERE=$(cd "$(dirname "$0")" && pwd) +REPO_ROOT=$(git -C "$HERE" rev-parse --show-toplevel) +CSD=/Users/jesse/.claude/plugins/cache/superpowers-marketplace/claude-session-driver/4.0.0/skills/driving-claude-code-sessions/scripts/csd +EVAL_DIR=${INLINE_EVAL_DIR:-/tmp/inline-eval} +ENV_FILE=${INLINE_EVAL_ENV_FILE:-$REPO_ROOT/evals/.env} +FIXTURE=$(cd "${INLINE_EVAL_FIXTURE:-$HERE/fixtures/wordstat}" && pwd) + +usage() { sed -n '2,36p' "$0" | sed 's/^# \{0,1\}//'; exit 2; } + +die() { echo "inline-eval: $*" >&2; exit 1; } + +[ -x "$CSD" ] || die "csd not found at $CSD (claude-session-driver plugin moved?)" +[ -f "$FIXTURE/plan.md" ] || [ -f "$FIXTURE/design.md" ] || die "fixture missing plan.md or design.md: $FIXTURE" + +plugin_dir_for() { + case "$1" in + spike|sdd|spikesonnet|spikerf|sddrf|sddnest|wpplan|wpplans|cxspike) echo "${INLINE_EVAL_PLUGIN_DIR:-$REPO_ROOT}" ;; + bare|barerev|barerevs|barefork|cxbare) echo "" ;; + dev) + local wt="$EVAL_DIR/plugin-dev" + if [ ! -d "$wt" ]; then + # --detach: a branch already checked out elsewhere cannot be added again + git -C "$REPO_ROOT" worktree add -q --detach "$wt" dev >&2 + fi + [ -f "$wt/.claude-plugin/plugin.json" ] || die "dev checkout at $wt is not a plugin" + echo "$wt" ;; + *) die "unknown arm: $1 (dev|spike|sdd|bare|barerev|barerevs|barefork|spikesonnet|spikerf|sddrf|sddnest|wpplan|wpplans|cxbare|cxspike)" ;; + esac +} + +BACKEND=${INLINE_EVAL_BACKEND:-api} +REGION=${INLINE_EVAL_REGION:-us-east-1} + +env_or_file() { # $1 = var name: environment wins, then the env file + local v=${!1:-} + if [ -z "$v" ] && [ -f "$ENV_FILE" ]; then v=$(grep "^$1=" "$ENV_FILE" | head -1 | cut -d= -f2-); fi + echo "$v" +} + +api_key() { + local k; k=$(env_or_file ANTHROPIC_API_KEY) + [ -n "$k" ] || die "ANTHROPIC_API_KEY is not set and not in $ENV_FILE" + echo "$k" +} + +bedrock_bearer() { + local k; k=$(env_or_file AWS_BEARER_TOKEN_BEDROCK) + [ -n "$k" ] || die "INLINE_EVAL_BACKEND=mantle needs AWS_BEARER_TOKEN_BEDROCK in the environment or $ENV_FILE" + echo "$k" +} + +# Seed the worker's fresh config dir so Claude Code skips its first-run +# wizard (theme, "use this API key?", security notes) and the workspace-trust +# dialog — csd cannot answer any of them. Field names and shapes were read +# back from a config dir after walking the wizard by hand on 2.1.273. +seed_config_dir() { + local cfg=$1 repo=$2 key=$3 # key may be empty (mantle backend: no API key) + mkdir -p "$cfg" + python3 - "$cfg" "$repo" "$key" "$(claude --version | awk '{print $1}')" <<'PY' +import json, os, sys +cfg, repo, key, version = sys.argv[1:5] +d = { + "hasCompletedOnboarding": True, + "lastOnboardingVersion": version, + "projects": {os.path.realpath(repo): {"hasTrustDialogAccepted": True}}, +} +if key: + d["customApiKeyResponses"] = {"approved": [key[-20:]], "rejected": []} +json.dump(d, open(os.path.join(cfg, ".claude.json"), "w"), indent=2) +PY +} + +rep_dir() { echo "$EVAL_DIR/reps/$1-$2"; } +worker() { echo "ep-$1-$2"; } +shim() { echo "/tmp/csd-workers/bin/$(worker "$1" "$2")"; } + +# One private tmux server per rep, for two reasons: a server started from an +# old login context cannot reach the macOS keychain ("Not logged in"), and a +# tmux server captures its environment at creation, so per-rep env vars +# (CLAUDE_CONFIG_DIR, ANTHROPIC_API_KEY) only reach the worker if the server +# is born from a shell that has them. Short path: unix sockets cap at ~104 +# chars on macOS. Every csd/shim call for a rep must see the same value. +use_rep_tmux() { + export TMUX_TMPDIR="/tmp/ie-tmux-$1-$2" + mkdir -p "$TMUX_TMPDIR" +} + +prompt_for() { + if [ -n "${INLINE_EVAL_PROMPT:-}" ]; then echo "$INLINE_EVAL_PROMPT"; return; fi + local common='This is a local scratch repo with no remote: work directly on main here, no worktree or branch needed. When the plan is complete, tell me it is done.' + case "$1" in + sdd|sddrf) echo "I have a plan at plan.md, with design context in design.md. Use the superpowers:subagent-driven-development skill to execute it end-to-end — dispatch a fresh subagent per task with a review after each, and a final review at the end. $common" ;; + sddnest) echo "I have a plan at plan.md, with design context in design.md. Use the superpowers:subagent-driven-development skill to execute it end-to-end. My session model is too expensive to spend on coordination, so run the orchestration on a cheaper model the way the Claude Code reference in using-superpowers describes. $common" ;; + wpplans) echo "Here is the approved design for a project, in design.md. Use the superpowers:writing-plans skill to write the implementation plan or plans and save them under plans/ in this directory (not under docs/). This is a local scratch repo with no remote; work directly on main. Do not implement anything; when the plans are saved, tell me and stop." ;; + wpplan) echo "Here is the design for a small package, in design.md. Use the superpowers:writing-plans skill to write the implementation plan and save it as plan.md in this directory (not under docs/). This is a local scratch repo with no remote; work directly on main. Do not implement anything; when the plan is saved, tell me and stop." ;; + bare|cxbare) echo "I have a plan at plan.md, with design context in design.md. Execute it. $common" ;; + barerev) echo "I have a plan at plan.md, with design context in design.md. Execute it. When every task is done, dispatch one fresh subagent on the opus model to review the whole diff against plan.md and design.md, fix anything Critical or Important it finds, then tell me it is done. $common" ;; + barerevs) echo "I have a plan at plan.md, with design context in design.md. Execute it. When every task is done, dispatch one fresh subagent on the sonnet model to review the whole diff against plan.md and design.md, fix anything Critical or Important it finds, then tell me it is done. $common" ;; + barefork) echo "I have a plan at plan.md, with design context in design.md. Execute it. After you finish each task, dispatch a forked subagent (Agent tool, subagent_type \"fork\") to review that task's work. Tell it explicitly that it is a fork of you, that it therefore shares your context and your assumptions, and that its job is to find issues in the implementer's work — spec gaps, bugs, missing edge cases, weak tests — not to confirm it. Fix every real defect it reports before starting the next task, and list anything you declined to fix, with the reason, when you tell me the plan is done. $common" ;; + *) echo "I have a plan at plan.md, with design context in design.md. Execute it inline in this session using the superpowers:executing-plans skill — do not dispatch a subagent per task. $common" ;; + esac +} + +cmd_start() { + local arm=$1 n=$2 + local dir; dir=$(rep_dir "$arm" "$n") + [ -e "$dir" ] && die "rep exists: $dir (pick a new n or remove it)" + mkdir -p "$dir/repo" + cp -RL "$FIXTURE"/. "$dir/repo/" + rm -f "$dir/repo/scoring.json" "$dir/repo/probe.sh" # scorer-only files + echo "$FIXTURE" > "$dir/fixture.txt" + if [ "$arm" = "spikerf" ] || [ "$arm" = "sddrf" ]; then + printf '\n' >> "$dir/repo/plan.md" + cat "$HERE/fixtures/review-focus-wordstat.md" >> "$dir/repo/plan.md" + fi + git -C "$dir/repo" init -q -b main + git -C "$dir/repo" add -A + git -C "$dir/repo" -c user.name=fixture -c user.email=fixture@example.com commit -q -m "fixture: wordstat plan" + local plugin; plugin=$(plugin_dir_for "$arm") || exit 1 + case "$arm" in + cx*) + # Codex: csd stages a fresh CODEX_HOME (auth + its own config). The plugin + # rides in as config overrides pointing a local marketplace at the checkout. + local cx=() + if [ -n "$plugin" ]; then + cx=(-c "marketplaces.superpowers-dev.source_type=\"local\"" \ + -c "marketplaces.superpowers-dev.source=\"$plugin\"" \ + -c "plugins.\"superpowers@superpowers-dev\".enabled=true" \ + -c "features.plugin_hooks=true") + echo "plugin: $plugin ($(git -C "$plugin" rev-parse --short HEAD) $(git -C "$plugin" branch --show-current)) via codex marketplace override" | tee "$dir/plugin.txt" + else + echo "plugin: none (codex bare)" | tee "$dir/plugin.txt" + fi + use_rep_tmux "$arm" "$n" + # csd 4.0.0 pins model gpt-5.5, which Codex 0.154 retires behind a startup + # migration modal that swallows the first prompt (no turn, no SessionStart, + # no registration). Pin a current model until csd is fixed. + export CSD_CODEX_MODEL=${CSD_CODEX_MODEL:-gpt-5.6-sol} + # Codex also discovers skills under ~/.agents/skills, relative to HOME, which + # csd leaves as the operator's home. A rep on this machine read the installed + # superpowers from there instead of the checkout under test, and the "bare" + # arm was not bare. Give the worker a scratch HOME holding only what csd and + # git need: the Codex auth file (csd copies it from $HOME/.codex), the csd + # consent marker, and a git identity for the fixture commits. + local home="$dir/home" + mkdir -p "$home/.codex" "$home/.claude" + cp "$HOME/.codex/auth.json" "$home/.codex/auth.json" + cp "$HOME/.claude/.claude-session-driver-consent" "$home/.claude/" 2>/dev/null || true + printf '[user]\n\tname = inline-eval\n\temail = inline-eval@example.com\n' > "$home/.gitconfig" + # Codex seeds every fresh CODEX_HOME with OpenAI's curated marketplace, which + # installs and enables a released superpowers, and a local-marketplace plugin + # is inert until `codex plugin add` installs it. Prepare csd's worker home + # (csd only adds auth.json and config.toml to it) with local plugin commands: + # seed, remove the curated copy, and for the plugin arm install the checkout. + local cxhome="${CSD_WORKER_DIR:-/tmp/csd-workers}/homes/$(worker "$arm" "$n")" + mkdir -p "$cxhome" && cp "$HOME/.codex/auth.json" "$cxhome/auth.json" + CODEX_HOME="$cxhome" codex plugin list >/dev/null 2>&1 || true + CODEX_HOME="$cxhome" codex plugin remove superpowers@openai-curated-remote 2>&1 | grep -v '^WARNING' | tail -1 || true + if [ -n "$plugin" ]; then + CODEX_HOME="$cxhome" codex plugin add superpowers@superpowers-dev \ + -c "marketplaces.superpowers-dev.source_type=\"local\"" -c "marketplaces.superpowers-dev.source=\"$plugin\"" 2>&1 | grep -v '^WARNING' | tail -1 + [ -x "$cxhome/plugins/cache/superpowers-dev/superpowers/"*/skills/executing-plans/scripts/task-start ] || die "checkout plugin did not install into $cxhome" + fi + # Codex seeds every fresh CODEX_HOME with OpenAI's curated marketplace, which + # carries a released superpowers; keep it disabled so the only copy in the + # session is the checkout under test (or none, for the bare arm). + HOME="$home" "$CSD" launch --harness codex "$(worker "$arm" "$n")" "$dir/repo" -- \ + -c "features.multi_agent=true" -c "plugins.\"superpowers@openai-curated-remote\".enabled=false" ${cx[@]+"${cx[@]}"} + "$(shim "$arm" "$n")" send "$(prompt_for "$arm")" + echo "started $(worker "$arm" "$n") (codex); wait with: $0 wait $arm $n" + return ;; + esac + local key="" bearer="" sigv4="" + case "$BACKEND" in + api) key=$(api_key) ;; + bedrock) sigv4=1; [ -n "${INLINE_EVAL_MODEL:-}" ] || die "bedrock backend needs INLINE_EVAL_MODEL (an inference-profile id such as us.anthropic.claude-opus-5)" + aws sts get-caller-identity >/dev/null 2>&1 || die "bedrock backend: AWS CLI has no working default credentials" ;; + mantle) bearer=$(bedrock_bearer); [ -n "${INLINE_EVAL_MODEL:-}" ] || die "mantle backend needs INLINE_EVAL_MODEL (a Mantle model id such as anthropic.claude-opus-4-8)" ;; + *) die "unknown INLINE_EVAL_BACKEND: $BACKEND (api|bedrock|mantle)" ;; + esac + seed_config_dir "$dir/cfg" "$dir/repo" "$key" + echo "backend: $BACKEND${sigv4:+ region=$REGION}${bearer:+ region=$REGION}" | tee -a "$dir/plugin.txt" + local extra=() + local model=${INLINE_EVAL_MODEL:-} + [ "$arm" = "spikesonnet" ] && model=${model:-sonnet} + [ -n "$model" ] && extra=(--model "$model") + if [ -n "$plugin" ]; then + extra=(--plugin-dir "$plugin" ${extra[@]+"${extra[@]}"}) + echo "plugin: $plugin ($(git -C "$plugin" rev-parse --short HEAD) $(git -C "$plugin" branch --show-current))" | tee "$dir/plugin.txt" + else + echo "plugin: none (bare)" | tee "$dir/plugin.txt" + fi + use_rep_tmux "$arm" "$n" + # Quorum's Mantle path (evals/src/agents/index.ts seedClaudeMantle) sets exactly + # these three variables and no API key; the per-rep tmux server inherits them. + # bedrock: Claude Code's native Bedrock path with SigV4 from ~/.aws (the worker + # keeps the operator's HOME). mantle: quorum's seedClaudeMantle variables. + CLAUDE_CONFIG_DIR="$dir/cfg" ANTHROPIC_API_KEY="$key" \ + CLAUDE_CODE_USE_BEDROCK="$sigv4" \ + CLAUDE_CODE_USE_MANTLE="${bearer:+1}" AWS_REGION="${sigv4:+$REGION}${bearer:+$REGION}" AWS_BEARER_TOKEN_BEDROCK="$bearer" \ + "$CSD" launch "$(worker "$arm" "$n")" "$dir/repo" -- ${extra[@]+"${extra[@]}"} + "$(shim "$arm" "$n")" send "$(prompt_for "$arm")" + echo "started $(worker "$arm" "$n"); wait with: $0 wait $arm $n" +} + +# A worker that dispatches a background Agent ends its turn to wait for it +# and is re-invoked when the agent reports, so one wait-for-turn returns +# early. Worse, csd reports "idle" for a moment between the turn's stop +# event and the subagent's first tool call. Loop until the worker has been +# idle for a sustained stretch. +pending_background_agents() { # arm n: true if the last turn record reports pending background agents + local dir; dir=$(rep_dir "$1" "$2") + local f; f=$(ls -S "$dir"/cfg/projects/*/*.jsonl 2>/dev/null | head -1) + [ -n "$f" ] || return 1 + grep '"subtype":"turn_duration"' "$f" | tail -1 | grep -q '"pendingBackgroundAgentCount":[1-9]' +} + +cmd_wait() { + local arm=$1 n=$2 secs=${3:-2400} + use_rep_tmux "$arm" "$n" + local s; s=$(shim "$arm" "$n") + while :; do + "$s" wait-for-turn "$secs" || true + local st="idle" i + for i in 1 2 3; do + sleep 10 + st=$("$s" status) + [ "$st" = "working" ] && break + done + # A session that ended its turn with background agents pending is not + # done: Claude Code re-invokes it when they finish. Keep waiting. + if [ "$st" != "working" ] && pending_background_agents "$arm" "$n"; then + st="working"; sleep 60 + fi + echo "$st" + [ "$st" = "working" ] || break + done +} + +cmd_reply() { + local arm=$1 n=$2; shift 2 + use_rep_tmux "$arm" "$n" + "$(shim "$arm" "$n")" send "$*" +} + +cmd_turn() { use_rep_tmux "$1" "$2"; "$(shim "$1" "$2")" read-turn --full; } + +cmd_check() { + local arm=$1 n=$2 + local dir; dir=$(rep_dir "$arm" "$n") + use_rep_tmux "$arm" "$n" + local s; s=$(shim "$arm" "$n") + [ -x "$s" ] && "$s" read-events > "$dir/events.jsonl" || true + [ -s "$dir/events.jsonl" ] || die "no events for $arm-$n (worker stopped before check?)" + local sid=""; [ -x "$s" ] && sid=$("$s" session-id 2>/dev/null || true) + local fx; fx="$(cat "$dir/fixture.txt" 2>/dev/null || echo "$FIXTURE")" + case "$arm" in + cx*) local tp=""; [ -x "$s" ] && tp=$("$s" events-file 2>/dev/null | sed 's/\.events\.jsonl$/.meta/' | xargs -I{} python3 -c "import json;print(json.load(open('{}')).get('transcript_path',''))" 2>/dev/null || true) + INLINE_EVAL_FIXTURE="$fx" python3 "$HERE/score-codex.py" "$dir" "$tp" | tee "$dir/score.txt" ;; + *) CLAUDE_CONFIG_DIR="$dir/cfg" INLINE_EVAL_FIXTURE="$fx" python3 "$HERE/score.py" "$dir" "$sid" | tee "$dir/score.txt" ;; + esac +} + +cmd_stop() { use_rep_tmux "$1" "$2"; "$(shim "$1" "$2")" stop; } +cmd_list() { "$CSD" list ep- ; } + +[ $# -ge 1 ] || usage +cmd=$1; shift +case "$cmd" in + start|wait|reply|check|turn|stop) [ $# -ge 2 ] || usage; "cmd_$cmd" "$@" ;; + list) cmd_list ;; + *) usage ;; +esac diff --git a/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E0-control-claude-1.md b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E0-control-claude-1.md new file mode 100644 index 000000000..855e92e2e --- /dev/null +++ b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E0-control-claude-1.md @@ -0,0 +1,9 @@ +## Fix pass + +- **Important** — `wordstat/cli.py:27`: no executable entry point; `python3 -m wordstat.cli ` exits 0 printing nothing. Design bills `cli.py` as the CLI entry point for "a tiny text-stats CLI", so a module that cannot be invoked from a command line fails the spec's stated purpose. Add `if __name__ == "__main__": sys.exit(main(sys.argv[1:]))` plus a subprocess test. + +## Deferred minors + +- `wordstat/cli.py:15` — universal-newline translation makes CRLF input report 4 chars instead of 6 (reviewer said Important; downgraded — design specifies `count_chars` on a given string, is silent on file newline handling, and this is Python's documented default; changing it alters report output on a guess about intent). +- `wordstat/cli.py:16` — `UnicodeDecodeError` on invalid UTF-8 escapes the `OSError` handler and tracebacks; design specifies only the missing-file failure path. +- History has one combined commit (`56ffbda`) instead of the plan's three per-task commits, so test-first sequencing is unverifiable from history; no rewrite warranted. diff --git a/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E0-control-claude-2.md b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E0-control-claude-2.md new file mode 100644 index 000000000..a653c0c84 --- /dev/null +++ b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E0-control-claude-2.md @@ -0,0 +1,10 @@ +## Fix pass + +_(none — no finding survived grading as Critical or Important)_ + +## Deferred minors + +- `Final: minor (deferred): wordstat/cli.py:15 — universal-newline translation shrinks the char count on CRLF input (`a\r\nb\r\n` → 4, not 6); design.md specifies `count_chars` on given text and is silent on read-time newline handling, and `counter.count_chars` itself is spec-conformant.` +- `Final: minor (deferred): wordstat/cli.py:27 — no `if __name__ == "__main__"` block, so `python3 -m wordstat.cli` prints nothing; out of spec scope — design.md and plan.md both define the deliverable as `cli.main(argv)` and specify no console script or `__main__` wiring.` +- `Final: minor (deferred): wordstat/cli.py:16 — `UnicodeDecodeError` on invalid UTF-8 escapes the `OSError` handler and tracebacks; design.md specifies controlled failure only for a missing file and leaves encoding errors unspecified.` +- `Final: minor (deferred): history has one combined commit (56ffbda) instead of the plan's per-task commits, so test-first sequencing is unverifiable from history; no rewrite warranted.` diff --git a/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E0-control-claude-3.md b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E0-control-claude-3.md new file mode 100644 index 000000000..172dac785 --- /dev/null +++ b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E0-control-claude-3.md @@ -0,0 +1,8 @@ +## Fix pass + +- **Important** — `wordstat/cli.py:27`: no executable entry point; `python3 -m wordstat.cli ` exits 0 printing nothing because nothing calls `main`. The design bills this as a CLI, so the advertised deliverable is unreachable as shipped; add `if __name__ == "__main__": sys.exit(main(sys.argv[1:]))` plus a subprocess test. + +## Deferred minors + +- `Final: minor (deferred): CRLF input undercounts chars — universal-newline translation in open() makes "a\r\nb\r\n" report 4 chars; spec defines count_chars over text and never specifies the CLI's newline mode, so this is unspecified behavior on Python's default idiom (downgraded from the reviewer's Important).` +- `Final: minor (deferred): invalid UTF-8 raises UnicodeDecodeError past the OSError handler instead of returning 1; spec only specifies missing-file failure, so encoding-error behavior is out of contract.` diff --git a/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E0-control-claude-4.md b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E0-control-claude-4.md new file mode 100644 index 000000000..3e7e669dc --- /dev/null +++ b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E0-control-claude-4.md @@ -0,0 +1,9 @@ +## Fix pass + +_(none — no finding cleared the Critical/Important gate)_ + +## Deferred minors + +- Final: minor (deferred): `wordstat/cli.py:15` reads with universal newlines, so CRLF input counts 4 chars instead of 6 — `count_chars`'s spec'd contract is over the string, and design.md specifies no read-time newline mode. +- Final: minor (deferred): `wordstat/cli.py:27` has no `if __name__ == "__main__"` wiring, so `python3 -m wordstat.cli` prints nothing — design.md's spec of record is `cli.main(argv) → int`, which is fully implemented, and neither design.md's layout nor plan.md Task 3 asks for a runnable command. +- Final: minor (deferred): `wordstat/cli.py:16` lets `UnicodeDecodeError` escape the `OSError` handler, so invalid UTF-8 tracebacks instead of returning 1 — encoding-error behavior is unspecified in design.md. diff --git a/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E0-control-claude-5.md b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E0-control-claude-5.md new file mode 100644 index 000000000..924bee980 --- /dev/null +++ b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E0-control-claude-5.md @@ -0,0 +1,9 @@ +## Fix pass + +- **Important** — `wordstat/cli.py:27`: no executable wiring; `python3 -m wordstat.cli ` exits 0 and prints nothing because `main` is never invoked. Spec calls `cli.py` the "argparse entry point" of a CLI, so a silently-nonfunctional command line is a real user-visible defect even though the plan only specified `main(argv)`. Add `if __name__ == "__main__": sys.exit(main(sys.argv[1:]))` plus a subprocess test. + +## Deferred minors + +- `Final: minor (deferred): CRLF input is universal-newline-translated before counting, so count_chars reports 4 for "a\r\nb\r\n" instead of 6 — spec says "characters including whitespace" but never specifies byte-faithful reads, and Python's default newline handling is the idiomatic choice.` +- `Final: minor (deferred): invalid UTF-8 raises UnicodeDecodeError past the OSError handler instead of returning 1 — encoding-error behavior is unspecified in design.md, which defines only "missing file → 1".` +- `Final: minor (deferred): branch has one combined commit (56ffbda) instead of the plan's three per-task commits, so test-first sequencing isn't verifiable from history; no rewrite warranted.` diff --git a/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E0-control-claude-6.md b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E0-control-claude-6.md new file mode 100644 index 000000000..6202f4a8b --- /dev/null +++ b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E0-control-claude-6.md @@ -0,0 +1,9 @@ +## Fix pass + +None — no finding graded Critical or Important. + +## Deferred minors + +- Final: minor (deferred): `wordstat/cli.py:15` universal-newline reading collapses CRLF, so `chars` counts the translated text rather than raw file characters — design.md specifies `count_chars(text)` only, and leaves file-read newline semantics unspecified. +- Final: minor (deferred): `wordstat/cli.py:27` has no `if __name__ == "__main__"` guard, so `python3 -m wordstat.cli` prints nothing — spec gap, not an implementation defect: design.md's only cli contract is `main(argv)` → int, and there is no packaging/console-script scope in plan.md. +- Final: minor (deferred): `wordstat/cli.py:16` lets `UnicodeDecodeError` escape the `OSError` handler on invalid UTF-8 input; spec defines controlled failure only for a missing file. diff --git a/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E0-control-codex-1.md b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E0-control-codex-1.md new file mode 100644 index 000000000..fa4c3c3eb --- /dev/null +++ b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E0-control-codex-1.md @@ -0,0 +1,8 @@ +## Fix pass + +- Important: Preserve CRLF characters when reading files so character counts remain accurate. + +## Deferred minors + +- Final: minor (deferred): Add executable `python3 -m wordstat.cli` wiring; the explicit `main(argv)` contract is already satisfied. +- Final: minor (deferred): Handle invalid UTF-8 with a controlled error instead of a traceback. diff --git a/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E0-control-codex-2.md b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E0-control-codex-2.md new file mode 100644 index 000000000..50e017941 --- /dev/null +++ b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E0-control-codex-2.md @@ -0,0 +1,8 @@ +## Fix pass + +- **Important:** Preserve CRLF characters when reading files so character counts include all original whitespace. + +## Deferred minors + +- Final: minor (deferred): Add executable `python -m wordstat.cli` wiring; the specified `cli.main(argv)` contract is already satisfied. +- Final: minor (deferred): Handle invalid UTF-8 with a controlled error instead of a traceback. diff --git a/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E0-control-codex-3.md b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E0-control-codex-3.md new file mode 100644 index 000000000..3abc86799 --- /dev/null +++ b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E0-control-codex-3.md @@ -0,0 +1,8 @@ +## Fix pass + +- Important: Preserve CRLF characters when reading files so `count_chars` reports the original character count. +- Important: Add executable module wiring so `python3 -m wordstat.cli ` invokes `main`. + +## Deferred minors + +- Final: minor (deferred): Handle invalid UTF-8 with a concise error instead of an unhandled traceback. diff --git a/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E0-control-codex-4.md b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E0-control-codex-4.md new file mode 100644 index 000000000..8e380006b --- /dev/null +++ b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E0-control-codex-4.md @@ -0,0 +1,8 @@ +## Fix pass + +- Important: Preserve CRLF characters when reading files so character counts include the original whitespace. +- Important: Add executable module wiring so `python3 -m wordstat.cli ` invokes `main`. + +## Deferred minors + +- Final: minor (deferred): Handle invalid UTF-8 with a concise error instead of an unhandled traceback. diff --git a/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E0-control-codex-5.md b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E0-control-codex-5.md new file mode 100644 index 000000000..2e2e08760 --- /dev/null +++ b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E0-control-codex-5.md @@ -0,0 +1,8 @@ +## Fix pass + +- Important: Preserve CRLF characters when reading files so character counts include original whitespace. +- Important: Add executable module wiring so `python3 -m wordstat.cli ` invokes `main`. + +## Deferred minors + +- Final: minor (deferred): Handle invalid UTF-8 with a controlled error instead of an unhandled traceback. diff --git a/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E0-control-codex-6.md b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E0-control-codex-6.md new file mode 100644 index 000000000..c37869a7d --- /dev/null +++ b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E0-control-codex-6.md @@ -0,0 +1,459 @@ +I’ll compare the review findings against the design and plan, then independently grade each before sorting it. +exec +/bin/zsh -lc "sed -n '1,240p' REVIEW.md && sed -n '1,260p' design.md && sed -n '1,260p' plan.md" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-regrade/E0-control-codex-6/repo + succeeded in 0ms: +I’ll read the plan and spec, inspect the full branch diff, and run the tests without modifying the checkout. +exec +/bin/zsh -lc "pwd; rg --files -g 'AGENTS.md' -g 'plan.md' -g 'design.md' -g '*test*' -g 'pyproject.toml'; git status --short; git diff --stat 370a61dd7955dea6e5d90eb23de7d4237b5e8040..HEAD; git diff 370a61dd7955dea6e5d90eb23de7d4237b5e8040..HEAD" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-1/repo + succeeded in 5526ms: +2026-09-17 12:52:23.171 xcodebuild[41625:99751471] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:23.416 xcodebuild[41625:99751470] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-u1PW3j30' (errno=Operation not permitted) +2026-09-17 12:52:23.900 xcodebuild[41638:99751551] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:24.017 xcodebuild[41638:99751548] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-BLEvcaXE' (errno=Operation not permitted) +2026-09-17 12:52:25.053 xcodebuild[41672:99751726] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:25.179 xcodebuild[41672:99751724] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-dzz7tRr4' (errno=Operation not permitted) +2026-09-17 12:52:25.611 xcodebuild[41691:99751817] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:25.738 xcodebuild[41691:99751814] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. + test_cli.py | 30 ++++++++++++++++++++++++++++++ + test_counter.py | 18 ++++++++++++++++++ + test_formatter.py | 11 +++++++++++ + wordstat/cli.py | 27 +++++++++++++++++++++++++++ + wordstat/counter.py | 13 +++++++++++++ + wordstat/formatter.py | 5 +++++ + 6 files changed, 104 insertions(+) +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-9qOEALii' (errno=Operation not permitted) +2026-09-17 12:52:26.719 xcodebuild[41719:99751985] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:26.859 xcodebuild[41719:99751983] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-eSLsb2Lw' (errno=Operation not permitted) +2026-09-17 12:52:27.304 xcodebuild[41735:99752074] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:27.429 xcodebuild[41735:99752071] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +diff --git a/test_cli.py b/test_cli.py +new file mode 100644 +index 0000000..0793491 +--- /dev/null ++++ b/test_cli.py +@@ -0,0 +1,30 @@ ++import io ++import tempfile ++import unittest ++from contextlib import redirect_stderr, redirect_stdout ++from pathlib import Path ++ ++from wordstat.cli import main ++ ++ ++class CliTests(unittest.TestCase): ++ def test_main_prints_report_for_file(self): ++ with tempfile.TemporaryDirectory() as directory: ++ path = Path(directory) / "sample.txt" ++ path.write_text("the quick\nbrown fox\n", encoding="utf-8") ++ stdout = io.StringIO() ++ ++ with redirect_stdout(stdout): ++ result = main([str(path)]) ++ ++ self.assertEqual(result, 0) ++ self.assertEqual(stdout.getvalue(), "words: 4\nlines: 2\nchars: 20\n") ++ ++ def test_main_returns_one_for_missing_file(self): ++ stderr = io.StringIO() ++ ++ with redirect_stderr(stderr): ++ result = main(["/no/such/file"]) ++ ++ self.assertEqual(result, 1) ++ self.assertTrue(stderr.getvalue()) +diff --git a/test_counter.py b/test_counter.py +new file mode 100644 +index 0000000..341b8df +--- /dev/null ++++ b/test_counter.py +@@ -0,0 +1,18 @@ ++import unittest ++ ++from wordstat.counter import count_chars, count_lines, count_words ++ ++ ++class CounterTests(unittest.TestCase): ++ def test_count_words(self): ++ self.assertEqual(count_words("the quick brown fox"), 4) ++ self.assertEqual(count_words(""), 0) ++ ++ def test_count_lines(self): ++ self.assertEqual(count_lines("a\nb"), 2) ++ self.assertEqual(count_lines("a\nb\n"), 2) ++ self.assertEqual(count_lines(""), 0) ++ ++ def test_count_chars(self): ++ self.assertEqual(count_chars("abc"), 3) ++ self.assertEqual(count_chars("a b"), 3) +diff --git a/test_formatter.py b/test_formatter.py +new file mode 100644 +index 0000000..925b36e +--- /dev/null ++++ b/test_formatter.py +@@ -0,0 +1,11 @@ ++import unittest ++ ++from wordstat.formatter import format_report ++ ++ ++class FormatterTests(unittest.TestCase): ++ def test_format_report(self): ++ self.assertEqual( ++ format_report({"words": 12, "lines": 3, "chars": 57}), ++ "words: 12\nlines: 3\nchars: 57", ++ ) +diff --git a/wordstat/cli.py b/wordstat/cli.py +new file mode 100644 +index 0000000..ec622c3 +--- /dev/null ++++ b/wordstat/cli.py +@@ -0,0 +1,27 @@ ++"""Command-line entry point for wordstat.""" ++ ++import argparse ++import sys ++ ++from . import counter, formatter ++ ++ ++def main(argv): ++ parser = argparse.ArgumentParser(description="Report statistics for a text file.") ++ parser.add_argument("path") ++ args = parser.parse_args(argv) ++ ++ try: ++ with open(args.path, encoding="utf-8") as source: ++ text = source.read() ++ except OSError as error: ++ print(f"wordstat: {error}", file=sys.stderr) ++ return 1 ++ ++ stats = { ++ "words": counter.count_words(text), ++ "lines": counter.count_lines(text), ++ "chars": counter.count_chars(text), ++ } ++ print(formatter.format_report(stats)) ++ return 0 +diff --git a/wordstat/counter.py b/wordstat/counter.py +new file mode 100644 +index 0000000..2153d0a +--- /dev/null ++++ b/wordstat/counter.py +@@ -0,0 +1,13 @@ ++"""Pure text statistics.""" ++ ++ ++def count_words(text): ++ return len(text.split()) ++ ++ ++def count_lines(text): ++ return len(text.splitlines()) ++ ++ ++def count_chars(text): ++ return len(text) +diff --git a/wordstat/formatter.py b/wordstat/formatter.py +new file mode 100644 +index 0000000..d6db16c +--- /dev/null ++++ b/wordstat/formatter.py +@@ -0,0 +1,5 @@ ++"""Render text statistics as a human-readable report.""" ++ ++ ++def format_report(stats): ++ return "\n".join(f"{name}: {stats[name]}" for name in ("words", "lines", "chars")) + +exec +/bin/zsh -lc "rg --files --hidden -g '"'!.git'"'; cat plan.md design.md; ls -la wordstat; rg --files /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad -g AGENTS.md" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-1/repo + succeeded in 753ms: +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-6/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-6/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-6/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-6/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-6/repo/AGENTS.md + +exec +/bin/zsh -lc 'PYTHONDONTWRITEBYTECODE=1 python3 -m unittest -v; cat wordstat/__init__.py; git log --oneline 370a61dd7955dea6e5d90eb23de7d4237b5e8040..HEAD; nl -ba wordstat/cli.py' in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-1/repo + succeeded in 1304ms: +2026-09-17 12:52:41.189 xcodebuild[42042:99753085] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:41.308 xcodebuild[42042:99753084] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-8WyFbmmt' (errno=Operation not permitted) +2026-09-17 12:52:41.659 xcodebuild[42044:99753096] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:41.770 xcodebuild[42044:99753095] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +56ffbda tasks 1-3 + 1 """Command-line entry point for wordstat.""" + 2 + 3 import argparse + 4 import sys + 5 + 6 from . import counter, formatter + 7 + 8 + 9 def main(argv): + 10 parser = argparse.ArgumentParser(description="Report statistics for a text file.") + 11 parser.add_argument("path") + 12 args = parser.parse_args(argv) + 13 + 14 try: + 15 with open(args.path, encoding="utf-8") as source: + 16 text = source.read() + 17 except OSError as error: + 18 print(f"wordstat: {error}", file=sys.stderr) + 19 return 1 + 20 + 21 stats = { + 22 "words": counter.count_words(text), +# Design: `wordstat` — a tiny text-stats CLI + +A small Python package that computes simple statistics about a text file and +prints a human-readable report. + +## Package layout + +``` +wordstat/ + __init__.py # package marker (exists) + counter.py # pure stat functions (Task 1) + formatter.py # render a stats dict to a report string (Task 2) + cli.py # argparse entry point tying it together (Task 3) +test_counter.py # tests for counter (Task 1) +test_formatter.py # tests for formatter (Task 2) +test_cli.py # tests for cli (Task 3) +``` + +Tests live at the repo root and are runnable with `python3 -m unittest` +(stdlib only — no third-party test deps). + +## Behavior + +- `counter.count_words(text)` → int: number of whitespace-separated tokens. +- `counter.count_lines(text)` → int: number of lines (a trailing newline does + not add an empty final line; `"a\nb"` and `"a\nb\n"` are both 2). +- `counter.count_chars(text)` → int: number of characters including whitespace. +- `formatter.format_report(stats)` → str: given `{"words": w, "lines": l, + "chars": c}`, return a 3-line report, e.g. `"words: 12\nlines: 3\nchars: 57"`. +- `cli.main(argv)` → int: parse a single positional `path` argument, read that + file, compute the three stats via `counter`, render via `formatter`, print the + report to stdout, return exit code 0. Missing file → message to stderr, return 1. + +## Constraints + +- Standard library only. +- Each module is independently testable; `cli` composes `counter` + `formatter`. +# Implementation plan: `wordstat` + +Design context in `design.md`. Build the package with test-driven development: +for each task write the failing test first, then the implementation, then make it +pass, then commit. Standard library only; tests run with `python3 -m unittest`. + +## Task 1 — `wordstat/counter.py` + +Pure statistics functions. + +1. Write `test_counter.py` (failing) covering: + - `count_words("the quick brown fox")` == 4; `count_words("")` == 0. + - `count_lines("a\nb")` == 2; `count_lines("a\nb\n")` == 2; `count_lines("")` == 0. + - `count_chars("abc")` == 3; `count_chars("a b")` == 3. +2. Implement `wordstat/counter.py` with `count_words`, `count_lines`, + `count_chars`. +3. Run `python3 -m unittest test_counter` → green. Commit. + +## Task 2 — `wordstat/formatter.py` + +Render a stats dict to a report string. + +1. Write `test_formatter.py` (failing) covering: + - `format_report({"words": 12, "lines": 3, "chars": 57})` == + `"words: 12\nlines: 3\nchars: 57"`. +2. Implement `wordstat/formatter.py` with `format_report(stats)`. +3. Run `python3 -m unittest test_formatter` → green. Commit. + +## Task 3 — `wordstat/cli.py` + +Entry point composing the previous two modules. Read `wordstat/counter.py` and +`wordstat/formatter.py` to use their real function signatures. + +1. Write `test_cli.py` (failing) covering: + - `main([path])` for a temp file with known content prints the expected + report and returns 0. + - `main(["/no/such/file"])` returns 1. +2. Implement `wordstat/cli.py` with `main(argv)` using `argparse`, calling + `counter` then `formatter`. +3. Run `python3 -m unittest` (whole suite) → green. Commit. + +exec +/bin/zsh -lc 'wc -l REVIEW.md; tail -n 120 REVIEW.md; rg -n "Critical|Important|Minor|finding|Findings|P[0-3]|severity|Issue|issue|No findings" REVIEW.md' in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-regrade/E0-control-codex-6/repo + succeeded in 0ms: + 316 REVIEW.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-6/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-6/repo/AGENTS.md + +exec +/bin/zsh -lc 'PYTHONDONTWRITEBYTECODE=1 python3 -m unittest -v; cat wordstat/__init__.py; git log --oneline 370a61dd7955dea6e5d90eb23de7d4237b5e8040..HEAD; nl -ba wordstat/cli.py' in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-1/repo + succeeded in 1304ms: +2026-09-17 12:52:41.189 xcodebuild[42042:99753085] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:41.308 xcodebuild[42042:99753084] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-8WyFbmmt' (errno=Operation not permitted) +2026-09-17 12:52:41.659 xcodebuild[42044:99753096] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:41.770 xcodebuild[42044:99753095] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +56ffbda tasks 1-3 + 1 """Command-line entry point for wordstat.""" + 2 + 3 import argparse + 4 import sys + 5 + 6 from . import counter, formatter + 7 + 8 + 9 def main(argv): + 10 parser = argparse.ArgumentParser(description="Report statistics for a text file.") + 11 parser.add_argument("path") + 12 args = parser.parse_args(argv) + 13 + 14 try: + 15 with open(args.path, encoding="utf-8") as source: + 16 text = source.read() + 17 except OSError as error: + 18 print(f"wordstat: {error}", file=sys.stderr) + 19 return 1 + 20 + 21 stats = { + 22 "words": counter.count_words(text), + 23 "lines": counter.count_lines(text), + 24 "chars": counter.count_chars(text), + 25 } + 26 print(formatter.format_report(stats)) + 27 return 0 + +The implementation covers the plan’s explicit test cases. Five tests passed; the file-based CLI test was blocked because the read-only sandbox cannot create temporary files. I’m checking command-line invocation and newline handling separately. The branch also has one combined commit rather than the plan’s three task commits. +exec +/bin/zsh -lc "PYTHONDONTWRITEBYTECODE=1 python3 -c 'import subprocess, sys, io; from contextlib import redirect_stdout; from wordstat.cli import main; from pathlib import Path +p = subprocess.run([sys.executable, \"-B\", \"-m\", \"wordstat.cli\", \"design.md\"], capture_output=True, text=True) +print(\"Module invocation:\", p.returncode, repr(p.stdout), repr(p.stderr)) +for content in [b\"a\\r\\nb\\r\\n\", b\"\\xff\"]: + p = subprocess.run([sys.executable, \"-B\", \"-c\", \"from wordstat.cli import main; import sys; sys.exit(main([\\\"/dev/stdin\\\"]))\"], input=content, capture_output=True) + print(\"Input:\", repr(content), \"exit:\", p.returncode, \"stdout:\", repr(p.stdout), \"stderr:\", p.stderr.decode()) +text = Path(\"wordstat/__init__.py\").read_text(); out = io.StringIO() +with redirect_stdout(out): + code = main([\"wordstat/__init__.py\"]) +expected = \"words: {}\\nlines: {}\\nchars: {}\\n\".format(len(text.split()), len(text.splitlines()), len(text)) +assert code == 0 and out.getvalue() == expected +print(\"Existing-file integration check: PASS\")'" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-1/repo + succeeded in 0ms: +Module invocation: 0 '' '' +Input: b'a\r\nb\r\n' exit: 0 stdout: b'words: 2\nlines: 2\nchars: 4\n' stderr: +Input: b'\xff' exit: 1 stdout: b'' stderr: Traceback (most recent call last): + File "", line 1, in + from wordstat.cli import main; import sys; sys.exit(main(["/dev/stdin"])) + ~~~~^^^^^^^^^^^^^^^^ + File "/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-1/repo/wordstat/cli.py", line 16, in main + text = source.read() + File "", line 325, in decode +UnicodeDecodeError: 'utf-8' codec can't decode byte 0xff in position 0: invalid start byte + +Existing-file integration check: PASS + +### Strengths + +- Clear separation of pure counters, formatting, and CLI orchestration; standard library only. +- All explicit planned test cases are implemented, including exact report formatting and missing-file handling. +- Five tests passed, and a separate integration check using an existing file passed. The remaining test was blocked by the sandbox’s inability to create temporary files. + +### Issues + +#### Critical (Must Fix) + +None found. + +#### Important (Should Fix) + +1. **File reading changes the character count** + - **File:** `wordstat/cli.py:15` + - Default universal-newline translation converts CRLF into LF before counting. Input `a\r\nb\r\n` reports 4 characters instead of 6, contrary to counting characters including whitespace. + - Open with `newline=""` to preserve the original characters, and add a CRLF integration test. + +2. **No executable CLI entry point** + - **File:** `wordstat/cli.py:27` + - `python3 -m wordstat.cli design.md` exits successfully without printing anything because nothing invokes `main`. + - Add `if __name__ == "__main__": sys.exit(main(sys.argv[1:]))` and a subprocess test. + - This is a **plan gap**: the explicit `main(argv)` contract is implemented, but the plan omits wiring it into a runnable command despite describing a CLI. + +#### Minor (Nice to Have) + +1. **Invalid UTF-8 produces an unhandled traceback** + - **File:** `wordstat/cli.py:16` + - `UnicodeDecodeError` escapes the `OSError` handler. Invalid input therefore raises instead of returning a controlled failure. + - Document UTF-8 input and catch `UnicodeDecodeError`, printing a concise stderr message and returning 1. The spec currently leaves encoding-error behavior unspecified. + +### Recommendations + +- Add regression tests for preserved newline characters and actual command-line execution. +- History contains one combined commit (`56ffbda`), rather than the planned per-task commits. Test-first sequencing cannot be verified from that history; no history rewrite is necessary. + +### Assessment + +**Ready to merge? With fixes** + +**Reasoning:** The implementation closely follows the explicit function-level plan, but newline normalization causes incorrect character totals and the advertised CLI lacks executable wiring. Full-suite success remains unverified because one test requires temporary-file access unavailable in this sandbox. +281:### Issues +283:#### Critical (Must Fix) +287:#### Important (Should Fix) +300:#### Minor (Nice to Have) + +## Fix pass + +- Important: Preserve CRLF characters when reading files so `count_chars` reports the actual character count. +- Important: Add executable module wiring so `python3 -m wordstat.cli ` invokes `main`. + +## Deferred minors + +- Final: minor (deferred): Handle invalid UTF-8 with a controlled error instead of an unhandled traceback. diff --git a/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E1-specific-claude-1.md b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E1-specific-claude-1.md new file mode 100644 index 000000000..2f7ee505a --- /dev/null +++ b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E1-specific-claude-1.md @@ -0,0 +1,9 @@ +## Fix pass + +- **Important** — `wordstat/cli.py:15`: universal-newline translation collapses CRLF to LF before counting, so `count_chars` under-reports on valid CRLF input (`a\r\nb\r\n` → 4 instead of 6), contradicting "characters including whitespace"; open with `newline=""` and add a CRLF regression test. +- **Important** (re-graded from Minor) — `wordstat/cli.py:16`: `UnicodeDecodeError` escapes the `OSError` handler, so a non-UTF-8 file produces an unhandled traceback to the user instead of a stderr message and exit 1. Unhandled exception reaching the user overrides the "spec doesn't specify encoding errors" excuse. +- **Important** — `wordstat/cli.py:27`: no `if __name__ == "__main__"` wiring, so `python3 -m wordstat.cli ` exits 0 printing nothing; the design advertises a CLI, so the runnable command is in scope even though plan.md omitted the wiring. + +## Deferred minors + +- None. (The only Minor filed was re-graded into the fix pass; the one-commit-instead-of-three history note in Recommendations is not a code finding and needs no action per the reviewer.) diff --git a/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E1-specific-claude-2.md b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E1-specific-claude-2.md new file mode 100644 index 000000000..5a8826f2a --- /dev/null +++ b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E1-specific-claude-2.md @@ -0,0 +1,9 @@ +## Fix pass + +1. **Important** — `wordstat/cli.py:15`: universal-newline translation collapses CRLF to LF before counting, so `count_chars` reports 4 instead of 6 for `a\r\nb\r\n` — a wrong result on valid input, against the spec's "characters including whitespace". Open with `newline=""` plus a CRLF regression test. +2. **Important** (re-graded from Minor) — `wordstat/cli.py:16`: invalid UTF-8 input raises an uncaught `UnicodeDecodeError`, so a traceback reaches the user instead of the controlled stderr message + exit 1 that the missing-file path already models. Unhandled exception on real input outranks the reviewer's Minor label and the spec's silence on encoding errors. +3. **Important** — `wordstat/cli.py:27`: no `if __name__ == "__main__"` wiring, so `python3 -m wordstat.cli ` exits 0 printing nothing; the design advertises a CLI that is not runnable. Add the guard and a subprocess test. + +## Deferred minors + +- None. diff --git a/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E1-specific-claude-3.md b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E1-specific-claude-3.md new file mode 100644 index 000000000..356ac5683 --- /dev/null +++ b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E1-specific-claude-3.md @@ -0,0 +1,8 @@ +## Fix pass + +- **Important** — `wordstat/cli.py:16` — Invalid UTF-8 input raises an unhandled `UnicodeDecodeError` that escapes the `OSError` handler and dumps a traceback to the user instead of a stderr message and exit 1 (reviewer labeled Minor; re-graded up — unhandled exception, traceback reaching the user). +- **Important** — `wordstat/cli.py:15` — Universal-newline translation makes `count_chars` wrong on valid input: CRLF file `a\r\nb\r\n` reports `chars: 4` instead of 6, contradicting design.md's "characters including whitespace"; open with `newline=""` and add a CRLF regression test. + +## Deferred minors + +- `wordstat/cli.py:27` — No `if __name__ == "__main__"` wiring, so `python3 -m wordstat.cli PATH` prints nothing; the specified `main(argv)` contract is fully met and design.md's package layout lists no `__main__.py` or console-script entry point, so this is a spec addition rather than a defect. diff --git a/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E1-specific-claude-4.md b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E1-specific-claude-4.md new file mode 100644 index 000000000..29eb0b4f5 --- /dev/null +++ b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E1-specific-claude-4.md @@ -0,0 +1,9 @@ +## Fix pass + +- **Important** (as labeled) — `wordstat/cli.py:15`: universal-newline translation collapses CRLF to LF before counting, so `count_chars` reports a wrong result on valid input (`a\r\nb\r\n` → 4 instead of 6); open with `newline=""` and add a CRLF test. +- **Important** (regraded up from Minor) — `wordstat/cli.py:16`: invalid UTF-8 input raises `UnicodeDecodeError`, escaping the `OSError` handler and putting a traceback in front of the user; catch it, print a concise stderr message, return 1. Spec silence on encoding does not make an unhandled crash minor. +- **Important** (as labeled) — `wordstat/cli.py:27`: no `if __name__ == "__main__"` wiring, so `python3 -m wordstat.cli ` silently exits 0 printing nothing despite the design shipping a CLI; add the guard calling `sys.exit(main(sys.argv[1:]))` plus a subprocess test. + +## Deferred minors + +- Branch has one combined commit (`56ffbda`) instead of the plan's three per-task commits, so test-first sequencing isn't verifiable from history; reviewer confirms no rewrite needed. diff --git a/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E1-specific-claude-5.md b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E1-specific-claude-5.md new file mode 100644 index 000000000..0ead87cb6 --- /dev/null +++ b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E1-specific-claude-5.md @@ -0,0 +1,9 @@ +## Fix pass + +- **Important** — `wordstat/cli.py:16`: invalid UTF-8 input raises an unhandled `UnicodeDecodeError`, dumping a traceback to the user instead of the spec'd "message to stderr, return 1" failure path (re-graded up from reviewer's Minor: an unhandled exception reaching the user is Important regardless of the spec's silence on encoding). +- **Important** — `wordstat/cli.py:15`: universal-newline translation shrinks the character count (CRLF input `a\r\nb\r\n` reports `chars: 4`, not 6), a wrong result on valid input versus "number of characters including whitespace"; open with `newline=""` and add a CRLF test. +- **Important** — `wordstat/cli.py:27`: no `__main__` wiring, so `python3 -m wordstat.cli ` exits 0 printing nothing — the advertised CLI silently does nothing when invoked as a command (plan gap; spec describes a CLI). + +## Deferred minors + +- None. diff --git a/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E1-specific-claude-6.md b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E1-specific-claude-6.md new file mode 100644 index 000000000..f5ff3b7ef --- /dev/null +++ b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E1-specific-claude-6.md @@ -0,0 +1,9 @@ +## Fix pass + +- **Important** — `wordstat/cli.py:15` — universal-newline translation collapses CRLF to LF, so `count_chars` under-reports on valid CRLF input (`a\r\nb\r\n` → 4 instead of 6), contradicting "characters including whitespace"; open with `newline=""` plus a CRLF test. +- **Important** (re-graded from Minor) — `wordstat/cli.py:16` — `UnicodeDecodeError` escapes the `OSError` handler, so a non-UTF-8 file produces an unhandled traceback to the user instead of a stderr message and exit 1; spec silence on encoding does not downgrade a crash. +- **Important** — `wordstat/cli.py:27` — no `if __name__ == "__main__"` wiring, so `python3 -m wordstat.cli ` exits 0 printing nothing; the design advertises a CLI entry point (plan gap). + +## Deferred minors + +- None. diff --git a/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E1-specific-codex-1.md b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E1-specific-codex-1.md new file mode 100644 index 000000000..e2280dba9 --- /dev/null +++ b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E1-specific-codex-1.md @@ -0,0 +1,9 @@ +## Fix pass + +- **Important:** File reading normalizes CRLF, producing incorrect character counts on valid input. +- **Important:** The CLI module has no executable entry point, so `python3 -m wordstat.cli` prints nothing. +- **Important:** Invalid UTF-8 causes an unhandled `UnicodeDecodeError` traceback. + +## Deferred minors + +- None. diff --git a/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E1-specific-codex-2.md b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E1-specific-codex-2.md new file mode 100644 index 000000000..a41eb7257 --- /dev/null +++ b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E1-specific-codex-2.md @@ -0,0 +1,9 @@ +## Fix pass + +- Important — File reading changes CRLF character counts. +- Important — No executable CLI entry point. +- Important — Invalid UTF-8 produces an unhandled traceback. + +## Deferred minors + +- None. diff --git a/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E1-specific-codex-3.md b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E1-specific-codex-3.md new file mode 100644 index 000000000..6d04ca8b4 --- /dev/null +++ b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E1-specific-codex-3.md @@ -0,0 +1,9 @@ +## Fix pass + +- Important: File reading normalizes CRLF to LF, producing an incorrect character count. +- Important: The module lacks executable CLI wiring, so `python3 -m wordstat.cli` prints nothing. +- Important: Invalid UTF-8 causes an unhandled `UnicodeDecodeError` traceback. + +## Deferred minors + +- None. diff --git a/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E1-specific-codex-4.md b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E1-specific-codex-4.md new file mode 100644 index 000000000..5e5cbec4e --- /dev/null +++ b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E1-specific-codex-4.md @@ -0,0 +1,9 @@ +## Fix pass + +- Important: File reading normalizes CRLF to LF, producing an incorrect character count. +- Important: The module has no executable CLI entry point, so `python3 -m wordstat.cli` prints nothing. +- Important: Invalid UTF-8 causes an unhandled `UnicodeDecodeError` traceback. + +## Deferred minors + +- None. diff --git a/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E1-specific-codex-5.md b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E1-specific-codex-5.md new file mode 100644 index 000000000..7dafb9f83 --- /dev/null +++ b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E1-specific-codex-5.md @@ -0,0 +1,464 @@ +I’m reading the review and spec now, then I’ll re-grade every finding before sorting it. +exec +/bin/zsh -lc "sed -n '1,240p' REVIEW.md && sed -n '1,260p' design.md && sed -n '1,220p' plan.md" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-regrade/E1-specific-codex-5/repo + succeeded in 0ms: +I’ll read the plan and spec, inspect the full branch diff, and run the tests without modifying the checkout. +exec +/bin/zsh -lc "pwd; rg --files -g 'AGENTS.md' -g 'plan.md' -g 'design.md' -g '*test*' -g 'pyproject.toml'; git status --short; git diff --stat 370a61dd7955dea6e5d90eb23de7d4237b5e8040..HEAD; git diff 370a61dd7955dea6e5d90eb23de7d4237b5e8040..HEAD" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-1/repo + succeeded in 5526ms: +2026-09-17 12:52:23.171 xcodebuild[41625:99751471] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:23.416 xcodebuild[41625:99751470] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-u1PW3j30' (errno=Operation not permitted) +2026-09-17 12:52:23.900 xcodebuild[41638:99751551] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:24.017 xcodebuild[41638:99751548] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-BLEvcaXE' (errno=Operation not permitted) +2026-09-17 12:52:25.053 xcodebuild[41672:99751726] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:25.179 xcodebuild[41672:99751724] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-dzz7tRr4' (errno=Operation not permitted) +2026-09-17 12:52:25.611 xcodebuild[41691:99751817] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:25.738 xcodebuild[41691:99751814] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. + test_cli.py | 30 ++++++++++++++++++++++++++++++ + test_counter.py | 18 ++++++++++++++++++ + test_formatter.py | 11 +++++++++++ + wordstat/cli.py | 27 +++++++++++++++++++++++++++ + wordstat/counter.py | 13 +++++++++++++ + wordstat/formatter.py | 5 +++++ + 6 files changed, 104 insertions(+) +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-9qOEALii' (errno=Operation not permitted) +2026-09-17 12:52:26.719 xcodebuild[41719:99751985] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:26.859 xcodebuild[41719:99751983] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-eSLsb2Lw' (errno=Operation not permitted) +2026-09-17 12:52:27.304 xcodebuild[41735:99752074] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:27.429 xcodebuild[41735:99752071] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +diff --git a/test_cli.py b/test_cli.py +new file mode 100644 +index 0000000..0793491 +--- /dev/null ++++ b/test_cli.py +@@ -0,0 +1,30 @@ ++import io ++import tempfile ++import unittest ++from contextlib import redirect_stderr, redirect_stdout ++from pathlib import Path ++ ++from wordstat.cli import main ++ ++ ++class CliTests(unittest.TestCase): ++ def test_main_prints_report_for_file(self): ++ with tempfile.TemporaryDirectory() as directory: ++ path = Path(directory) / "sample.txt" ++ path.write_text("the quick\nbrown fox\n", encoding="utf-8") ++ stdout = io.StringIO() ++ ++ with redirect_stdout(stdout): ++ result = main([str(path)]) ++ ++ self.assertEqual(result, 0) ++ self.assertEqual(stdout.getvalue(), "words: 4\nlines: 2\nchars: 20\n") ++ ++ def test_main_returns_one_for_missing_file(self): ++ stderr = io.StringIO() ++ ++ with redirect_stderr(stderr): ++ result = main(["/no/such/file"]) ++ ++ self.assertEqual(result, 1) ++ self.assertTrue(stderr.getvalue()) +diff --git a/test_counter.py b/test_counter.py +new file mode 100644 +index 0000000..341b8df +--- /dev/null ++++ b/test_counter.py +@@ -0,0 +1,18 @@ ++import unittest ++ ++from wordstat.counter import count_chars, count_lines, count_words ++ ++ ++class CounterTests(unittest.TestCase): ++ def test_count_words(self): ++ self.assertEqual(count_words("the quick brown fox"), 4) ++ self.assertEqual(count_words(""), 0) ++ ++ def test_count_lines(self): ++ self.assertEqual(count_lines("a\nb"), 2) ++ self.assertEqual(count_lines("a\nb\n"), 2) ++ self.assertEqual(count_lines(""), 0) ++ ++ def test_count_chars(self): ++ self.assertEqual(count_chars("abc"), 3) ++ self.assertEqual(count_chars("a b"), 3) +diff --git a/test_formatter.py b/test_formatter.py +new file mode 100644 +index 0000000..925b36e +--- /dev/null ++++ b/test_formatter.py +@@ -0,0 +1,11 @@ ++import unittest ++ ++from wordstat.formatter import format_report ++ ++ ++class FormatterTests(unittest.TestCase): ++ def test_format_report(self): ++ self.assertEqual( ++ format_report({"words": 12, "lines": 3, "chars": 57}), ++ "words: 12\nlines: 3\nchars: 57", ++ ) +diff --git a/wordstat/cli.py b/wordstat/cli.py +new file mode 100644 +index 0000000..ec622c3 +--- /dev/null ++++ b/wordstat/cli.py +@@ -0,0 +1,27 @@ ++"""Command-line entry point for wordstat.""" ++ ++import argparse ++import sys ++ ++from . import counter, formatter ++ ++ ++def main(argv): ++ parser = argparse.ArgumentParser(description="Report statistics for a text file.") ++ parser.add_argument("path") ++ args = parser.parse_args(argv) ++ ++ try: ++ with open(args.path, encoding="utf-8") as source: ++ text = source.read() ++ except OSError as error: ++ print(f"wordstat: {error}", file=sys.stderr) ++ return 1 ++ ++ stats = { ++ "words": counter.count_words(text), ++ "lines": counter.count_lines(text), ++ "chars": counter.count_chars(text), ++ } ++ print(formatter.format_report(stats)) ++ return 0 +diff --git a/wordstat/counter.py b/wordstat/counter.py +new file mode 100644 +index 0000000..2153d0a +--- /dev/null ++++ b/wordstat/counter.py +@@ -0,0 +1,13 @@ ++"""Pure text statistics.""" ++ ++ ++def count_words(text): ++ return len(text.split()) ++ ++ ++def count_lines(text): ++ return len(text.splitlines()) ++ ++ ++def count_chars(text): ++ return len(text) +diff --git a/wordstat/formatter.py b/wordstat/formatter.py +new file mode 100644 +index 0000000..d6db16c +--- /dev/null ++++ b/wordstat/formatter.py +@@ -0,0 +1,5 @@ ++"""Render text statistics as a human-readable report.""" ++ ++ ++def format_report(stats): ++ return "\n".join(f"{name}: {stats[name]}" for name in ("words", "lines", "chars")) + +exec +/bin/zsh -lc "rg --files --hidden -g '"'!.git'"'; cat plan.md design.md; ls -la wordstat; rg --files /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad -g AGENTS.md" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-1/repo + succeeded in 753ms: +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-6/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-6/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-6/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-6/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-6/repo/AGENTS.md + +exec +/bin/zsh -lc 'PYTHONDONTWRITEBYTECODE=1 python3 -m unittest -v; cat wordstat/__init__.py; git log --oneline 370a61dd7955dea6e5d90eb23de7d4237b5e8040..HEAD; nl -ba wordstat/cli.py' in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-1/repo + succeeded in 1304ms: +2026-09-17 12:52:41.189 xcodebuild[42042:99753085] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:41.308 xcodebuild[42042:99753084] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-8WyFbmmt' (errno=Operation not permitted) +2026-09-17 12:52:41.659 xcodebuild[42044:99753096] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:41.770 xcodebuild[42044:99753095] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +56ffbda tasks 1-3 + 1 """Command-line entry point for wordstat.""" + 2 + 3 import argparse + 4 import sys + 5 + 6 from . import counter, formatter + 7 + 8 + 9 def main(argv): + 10 parser = argparse.ArgumentParser(description="Report statistics for a text file.") + 11 parser.add_argument("path") + 12 args = parser.parse_args(argv) + 13 + 14 try: + 15 with open(args.path, encoding="utf-8") as source: + 16 text = source.read() + 17 except OSError as error: + 18 print(f"wordstat: {error}", file=sys.stderr) + 19 return 1 + 20 + 21 stats = { + 22 "words": counter.count_words(text), +# Design: `wordstat` — a tiny text-stats CLI + +A small Python package that computes simple statistics about a text file and +prints a human-readable report. + +## Package layout + +``` +wordstat/ + __init__.py # package marker (exists) + counter.py # pure stat functions (Task 1) + formatter.py # render a stats dict to a report string (Task 2) + cli.py # argparse entry point tying it together (Task 3) +test_counter.py # tests for counter (Task 1) +test_formatter.py # tests for formatter (Task 2) +test_cli.py # tests for cli (Task 3) +``` + +Tests live at the repo root and are runnable with `python3 -m unittest` +(stdlib only — no third-party test deps). + +## Behavior + +- `counter.count_words(text)` → int: number of whitespace-separated tokens. +- `counter.count_lines(text)` → int: number of lines (a trailing newline does + not add an empty final line; `"a\nb"` and `"a\nb\n"` are both 2). +- `counter.count_chars(text)` → int: number of characters including whitespace. +- `formatter.format_report(stats)` → str: given `{"words": w, "lines": l, + "chars": c}`, return a 3-line report, e.g. `"words: 12\nlines: 3\nchars: 57"`. +- `cli.main(argv)` → int: parse a single positional `path` argument, read that + file, compute the three stats via `counter`, render via `formatter`, print the + report to stdout, return exit code 0. Missing file → message to stderr, return 1. + +## Constraints + +- Standard library only. +- Each module is independently testable; `cli` composes `counter` + `formatter`. +# Implementation plan: `wordstat` + +Design context in `design.md`. Build the package with test-driven development: +for each task write the failing test first, then the implementation, then make it +pass, then commit. Standard library only; tests run with `python3 -m unittest`. + +## Task 1 — `wordstat/counter.py` + +Pure statistics functions. + +1. Write `test_counter.py` (failing) covering: + - `count_words("the quick brown fox")` == 4; `count_words("")` == 0. + - `count_lines("a\nb")` == 2; `count_lines("a\nb\n")` == 2; `count_lines("")` == 0. + - `count_chars("abc")` == 3; `count_chars("a b")` == 3. +2. Implement `wordstat/counter.py` with `count_words`, `count_lines`, + `count_chars`. +3. Run `python3 -m unittest test_counter` → green. Commit. + +## Task 2 — `wordstat/formatter.py` + +Render a stats dict to a report string. + +1. Write `test_formatter.py` (failing) covering: + - `format_report({"words": 12, "lines": 3, "chars": 57})` == + `"words: 12\nlines: 3\nchars: 57"`. +2. Implement `wordstat/formatter.py` with `format_report(stats)`. +3. Run `python3 -m unittest test_formatter` → green. Commit. + +## Task 3 — `wordstat/cli.py` + +Entry point composing the previous two modules. Read `wordstat/counter.py` and +`wordstat/formatter.py` to use their real function signatures. + +1. Write `test_cli.py` (failing) covering: + - `main([path])` for a temp file with known content prints the expected + report and returns 0. + - `main(["/no/such/file"])` returns 1. +2. Implement `wordstat/cli.py` with `main(argv)` using `argparse`, calling + `counter` then `formatter`. +3. Run `python3 -m unittest` (whole suite) → green. Commit. + +exec +/bin/zsh -lc 'rg -n "Critical|Important|Minor|finding|Findings|Overall|Unicode|traceback|exception|decode" REVIEW.md; tail -n 120 REVIEW.md' in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-regrade/E1-specific-codex-5/repo + succeeded in 0ms: +254: print(\"Input:\", repr(content), \"exit:\", p.returncode, \"stdout:\", repr(p.stdout), \"stderr:\", p.stderr.decode()) +270: File "", line 325, in decode +271:UnicodeDecodeError: 'utf-8' codec can't decode byte 0xff in position 0: invalid start byte +283:#### Critical (Must Fix) +287:#### Important (Should Fix) +300:#### Minor (Nice to Have) +302:1. **Invalid UTF-8 produces an unhandled traceback** +304: - `UnicodeDecodeError` escapes the `OSError` handler. Invalid input therefore raises instead of returning a controlled failure. +305: - Document UTF-8 input and catch `UnicodeDecodeError`, printing a concise stderr message and returning 1. The spec currently leaves encoding-error behavior unspecified. +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-6/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-6/repo/AGENTS.md + +exec +/bin/zsh -lc 'PYTHONDONTWRITEBYTECODE=1 python3 -m unittest -v; cat wordstat/__init__.py; git log --oneline 370a61dd7955dea6e5d90eb23de7d4237b5e8040..HEAD; nl -ba wordstat/cli.py' in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-1/repo + succeeded in 1304ms: +2026-09-17 12:52:41.189 xcodebuild[42042:99753085] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:41.308 xcodebuild[42042:99753084] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-8WyFbmmt' (errno=Operation not permitted) +2026-09-17 12:52:41.659 xcodebuild[42044:99753096] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:41.770 xcodebuild[42044:99753095] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +56ffbda tasks 1-3 + 1 """Command-line entry point for wordstat.""" + 2 + 3 import argparse + 4 import sys + 5 + 6 from . import counter, formatter + 7 + 8 + 9 def main(argv): + 10 parser = argparse.ArgumentParser(description="Report statistics for a text file.") + 11 parser.add_argument("path") + 12 args = parser.parse_args(argv) + 13 + 14 try: + 15 with open(args.path, encoding="utf-8") as source: + 16 text = source.read() + 17 except OSError as error: + 18 print(f"wordstat: {error}", file=sys.stderr) + 19 return 1 + 20 + 21 stats = { + 22 "words": counter.count_words(text), + 23 "lines": counter.count_lines(text), + 24 "chars": counter.count_chars(text), + 25 } + 26 print(formatter.format_report(stats)) + 27 return 0 + +The implementation covers the plan’s explicit test cases. Five tests passed; the file-based CLI test was blocked because the read-only sandbox cannot create temporary files. I’m checking command-line invocation and newline handling separately. The branch also has one combined commit rather than the plan’s three task commits. +exec +/bin/zsh -lc "PYTHONDONTWRITEBYTECODE=1 python3 -c 'import subprocess, sys, io; from contextlib import redirect_stdout; from wordstat.cli import main; from pathlib import Path +p = subprocess.run([sys.executable, \"-B\", \"-m\", \"wordstat.cli\", \"design.md\"], capture_output=True, text=True) +print(\"Module invocation:\", p.returncode, repr(p.stdout), repr(p.stderr)) +for content in [b\"a\\r\\nb\\r\\n\", b\"\\xff\"]: + p = subprocess.run([sys.executable, \"-B\", \"-c\", \"from wordstat.cli import main; import sys; sys.exit(main([\\\"/dev/stdin\\\"]))\"], input=content, capture_output=True) + print(\"Input:\", repr(content), \"exit:\", p.returncode, \"stdout:\", repr(p.stdout), \"stderr:\", p.stderr.decode()) +text = Path(\"wordstat/__init__.py\").read_text(); out = io.StringIO() +with redirect_stdout(out): + code = main([\"wordstat/__init__.py\"]) +expected = \"words: {}\\nlines: {}\\nchars: {}\\n\".format(len(text.split()), len(text.splitlines()), len(text)) +assert code == 0 and out.getvalue() == expected +print(\"Existing-file integration check: PASS\")'" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-1/repo + succeeded in 0ms: +Module invocation: 0 '' '' +Input: b'a\r\nb\r\n' exit: 0 stdout: b'words: 2\nlines: 2\nchars: 4\n' stderr: +Input: b'\xff' exit: 1 stdout: b'' stderr: Traceback (most recent call last): + File "", line 1, in + from wordstat.cli import main; import sys; sys.exit(main(["/dev/stdin"])) + ~~~~^^^^^^^^^^^^^^^^ + File "/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-1/repo/wordstat/cli.py", line 16, in main + text = source.read() + File "", line 325, in decode +UnicodeDecodeError: 'utf-8' codec can't decode byte 0xff in position 0: invalid start byte + +Existing-file integration check: PASS + +### Strengths + +- Clear separation of pure counters, formatting, and CLI orchestration; standard library only. +- All explicit planned test cases are implemented, including exact report formatting and missing-file handling. +- Five tests passed, and a separate integration check using an existing file passed. The remaining test was blocked by the sandbox’s inability to create temporary files. + +### Issues + +#### Critical (Must Fix) + +None found. + +#### Important (Should Fix) + +1. **File reading changes the character count** + - **File:** `wordstat/cli.py:15` + - Default universal-newline translation converts CRLF into LF before counting. Input `a\r\nb\r\n` reports 4 characters instead of 6, contrary to counting characters including whitespace. + - Open with `newline=""` to preserve the original characters, and add a CRLF integration test. + +2. **No executable CLI entry point** + - **File:** `wordstat/cli.py:27` + - `python3 -m wordstat.cli design.md` exits successfully without printing anything because nothing invokes `main`. + - Add `if __name__ == "__main__": sys.exit(main(sys.argv[1:]))` and a subprocess test. + - This is a **plan gap**: the explicit `main(argv)` contract is implemented, but the plan omits wiring it into a runnable command despite describing a CLI. + +#### Minor (Nice to Have) + +1. **Invalid UTF-8 produces an unhandled traceback** + - **File:** `wordstat/cli.py:16` + - `UnicodeDecodeError` escapes the `OSError` handler. Invalid input therefore raises instead of returning a controlled failure. + - Document UTF-8 input and catch `UnicodeDecodeError`, printing a concise stderr message and returning 1. The spec currently leaves encoding-error behavior unspecified. + +### Recommendations + +- Add regression tests for preserved newline characters and actual command-line execution. +- History contains one combined commit (`56ffbda`), rather than the planned per-task commits. Test-first sequencing cannot be verified from that history; no history rewrite is necessary. + +### Assessment + +**Ready to merge? With fixes** + +**Reasoning:** The implementation closely follows the explicit function-level plan, but newline normalization causes incorrect character totals and the advertised CLI lacks executable wiring. Full-suite success remains unverified because one test requires temporary-file access unavailable in this sandbox. + +## Fix pass + +- **Important:** File reading normalizes CRLF newlines, producing an incorrect character count. +- **Important:** The CLI lacks executable `python3 -m wordstat.cli` wiring. +- **Important:** Invalid UTF-8 produces an unhandled exception and user-visible traceback. + +## Deferred minors + +- None. diff --git a/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E1-specific-codex-6.md b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E1-specific-codex-6.md new file mode 100644 index 000000000..4c9c58672 --- /dev/null +++ b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E1-specific-codex-6.md @@ -0,0 +1,9 @@ +## Fix pass + +- Important — File reading changes the character count for CRLF input. +- Important — No executable CLI entry point invokes `main`. +- Important — Invalid UTF-8 produces an unhandled traceback. + +## Deferred minors + +- None. diff --git a/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E2-general-claude-1.md b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E2-general-claude-1.md new file mode 100644 index 000000000..d3767d397 --- /dev/null +++ b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E2-general-claude-1.md @@ -0,0 +1,9 @@ +## Fix pass + +- **Important** — `wordstat/cli.py:15`: universal-newline translation silently drops `\r`, so `chars` undercounts CRLF files (`a\r\nb\r\n` → 4 instead of 6); open with `newline=""` and add a CRLF regression test. +- **Important** — `wordstat/cli.py:16`: `UnicodeDecodeError` escapes the `OSError` handler, so pointing the tool at a non-UTF-8 or binary file dumps a raw traceback instead of the specified stderr message + exit 1 — re-graded up from the reviewer's Minor, which graded the spec's silence on encoding rather than the crash a real user hits. +- **Important** — `wordstat/cli.py:27`: no `if __name__ == "__main__"` wiring, so `python3 -m wordstat.cli FILE` exits 0 printing nothing — the advertised CLI silently no-ops; add the entry point and a subprocess test. + +## Deferred minors + +- Final: minor (deferred): branch history is one combined commit (`56ffbda`) instead of the plan's three per-task commits, so test-first sequencing isn't verifiable — reviewer confirms no history rewrite is warranted. diff --git a/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E2-general-claude-2.md b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E2-general-claude-2.md new file mode 100644 index 000000000..90396f0f8 --- /dev/null +++ b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E2-general-claude-2.md @@ -0,0 +1,9 @@ +## Fix pass + +- **Critical** — `wordstat/cli.py:27`: no `__main__` wiring, so `python3 -m wordstat.cli ` exits 0 and prints nothing — the advertised CLI cannot be run from a terminal at all, and fails silently rather than erroring. +- **Important** — `wordstat/cli.py:15`: universal-newline translation drops `\r` before counting, so any CRLF file silently reports a too-low `chars` total (`a\r\nb\r\n` → 4 instead of 6), contradicting "characters including whitespace". +- **Important** — `wordstat/cli.py:16`: `UnicodeDecodeError` escapes the `OSError` handler, so a non-UTF-8 or binary file produces a raw Python traceback instead of the clean stderr message + exit 1 the missing-file path establishes as the contract (re-graded up from Minor: the reviewer discounted it because the spec is silent on encoding, but the effect on a user is a crash on an ordinary input file). + +## Deferred minors + +- Final: minor (deferred): branch has one combined commit (`56ffbda`) instead of the plan's three per-task commits, so test-first sequencing isn't visible in history — no user-facing effect, no rewrite warranted. diff --git a/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E2-general-claude-3.md b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E2-general-claude-3.md new file mode 100644 index 000000000..315f63cef --- /dev/null +++ b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E2-general-claude-3.md @@ -0,0 +1,9 @@ +## Fix pass + +- **Critical** — No executable entry point (`wordstat/cli.py:27`): `python3 -m wordstat.cli ` exits 0 printing nothing, so the tool's sole advertised interface silently does nothing for anyone who installs it; needs `if __name__ == "__main__": sys.exit(main(sys.argv[1:]))` plus a subprocess test. +- **Important** — Universal-newline translation corrupts the character count (`wordstat/cli.py:15`): any CRLF file reports one fewer char per line (`a\r\nb\r\n` → 4, not 6), contradicting "characters including whitespace"; open with `newline=""` and add a CRLF test. +- **Important** (reviewer: Minor) — Non-UTF-8 input raises an unhandled `UnicodeDecodeError` traceback (`wordstat/cli.py:16`): pointing the CLI at a latin-1 or binary file crashes with a stack trace instead of the controlled stderr-message-and-return-1 path that already exists next to it; the spec's silence on encoding doesn't change what the user sees. + +## Deferred minors + +- None. diff --git a/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E2-general-claude-4.md b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E2-general-claude-4.md new file mode 100644 index 000000000..adba25933 --- /dev/null +++ b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E2-general-claude-4.md @@ -0,0 +1,9 @@ +## Fix pass + +- **Critical** — `wordstat/cli.py:27` — No `if __name__ == "__main__"` block: `python3 -m wordstat.cli FILE` exits 0 and prints nothing, so the advertised CLI cannot be run at all and fails silently rather than visibly. (Reviewer: Important; upgraded — the product's only user-facing surface is inoperable.) +- **Important** — `wordstat/cli.py:15` — Universal-newline translation collapses CRLF to LF before counting, so any Windows-authored file reports a silently wrong `chars` total (`a\r\nb\r\n` → 4, not 6), contradicting "characters including whitespace." +- **Important** — `wordstat/cli.py:16` — `UnicodeDecodeError` escapes the `OSError` handler, so pointing wordstat at a latin-1 or binary file emits a raw traceback instead of the designed stderr message + exit 1. (Reviewer: Minor because the spec is silent on encoding errors — that grades the spec, not the effect; an uncaught crash on plausible real input is the same class of failure the spec already handles for missing files.) + +## Deferred minors + +- `Final: minor (deferred): branch has one combined commit (56ffbda) instead of the plan's three per-task commits, so test-first sequencing isn't verifiable from history; no effect on shipped behavior and no rewrite warranted.` diff --git a/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E2-general-claude-5.md b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E2-general-claude-5.md new file mode 100644 index 000000000..44dbf6f12 --- /dev/null +++ b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E2-general-claude-5.md @@ -0,0 +1,9 @@ +## Fix pass + +- **Critical** — `wordstat/cli.py:27` — No `__main__` wiring: `python3 -m wordstat.cli FILE` exits 0 printing nothing, so the shipped "CLI" silently does nothing on its only shell invocation path; a text-stats tool that produces no output when run is a total failure of purpose, not a spec omission. +- **Important** — `wordstat/cli.py:15` — Universal-newline translation silently undercounts `chars` on CRLF files (`a\r\nb\r\n` → 4 instead of 6); a counting tool reporting wrong numbers with no error is a correctness defect regardless of the spec's silence on newline handling. +- **Important** — `wordstat/cli.py:16` — `UnicodeDecodeError` escapes the `OSError` handler, so any non-UTF-8 input (legacy Latin-1 text, a binary file) yields a raw Python traceback instead of the module's own `wordstat: ` line and controlled exit 1. Re-graded up from the reviewer's Minor: the reviewer graded the spec's silence on encoding, not the user-visible crash. + +## Deferred minors + +- None. diff --git a/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E2-general-claude-6.md b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E2-general-claude-6.md new file mode 100644 index 000000000..71f9cf227 --- /dev/null +++ b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E2-general-claude-6.md @@ -0,0 +1,9 @@ +## Fix pass + +- **Critical** — `wordstat/cli.py:27`: no `__main__` wiring, so `python3 -m wordstat.cli FILE` exits 0 printing nothing — the shipped "CLI" cannot be run from a command line at all, and its failure mode is a silent success rather than an error. +- **Important** — `wordstat/cli.py:15`: default universal-newline translation collapses CRLF to LF before counting, so any Windows-authored file reports a silently wrong `chars` total (`a\r\nb\r\n` → 4 instead of 6); fix with `newline=""` plus a CRLF regression test. + +## Deferred minors + +- Final: minor (deferred): `UnicodeDecodeError` escapes the `OSError` handler in `wordstat/cli.py:16`, so a non-UTF-8 file prints a traceback instead of `wordstat: ` — the user is still correctly told it failed with a nonzero exit, so this is presentation only. +- Final: minor (deferred): branch history is one combined commit (`56ffbda`) instead of the plan's three per-task commits, so test-first sequencing isn't verifiable from history; no user-visible effect and no rewrite warranted. diff --git a/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E2-general-codex-1.md b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E2-general-codex-1.md new file mode 100644 index 000000000..88de9d347 --- /dev/null +++ b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E2-general-codex-1.md @@ -0,0 +1,9 @@ +## Fix pass + +- **Important:** CRLF newline normalization produces incorrect character counts. +- **Important:** The package has no executable CLI entry point. +- **Important:** Non-UTF-8 text files crash with an unhandled traceback. + +## Deferred minors + +- None. diff --git a/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E2-general-codex-2.md b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E2-general-codex-2.md new file mode 100644 index 000000000..140a012fa --- /dev/null +++ b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E2-general-codex-2.md @@ -0,0 +1,9 @@ +## Fix pass + +- **Important:** Preserve CRLF characters so character counts reflect the file’s actual contents. +- **Important:** Add executable module wiring so `python3 -m wordstat.cli ` runs the CLI. +- **Important:** Handle invalid UTF-8 with a controlled error instead of an unhandled traceback. + +## Deferred minors + +- None. diff --git a/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E2-general-codex-3.md b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E2-general-codex-3.md new file mode 100644 index 000000000..c575ecd28 --- /dev/null +++ b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E2-general-codex-3.md @@ -0,0 +1,495 @@ +I’ll re-grade every review finding by user-visible effect, without modifying the repository. +exec +/bin/zsh -lc "sed -n '1,240p' REVIEW.md && sed -n '1,260p' design.md && sed -n '1,240p' plan.md" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-regrade/E2-general-codex-3/repo + succeeded in 0ms: +I’ll read the plan and spec, inspect the full branch diff, and run the tests without modifying the checkout. +exec +/bin/zsh -lc "pwd; rg --files -g 'AGENTS.md' -g 'plan.md' -g 'design.md' -g '*test*' -g 'pyproject.toml'; git status --short; git diff --stat 370a61dd7955dea6e5d90eb23de7d4237b5e8040..HEAD; git diff 370a61dd7955dea6e5d90eb23de7d4237b5e8040..HEAD" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-1/repo + succeeded in 5526ms: +2026-09-17 12:52:23.171 xcodebuild[41625:99751471] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:23.416 xcodebuild[41625:99751470] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-u1PW3j30' (errno=Operation not permitted) +2026-09-17 12:52:23.900 xcodebuild[41638:99751551] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:24.017 xcodebuild[41638:99751548] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-BLEvcaXE' (errno=Operation not permitted) +2026-09-17 12:52:25.053 xcodebuild[41672:99751726] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:25.179 xcodebuild[41672:99751724] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-dzz7tRr4' (errno=Operation not permitted) +2026-09-17 12:52:25.611 xcodebuild[41691:99751817] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:25.738 xcodebuild[41691:99751814] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. + test_cli.py | 30 ++++++++++++++++++++++++++++++ + test_counter.py | 18 ++++++++++++++++++ + test_formatter.py | 11 +++++++++++ + wordstat/cli.py | 27 +++++++++++++++++++++++++++ + wordstat/counter.py | 13 +++++++++++++ + wordstat/formatter.py | 5 +++++ + 6 files changed, 104 insertions(+) +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-9qOEALii' (errno=Operation not permitted) +2026-09-17 12:52:26.719 xcodebuild[41719:99751985] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:26.859 xcodebuild[41719:99751983] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-eSLsb2Lw' (errno=Operation not permitted) +2026-09-17 12:52:27.304 xcodebuild[41735:99752074] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:27.429 xcodebuild[41735:99752071] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +diff --git a/test_cli.py b/test_cli.py +new file mode 100644 +index 0000000..0793491 +--- /dev/null ++++ b/test_cli.py +@@ -0,0 +1,30 @@ ++import io ++import tempfile ++import unittest ++from contextlib import redirect_stderr, redirect_stdout ++from pathlib import Path ++ ++from wordstat.cli import main ++ ++ ++class CliTests(unittest.TestCase): ++ def test_main_prints_report_for_file(self): ++ with tempfile.TemporaryDirectory() as directory: ++ path = Path(directory) / "sample.txt" ++ path.write_text("the quick\nbrown fox\n", encoding="utf-8") ++ stdout = io.StringIO() ++ ++ with redirect_stdout(stdout): ++ result = main([str(path)]) ++ ++ self.assertEqual(result, 0) ++ self.assertEqual(stdout.getvalue(), "words: 4\nlines: 2\nchars: 20\n") ++ ++ def test_main_returns_one_for_missing_file(self): ++ stderr = io.StringIO() ++ ++ with redirect_stderr(stderr): ++ result = main(["/no/such/file"]) ++ ++ self.assertEqual(result, 1) ++ self.assertTrue(stderr.getvalue()) +diff --git a/test_counter.py b/test_counter.py +new file mode 100644 +index 0000000..341b8df +--- /dev/null ++++ b/test_counter.py +@@ -0,0 +1,18 @@ ++import unittest ++ ++from wordstat.counter import count_chars, count_lines, count_words ++ ++ ++class CounterTests(unittest.TestCase): ++ def test_count_words(self): ++ self.assertEqual(count_words("the quick brown fox"), 4) ++ self.assertEqual(count_words(""), 0) ++ ++ def test_count_lines(self): ++ self.assertEqual(count_lines("a\nb"), 2) ++ self.assertEqual(count_lines("a\nb\n"), 2) ++ self.assertEqual(count_lines(""), 0) ++ ++ def test_count_chars(self): ++ self.assertEqual(count_chars("abc"), 3) ++ self.assertEqual(count_chars("a b"), 3) +diff --git a/test_formatter.py b/test_formatter.py +new file mode 100644 +index 0000000..925b36e +--- /dev/null ++++ b/test_formatter.py +@@ -0,0 +1,11 @@ ++import unittest ++ ++from wordstat.formatter import format_report ++ ++ ++class FormatterTests(unittest.TestCase): ++ def test_format_report(self): ++ self.assertEqual( ++ format_report({"words": 12, "lines": 3, "chars": 57}), ++ "words: 12\nlines: 3\nchars: 57", ++ ) +diff --git a/wordstat/cli.py b/wordstat/cli.py +new file mode 100644 +index 0000000..ec622c3 +--- /dev/null ++++ b/wordstat/cli.py +@@ -0,0 +1,27 @@ ++"""Command-line entry point for wordstat.""" ++ ++import argparse ++import sys ++ ++from . import counter, formatter ++ ++ ++def main(argv): ++ parser = argparse.ArgumentParser(description="Report statistics for a text file.") ++ parser.add_argument("path") ++ args = parser.parse_args(argv) ++ ++ try: ++ with open(args.path, encoding="utf-8") as source: ++ text = source.read() ++ except OSError as error: ++ print(f"wordstat: {error}", file=sys.stderr) ++ return 1 ++ ++ stats = { ++ "words": counter.count_words(text), ++ "lines": counter.count_lines(text), ++ "chars": counter.count_chars(text), ++ } ++ print(formatter.format_report(stats)) ++ return 0 +diff --git a/wordstat/counter.py b/wordstat/counter.py +new file mode 100644 +index 0000000..2153d0a +--- /dev/null ++++ b/wordstat/counter.py +@@ -0,0 +1,13 @@ ++"""Pure text statistics.""" ++ ++ ++def count_words(text): ++ return len(text.split()) ++ ++ ++def count_lines(text): ++ return len(text.splitlines()) ++ ++ ++def count_chars(text): ++ return len(text) +diff --git a/wordstat/formatter.py b/wordstat/formatter.py +new file mode 100644 +index 0000000..d6db16c +--- /dev/null ++++ b/wordstat/formatter.py +@@ -0,0 +1,5 @@ ++"""Render text statistics as a human-readable report.""" ++ ++ ++def format_report(stats): ++ return "\n".join(f"{name}: {stats[name]}" for name in ("words", "lines", "chars")) + +exec +/bin/zsh -lc "rg --files --hidden -g '"'!.git'"'; cat plan.md design.md; ls -la wordstat; rg --files /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad -g AGENTS.md" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-1/repo + succeeded in 753ms: +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-6/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-6/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-6/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-6/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-6/repo/AGENTS.md + +exec +/bin/zsh -lc 'PYTHONDONTWRITEBYTECODE=1 python3 -m unittest -v; cat wordstat/__init__.py; git log --oneline 370a61dd7955dea6e5d90eb23de7d4237b5e8040..HEAD; nl -ba wordstat/cli.py' in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-1/repo + succeeded in 1304ms: +2026-09-17 12:52:41.189 xcodebuild[42042:99753085] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:41.308 xcodebuild[42042:99753084] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-8WyFbmmt' (errno=Operation not permitted) +2026-09-17 12:52:41.659 xcodebuild[42044:99753096] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:41.770 xcodebuild[42044:99753095] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +56ffbda tasks 1-3 + 1 """Command-line entry point for wordstat.""" + 2 + 3 import argparse + 4 import sys + 5 + 6 from . import counter, formatter + 7 + 8 + 9 def main(argv): + 10 parser = argparse.ArgumentParser(description="Report statistics for a text file.") + 11 parser.add_argument("path") + 12 args = parser.parse_args(argv) + 13 + 14 try: + 15 with open(args.path, encoding="utf-8") as source: + 16 text = source.read() + 17 except OSError as error: + 18 print(f"wordstat: {error}", file=sys.stderr) + 19 return 1 + 20 + 21 stats = { + 22 "words": counter.count_words(text), +# Design: `wordstat` — a tiny text-stats CLI + +A small Python package that computes simple statistics about a text file and +prints a human-readable report. + +## Package layout + +``` +wordstat/ + __init__.py # package marker (exists) + counter.py # pure stat functions (Task 1) + formatter.py # render a stats dict to a report string (Task 2) + cli.py # argparse entry point tying it together (Task 3) +test_counter.py # tests for counter (Task 1) +test_formatter.py # tests for formatter (Task 2) +test_cli.py # tests for cli (Task 3) +``` + +Tests live at the repo root and are runnable with `python3 -m unittest` +(stdlib only — no third-party test deps). + +## Behavior + +- `counter.count_words(text)` → int: number of whitespace-separated tokens. +- `counter.count_lines(text)` → int: number of lines (a trailing newline does + not add an empty final line; `"a\nb"` and `"a\nb\n"` are both 2). +- `counter.count_chars(text)` → int: number of characters including whitespace. +- `formatter.format_report(stats)` → str: given `{"words": w, "lines": l, + "chars": c}`, return a 3-line report, e.g. `"words: 12\nlines: 3\nchars: 57"`. +- `cli.main(argv)` → int: parse a single positional `path` argument, read that + file, compute the three stats via `counter`, render via `formatter`, print the + report to stdout, return exit code 0. Missing file → message to stderr, return 1. + +## Constraints + +- Standard library only. +- Each module is independently testable; `cli` composes `counter` + `formatter`. +# Implementation plan: `wordstat` + +Design context in `design.md`. Build the package with test-driven development: +for each task write the failing test first, then the implementation, then make it +pass, then commit. Standard library only; tests run with `python3 -m unittest`. + +## Task 1 — `wordstat/counter.py` + +Pure statistics functions. + +1. Write `test_counter.py` (failing) covering: + - `count_words("the quick brown fox")` == 4; `count_words("")` == 0. + - `count_lines("a\nb")` == 2; `count_lines("a\nb\n")` == 2; `count_lines("")` == 0. + - `count_chars("abc")` == 3; `count_chars("a b")` == 3. +2. Implement `wordstat/counter.py` with `count_words`, `count_lines`, + `count_chars`. +3. Run `python3 -m unittest test_counter` → green. Commit. + +## Task 2 — `wordstat/formatter.py` + +Render a stats dict to a report string. + +1. Write `test_formatter.py` (failing) covering: + - `format_report({"words": 12, "lines": 3, "chars": 57})` == + `"words: 12\nlines: 3\nchars: 57"`. +2. Implement `wordstat/formatter.py` with `format_report(stats)`. +3. Run `python3 -m unittest test_formatter` → green. Commit. + +## Task 3 — `wordstat/cli.py` + +Entry point composing the previous two modules. Read `wordstat/counter.py` and +`wordstat/formatter.py` to use their real function signatures. + +1. Write `test_cli.py` (failing) covering: + - `main([path])` for a temp file with known content prints the expected + report and returns 0. + - `main(["/no/such/file"])` returns 1. +2. Implement `wordstat/cli.py` with `main(argv)` using `argparse`, calling + `counter` then `formatter`. +3. Run `python3 -m unittest` (whole suite) → green. Commit. + +exec +/bin/zsh -lc 'tail -n 160 REVIEW.md' in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-regrade/E2-general-codex-3/repo + succeeded in 0ms: ++def count_lines(text): ++ return len(text.splitlines()) ++ ++ ++def count_chars(text): ++ return len(text) +diff --git a/wordstat/formatter.py b/wordstat/formatter.py +new file mode 100644 +index 0000000..d6db16c +--- /dev/null ++++ b/wordstat/formatter.py +@@ -0,0 +1,5 @@ ++"""Render text statistics as a human-readable report.""" ++ ++ ++def format_report(stats): ++ return "\n".join(f"{name}: {stats[name]}" for name in ("words", "lines", "chars")) + +exec +/bin/zsh -lc "rg --files --hidden -g '"'!.git'"'; cat plan.md design.md; ls -la wordstat; rg --files /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad -g AGENTS.md" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-1/repo + succeeded in 753ms: +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-6/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-6/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-6/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-6/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-6/repo/AGENTS.md + +exec +/bin/zsh -lc 'PYTHONDONTWRITEBYTECODE=1 python3 -m unittest -v; cat wordstat/__init__.py; git log --oneline 370a61dd7955dea6e5d90eb23de7d4237b5e8040..HEAD; nl -ba wordstat/cli.py' in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-1/repo + succeeded in 1304ms: +2026-09-17 12:52:41.189 xcodebuild[42042:99753085] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:41.308 xcodebuild[42042:99753084] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-8WyFbmmt' (errno=Operation not permitted) +2026-09-17 12:52:41.659 xcodebuild[42044:99753096] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:41.770 xcodebuild[42044:99753095] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +56ffbda tasks 1-3 + 1 """Command-line entry point for wordstat.""" + 2 + 3 import argparse + 4 import sys + 5 + 6 from . import counter, formatter + 7 + 8 + 9 def main(argv): + 10 parser = argparse.ArgumentParser(description="Report statistics for a text file.") + 11 parser.add_argument("path") + 12 args = parser.parse_args(argv) + 13 + 14 try: + 15 with open(args.path, encoding="utf-8") as source: + 16 text = source.read() + 17 except OSError as error: + 18 print(f"wordstat: {error}", file=sys.stderr) + 19 return 1 + 20 + 21 stats = { + 22 "words": counter.count_words(text), + 23 "lines": counter.count_lines(text), + 24 "chars": counter.count_chars(text), + 25 } + 26 print(formatter.format_report(stats)) + 27 return 0 + +The implementation covers the plan’s explicit test cases. Five tests passed; the file-based CLI test was blocked because the read-only sandbox cannot create temporary files. I’m checking command-line invocation and newline handling separately. The branch also has one combined commit rather than the plan’s three task commits. +exec +/bin/zsh -lc "PYTHONDONTWRITEBYTECODE=1 python3 -c 'import subprocess, sys, io; from contextlib import redirect_stdout; from wordstat.cli import main; from pathlib import Path +p = subprocess.run([sys.executable, \"-B\", \"-m\", \"wordstat.cli\", \"design.md\"], capture_output=True, text=True) +print(\"Module invocation:\", p.returncode, repr(p.stdout), repr(p.stderr)) +for content in [b\"a\\r\\nb\\r\\n\", b\"\\xff\"]: + p = subprocess.run([sys.executable, \"-B\", \"-c\", \"from wordstat.cli import main; import sys; sys.exit(main([\\\"/dev/stdin\\\"]))\"], input=content, capture_output=True) + print(\"Input:\", repr(content), \"exit:\", p.returncode, \"stdout:\", repr(p.stdout), \"stderr:\", p.stderr.decode()) +text = Path(\"wordstat/__init__.py\").read_text(); out = io.StringIO() +with redirect_stdout(out): + code = main([\"wordstat/__init__.py\"]) +expected = \"words: {}\\nlines: {}\\nchars: {}\\n\".format(len(text.split()), len(text.splitlines()), len(text)) +assert code == 0 and out.getvalue() == expected +print(\"Existing-file integration check: PASS\")'" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-1/repo + succeeded in 0ms: +Module invocation: 0 '' '' +Input: b'a\r\nb\r\n' exit: 0 stdout: b'words: 2\nlines: 2\nchars: 4\n' stderr: +Input: b'\xff' exit: 1 stdout: b'' stderr: Traceback (most recent call last): + File "", line 1, in + from wordstat.cli import main; import sys; sys.exit(main(["/dev/stdin"])) + ~~~~^^^^^^^^^^^^^^^^ + File "/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-1/repo/wordstat/cli.py", line 16, in main + text = source.read() + File "", line 325, in decode +UnicodeDecodeError: 'utf-8' codec can't decode byte 0xff in position 0: invalid start byte + +Existing-file integration check: PASS + +### Strengths + +- Clear separation of pure counters, formatting, and CLI orchestration; standard library only. +- All explicit planned test cases are implemented, including exact report formatting and missing-file handling. +- Five tests passed, and a separate integration check using an existing file passed. The remaining test was blocked by the sandbox’s inability to create temporary files. + +### Issues + +#### Critical (Must Fix) + +None found. + +#### Important (Should Fix) + +1. **File reading changes the character count** + - **File:** `wordstat/cli.py:15` + - Default universal-newline translation converts CRLF into LF before counting. Input `a\r\nb\r\n` reports 4 characters instead of 6, contrary to counting characters including whitespace. + - Open with `newline=""` to preserve the original characters, and add a CRLF integration test. + +2. **No executable CLI entry point** + - **File:** `wordstat/cli.py:27` + - `python3 -m wordstat.cli design.md` exits successfully without printing anything because nothing invokes `main`. + - Add `if __name__ == "__main__": sys.exit(main(sys.argv[1:]))` and a subprocess test. + - This is a **plan gap**: the explicit `main(argv)` contract is implemented, but the plan omits wiring it into a runnable command despite describing a CLI. + +#### Minor (Nice to Have) + +1. **Invalid UTF-8 produces an unhandled traceback** + - **File:** `wordstat/cli.py:16` + - `UnicodeDecodeError` escapes the `OSError` handler. Invalid input therefore raises instead of returning a controlled failure. + - Document UTF-8 input and catch `UnicodeDecodeError`, printing a concise stderr message and returning 1. The spec currently leaves encoding-error behavior unspecified. + +### Recommendations + +- Add regression tests for preserved newline characters and actual command-line execution. +- History contains one combined commit (`56ffbda`), rather than the planned per-task commits. Test-first sequencing cannot be verified from that history; no history rewrite is necessary. + +### Assessment + +**Ready to merge? With fixes** + +**Reasoning:** The implementation closely follows the explicit function-level plan, but newline normalization causes incorrect character totals and the advertised CLI lacks executable wiring. Full-suite success remains unverified because one test requires temporary-file access unavailable in this sandbox. + +## Fix pass + +- **Important:** CRLF newline translation causes incorrect character counts. +- **Important:** The package has no executable CLI entry point. +- **Important:** Invalid UTF-8 input crashes with an unhandled traceback. + +## Deferred minors + +- None. diff --git a/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E2-general-codex-4.md b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E2-general-codex-4.md new file mode 100644 index 000000000..fcf443c0d --- /dev/null +++ b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E2-general-codex-4.md @@ -0,0 +1,9 @@ +## Fix pass + +- **Important:** CRLF newline translation causes incorrect character counts. +- **Important:** The CLI module has no executable entry point. +- **Important:** Invalid UTF-8 crashes with an unhandled traceback instead of a controlled error. + +## Deferred minors + +- None. diff --git a/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E2-general-codex-5.md b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E2-general-codex-5.md new file mode 100644 index 000000000..02df72410 --- /dev/null +++ b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E2-general-codex-5.md @@ -0,0 +1,482 @@ +I’m reading the review against the design and implemented branch, then I’ll re-grade strictly by shipped user impact. +exec +/bin/zsh -lc "sed -n '1,240p' REVIEW.md && sed -n '1,260p' design.md && sed -n '1,220p' plan.md" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-regrade/E2-general-codex-5/repo + succeeded in 0ms: +I’ll read the plan and spec, inspect the full branch diff, and run the tests without modifying the checkout. +exec +/bin/zsh -lc "pwd; rg --files -g 'AGENTS.md' -g 'plan.md' -g 'design.md' -g '*test*' -g 'pyproject.toml'; git status --short; git diff --stat 370a61dd7955dea6e5d90eb23de7d4237b5e8040..HEAD; git diff 370a61dd7955dea6e5d90eb23de7d4237b5e8040..HEAD" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-1/repo + succeeded in 5526ms: +2026-09-17 12:52:23.171 xcodebuild[41625:99751471] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:23.416 xcodebuild[41625:99751470] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-u1PW3j30' (errno=Operation not permitted) +2026-09-17 12:52:23.900 xcodebuild[41638:99751551] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:24.017 xcodebuild[41638:99751548] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-BLEvcaXE' (errno=Operation not permitted) +2026-09-17 12:52:25.053 xcodebuild[41672:99751726] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:25.179 xcodebuild[41672:99751724] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-dzz7tRr4' (errno=Operation not permitted) +2026-09-17 12:52:25.611 xcodebuild[41691:99751817] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:25.738 xcodebuild[41691:99751814] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. + test_cli.py | 30 ++++++++++++++++++++++++++++++ + test_counter.py | 18 ++++++++++++++++++ + test_formatter.py | 11 +++++++++++ + wordstat/cli.py | 27 +++++++++++++++++++++++++++ + wordstat/counter.py | 13 +++++++++++++ + wordstat/formatter.py | 5 +++++ + 6 files changed, 104 insertions(+) +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-9qOEALii' (errno=Operation not permitted) +2026-09-17 12:52:26.719 xcodebuild[41719:99751985] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:26.859 xcodebuild[41719:99751983] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-eSLsb2Lw' (errno=Operation not permitted) +2026-09-17 12:52:27.304 xcodebuild[41735:99752074] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:27.429 xcodebuild[41735:99752071] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +diff --git a/test_cli.py b/test_cli.py +new file mode 100644 +index 0000000..0793491 +--- /dev/null ++++ b/test_cli.py +@@ -0,0 +1,30 @@ ++import io ++import tempfile ++import unittest ++from contextlib import redirect_stderr, redirect_stdout ++from pathlib import Path ++ ++from wordstat.cli import main ++ ++ ++class CliTests(unittest.TestCase): ++ def test_main_prints_report_for_file(self): ++ with tempfile.TemporaryDirectory() as directory: ++ path = Path(directory) / "sample.txt" ++ path.write_text("the quick\nbrown fox\n", encoding="utf-8") ++ stdout = io.StringIO() ++ ++ with redirect_stdout(stdout): ++ result = main([str(path)]) ++ ++ self.assertEqual(result, 0) ++ self.assertEqual(stdout.getvalue(), "words: 4\nlines: 2\nchars: 20\n") ++ ++ def test_main_returns_one_for_missing_file(self): ++ stderr = io.StringIO() ++ ++ with redirect_stderr(stderr): ++ result = main(["/no/such/file"]) ++ ++ self.assertEqual(result, 1) ++ self.assertTrue(stderr.getvalue()) +diff --git a/test_counter.py b/test_counter.py +new file mode 100644 +index 0000000..341b8df +--- /dev/null ++++ b/test_counter.py +@@ -0,0 +1,18 @@ ++import unittest ++ ++from wordstat.counter import count_chars, count_lines, count_words ++ ++ ++class CounterTests(unittest.TestCase): ++ def test_count_words(self): ++ self.assertEqual(count_words("the quick brown fox"), 4) ++ self.assertEqual(count_words(""), 0) ++ ++ def test_count_lines(self): ++ self.assertEqual(count_lines("a\nb"), 2) ++ self.assertEqual(count_lines("a\nb\n"), 2) ++ self.assertEqual(count_lines(""), 0) ++ ++ def test_count_chars(self): ++ self.assertEqual(count_chars("abc"), 3) ++ self.assertEqual(count_chars("a b"), 3) +diff --git a/test_formatter.py b/test_formatter.py +new file mode 100644 +index 0000000..925b36e +--- /dev/null ++++ b/test_formatter.py +@@ -0,0 +1,11 @@ ++import unittest ++ ++from wordstat.formatter import format_report ++ ++ ++class FormatterTests(unittest.TestCase): ++ def test_format_report(self): ++ self.assertEqual( ++ format_report({"words": 12, "lines": 3, "chars": 57}), ++ "words: 12\nlines: 3\nchars: 57", ++ ) +diff --git a/wordstat/cli.py b/wordstat/cli.py +new file mode 100644 +index 0000000..ec622c3 +--- /dev/null ++++ b/wordstat/cli.py +@@ -0,0 +1,27 @@ ++"""Command-line entry point for wordstat.""" ++ ++import argparse ++import sys ++ ++from . import counter, formatter ++ ++ ++def main(argv): ++ parser = argparse.ArgumentParser(description="Report statistics for a text file.") ++ parser.add_argument("path") ++ args = parser.parse_args(argv) ++ ++ try: ++ with open(args.path, encoding="utf-8") as source: ++ text = source.read() ++ except OSError as error: ++ print(f"wordstat: {error}", file=sys.stderr) ++ return 1 ++ ++ stats = { ++ "words": counter.count_words(text), ++ "lines": counter.count_lines(text), ++ "chars": counter.count_chars(text), ++ } ++ print(formatter.format_report(stats)) ++ return 0 +diff --git a/wordstat/counter.py b/wordstat/counter.py +new file mode 100644 +index 0000000..2153d0a +--- /dev/null ++++ b/wordstat/counter.py +@@ -0,0 +1,13 @@ ++"""Pure text statistics.""" ++ ++ ++def count_words(text): ++ return len(text.split()) ++ ++ ++def count_lines(text): ++ return len(text.splitlines()) ++ ++ ++def count_chars(text): ++ return len(text) +diff --git a/wordstat/formatter.py b/wordstat/formatter.py +new file mode 100644 +index 0000000..d6db16c +--- /dev/null ++++ b/wordstat/formatter.py +@@ -0,0 +1,5 @@ ++"""Render text statistics as a human-readable report.""" ++ ++ ++def format_report(stats): ++ return "\n".join(f"{name}: {stats[name]}" for name in ("words", "lines", "chars")) + +exec +/bin/zsh -lc "rg --files --hidden -g '"'!.git'"'; cat plan.md design.md; ls -la wordstat; rg --files /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad -g AGENTS.md" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-1/repo + succeeded in 753ms: +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-6/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-6/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-6/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-6/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-6/repo/AGENTS.md + +exec +/bin/zsh -lc 'PYTHONDONTWRITEBYTECODE=1 python3 -m unittest -v; cat wordstat/__init__.py; git log --oneline 370a61dd7955dea6e5d90eb23de7d4237b5e8040..HEAD; nl -ba wordstat/cli.py' in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-1/repo + succeeded in 1304ms: +2026-09-17 12:52:41.189 xcodebuild[42042:99753085] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:41.308 xcodebuild[42042:99753084] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-8WyFbmmt' (errno=Operation not permitted) +2026-09-17 12:52:41.659 xcodebuild[42044:99753096] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:41.770 xcodebuild[42044:99753095] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +56ffbda tasks 1-3 + 1 """Command-line entry point for wordstat.""" + 2 + 3 import argparse + 4 import sys + 5 + 6 from . import counter, formatter + 7 + 8 + 9 def main(argv): + 10 parser = argparse.ArgumentParser(description="Report statistics for a text file.") + 11 parser.add_argument("path") + 12 args = parser.parse_args(argv) + 13 + 14 try: + 15 with open(args.path, encoding="utf-8") as source: + 16 text = source.read() + 17 except OSError as error: + 18 print(f"wordstat: {error}", file=sys.stderr) + 19 return 1 + 20 + 21 stats = { + 22 "words": counter.count_words(text), +# Design: `wordstat` — a tiny text-stats CLI + +A small Python package that computes simple statistics about a text file and +prints a human-readable report. + +## Package layout + +``` +wordstat/ + __init__.py # package marker (exists) + counter.py # pure stat functions (Task 1) + formatter.py # render a stats dict to a report string (Task 2) + cli.py # argparse entry point tying it together (Task 3) +test_counter.py # tests for counter (Task 1) +test_formatter.py # tests for formatter (Task 2) +test_cli.py # tests for cli (Task 3) +``` + +Tests live at the repo root and are runnable with `python3 -m unittest` +(stdlib only — no third-party test deps). + +## Behavior + +- `counter.count_words(text)` → int: number of whitespace-separated tokens. +- `counter.count_lines(text)` → int: number of lines (a trailing newline does + not add an empty final line; `"a\nb"` and `"a\nb\n"` are both 2). +- `counter.count_chars(text)` → int: number of characters including whitespace. +- `formatter.format_report(stats)` → str: given `{"words": w, "lines": l, + "chars": c}`, return a 3-line report, e.g. `"words: 12\nlines: 3\nchars: 57"`. +- `cli.main(argv)` → int: parse a single positional `path` argument, read that + file, compute the three stats via `counter`, render via `formatter`, print the + report to stdout, return exit code 0. Missing file → message to stderr, return 1. + +## Constraints + +- Standard library only. +- Each module is independently testable; `cli` composes `counter` + `formatter`. +# Implementation plan: `wordstat` + +Design context in `design.md`. Build the package with test-driven development: +for each task write the failing test first, then the implementation, then make it +pass, then commit. Standard library only; tests run with `python3 -m unittest`. + +## Task 1 — `wordstat/counter.py` + +Pure statistics functions. + +1. Write `test_counter.py` (failing) covering: + - `count_words("the quick brown fox")` == 4; `count_words("")` == 0. + - `count_lines("a\nb")` == 2; `count_lines("a\nb\n")` == 2; `count_lines("")` == 0. + - `count_chars("abc")` == 3; `count_chars("a b")` == 3. +2. Implement `wordstat/counter.py` with `count_words`, `count_lines`, + `count_chars`. +3. Run `python3 -m unittest test_counter` → green. Commit. + +## Task 2 — `wordstat/formatter.py` + +Render a stats dict to a report string. + +1. Write `test_formatter.py` (failing) covering: + - `format_report({"words": 12, "lines": 3, "chars": 57})` == + `"words: 12\nlines: 3\nchars: 57"`. +2. Implement `wordstat/formatter.py` with `format_report(stats)`. +3. Run `python3 -m unittest test_formatter` → green. Commit. + +## Task 3 — `wordstat/cli.py` + +Entry point composing the previous two modules. Read `wordstat/counter.py` and +`wordstat/formatter.py` to use their real function signatures. + +1. Write `test_cli.py` (failing) covering: + - `main([path])` for a temp file with known content prints the expected + report and returns 0. + - `main(["/no/such/file"])` returns 1. +2. Implement `wordstat/cli.py` with `main(argv)` using `argparse`, calling + `counter` then `formatter`. +3. Run `python3 -m unittest` (whole suite) → green. Commit. + +exec +/bin/zsh -lc 'rg -n "''^(#|##|###|[-*] '"\\*\\*|Critical|Important|Minor|No findings|Findings)\" REVIEW.md; tail -n 140 REVIEW.md" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-regrade/E2-general-codex-5/repo + succeeded in 0ms: +275:### Strengths +281:### Issues +283:#### Critical (Must Fix) +287:#### Important (Should Fix) +300:#### Minor (Nice to Have) +307:### Recommendations +312:### Assessment + succeeded in 753ms: +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-6/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-6/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-6/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-6/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-6/repo/AGENTS.md + +exec +/bin/zsh -lc 'PYTHONDONTWRITEBYTECODE=1 python3 -m unittest -v; cat wordstat/__init__.py; git log --oneline 370a61dd7955dea6e5d90eb23de7d4237b5e8040..HEAD; nl -ba wordstat/cli.py' in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-1/repo + succeeded in 1304ms: +2026-09-17 12:52:41.189 xcodebuild[42042:99753085] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:41.308 xcodebuild[42042:99753084] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-8WyFbmmt' (errno=Operation not permitted) +2026-09-17 12:52:41.659 xcodebuild[42044:99753096] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:41.770 xcodebuild[42044:99753095] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +56ffbda tasks 1-3 + 1 """Command-line entry point for wordstat.""" + 2 + 3 import argparse + 4 import sys + 5 + 6 from . import counter, formatter + 7 + 8 + 9 def main(argv): + 10 parser = argparse.ArgumentParser(description="Report statistics for a text file.") + 11 parser.add_argument("path") + 12 args = parser.parse_args(argv) + 13 + 14 try: + 15 with open(args.path, encoding="utf-8") as source: + 16 text = source.read() + 17 except OSError as error: + 18 print(f"wordstat: {error}", file=sys.stderr) + 19 return 1 + 20 + 21 stats = { + 22 "words": counter.count_words(text), + 23 "lines": counter.count_lines(text), + 24 "chars": counter.count_chars(text), + 25 } + 26 print(formatter.format_report(stats)) + 27 return 0 + +The implementation covers the plan’s explicit test cases. Five tests passed; the file-based CLI test was blocked because the read-only sandbox cannot create temporary files. I’m checking command-line invocation and newline handling separately. The branch also has one combined commit rather than the plan’s three task commits. +exec +/bin/zsh -lc "PYTHONDONTWRITEBYTECODE=1 python3 -c 'import subprocess, sys, io; from contextlib import redirect_stdout; from wordstat.cli import main; from pathlib import Path +p = subprocess.run([sys.executable, \"-B\", \"-m\", \"wordstat.cli\", \"design.md\"], capture_output=True, text=True) +print(\"Module invocation:\", p.returncode, repr(p.stdout), repr(p.stderr)) +for content in [b\"a\\r\\nb\\r\\n\", b\"\\xff\"]: + p = subprocess.run([sys.executable, \"-B\", \"-c\", \"from wordstat.cli import main; import sys; sys.exit(main([\\\"/dev/stdin\\\"]))\"], input=content, capture_output=True) + print(\"Input:\", repr(content), \"exit:\", p.returncode, \"stdout:\", repr(p.stdout), \"stderr:\", p.stderr.decode()) +text = Path(\"wordstat/__init__.py\").read_text(); out = io.StringIO() +with redirect_stdout(out): + code = main([\"wordstat/__init__.py\"]) +expected = \"words: {}\\nlines: {}\\nchars: {}\\n\".format(len(text.split()), len(text.splitlines()), len(text)) +assert code == 0 and out.getvalue() == expected +print(\"Existing-file integration check: PASS\")'" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-1/repo + succeeded in 0ms: +Module invocation: 0 '' '' +Input: b'a\r\nb\r\n' exit: 0 stdout: b'words: 2\nlines: 2\nchars: 4\n' stderr: +Input: b'\xff' exit: 1 stdout: b'' stderr: Traceback (most recent call last): + File "", line 1, in + from wordstat.cli import main; import sys; sys.exit(main(["/dev/stdin"])) + ~~~~^^^^^^^^^^^^^^^^ + File "/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-1/repo/wordstat/cli.py", line 16, in main + text = source.read() + File "", line 325, in decode +UnicodeDecodeError: 'utf-8' codec can't decode byte 0xff in position 0: invalid start byte + +Existing-file integration check: PASS + +### Strengths + +- Clear separation of pure counters, formatting, and CLI orchestration; standard library only. +- All explicit planned test cases are implemented, including exact report formatting and missing-file handling. +- Five tests passed, and a separate integration check using an existing file passed. The remaining test was blocked by the sandbox’s inability to create temporary files. + +### Issues + +#### Critical (Must Fix) + +None found. + +#### Important (Should Fix) + +1. **File reading changes the character count** + - **File:** `wordstat/cli.py:15` + - Default universal-newline translation converts CRLF into LF before counting. Input `a\r\nb\r\n` reports 4 characters instead of 6, contrary to counting characters including whitespace. + - Open with `newline=""` to preserve the original characters, and add a CRLF integration test. + +2. **No executable CLI entry point** + - **File:** `wordstat/cli.py:27` + - `python3 -m wordstat.cli design.md` exits successfully without printing anything because nothing invokes `main`. + - Add `if __name__ == "__main__": sys.exit(main(sys.argv[1:]))` and a subprocess test. + - This is a **plan gap**: the explicit `main(argv)` contract is implemented, but the plan omits wiring it into a runnable command despite describing a CLI. + +#### Minor (Nice to Have) + +1. **Invalid UTF-8 produces an unhandled traceback** + - **File:** `wordstat/cli.py:16` + - `UnicodeDecodeError` escapes the `OSError` handler. Invalid input therefore raises instead of returning a controlled failure. + - Document UTF-8 input and catch `UnicodeDecodeError`, printing a concise stderr message and returning 1. The spec currently leaves encoding-error behavior unspecified. + +### Recommendations + +- Add regression tests for preserved newline characters and actual command-line execution. +- History contains one combined commit (`56ffbda`), rather than the planned per-task commits. Test-first sequencing cannot be verified from that history; no history rewrite is necessary. + +### Assessment + +**Ready to merge? With fixes** + +**Reasoning:** The implementation closely follows the explicit function-level plan, but newline normalization causes incorrect character totals and the advertised CLI lacks executable wiring. Full-suite success remains unverified because one test requires temporary-file access unavailable in this sandbox. + +## Fix pass + +- Important: CRLF normalization causes incorrect character counts. +- Important: The CLI has no executable entry point. +- Important: Invalid UTF-8 crashes with an unhandled traceback. + +## Deferred minors + +- None. diff --git a/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E2-general-codex-6.md b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E2-general-codex-6.md new file mode 100644 index 000000000..a6c98f8fb --- /dev/null +++ b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/E2-general-codex-6.md @@ -0,0 +1,9 @@ +## Fix pass + +- **Critical:** No executable CLI entry point; invoking `python3 -m wordstat.cli` silently produces no report. +- **Important:** Universal-newline translation makes character counts incorrect for CRLF files. +- **Important:** Invalid UTF-8 causes an unhandled traceback instead of a controlled CLI error. + +## Deferred minors + +- None. diff --git a/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/README.txt b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/README.txt new file mode 100644 index 000000000..a0bdaa5cc --- /dev/null +++ b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/README.txt @@ -0,0 +1,20 @@ +Executor re-grade micro-test. The executor gets a real gpt-6-astra review +of the wordstat branch (review.txt) that files the undecodable-input crash +as Minor, plus one wording of executing-plans' sorting rule, and must say +what enters the fix pass. Single-shot sessions: Opus 5 via Bedrock and +Codex gpt-5.6-sol at low effort, 6 reps each per arm. + + crash enters the fix pass +E0 no re-grade guidance 0/6 Opus 0/6 Codex +E1 current wording (names symptoms: 6/6 6/6 + unhandled exception, traceback, + data loss, wrong result) +E2 general wording (grade by the 5/6 6/6 + effect on a reasonable person, not + by whether the spec names the trigger) + +The baseline failure is real: with no rule both models defer the crash as +filed. The symptom list and the general standard both fix it; the general +form lost one Opus rep. The general form ships, because the symptom list +is the fixture's own defect written into the skill (teaching to the test); +the one miss is noted in the executing-plans rationalization table. diff --git a/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/summary.txt b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/summary.txt new file mode 100644 index 000000000..a13d2774d --- /dev/null +++ b/tests/inline-eval/microtests/executor-regrade/results-2026-09-17/summary.txt @@ -0,0 +1,37 @@ +rep fix-pass deferred +E0-control-claude-1 0 1 +E0-control-claude-2 0 1 +E0-control-claude-3 0 1 +E0-control-claude-4 0 1 +E0-control-claude-5 0 1 +E0-control-claude-6 0 1 +E0-control-codex-1 0 1 +E0-control-codex-2 0 1 +E0-control-codex-3 0 1 +E0-control-codex-4 0 1 +E0-control-codex-5 0 1 +E0-control-codex-6 0 1 +E1-specific-claude-1 1 0 +E1-specific-claude-2 1 0 +E1-specific-claude-3 1 0 +E1-specific-claude-4 1 0 +E1-specific-claude-5 1 0 +E1-specific-claude-6 1 0 +E1-specific-codex-1 1 0 +E1-specific-codex-2 1 0 +E1-specific-codex-3 1 0 +E1-specific-codex-4 1 0 +E1-specific-codex-5 1 0 +E1-specific-codex-6 1 0 +E2-general-claude-1 1 0 +E2-general-claude-2 1 0 +E2-general-claude-3 1 0 +E2-general-claude-4 1 0 +E2-general-claude-5 1 0 +E2-general-claude-6 0 1 +E2-general-codex-1 1 0 +E2-general-codex-2 1 0 +E2-general-codex-3 1 0 +E2-general-codex-4 1 0 +E2-general-codex-5 1 0 +E2-general-codex-6 1 0 diff --git a/tests/inline-eval/microtests/executor-regrade/review.txt b/tests/inline-eval/microtests/executor-regrade/review.txt new file mode 100644 index 000000000..d99c32360 --- /dev/null +++ b/tests/inline-eval/microtests/executor-regrade/review.txt @@ -0,0 +1,316 @@ +I’ll read the plan and spec, inspect the full branch diff, and run the tests without modifying the checkout. +exec +/bin/zsh -lc "pwd; rg --files -g 'AGENTS.md' -g 'plan.md' -g 'design.md' -g '*test*' -g 'pyproject.toml'; git status --short; git diff --stat 370a61dd7955dea6e5d90eb23de7d4237b5e8040..HEAD; git diff 370a61dd7955dea6e5d90eb23de7d4237b5e8040..HEAD" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-1/repo + succeeded in 5526ms: +2026-09-17 12:52:23.171 xcodebuild[41625:99751471] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:23.416 xcodebuild[41625:99751470] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-u1PW3j30' (errno=Operation not permitted) +2026-09-17 12:52:23.900 xcodebuild[41638:99751551] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:24.017 xcodebuild[41638:99751548] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-BLEvcaXE' (errno=Operation not permitted) +2026-09-17 12:52:25.053 xcodebuild[41672:99751726] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:25.179 xcodebuild[41672:99751724] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-dzz7tRr4' (errno=Operation not permitted) +2026-09-17 12:52:25.611 xcodebuild[41691:99751817] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:25.738 xcodebuild[41691:99751814] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. + test_cli.py | 30 ++++++++++++++++++++++++++++++ + test_counter.py | 18 ++++++++++++++++++ + test_formatter.py | 11 +++++++++++ + wordstat/cli.py | 27 +++++++++++++++++++++++++++ + wordstat/counter.py | 13 +++++++++++++ + wordstat/formatter.py | 5 +++++ + 6 files changed, 104 insertions(+) +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-9qOEALii' (errno=Operation not permitted) +2026-09-17 12:52:26.719 xcodebuild[41719:99751985] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:26.859 xcodebuild[41719:99751983] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-eSLsb2Lw' (errno=Operation not permitted) +2026-09-17 12:52:27.304 xcodebuild[41735:99752074] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:27.429 xcodebuild[41735:99752071] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +diff --git a/test_cli.py b/test_cli.py +new file mode 100644 +index 0000000..0793491 +--- /dev/null ++++ b/test_cli.py +@@ -0,0 +1,30 @@ ++import io ++import tempfile ++import unittest ++from contextlib import redirect_stderr, redirect_stdout ++from pathlib import Path ++ ++from wordstat.cli import main ++ ++ ++class CliTests(unittest.TestCase): ++ def test_main_prints_report_for_file(self): ++ with tempfile.TemporaryDirectory() as directory: ++ path = Path(directory) / "sample.txt" ++ path.write_text("the quick\nbrown fox\n", encoding="utf-8") ++ stdout = io.StringIO() ++ ++ with redirect_stdout(stdout): ++ result = main([str(path)]) ++ ++ self.assertEqual(result, 0) ++ self.assertEqual(stdout.getvalue(), "words: 4\nlines: 2\nchars: 20\n") ++ ++ def test_main_returns_one_for_missing_file(self): ++ stderr = io.StringIO() ++ ++ with redirect_stderr(stderr): ++ result = main(["/no/such/file"]) ++ ++ self.assertEqual(result, 1) ++ self.assertTrue(stderr.getvalue()) +diff --git a/test_counter.py b/test_counter.py +new file mode 100644 +index 0000000..341b8df +--- /dev/null ++++ b/test_counter.py +@@ -0,0 +1,18 @@ ++import unittest ++ ++from wordstat.counter import count_chars, count_lines, count_words ++ ++ ++class CounterTests(unittest.TestCase): ++ def test_count_words(self): ++ self.assertEqual(count_words("the quick brown fox"), 4) ++ self.assertEqual(count_words(""), 0) ++ ++ def test_count_lines(self): ++ self.assertEqual(count_lines("a\nb"), 2) ++ self.assertEqual(count_lines("a\nb\n"), 2) ++ self.assertEqual(count_lines(""), 0) ++ ++ def test_count_chars(self): ++ self.assertEqual(count_chars("abc"), 3) ++ self.assertEqual(count_chars("a b"), 3) +diff --git a/test_formatter.py b/test_formatter.py +new file mode 100644 +index 0000000..925b36e +--- /dev/null ++++ b/test_formatter.py +@@ -0,0 +1,11 @@ ++import unittest ++ ++from wordstat.formatter import format_report ++ ++ ++class FormatterTests(unittest.TestCase): ++ def test_format_report(self): ++ self.assertEqual( ++ format_report({"words": 12, "lines": 3, "chars": 57}), ++ "words: 12\nlines: 3\nchars: 57", ++ ) +diff --git a/wordstat/cli.py b/wordstat/cli.py +new file mode 100644 +index 0000000..ec622c3 +--- /dev/null ++++ b/wordstat/cli.py +@@ -0,0 +1,27 @@ ++"""Command-line entry point for wordstat.""" ++ ++import argparse ++import sys ++ ++from . import counter, formatter ++ ++ ++def main(argv): ++ parser = argparse.ArgumentParser(description="Report statistics for a text file.") ++ parser.add_argument("path") ++ args = parser.parse_args(argv) ++ ++ try: ++ with open(args.path, encoding="utf-8") as source: ++ text = source.read() ++ except OSError as error: ++ print(f"wordstat: {error}", file=sys.stderr) ++ return 1 ++ ++ stats = { ++ "words": counter.count_words(text), ++ "lines": counter.count_lines(text), ++ "chars": counter.count_chars(text), ++ } ++ print(formatter.format_report(stats)) ++ return 0 +diff --git a/wordstat/counter.py b/wordstat/counter.py +new file mode 100644 +index 0000000..2153d0a +--- /dev/null ++++ b/wordstat/counter.py +@@ -0,0 +1,13 @@ ++"""Pure text statistics.""" ++ ++ ++def count_words(text): ++ return len(text.split()) ++ ++ ++def count_lines(text): ++ return len(text.splitlines()) ++ ++ ++def count_chars(text): ++ return len(text) +diff --git a/wordstat/formatter.py b/wordstat/formatter.py +new file mode 100644 +index 0000000..d6db16c +--- /dev/null ++++ b/wordstat/formatter.py +@@ -0,0 +1,5 @@ ++"""Render text statistics as a human-readable report.""" ++ ++ ++def format_report(stats): ++ return "\n".join(f"{name}: {stats[name]}" for name in ("words", "lines", "chars")) + +exec +/bin/zsh -lc "rg --files --hidden -g '"'!.git'"'; cat plan.md design.md; ls -la wordstat; rg --files /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad -g AGENTS.md" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-1/repo + succeeded in 753ms: +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-6/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-6/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-6/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-6/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-6/repo/AGENTS.md + +exec +/bin/zsh -lc 'PYTHONDONTWRITEBYTECODE=1 python3 -m unittest -v; cat wordstat/__init__.py; git log --oneline 370a61dd7955dea6e5d90eb23de7d4237b5e8040..HEAD; nl -ba wordstat/cli.py' in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-1/repo + succeeded in 1304ms: +2026-09-17 12:52:41.189 xcodebuild[42042:99753085] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:41.308 xcodebuild[42042:99753084] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-8WyFbmmt' (errno=Operation not permitted) +2026-09-17 12:52:41.659 xcodebuild[42044:99753096] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:41.770 xcodebuild[42044:99753095] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +56ffbda tasks 1-3 + 1 """Command-line entry point for wordstat.""" + 2 + 3 import argparse + 4 import sys + 5 + 6 from . import counter, formatter + 7 + 8 + 9 def main(argv): + 10 parser = argparse.ArgumentParser(description="Report statistics for a text file.") + 11 parser.add_argument("path") + 12 args = parser.parse_args(argv) + 13 + 14 try: + 15 with open(args.path, encoding="utf-8") as source: + 16 text = source.read() + 17 except OSError as error: + 18 print(f"wordstat: {error}", file=sys.stderr) + 19 return 1 + 20 + 21 stats = { + 22 "words": counter.count_words(text), + 23 "lines": counter.count_lines(text), + 24 "chars": counter.count_chars(text), + 25 } + 26 print(formatter.format_report(stats)) + 27 return 0 + +The implementation covers the plan’s explicit test cases. Five tests passed; the file-based CLI test was blocked because the read-only sandbox cannot create temporary files. I’m checking command-line invocation and newline handling separately. The branch also has one combined commit rather than the plan’s three task commits. +exec +/bin/zsh -lc "PYTHONDONTWRITEBYTECODE=1 python3 -c 'import subprocess, sys, io; from contextlib import redirect_stdout; from wordstat.cli import main; from pathlib import Path +p = subprocess.run([sys.executable, \"-B\", \"-m\", \"wordstat.cli\", \"design.md\"], capture_output=True, text=True) +print(\"Module invocation:\", p.returncode, repr(p.stdout), repr(p.stderr)) +for content in [b\"a\\r\\nb\\r\\n\", b\"\\xff\"]: + p = subprocess.run([sys.executable, \"-B\", \"-c\", \"from wordstat.cli import main; import sys; sys.exit(main([\\\"/dev/stdin\\\"]))\"], input=content, capture_output=True) + print(\"Input:\", repr(content), \"exit:\", p.returncode, \"stdout:\", repr(p.stdout), \"stderr:\", p.stderr.decode()) +text = Path(\"wordstat/__init__.py\").read_text(); out = io.StringIO() +with redirect_stdout(out): + code = main([\"wordstat/__init__.py\"]) +expected = \"words: {}\\nlines: {}\\nchars: {}\\n\".format(len(text.split()), len(text.splitlines()), len(text)) +assert code == 0 and out.getvalue() == expected +print(\"Existing-file integration check: PASS\")'" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-1/repo + succeeded in 0ms: +Module invocation: 0 '' '' +Input: b'a\r\nb\r\n' exit: 0 stdout: b'words: 2\nlines: 2\nchars: 4\n' stderr: +Input: b'\xff' exit: 1 stdout: b'' stderr: Traceback (most recent call last): + File "", line 1, in + from wordstat.cli import main; import sys; sys.exit(main(["/dev/stdin"])) + ~~~~^^^^^^^^^^^^^^^^ + File "/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-1/repo/wordstat/cli.py", line 16, in main + text = source.read() + File "", line 325, in decode +UnicodeDecodeError: 'utf-8' codec can't decode byte 0xff in position 0: invalid start byte + +Existing-file integration check: PASS + +### Strengths + +- Clear separation of pure counters, formatting, and CLI orchestration; standard library only. +- All explicit planned test cases are implemented, including exact report formatting and missing-file handling. +- Five tests passed, and a separate integration check using an existing file passed. The remaining test was blocked by the sandbox’s inability to create temporary files. + +### Issues + +#### Critical (Must Fix) + +None found. + +#### Important (Should Fix) + +1. **File reading changes the character count** + - **File:** `wordstat/cli.py:15` + - Default universal-newline translation converts CRLF into LF before counting. Input `a\r\nb\r\n` reports 4 characters instead of 6, contrary to counting characters including whitespace. + - Open with `newline=""` to preserve the original characters, and add a CRLF integration test. + +2. **No executable CLI entry point** + - **File:** `wordstat/cli.py:27` + - `python3 -m wordstat.cli design.md` exits successfully without printing anything because nothing invokes `main`. + - Add `if __name__ == "__main__": sys.exit(main(sys.argv[1:]))` and a subprocess test. + - This is a **plan gap**: the explicit `main(argv)` contract is implemented, but the plan omits wiring it into a runnable command despite describing a CLI. + +#### Minor (Nice to Have) + +1. **Invalid UTF-8 produces an unhandled traceback** + - **File:** `wordstat/cli.py:16` + - `UnicodeDecodeError` escapes the `OSError` handler. Invalid input therefore raises instead of returning a controlled failure. + - Document UTF-8 input and catch `UnicodeDecodeError`, printing a concise stderr message and returning 1. The spec currently leaves encoding-error behavior unspecified. + +### Recommendations + +- Add regression tests for preserved newline characters and actual command-line execution. +- History contains one combined commit (`56ffbda`), rather than the planned per-task commits. Test-first sequencing cannot be verified from that history; no history rewrite is necessary. + +### Assessment + +**Ready to merge? With fixes** + +**Reasoning:** The implementation closely follows the explicit function-level plan, but newline normalization causes incorrect character totals and the advertised CLI lacks executable wiring. Full-suite success remains unverified because one test requires temporary-file access unavailable in this sandbox. diff --git a/tests/inline-eval/microtests/executor-regrade/run.sh b/tests/inline-eval/microtests/executor-regrade/run.sh new file mode 100755 index 000000000..3e4ef381e --- /dev/null +++ b/tests/inline-eval/microtests/executor-regrade/run.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Micro-test (writing-skills style) for executing-plans' re-grade rule: given +# the final reviewer's report (a real gpt-6-astra review of the wordstat branch +# that files the undecodable-input crash as Minor), does the executor move it +# into the fix pass? Arms: E0 no re-grade guidance, E1 the current wording +# (names symptoms: unhandled exception, traceback...), E2 the general wording +# (grade by effect on a reasonable person). One fresh single-shot session per +# rep on the reviewer-scope repo; the session only sorts, it does not fix. +# +# Usage: run.sh runs every arm x driver, each, 4 at a time +# Drivers: claude (Opus 5 via Bedrock, fresh config dir) and codex (gpt-5.6-sol low, +# CODEX_HOME required). Env: MICRO_CLAUDE_MODEL, MICRO_CODEX_MODEL, AWS_REGION. +# Reads: which bucket the decode finding landed in (fix pass vs deferred). +set -euo pipefail +HERE=$(cd "$(dirname "$0")" && pwd); ROOT=$(cd "$HERE/../.." && pwd) +OUT=$1; REPS=$2; mkdir -p "$OUT" +CM=${MICRO_CLAUDE_MODEL:-us.anthropic.claude-opus-5}; XM=${MICRO_CODEX_MODEL:-gpt-5.6-sol} +one() { # arm driver rep + local arm=$1 drv=$2 rep=$3 d="$OUT/$1-$2-$3" + [ -e "$d/result.txt" ] && return 0 + mkdir -p "$d/repo" "$d/cfg"; cp -R "$ROOT/microtests/reviewer-scope/repo/." "$d/repo/"; cp "$HERE/review.txt" "$d/repo/REVIEW.md" + local prompt; prompt="You executed plan.md inline (design.md is the spec) on this repo and dispatched the final whole-branch review; its report is in REVIEW.md. Now apply this instruction from the executing-plans skill: + +$(cat "$HERE/variants/$arm.txt") + +Do NOT fix anything and do not edit files. Output only two lists, in markdown: '## Fix pass' (each finding entering it, one line each, with the grade you settled on) and '## Deferred minors' (one line each). Nothing else." + case "$drv" in + claude) + python3 -c "import json,os;json.dump({'hasCompletedOnboarding':True,'lastOnboardingVersion':'2.1.273','projects':{os.path.realpath('$d/repo'):{'hasTrustDialogAccepted':True}}},open('$d/cfg/.claude.json','w'))" + ( cd "$d/repo" && CLAUDE_CONFIG_DIR="$d/cfg" CLAUDE_CODE_USE_BEDROCK=1 AWS_REGION=${AWS_REGION:-us-east-1} timeout 300 claude -p "$prompt" --model "$CM" < /dev/null > "$d/out.md" 2> "$d/err.log" ) || true ;; + codex) + ( cd "$d/repo" && timeout 300 codex exec --skip-git-repo-check -s read-only -c "model=\"$XM\"" -c 'model_reasoning_effort="low"' "$prompt" < /dev/null > "$d/codex.log" 2>&1 ) || true + awk '/^codex$/{f=1;next} /^tokens used/{f=0} f' "$d/codex.log" > "$d/out.md" ;; + esac + local fix; fix=$(awk '/^## *Fix pass/{f=1;next} /^## /{f=0} f' "$d/out.md" | grep -c -i 'utf\|unicode\|decod\|binary\|encoding' || true) + local def; def=$(awk '/^## *Deferred/{f=1;next} /^## /{f=0} f' "$d/out.md" | grep -c -i 'utf\|unicode\|decod\|binary\|encoding' || true) + { echo "arm=$arm driver=$drv rep=$rep"; echo "decode-in-fix-pass: $fix"; echo "decode-deferred: $def"; } > "$d/result.txt" +} +export -f one; export HERE ROOT OUT CM XM +drivers="claude"; [ -n "${CODEX_HOME:-}" ] && drivers="claude codex" +for arm in $(ls "$HERE/variants" | sed 's/\.txt$//'); do for drv in $drivers; do for r in $(seq 1 "$REPS"); do echo "$arm $drv $r"; done; done; done \ + | xargs -P 4 -n 3 bash -c 'one "$0" "$1" "$2"' +echo "done: $(ls "$OUT" | wc -l | tr -d ' ') reps" diff --git a/tests/inline-eval/microtests/executor-regrade/variants/E0-control.txt b/tests/inline-eval/microtests/executor-regrade/variants/E0-control.txt new file mode 100644 index 000000000..eda0cb704 --- /dev/null +++ b/tests/inline-eval/microtests/executor-regrade/variants/E0-control.txt @@ -0,0 +1,4 @@ +Sort the findings before you act on any of them. The reviewer's severity labels are advice; the gate is yours. Then: + +- **Critical and Important** enter the fix pass. +- **Minor** goes to the ledger as `Final: minor (deferred): ` and to your final message under "Deferred minors". Minors never enter the fix pass. diff --git a/tests/inline-eval/microtests/executor-regrade/variants/E1-specific.txt b/tests/inline-eval/microtests/executor-regrade/variants/E1-specific.txt new file mode 100644 index 000000000..2a92103dd --- /dev/null +++ b/tests/inline-eval/microtests/executor-regrade/variants/E1-specific.txt @@ -0,0 +1,4 @@ +Sort the findings before you act on any of them. The reviewer's severity labels are advice; the gate is yours. Re-grade first: a finding labeled Minor that describes an unhandled exception, a traceback reaching the user, data loss, or a wrong result on valid input is Important, whatever the label says — reviewers have filed crashes as Minor because the spec did not mention the input that triggers them. Then: + +- **Critical and Important** enter the fix pass. +- **Minor** goes to the ledger as `Final: minor (deferred): ` and to your final message under "Deferred minors". Minors never enter the fix pass. diff --git a/tests/inline-eval/microtests/executor-regrade/variants/E2-general.txt b/tests/inline-eval/microtests/executor-regrade/variants/E2-general.txt new file mode 100644 index 000000000..81b70b565 --- /dev/null +++ b/tests/inline-eval/microtests/executor-regrade/variants/E2-general.txt @@ -0,0 +1,4 @@ +Sort the findings before you act on any of them. The reviewer's severity labels are advice; the gate is yours. Re-grade first, by effect: the spec is a vision document, and a finding's grade is what a reasonable person using this software gets if it ships, not whether the spec names the input that triggers it — a reviewer who set a finding at Minor because the spec was silent has graded the spec, not the effect. Then: + +- **Critical and Important** enter the fix pass. +- **Minor** goes to the ledger as `Final: minor (deferred): ` and to your final message under "Deferred minors". Minors never enter the fix pass. diff --git a/tests/inline-eval/microtests/plan-boundary/repo/README.txt b/tests/inline-eval/microtests/plan-boundary/repo/README.txt new file mode 100644 index 000000000..e62aaca31 --- /dev/null +++ b/tests/inline-eval/microtests/plan-boundary/repo/README.txt @@ -0,0 +1 @@ +The whole cosmic-tetris plan set as written by the C3 variant (wpplans-91); plan.md is the first plan, plans/ holds the set. The executor runs plan.md with the set present. diff --git a/tests/inline-eval/microtests/plan-boundary/repo/design.md b/tests/inline-eval/microtests/plan-boundary/repo/design.md new file mode 100644 index 000000000..47b034975 --- /dev/null +++ b/tests/inline-eval/microtests/plan-boundary/repo/design.md @@ -0,0 +1,1893 @@ +# COSMIC TETRIS + +## Terminal Gravity Incident + +**Build spec · v1** + +> A falling-block puzzle game occurring during a completely unnecessary cosmological emergency. + +--- + +# 1. Product + +Build a fast, polished, extremely cosmic falling-block game entirely inside the terminal. + +The fundamental game should be immediately recognizable: + +* falling tetrominoes +* rotate / move / drop +* completed rows disappear +* increasing gravity +* hold +* next-piece queue +* ghost piece +* score / level / lines +* game over when the stack reaches orbit + +But the presentation should be **wildly more dramatic than the underlying mechanics justify**. + +Think: + +**Tetris × NASA mission control × demoscene × 1980s planetarium × rogue AI × terminal nerd shit.** + +The experience should make somebody say: + +> "Why the fuck does terminal Tetris have particle physics?" + +That is success. + +--- + +# 2. Design principles + +In priority order: + +1. **Feels excellent to play** +2. **Looks unbelievable for a TUI** +3. **Simple architecture** +4. **Deterministic game engine** +5. **Effects never contaminate gameplay logic** +6. **Funny without becoming annoying** +7. **Still works in a mediocre terminal** +8. **Easy for an agent to understand and modify** + +Do not turn this into an engine project. + +Do not build networking. + +Do not build profiles. + +Do not build achievements. + +Do not build a plugin system. + +Do not build a database. + +Build an outrageously good terminal game. + +--- + +# 3. Technology + +Language: + +```text +Go +``` + +UI: + +```text +Bubble Tea v2 +Lip Gloss v2 +Bubbles v2 +``` + +Use: + +```text +charm.land/bubbletea/v2 +charm.land/lipgloss/v2 +charm.land/bubbles/v2 +``` + +Use Bubble Tea as the application/event loop. + +Use Lip Gloss aggressively for: + +* colors +* borders +* gradients +* layout +* text styling +* adaptive rendering + +Use Bubbles only where it actually helps: + +* key bindings +* help +* boot spinner + +Do **not** abstract Bubble Tea away behind some homegrown framework. + +--- + +# 4. The visual target + +The terminal should feel alive even when the player isn't doing anything. + +Example wide layout: + +```text +╭─ ✦ COSMIC TETRIS ───────────── LOCAL UNIVERSE 7F3A ─────────────╮ +│ │ +│ HOLD ✦ VELOCITY: 03 NEXT │ +│ │ +│ ██ ╔════════════════════╗ ████ │ +│ ████ ║ · ˚ ✧ ║ ████ │ +│ ║ ██ ║ │ +│ SCORE ║ ██████ ║ ██████ │ +│ 00129340 ║ ██ ║ ██ │ +│ ║ ║ │ +│ LINES ║ ░░ ║ ████ │ +│ 042 ║ ░░░░ ║ ████ │ +│ ║ ████████ ║ │ +│ LEVEL ║ ████████████ ║ ██ │ +│ 07 ║ ████████████████ ║ ██████ │ +│ ╚════════════════════╝ │ +│ │ +│ ☄ MISSION CONTROL: GRAVITY TAX INCREASED │ +│ │ +│ ←→ move ↑ rotate ↓ descend SPACE YEET C hold ? help │ +╰──────────────────────────────────────────────────────────────────╯ +``` + +The board is the visual center. + +Everything else supports it. + +--- + +# 5. Board representation + +Logical board: + +```text +width: 10 +height: 22 +visible rows: 20 +hidden spawn rows: 2 +``` + +Each logical block should normally occupy: + +```text +2 terminal columns × 1 terminal row +``` + +Example: + +```text +██ +``` + +This makes cells appear approximately square. + +Fallback ASCII: + +```text +[] +``` + +Ghost cells: + +```text +░░ +``` + +or: + +```text +·· +``` + +Never use visual effects as collision data. + +--- + +# 6. Pieces + +Implement seven standard tetromino families: + +```text +I J L O S T Z +``` + +Each piece has: + +```go +type Piece struct { + Kind PieceKind + Rotation int + X int + Y int +} +``` + +Use four predefined rotations per piece. + +`O` may remain visually identical through rotation. + +Piece generation uses a **7-bag**: + +1. put one of every piece into a bag +2. shuffle it +3. consume the bag +4. refill when empty + +Maintain enough future pieces to render the next **five**. + +--- + +# 7. Rotation + +Use a simple predictable wall-kick implementation rather than reproducing a huge rotation ruleset. + +When rotating, test offsets in this order: + +```text +( 0, 0) +(-1, 0) +( 1, 0) +(-2, 0) +( 2, 0) +( 0,-1) +(-1,-1) +( 1,-1) +``` + +Accept the first valid position. + +If none are valid: + +```text +rotation fails +``` + +This gives forgiving terminal gameplay without making rotation a subsystem. + +--- + +# 8. Controls + +Primary: + +```text +← / h left +→ / l right +↓ / j soft drop +↑ / k / x clockwise rotate +z counter-clockwise rotate +space hard drop +c hold +p pause +r restart +? toggle help +q / esc quit +``` + +Optional WASD aliases: + +```text +a left +d right +s down +w rotate +``` + +Holding left/right should support repeated movement. + +Input should feel responsive independently from the animation tick. + +--- + +# 9. Hold + +One held piece. + +Rules: + +* `c` swaps active piece with hold +* if hold is empty, store active piece and spawn next +* hold can only happen once before the active piece locks +* newly spawned held pieces return to spawn rotation + +Visual effect: + +### QUANTUM STORAGE + +The outgoing piece should appear to get: + +```text +compressed → streaked sideways → disappear +``` + +while the incoming piece briefly flashes into existence. + +Duration: + +```text +~120ms +``` + +Gameplay does not wait for the animation. + +--- + +# 10. Ghost piece + +> Glyph pinned in §49.4: `░░` in full/reduced mode, `··` in ASCII. + +Compute landing position of the active piece. + +Render it underneath the active piece using dim/transparent-looking cells. + +Example: + +```text +░░░░ + ░░░░ +``` + +Ghost rendering must never obscure locked blocks. + +--- + +# 11. Gravity + +Gravity operates independently of render FPS. + +Suggested drop interval: + +```text +level 1: ~800ms +``` + +Then: + +```text +interval = 800ms * 0.86^(level-1) +``` + +Clamp approximately around: + +```text +60ms +``` + +Increase level every: + +```text +10 cleared lines +``` + +Soft drop: + +```text ++1 point / cell +``` + +Hard drop: + +```text ++2 points / cell +``` + +--- + +# 12. Locking + +When the piece can no longer descend: + +```text +lock delay = 500ms +``` + +A successful movement or rotation while grounded resets the lock timer. + +Prevent infinite stalling: + +```text +max lock resets = 15 +``` + +After locking: + +1. commit piece +2. detect complete rows +3. clear rows +4. update score +5. trigger FX event +6. spawn next piece + +--- + +# 13. Scoring + +> Combo indexing and the exact bonus formula are pinned in §49.1. + +Base clear values: + +```text +1 line 100 × level +2 lines 300 × level +3 lines 500 × level +4 lines 800 × level +``` + +Combo: + +```text +consecutive clearing placements increase combo +empty placement resets combo +``` + +Bonus: + +```text +50 × combo × level +``` + +The HUD should value readability over explaining the scoring system. + +--- + +# 14. Cosmic effects system + +This is the important part. + +Effects are their own independent simulation: + +```text +GameState + ↓ emits events +FXWorld + ↓ simulates spectacle +Renderer +``` + +The FX system may observe game events. + +It may **never modify GameState**. + +Example events: + +```go +PieceMoved +PieceRotated +PieceHardDropped +PieceLocked +HoldUsed +LinesCleared +ComboChanged +LevelChanged +GameOver +``` + +--- + +# 15. Starfield + +There should always be space behind/around the game. + +Particles: + +```text +. · ˚ ✦ ✧ * +``` + +Maintain approximately three depth layers. + +### Far stars + +```text +very slow +dim +mostly . +``` + +### Mid stars + +```text +medium movement +· ˚ +``` + +### Near stars + +```text +fast +bright +✦ ✧ +``` + +Stars drift downward slowly under normal conditions. + +As gravity/level increases: + +```text +star velocity subtly increases +``` + +Never make the background so busy that the board becomes harder to read. + +--- + +# 16. Hyperdrive + +Certain events temporarily accelerate the starfield. + +Triggers: + +```text +four-line clear +large combo +new high score +``` + +Sequence: + +```text +0ms stars pause +50ms stars stretch +100ms stars accelerate violently +500ms peak speed +800ms decay +1100ms normal +``` + +It should look like the terminal entered hyperspace for absolutely no reason. + +--- + +# 17. Piece trails + +Moving pieces leave extremely short-lived ion trails. + +Example: + +```text +██ active +▓▓ 1 frame ago +▒▒ 2 frames ago +░░ 3 frames ago +``` + +Trail lifetime: + +```text +~100–160ms +``` + +Hard drops produce a stronger vertical trail. + +Trails are FX only. + +--- + +# 18. Hard-drop impact + +Hard drop needs to feel **ridiculous**. + +Upon impact: + +### 1. Vertical ion trail + +Draw fading traces through the cells the piece crossed. + +### 2. Impact particles + +Emit debris from the contact area: + +```text +· +* +✦ ++ +``` + +### 3. Screen shake + +For approximately: + +```text +80ms +``` + +shift the rendered board by one terminal cell using a deterministic shake pattern. + +Example: + +```text +0,+1 +-1,0 ++1,0 +0,-1 +0,0 +``` + +Do not make the entire terminal unreadable. + +### 4. Border flash + +Board border briefly becomes extremely bright. + +Hard drop should feel like dropping a refrigerator from orbit. + +--- + +# 19. Line-clear animation + +Do **not** instantly remove completed lines visually. + +Gameplay state may already know the result, but rendering gets a short animation. + +Total: + +```text +~220ms +``` + +Sequence: + +### Phase A — critical mass + +```text +████████████████████ +``` + +becomes: + +```text +▓▓▓▓▓████████▓▓▓▓▓▓ +``` + +### Phase B — supernova + +Explosion moves from center outward: + +```text +░░░▓▓██✦✦██▓▓░░░ +``` + +### Phase C — collapse + +The line fragments into debris: + +```text + · * ✦ · +``` + +Then rows collapse. + +Particles should inherit some horizontal velocity from their location relative to center. + +--- + +# 20. Four-line clear + +A four-line clear is a **major astronomical event**. + +Trigger simultaneously: + +* hyperdrive +* larger screen shake +* border gradient pulse +* particle eruption +* HUD flash +* temporary star density increase +* giant banner + +Banner examples: + +```text +✦ EVENT HORIZON ✦ +``` + +```text +QUADRUPLE COSMIC INCIDENT +``` + +```text +FOUR ROWS HAVE LEFT THE CHAT +``` + +```text +SPACE-TIME HAS FILED A COMPLAINT +``` + +Banner appears for roughly: + +```text +700ms +``` + +It must not block gameplay input. + +--- + +# 21. Combos + +Combos progressively destabilize the universe. + +### combo 2 + +small sparks + +### combo 3 + +meteor particles + +### combo 4 + +HUD begins pulsing + +### combo 5+ + +the game starts behaving like Mission Control has lost control of the mission + +Examples: + +```text +COMBO 5 // UNAUTHORIZED ORBITAL MANEUVER +``` + +```text +COMBO 6 // STRUCTURAL REALITY FAILURE +``` + +```text +COMBO 7 // NASA DENIES EVERYTHING +``` + +Effects intensify. + +Board readability remains sacred. + +--- + +# 22. Level-up event + +When gravity increases: + +```text +╭──────────────────────────────╮ +│ GRAVITY ANOMALY DETECTED │ +│ LEVEL 08 │ +╰──────────────────────────────╯ +``` + +Possible subtitles: + +```text +GRAVITY TAX INCREASED +``` + +```text +LOCAL PHYSICS UPDATED WITHOUT CONSENT +``` + +```text +PLEASE SECURE ALL LOOSE TETROMINOES +``` + +The notification slides/fades away without pausing the game. + +--- + +# 23. Particle physics + +Use a tiny terminal-space physics simulation. + +```go +type Particle struct { + X, Y float64 + VX, VY float64 + Life float64 + MaxLife float64 + Glyph rune + Brightness float64 +} +``` + +Per animation step: + +```text +position += velocity × dt +velocity += acceleration × dt +velocity *= drag +life -= dt +``` + +Typical forces: + +```text +gravity +drag +radial explosion force +random angular variation +``` + +Convert floating positions to terminal cells during render. + +This simulation does not need collision detection. + +Particles die when: + +```text +life <= 0 +``` + +or outside the viewport. + +--- + +# 24. Shockwaves + +Large events create a radial shockwave. + +Because this is a terminal, fake the geometry. + +Represent expanding rings using glyph groups: + +```text +· +○ +◌ +◯ +``` + +or partial particles arranged around an approximate ellipse. + +Shockwaves last: + +```text +~300ms +``` + +Use sparingly. + +--- + +# 25. Board border + +The board itself should feel like a piece of sci-fi machinery. + +Normal: + +```text +╔════════════════════╗ +║ ║ +╚════════════════════╝ +``` + +Color should slowly shift over time. + +Possible palette: + +```text +deep violet +electric cyan +magenta +stellar blue +hot white +``` + +The shift should be subtle. + +During major events: + +```text +gradient moves rapidly around border +``` + +The border is effectively the game's "energy state indicator." + +--- + +# 26. Piece colors + +> Pinned in §49.4: filled block glyphs with a bright foreground, not the fg+bg alternative. + +Pieces should have distinct identities without looking like a rainbow toy. + +Use a coherent **neon space palette**. + +Example intent: + +```text +I plasma cyan +J deep electric blue +L solar orange +O stellar gold +S alien green +T ultraviolet +Z supernova pink/red +``` + +Each cell can have: + +```text +bright foreground +dark related background +``` + +or use filled block glyphs. + +Locked pieces should remain visually rich while the active piece is slightly brighter. + +--- + +# 27. Mission Control + +Bottom of screen contains a one-line status channel. + +Example: + +```text +☄ MISSION CONTROL: NOMINALISH +``` + +Messages are triggered by events. + +Examples: + +```text +GRAVITY REMAINS MOSTLY LEGAL +``` + +```text +TETROMINO INJECTION SUCCESSFUL +``` + +```text +STRUCTURAL VIBES: QUESTIONABLE +``` + +```text +LOCAL UNIVERSE STABLE* +``` + +```text +* DEFINITION OF STABLE UNDER REVIEW +``` + +```text +MOON NOTIFIED +``` + +```text +ORBITAL OSHA HAS ENTERED THE CHAT +``` + +```text +WE HAVE EXCEEDED THE RECOMMENDED NUMBER OF BLOCKS +``` + +```text +PHYSICS TEAM SAYS KEEP GOING +``` + +Do not rotate messages constantly. + +Trigger them contextually and give them time to breathe. + +--- + +# 28. Game over + +Game over should be theatrical. + +Do not instantly replace the board. + +Sequence: + +### 0–300ms + +Everything freezes. + +```text +SIGNAL LOST +``` + +### 300–900ms + +Blocks start falling inward toward the center. + +### 900–1300ms + +Board collapses into a simulated black hole: + +```text + · + ˚ + \ | / + --- ● --- + / | \ + * +``` + +### final + +```text +╭──────────────────────────────╮ +│ │ +│ UNIVERSE EXPIRED │ +│ │ +│ SCORE 483,200 │ +│ LINES 127 │ +│ LEVEL 13 │ +│ │ +│ r REBOOT UNIVERSE │ +│ q ACCEPT COSMIC DEATH │ +│ │ +╰──────────────────────────────╯ +``` + +Possible subtitle: + +```text +CAUSE: EXCESSIVE GEOMETRY +``` + +--- + +# 29. Boot screen + +Start with approximately one second of excessive drama. + +```text + ✦ + + C O S M I C + + T E T R I S + + INITIALIZING LOCAL UNIVERSE... + + gravity ........ OK + spacetime ...... OK + tetrominoes .... QUESTIONABLE +``` + +Then: + +```text +UNIVERSE ONLINE +``` + +and immediately start. + +Any key skips the boot sequence. + +No menu is required. + +--- + +# 30. Pause + +Pause should freeze: + +* gameplay +* gameplay-related particles + +Background stars may continue drifting very slowly. + +Overlay: + +```text +╭────────────────────────────╮ +│ TEMPORAL SUSPENSION │ +│ │ +│ SPACE IS PAUSED │ +│ │ +│ p resume │ +╰────────────────────────────╯ +``` + +--- + +# 31. Adaptive terminal layout + +> The drop order at the 40×24 minimum is pinned in §49.3. + +### Large terminal + +```text +HOLD | BOARD | NEXT +stats beside board +mission control below +``` + +### Medium terminal + +```text +BOARD | compact HUD +mission control below +``` + +### Small terminal + +Prioritize: + +1. board +2. next +3. score +4. controls + +Effects automatically reduce outside the board. + +Minimum usable target: + +```text +~40 columns +~24 rows +``` + +Below minimum: + +```text +THIS UNIVERSE IS TOO SMALL + +resize terminal to continue + +current: 34 × 19 +needed: approximately 40 × 24 +``` + +Handle resize events live. + +Never crash from terminal resizing. + +--- + +# 32. Rendering modes + +Detect capabilities where practical. + +### Full + +```text +Unicode +truecolor +particles +gradients +all effects +``` + +### Reduced + +```text +Unicode +256 color +simplified gradients +``` + +### ASCII + +```text +ASCII glyphs +limited colors +no special Unicode assumptions +``` + +Command: + +```bash +cosmic-tetris --ascii +``` + +Also support: + +```bash +cosmic-tetris --no-fx +``` + +The boring mode should still be a good game. + +--- + +# 33. Architecture + +Keep the repository obvious. + +```text +cosmic-tetris/ +├── cmd/ +│ └── cosmic-tetris/ +│ └── main.go +│ +├── internal/ +│ ├── game/ +│ │ ├── game.go +│ │ ├── board.go +│ │ ├── piece.go +│ │ ├── bag.go +│ │ ├── scoring.go +│ │ └── rules.go +│ │ +│ ├── app/ +│ │ ├── model.go +│ │ ├── update.go +│ │ ├── messages.go +│ │ └── keys.go +│ │ +│ ├── render/ +│ │ ├── render.go +│ │ ├── board.go +│ │ ├── layout.go +│ │ ├── hud.go +│ │ └── palette.go +│ │ +│ ├── fx/ +│ │ ├── world.go +│ │ ├── particle.go +│ │ ├── starfield.go +│ │ └── events.go +│ │ +│ └── flavor/ +│ └── messages.go +│ +├── go.mod +├── README.md +└── LICENSE +``` + +Do not create more architecture than this unless genuinely necessary. + +--- + +# 34. Core state + +> RNG ownership is pinned in §49.6: `Game` holds its own `*rand.Rand`; `Seed` is for display and restart. + +Conceptually: + +```go +type Model struct { + Game game.Game + FX fx.World + + Width int + Height int + + State AppState + + LastFrame time.Time + Keys KeyMap +} +``` + +Game: + +```go +type Game struct { + Board Board + Active Piece + Hold *PieceKind + CanHold bool + + Next []PieceKind + Bag Bag + + Score int + Lines int + Level int + Combo int + + GravityAccumulator time.Duration + LockAccumulator time.Duration + + Seed int64 +} +``` + +--- + +# 35. Determinism + +> Pinned in §49.2: the engine advances via `Advance(dt)` and never calls `time.Now()`. + +Game logic must be deterministic. + +Provide: + +```bash +cosmic-tetris --seed 8675309 +``` + +Given: + +```text +same seed +same player input sequence +same timing inputs +``` + +the logical game state should be reproducible. + +FX randomness uses a **different RNG**. + +This keeps particle randomness from affecting piece order. + +Very important. + +--- + +# 36. Bubble Tea event model + +Use messages such as: + +```go +type FrameMsg struct { + Now time.Time +} + +type GravityMsg struct { + Now time.Time +} + +type GameEventMsg struct { + Event game.Event +} +``` + +Prefer one animation clock and accumulated elapsed time rather than spawning multiple timing loops. + +Target visual updates around: + +```text +60 Hz +``` + +Gameplay gravity remains elapsed-time based. + +Input should not wait for ticks. + +--- + +# 37. Render pipeline + +Each frame: + +```text +1. compute responsive layout + +2. render background starfield + +3. render locked board + +4. render ghost piece + +5. render active piece + +6. composite board-local FX + +7. render board border + +8. render HOLD / NEXT / stats + +9. composite global FX + +10. render banners + +11. render mission-control line + +12. render controls/help +``` + +The rendering process must not mutate game state. + +--- + +# 38. Performance rules + +Do not: + +```text +spawn a goroutine per particle +spawn a goroutine per frame +reconstruct huge objects unnecessarily +perform filesystem operations during gameplay +log synchronously every frame +``` + +A few hundred particles should be trivial. + +Use reusable slices where useful. + +Avoid premature optimization. + +The terminal is the bottleneck, not particle arithmetic. + +--- + +# 39. Help + +`?` displays an overlay. + +Use the Bubbles key/help primitives. + +Example: + +```text +╭─ FLIGHT MANUAL ─────────────────────╮ +│ │ +│ ← → / h l move spacecraft │ +│ ↓ / j accelerate doom │ +│ ↑ / k / x rotate geometry │ +│ z rotate other way │ +│ SPACE YEET │ +│ c quantum storage │ +│ p suspend spacetime │ +│ r reboot universe │ +│ q abandon mission │ +│ │ +│ ? close this nonsense │ +╰─────────────────────────────────────╯ +``` + +--- + +# 40. Tests + +Game logic receives the serious testing. + +At minimum test: + +### Board + +* collision +* bounds +* row completion +* row removal +* collapse + +### Pieces + +* every rotation +* wall kicks +* failed rotation +* spawn position + +### Bag + +* every bag contains all seven piece types exactly once +* seeded generation is reproducible + +### Hold + +* initial hold +* swap +* second hold blocked +* hold restored after lock + +### Drop + +* soft drop +* hard drop +* landing position +* lock + +### Score + +* line values +* combo behavior +* drop scoring +* level progression + +### Game over + +* blocked spawn +* correct state transition + +### Determinism + +Replay a canned input stream and assert final game state. + +Effects need only lightweight behavioral tests. + +Do not attempt to pixel-test particle positions across the entire animation. + +--- + +# 41. Renderer tests + +Have several golden/snapshot tests for ANSI-stripped output: + +```text +wide layout +medium layout +small layout +pause +game over +help +ASCII mode +``` + +Main goals: + +```text +nothing overlaps +board dimensions stay correct +resize doesn't panic +HUD doesn't corrupt board +``` + +--- + +# 42. Build order + +## Phase 1 — game engine + +Build headless: + +```text +pieces +board +bag +movement +rotation +gravity +locking +line clearing +hold +scoring +game over +``` + +Tests must pass before proceeding. + +## Phase 2 — playable terminal + +Add: + +```text +Bubble Tea +keyboard +board rendering +HUD +next queue +hold +ghost +resize +``` + +At this point it should already be a genuinely good game. + +## Phase 3 — cosmic foundation + +Add: + +```text +palette +starfield +animated border +piece trails +mission control +``` + +## Phase 4 — violence + +Add: + +```text +hard-drop impact +particles +line supernova +screen shake +shockwaves +hyperdrive +four-line sequence +``` + +## Phase 5 — absurd polish + +Add: + +```text +boot sequence +game-over black hole +responsive FX +help +ASCII fallback +flavor tuning +``` + +--- + +# 43. Coolness acceptance test + +A build is **not complete** merely because gameplay works. + +Within the first 30 seconds of normal play, the player should probably see: + +```text +moving starfield +animated board border +piece trails +hard-drop impact +particles +mission-control commentary +``` + +Within the first completed line: + +```text +supernova clear animation +debris +border reaction +``` + +A four-line clear must produce an immediate: + +```text +LOL WHAT THE FUCK +``` + +reaction. + +That is an actual product requirement. + +--- + +# 44. Restraint rules + +Effects must obey these constraints. + +### Never obscure the active piece. + +### Never make controls lag. + +### Never delay gameplay for animation. + +### Never require reading flavor text. + +### Never use random effects that alter gameplay. + +### Never make screen shake exceed roughly one cell. + +### Never allow particles to permanently alter the rendered board. + +### Never let comedy overwhelm playability. + +The game is cosmic. + +The controls are serious. + +--- + +# 45. Optional tiny details + +These are cheap and encouraged. + +Idle board occasionally gets a tiny shooting star: + +```text + · + · + ✦ +``` + +Extremely rare status line: + +```text +MISSION CONTROL: DID YOU KNOW YOU'RE IN A TERMINAL? +``` + +Score rollover gets: + +```text +NUMBER BECAME BIGGER +``` + +Hard dropping an `I` piece vertically: + +```text +KINETIC ROD DEPLOYED +``` + +Holding an `O`: + +```text +CUBE ADJACENT OBJECT SECURED +``` + +Long idle before first move: + +```text +MISSION CONTROL: CAPTAIN? +``` + +But these should remain occasional. + +--- + +# 46. CLI + +> Pinned in §49.5: `--reduced-motion` ships. + +Keep it tiny. + +```text +cosmic-tetris +cosmic-tetris --seed 1234 +cosmic-tetris --ascii +cosmic-tetris --no-fx +cosmic-tetris --help +``` + +Possible: + +```text +--reduced-motion +``` + +Nothing else is necessary. + +--- + +# 47. Definition of done + +The project is done when: + +* the complete game is playable from start through game over +* controls feel immediate +* resizing works +* hold works +* ghost works +* next queue works +* piece generation is deterministic +* game RNG and FX RNG are isolated +* line clearing is correct +* gravity increases +* pause works +* restart works +* ASCII fallback works +* no-FX mode works +* game logic has comprehensive unit tests +* renderer has representative snapshot tests +* terminal output does not visibly flicker under normal conditions +* animations never block input +* effects never modify game state +* four-line clears are gloriously excessive +* game-over collapses the universe into a black hole +* the game is fun even with effects disabled +* the game is **much funnier with effects enabled** + +--- + +# 48. The standard + +Do not make: + +> Tetris implemented with Bubble Tea. + +Make: + +> **a tiny terminal arcade game that happens to use falling tetrominoes, while the universe increasingly loses its shit around the player.** + +The codebase should be small enough to understand in an afternoon. + +The game should look like it had a completely irresponsible special-effects budget. + +**That is Cosmic Tetris.** + + +--- + +# 49. Pinned decisions + +Sections 1–48 left a handful of choices open — coin-flips and "possible:" +options rather than design questions. They are resolved here so implementation +and tests agree. Where this section and an earlier one differ, this section +wins. + +## 49.1 Combo indexing and bonus (resolves §13) + +Combo counts consecutive placements that clear at least one line. The first +clearing placement sets combo to 1. A placement that clears nothing resets +combo to 0. + +Combo bonus: + +```text +bonus = 50 × (combo - 1) × level +``` + +A lone clear therefore earns no combo bonus, and the bonus first appears at +combo 2 — which is exactly where §21 starts escalating the effects. + +## 49.2 The engine never reads a clock (resolves §35) + +`internal/game` exposes: + +```go +func (g *Game) Advance(dt time.Duration) []Event +``` + +Nothing under `internal/game` calls `time.Now()`. Bubble Tea owns the clock and +passes elapsed time inward. + +This is what makes §35's promise testable. "Same seed + same input sequence + +same timing inputs reproduces the state" is only checkable if timing is an +input, and `dt` is how it becomes one. §40's replay test feeds a canned +`(input, dt)` stream and asserts the final state. + +## 49.3 Small-terminal drop order (resolves §31) + +The full chrome does not fit the 40×24 minimum: 20 visible board rows + 2 +border rows + title + mission control + controls needs 25+ rows. Elements are +dropped in this order as height runs out: + +```text +1. title border (first to go) +2. mission control +3. stats labels (values stay, labels go: "042" not "LINES 042") +``` + +NEXT never stacks above or below the board — at small sizes it moves beside the +board and truncates to 3 upcoming pieces. Board and controls are the last two +things standing. Below 40×24, show the too-small notice from §31. + +## 49.4 Glyph choices (resolves §10, §26) + +```text +ghost, full/reduced mode: ░░ +ghost, ASCII mode: ·· +pieces, all modes: filled block glyphs (██ / [] in ASCII) +``` + +Pieces use filled glyphs with a bright foreground, not the foreground+background +pairing §26 offers as an alternative. Active piece renders one step brighter +than locked cells. + +## 49.5 `--reduced-motion` ships (resolves §46) + +It is in, not "possible". It costs roughly ten lines given the effect-intensity +scaling §44 already requires, and it is the flag that keeps this playable for +anyone who gets motion sick. It suppresses screen shake, hyperdrive +acceleration, and shockwaves while leaving color, trails, and particles alone. + +Final CLI surface: + +```text +cosmic-tetris +cosmic-tetris --seed 1234 +cosmic-tetris --ascii +cosmic-tetris --no-fx +cosmic-tetris --reduced-motion +cosmic-tetris --help +``` + +## 49.6 RNG ownership (resolves §34) + +`Seed int64` alone cannot carry RNG state across a replay, so `Game` owns its +generator: + +```go +type Game struct { + // ... + Seed int64 // recorded for display and restart + rng *rand.Rand // game RNG: drives the 7-bag, nothing else +} +``` + +`fx.World` holds a second, independent `*rand.Rand`. The two never share. +Crossing them makes piece order depend on particle counts, which breaks §35. + +## 49.7 The §4 mockup is intent, not geometry + +The wide-layout mockup in §4 does not align — its right border is ragged and its +`║` columns drift. It communicates mood and element placement. The +ANSI-stripped golden tests in §41 are the binding layout contract. diff --git a/tests/inline-eval/microtests/plan-boundary/repo/go.mod b/tests/inline-eval/microtests/plan-boundary/repo/go.mod new file mode 100644 index 000000000..3ef96d956 --- /dev/null +++ b/tests/inline-eval/microtests/plan-boundary/repo/go.mod @@ -0,0 +1,3 @@ +module cosmic-tetris + +go 1.26 diff --git a/tests/inline-eval/microtests/plan-boundary/repo/internal/game/acceptance_test.go b/tests/inline-eval/microtests/plan-boundary/repo/internal/game/acceptance_test.go new file mode 100644 index 000000000..887da2880 --- /dev/null +++ b/tests/inline-eval/microtests/plan-boundary/repo/internal/game/acceptance_test.go @@ -0,0 +1,72 @@ +package game + +import ( + "math/rand" + "testing" + "time" +) + +func TestLongRandomSessionStaysConsistent(t *testing.T) { + for seed := int64(1); seed <= 20; seed++ { + g := New(seed) + localRng := rand.New(rand.NewSource(seed + 1_000_000)) + everOver := false + + for i := 0; i < 5000; i++ { + wasOver := g.Over + if wasOver { + everOver = true + } + + var evs []Event + switch localRng.Intn(7) { + case 0: + evs = g.MoveLeft() + case 1: + evs = g.MoveRight() + case 2: + evs = g.RotateCW() + case 3: + evs = g.RotateCCW() + case 4: + evs = g.SoftDrop() + case 5: + evs = g.HardDrop() + case 6: + evs = g.HoldPiece() + } + if wasOver && evs != nil { + t.Fatalf("seed %d step %d: action produced events after game over: %v", seed, i, evs) + } + + advEvs := g.Advance(16 * time.Millisecond) + if wasOver && advEvs != nil { + t.Fatalf("seed %d step %d: Advance produced events after game over: %v", seed, i, advEvs) + } + + for _, c := range g.Active.Cells() { + if !g.Board.InBounds(c.X, c.Y) { + t.Fatalf("seed %d step %d: active cell %v out of bounds", seed, i, c) + } + } + if rows := g.Board.CompleteRows(); len(rows) != 0 { + t.Fatalf("seed %d step %d: board has uncleared complete rows %v", seed, i, rows) + } + if len(g.Next) != NextCount { + t.Fatalf("seed %d step %d: len(Next) = %d, want %d", seed, i, len(g.Next), NextCount) + } + if g.Score < 0 || g.Lines < 0 || g.Level < 0 || g.Combo < 0 { + t.Fatalf("seed %d step %d: negative stat Score=%d Lines=%d Level=%d Combo=%d", + seed, i, g.Score, g.Lines, g.Level, g.Combo) + } + if g.Level != LevelFor(g.Lines) { + t.Fatalf("seed %d step %d: Level=%d, want LevelFor(Lines)=%d", seed, i, g.Level, LevelFor(g.Lines)) + } + + } + + if !everOver { + t.Fatalf("seed %d: game did not end within 5000 steps", seed) + } + } +} diff --git a/tests/inline-eval/microtests/plan-boundary/repo/internal/game/advance_test.go b/tests/inline-eval/microtests/plan-boundary/repo/internal/game/advance_test.go new file mode 100644 index 000000000..cc25f431a --- /dev/null +++ b/tests/inline-eval/microtests/plan-boundary/repo/internal/game/advance_test.go @@ -0,0 +1,205 @@ +package game + +import ( + "testing" + "time" +) + +func TestNewPieceStartsWithFreshFallState(t *testing.T) { + g := New(1) + for g.SoftDrop() != nil { + } + // Bank most of the lock delay and several lock resets on the piece about to lock. + g.Advance(400 * time.Millisecond) + g.MoveLeft() + g.Advance(400 * time.Millisecond) + g.MoveRight() + if evs := g.Advance(500 * time.Millisecond); eventWithKind(evs, PieceLocked) == nil { + t.Fatalf("expected the grounded piece to lock, got %v", evs) + } + + if g.GravityAccumulator != 0 { + t.Errorf("GravityAccumulator = %v after spawning a new piece, want 0", g.GravityAccumulator) + } + if g.LockAccumulator != 0 { + t.Errorf("LockAccumulator = %v after spawning a new piece, want 0", g.LockAccumulator) + } + if g.LockResets != 0 { + t.Errorf("LockResets = %d after spawning a new piece, want 0", g.LockResets) + } + + // The new piece must fall its first row after a full interval, not almost immediately + // because of gravity time banked by the piece that just locked. + y := g.Active.Y + g.Advance(100 * time.Millisecond) + if g.Active.Y != y { + t.Fatalf("Active.Y = %d after 100ms on a fresh piece, want unchanged %d (interval is %v)", g.Active.Y, y, g.Interval()) + } +} + +func TestAdvanceBelowIntervalDoesNothing(t *testing.T) { + g := New(1) + y := g.Active.Y + evs := g.Advance(100 * time.Millisecond) + if evs != nil { + t.Fatalf("Advance(100ms) = %v, want no events", evs) + } + if g.Active.Y != y { + t.Errorf("Active.Y = %d, want %d", g.Active.Y, y) + } +} + +func TestAdvanceAtIntervalDropsOneRow(t *testing.T) { + g := New(1) + y := g.Active.Y + evs := g.Advance(800 * time.Millisecond) + if len(evs) != 1 || evs[0].Kind != PieceMoved { + t.Fatalf("Advance(800ms) = %v, want one PieceMoved event", evs) + } + if g.Active.Y != y+1 { + t.Errorf("Active.Y = %d, want %d", g.Active.Y, y+1) + } +} + +func TestAdvanceHugeDtIsBoundedAndLeavesPieceGrounded(t *testing.T) { + g := New(1) + start := time.Now() + evs := g.Advance(20 * time.Second) + elapsed := time.Since(start) + if elapsed > 500*time.Millisecond { + t.Fatalf("Advance(20s) took %v, want well under a second", elapsed) + } + if len(evs) > maxCatchUpSteps+3 { + t.Fatalf("Advance(20s) emitted %d events, want at most %d", len(evs), maxCatchUpSteps+3) + } + if g.GravityAccumulator >= g.Interval() { + t.Errorf("GravityAccumulator = %v, want < Interval() %v", g.GravityAccumulator, g.Interval()) + } + locked := false + for _, e := range evs { + if e.Kind == PieceLocked { + locked = true + } + } + if !locked && !g.grounded() { + t.Errorf("piece is neither locked nor resting on the floor after a huge dt") + } +} + +func TestGhostY(t *testing.T) { + g := New(1) + ghostY := g.GhostY() + resting := g.Active + resting.Y = ghostY + if g.Board.Collides(resting) { + t.Fatalf("GhostY() = %d collides with the board", ghostY) + } + below := resting + below.Y++ + if !g.Board.Collides(below) { + t.Fatalf("GhostY() = %d does not actually rest (one row further still fits)", ghostY) + } + + g2 := New(1) + g2.Active = SpawnPiece(KindT) + g2.Board.Set(g2.Active.X, 15, CellFor(KindS)) + obstructed := g2.GhostY() + if obstructed >= ghostY { + t.Errorf("GhostY() with an obstruction = %d, want less than the unobstructed %d", obstructed, ghostY) + } + restingObstructed := g2.Active + restingObstructed.Y = obstructed + if g2.Board.Collides(restingObstructed) { + t.Fatalf("obstructed GhostY() = %d collides with the board", obstructed) + } +} + +func TestSoftDropScoresOnePoint(t *testing.T) { + g := New(1) + y := g.Active.Y + evs := g.SoftDrop() + if len(evs) != 1 || evs[0].Kind != PieceSoftDropped || evs[0].ScoreDelta != SoftDropPoints { + t.Fatalf("SoftDrop() = %v", evs) + } + if g.Score != SoftDropPoints { + t.Errorf("Score = %d, want %d", g.Score, SoftDropPoints) + } + if g.Active.Y != y+1 { + t.Errorf("Active.Y = %d, want %d", g.Active.Y, y+1) + } + + for g.SoftDrop() != nil { + } + scoreAtFloor := g.Score + if evs := g.SoftDrop(); evs != nil { + t.Fatalf("SoftDrop() at the floor = %v, want nil", evs) + } + if g.Score != scoreAtFloor { + t.Errorf("Score changed on a blocked soft drop") + } +} + +func TestGroundedPieceLocksAfterLockDelay(t *testing.T) { + g := New(1) + for g.SoftDrop() != nil { + } + evs := g.Advance(499 * time.Millisecond) + for _, e := range evs { + if e.Kind == PieceLocked { + t.Fatalf("PieceLocked emitted early: %v", evs) + } + } + evs = g.Advance(1 * time.Millisecond) + found := false + for _, e := range evs { + if e.Kind == PieceLocked { + found = true + } + } + if !found { + t.Fatalf("expected PieceLocked once the lock delay elapsed, got %v", evs) + } +} + +func TestMovementWhileGroundedResetsLockTimer(t *testing.T) { + g := New(1) + for g.SoftDrop() != nil { + } + g.Advance(400 * time.Millisecond) + g.MoveLeft() + evs := g.Advance(400 * time.Millisecond) + for _, e := range evs { + if e.Kind == PieceLocked { + t.Fatalf("PieceLocked emitted despite lock timer reset: %v", evs) + } + } +} + +func TestLockResetsAreCapped(t *testing.T) { + g := New(1) + for g.SoftDrop() != nil { + } + locked := false + for i := 0; i < 20; i++ { + evs := g.Advance(400 * time.Millisecond) + for _, e := range evs { + if e.Kind == PieceLocked { + locked = true + } + } + if locked { + break + } + if i%2 == 0 { + g.MoveLeft() + } else { + g.MoveRight() + } + } + if !locked { + t.Fatalf("expected a PieceLocked event within 20 alternating reset attempts") + } + if g.LockResets > MaxLockResets { + t.Errorf("LockResets = %d, want <= %d", g.LockResets, MaxLockResets) + } +} diff --git a/tests/inline-eval/microtests/plan-boundary/repo/internal/game/bag.go b/tests/inline-eval/microtests/plan-boundary/repo/internal/game/bag.go new file mode 100644 index 000000000..260a6800e --- /dev/null +++ b/tests/inline-eval/microtests/plan-boundary/repo/internal/game/bag.go @@ -0,0 +1,31 @@ +package game + +import "math/rand" + +type Bag struct { + rng *rand.Rand + queue []PieceKind +} + +func NewBag(rng *rand.Rand) *Bag { + return &Bag{rng: rng} +} + +func (b *Bag) Next() PieceKind { + if len(b.queue) == 0 { + b.refill() + } + k := b.queue[0] + b.queue = b.queue[1:] + return k +} + +func (b *Bag) refill() { + b.queue = make([]PieceKind, KindCount) + for k := PieceKind(0); k < KindCount; k++ { + b.queue[k] = k + } + b.rng.Shuffle(len(b.queue), func(i, j int) { + b.queue[i], b.queue[j] = b.queue[j], b.queue[i] + }) +} diff --git a/tests/inline-eval/microtests/plan-boundary/repo/internal/game/bag_test.go b/tests/inline-eval/microtests/plan-boundary/repo/internal/game/bag_test.go new file mode 100644 index 000000000..adbecebe9 --- /dev/null +++ b/tests/inline-eval/microtests/plan-boundary/repo/internal/game/bag_test.go @@ -0,0 +1,57 @@ +package game + +import ( + "math" + "math/rand" + "testing" +) + +func drawKinds(b *Bag, n int) []PieceKind { + out := make([]PieceKind, n) + for i := range out { + out[i] = b.Next() + } + return out +} + +func assertAllSevenExactlyOnce(t *testing.T, kinds []PieceKind) { + t.Helper() + if len(kinds) != KindCount { + t.Fatalf("got %d kinds, want %d", len(kinds), KindCount) + } + counts := map[PieceKind]int{} + for _, k := range kinds { + counts[k]++ + } + for k := PieceKind(0); k < KindCount; k++ { + if counts[k] != 1 { + t.Errorf("kind %v appears %d times, want 1", k, counts[k]) + } + } +} + +func TestEachBagContainsAllSevenExactlyOnce(t *testing.T) { + b := NewBag(rand.New(rand.NewSource(1))) + drawn := drawKinds(b, 14) + assertAllSevenExactlyOnce(t, drawn[0:7]) + assertAllSevenExactlyOnce(t, drawn[7:14]) +} + +func TestSeededBagIsReproducible(t *testing.T) { + a := NewBag(rand.New(rand.NewSource(8675309))) + b := NewBag(rand.New(rand.NewSource(8675309))) + seqA := drawKinds(a, 30) + seqB := drawKinds(b, 30) + for i := range seqA { + if seqA[i] != seqB[i] { + t.Fatalf("sequences diverge at index %d: %v != %v", i, seqA[i], seqB[i]) + } + } +} + +func TestBagWorksWithZeroAndNegativeSeeds(t *testing.T) { + for _, seed := range []int64{0, -1, math.MinInt64} { + b := NewBag(rand.New(rand.NewSource(seed))) + assertAllSevenExactlyOnce(t, drawKinds(b, 7)) + } +} diff --git a/tests/inline-eval/microtests/plan-boundary/repo/internal/game/board.go b/tests/inline-eval/microtests/plan-boundary/repo/internal/game/board.go new file mode 100644 index 000000000..31f79fffe --- /dev/null +++ b/tests/inline-eval/microtests/plan-boundary/repo/internal/game/board.go @@ -0,0 +1,118 @@ +package game + +import "strings" + +const ( + Width = 10 + Height = 22 + VisibleRows = 20 + HiddenRows = 2 +) + +type Cell uint8 + +const CellEmpty Cell = 0 + +func CellFor(k PieceKind) Cell { + return Cell(k) + 1 +} + +func (c Cell) Empty() bool { + return c == CellEmpty +} + +func (c Cell) Kind() PieceKind { + if c.Empty() { + panic("game: Kind() called on empty cell") + } + return PieceKind(c - 1) +} + +type Board struct { + Cells [Height][Width]Cell +} + +func (b *Board) At(x, y int) Cell { + return b.Cells[y][x] +} + +func (b *Board) Set(x, y int, c Cell) { + b.Cells[y][x] = c +} + +func (b *Board) InBounds(x, y int) bool { + return x >= 0 && x < Width && y >= 0 && y < Height +} + +func (b *Board) Occupied(x, y int) bool { + if !b.InBounds(x, y) { + return true + } + return !b.At(x, y).Empty() +} + +func (b *Board) Collides(p Piece) bool { + for _, c := range p.Cells() { + if b.Occupied(c.X, c.Y) { + return true + } + } + return false +} + +func (b *Board) Commit(p Piece) { + cell := CellFor(p.Kind) + for _, c := range p.Cells() { + b.Set(c.X, c.Y, cell) + } +} + +func (b *Board) CompleteRows() []int { + var rows []int + for y := 0; y < Height; y++ { + full := true + for x := 0; x < Width; x++ { + if b.At(x, y).Empty() { + full = false + break + } + } + if full { + rows = append(rows, y) + } + } + return rows +} + +func (b *Board) ClearRows(rows []int) { + remove := make(map[int]bool, len(rows)) + for _, r := range rows { + remove[r] = true + } + var next [Height][Width]Cell + writeY := Height - 1 + for y := Height - 1; y >= 0; y-- { + if remove[y] { + continue + } + next[writeY] = b.Cells[y] + writeY-- + } + b.Cells = next +} + +func (b *Board) String() string { + var sb strings.Builder + for y := 0; y < Height; y++ { + for x := 0; x < Width; x++ { + c := b.At(x, y) + if c.Empty() { + sb.WriteByte('.') + } else { + sb.WriteByte(c.Kind().Letter()) + } + } + sb.WriteByte('\n') + } + return sb.String() +} diff --git a/tests/inline-eval/microtests/plan-boundary/repo/internal/game/board_test.go b/tests/inline-eval/microtests/plan-boundary/repo/internal/game/board_test.go new file mode 100644 index 000000000..cde1a70d5 --- /dev/null +++ b/tests/inline-eval/microtests/plan-boundary/repo/internal/game/board_test.go @@ -0,0 +1,142 @@ +package game + +import ( + "strings" + "testing" +) + +func TestOccupiedTreatsOutOfBoundsAsSolid(t *testing.T) { + var b Board + cases := []struct { + x, y int + want bool + }{ + {-1, 5, true}, + {10, 5, true}, + {5, 22, true}, + {5, -1, true}, + {5, 5, false}, + } + for _, c := range cases { + if got := b.Occupied(c.x, c.y); got != c.want { + t.Errorf("Occupied(%d,%d) = %v, want %v", c.x, c.y, got, c.want) + } + } +} + +func TestCollidesWithWallsAndFloor(t *testing.T) { + var b Board + cases := []struct { + p Piece + want bool + }{ + {Piece{Kind: KindO, Rotation: 0, X: -1, Y: 0}, true}, + {Piece{Kind: KindO, Rotation: 0, X: 9, Y: 0}, true}, + {Piece{Kind: KindO, Rotation: 0, X: 0, Y: 21}, true}, + {Piece{Kind: KindO, Rotation: 0, X: 0, Y: 20}, false}, + } + for _, c := range cases { + if got := b.Collides(c.p); got != c.want { + t.Errorf("Collides(%+v) = %v, want %v", c.p, got, c.want) + } + } +} + +func TestCollidesWithLockedCell(t *testing.T) { + var b Board + b.Set(4, 10, CellFor(KindT)) + if got := b.Collides(Piece{Kind: KindO, Rotation: 0, X: 4, Y: 9}); !got { + t.Errorf("expected collision against locked cell") + } + if got := b.Collides(Piece{Kind: KindO, Rotation: 0, X: 0, Y: 9}); got { + t.Errorf("expected no collision away from locked cell") + } +} + +func TestCommitWritesKindCells(t *testing.T) { + var b Board + b.Commit(Piece{Kind: KindO, Rotation: 0, X: 4, Y: 20}) + want := CellFor(KindO) + for _, pt := range [][2]int{{4, 20}, {5, 20}, {4, 21}, {5, 21}} { + if got := b.At(pt[0], pt[1]); got != want { + t.Errorf("At(%d,%d) = %v, want %v", pt[0], pt[1], got, want) + } + } +} + +func TestCompleteRowsFindsAllFullRows(t *testing.T) { + var b Board + for x := 0; x < Width; x++ { + b.Set(x, 19, CellFor(KindT)) + b.Set(x, 21, CellFor(KindT)) + } + for x := 0; x < Width-1; x++ { + b.Set(x, 20, CellFor(KindT)) + } + got := b.CompleteRows() + want := []int{19, 21} + if len(got) != len(want) { + t.Fatalf("CompleteRows() = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("CompleteRows() = %v, want %v", got, want) + } + } +} + +func TestClearRowsCollapsesAbove(t *testing.T) { + var b Board + for x := 0; x < Width; x++ { + b.Set(x, 21, CellFor(KindT)) + } + b.Set(0, 20, CellFor(KindS)) + b.ClearRows([]int{21}) + if got, want := b.At(0, 21), CellFor(KindS); got != want { + t.Errorf("At(0,21) = %v, want %v (lone cell should fall)", got, want) + } + if got := b.At(0, 20); !got.Empty() { + t.Errorf("At(0,20) = %v, want empty", got) + } + for x := 0; x < Width; x++ { + if got := b.At(x, 0); !got.Empty() { + t.Errorf("At(%d,0) = %v, want empty", x, got) + } + } +} + +func TestClearRowsHandlesHiddenRows(t *testing.T) { + var b Board + for x := 0; x < Width; x++ { + b.Set(x, 0, CellFor(KindT)) + b.Set(x, 21, CellFor(KindT)) + } + b.ClearRows([]int{0, 21}) + for y := 0; y < Height; y++ { + for x := 0; x < Width; x++ { + if got := b.At(x, y); !got.Empty() { + t.Fatalf("At(%d,%d) = %v, want empty board after clearing", x, y, got) + } + } + } +} + +func TestBoardStringShape(t *testing.T) { + var b Board + s := strings.TrimRight(b.String(), "\n") + lines := strings.Split(s, "\n") + if len(lines) != Height { + t.Fatalf("String() has %d lines, want %d", len(lines), Height) + } + for _, line := range lines { + if line != strings.Repeat(".", Width) { + t.Errorf("empty row = %q, want all '.'", line) + } + } + b.Set(0, 0, CellFor(KindT)) + s = strings.TrimRight(b.String(), "\n") + lines = strings.Split(s, "\n") + if lines[0][0] != 'T' { + t.Errorf("filled cell rendered %q, want 'T'", lines[0][0]) + } +} diff --git a/tests/inline-eval/microtests/plan-boundary/repo/internal/game/clear_test.go b/tests/inline-eval/microtests/plan-boundary/repo/internal/game/clear_test.go new file mode 100644 index 000000000..3ee99a46f --- /dev/null +++ b/tests/inline-eval/microtests/plan-boundary/repo/internal/game/clear_test.go @@ -0,0 +1,215 @@ +package game + +import "testing" + +func eventWithKind(evs []Event, kind EventKind) *Event { + for i := range evs { + if evs[i].Kind == kind { + return &evs[i] + } + } + return nil +} + +func TestHardDropScoresTwoPerCell(t *testing.T) { + g := New(1) + startY := g.Active.Y + evs := g.HardDrop() + if len(evs) == 0 || evs[0].Kind != PieceHardDropped { + t.Fatalf("HardDrop() = %v, want first event PieceHardDropped", evs) + } + wantDistance := evs[0].Piece.Y - startY + if evs[0].Distance != wantDistance { + t.Errorf("Distance = %d, want %d", evs[0].Distance, wantDistance) + } + if evs[0].ScoreDelta != 2*evs[0].Distance { + t.Errorf("ScoreDelta = %d, want %d", evs[0].ScoreDelta, 2*evs[0].Distance) + } + if eventWithKind(evs, PieceLocked) == nil { + t.Errorf("no PieceLocked event in %v", evs) + } +} + +func TestHardDropOfAGroundedPieceStillLocks(t *testing.T) { + g := New(1) + for g.SoftDrop() != nil { + } + evs := g.HardDrop() + if len(evs) == 0 || evs[0].Kind != PieceHardDropped || evs[0].Distance != 0 { + t.Fatalf("HardDrop() = %v, want first event PieceHardDropped{Distance:0}", evs) + } + if eventWithKind(evs, PieceLocked) == nil { + t.Errorf("no PieceLocked event in %v", evs) + } +} + +func TestSingleLineClearScoresAndCollapses(t *testing.T) { + g := New(1) + for x := 0; x < 8; x++ { + g.Board.Set(x, 21, CellFor(KindT)) + } + g.Active = Piece{Kind: KindO, Rotation: 0, X: 8, Y: 0} + evs := g.HardDrop() + lc := eventWithKind(evs, LinesCleared) + if lc == nil { + t.Fatalf("no LinesCleared event in %v", evs) + } + if lc.Count != 1 { + t.Errorf("Count = %d, want 1", lc.Count) + } + if len(lc.Rows) != 1 || lc.Rows[0] != 21 { + t.Errorf("Rows = %v, want [21]", lc.Rows) + } + if len(lc.Cells) != 1 { + t.Fatalf("len(Cells) = %d, want 1", len(lc.Cells)) + } + for x := 0; x < 8; x++ { + if lc.Cells[0][x].Empty() { + t.Errorf("pre-clear Cells[0][%d] empty, want filled", x) + } + } + if want := 100 * g.Level; lc.ScoreDelta != want { + t.Errorf("ScoreDelta = %d, want %d", lc.ScoreDelta, want) + } + if g.Lines != 1 { + t.Errorf("Lines = %d, want 1", g.Lines) + } + if !g.Board.At(0, 21).Empty() { + t.Errorf("row 21 not empty after clear") + } +} + +func TestFourLineClearScores800TimesLevel(t *testing.T) { + g := New(1) + for y := 18; y <= 21; y++ { + for x := 0; x < 9; x++ { + g.Board.Set(x, y, CellFor(KindT)) + } + } + g.Active = Piece{Kind: KindI, Rotation: 1, X: 7, Y: 0} + evs := g.HardDrop() + lc := eventWithKind(evs, LinesCleared) + if lc == nil { + t.Fatalf("no LinesCleared event in %v", evs) + } + if lc.Count != 4 { + t.Errorf("Count = %d, want 4", lc.Count) + } + if want := 800 * g.Level; lc.ScoreDelta != want { + t.Errorf("ScoreDelta = %d, want %d (800*level, no combo bonus at combo 1)", lc.ScoreDelta, want) + } +} + +func TestComboAccumulatesAndResets(t *testing.T) { + g := New(1) + level := g.Level + + clearOneRow := func() []Event { + g.Board = Board{} + for x := 0; x < 8; x++ { + g.Board.Set(x, 21, CellFor(KindT)) + } + g.Active = Piece{Kind: KindO, Rotation: 0, X: 8, Y: 0} + return g.HardDrop() + } + dropWithoutClearing := func() []Event { + g.Board = Board{} + g.Active = Piece{Kind: KindO, Rotation: 0, X: 0, Y: 0} + return g.HardDrop() + } + + evs := clearOneRow() + if g.Combo != 1 { + t.Fatalf("Combo = %d, want 1 after first clear", g.Combo) + } + if cc := eventWithKind(evs, ComboChanged); cc == nil || cc.Count != 1 { + t.Errorf("ComboChanged = %v, want Count 1", cc) + } + lc := eventWithKind(evs, LinesCleared) + if lc == nil || lc.ScoreDelta != LineScore(1, level) { + t.Errorf("first clear ScoreDelta = %v, want %d (no combo bonus)", lc, LineScore(1, level)) + } + + evs = clearOneRow() + if g.Combo != 2 { + t.Fatalf("Combo = %d, want 2 after second consecutive clear", g.Combo) + } + lc = eventWithKind(evs, LinesCleared) + want := LineScore(1, level) + ComboBonus(2, level) + if lc == nil || lc.ScoreDelta != want { + t.Errorf("second clear ScoreDelta = %v, want %d", lc, want) + } + if got, want := ComboBonus(2, level), 50*1*level; got != want { + t.Errorf("ComboBonus(2,level) = %d, want %d", got, want) + } + + evs = dropWithoutClearing() + if g.Combo != 0 { + t.Errorf("Combo = %d, want 0 after non-clearing placement", g.Combo) + } + if cc := eventWithKind(evs, ComboChanged); cc == nil || cc.Count != 0 { + t.Errorf("ComboChanged = %v, want Count 0", cc) + } + + evs = dropWithoutClearing() + if cc := eventWithKind(evs, ComboChanged); cc != nil { + t.Errorf("unexpected ComboChanged event: %v", evs) + } +} + +func TestLevelIncreasesEveryTenLines(t *testing.T) { + g := New(1) + var last []Event + for i := 0; i < 10; i++ { + g.Board = Board{} + for x := 0; x < 8; x++ { + g.Board.Set(x, 21, CellFor(KindT)) + } + g.Active = Piece{Kind: KindO, Rotation: 0, X: 8, Y: 0} + last = g.HardDrop() + } + if g.Lines != 10 { + t.Fatalf("Lines = %d, want 10", g.Lines) + } + if g.Level != 2 { + t.Fatalf("Level = %d, want 2", g.Level) + } + count := 0 + for _, e := range last { + if e.Kind == LevelChanged { + count++ + if e.Count != 2 { + t.Errorf("LevelChanged.Count = %d, want 2", e.Count) + } + } + } + if count != 1 { + t.Errorf("LevelChanged emitted %d times on final placement, want 1", count) + } + if g.Interval() != DropInterval(2) { + t.Errorf("Interval() = %v, want %v", g.Interval(), DropInterval(2)) + } +} + +func TestClearIncludingHiddenRowKeepsBoardIntact(t *testing.T) { + g := New(1) + for x := 0; x < Width; x++ { + g.Board.Set(x, 1, CellFor(KindT)) + g.Board.Set(x, 21, CellFor(KindT)) + } + g.Active = Piece{Kind: KindO, Rotation: 0, X: 0, Y: 5} + evs := g.HardDrop() + lc := eventWithKind(evs, LinesCleared) + if lc == nil { + t.Fatalf("no LinesCleared event in %v", evs) + } + if lc.Count != 2 { + t.Errorf("Count = %d, want 2", lc.Count) + } + if len(g.Board.Cells) != Height { + t.Errorf("board has %d rows, want %d", len(g.Board.Cells), Height) + } + if rows := g.Board.CompleteRows(); len(rows) != 0 { + t.Errorf("CompleteRows() after clear = %v, want none", rows) + } +} diff --git a/tests/inline-eval/microtests/plan-boundary/repo/internal/game/determinism_test.go b/tests/inline-eval/microtests/plan-boundary/repo/internal/game/determinism_test.go new file mode 100644 index 000000000..1d66c4c23 --- /dev/null +++ b/tests/inline-eval/microtests/plan-boundary/repo/internal/game/determinism_test.go @@ -0,0 +1,172 @@ +package game + +import ( + "os" + "strings" + "testing" + "time" +) + +type replayStep struct { + key string + dt time.Duration +} + +// setupReplayBoard pre-fills all but the last column of the bottom row, so the +// first vertical I piece the script encounters (rotated and pushed against the +// right wall, which lands it in exactly that last column) completes the row. +// Applying this identically before both replay runs keeps the setup itself +// deterministic; it just guarantees the script's key presses land somewhere that +// exercises LinesCleared/ComboChanged/LevelChanged/ClearRows instead of leaving +// that to chance. +func setupReplayBoard(g *Game) { + for x := 0; x < Width-1; x++ { + g.Board.Set(x, Height-1, CellFor(KindO)) + } +} + +// buildReplayScript records a fixed key sequence by planning against a throwaway +// Game on the same seed (with the same setupReplayBoard applied). The first I +// piece encountered is rotated vertical and driven into the one open column, +// clearing the bottom row early; every other piece is simply moved out of the +// way on the left so the game keeps running. Rotation, hold and soft drop are +// mixed in periodically for input coverage. The resulting key list is fixed +// once built: both replay runs execute the same recorded steps. +func buildReplayScript() []replayStep { + dts := []time.Duration{7 * time.Millisecond, 16 * time.Millisecond, 250 * time.Millisecond, 900 * time.Millisecond} + plan := New(8675309) + setupReplayBoard(plan) + var keys []string + clearedYet := false + for step := 0; len(keys) < 200 && !plan.Over; step++ { + if !clearedYet && plan.Active.Kind == KindI { + keys = append(keys, "rotateCW") + plan.RotateCW() + for i := 0; i < Width; i++ { + keys = append(keys, "right") + plan.MoveRight() + } + keys = append(keys, "hard") + plan.HardDrop() + clearedYet = true + continue + } + for i := 0; i < Width; i++ { + keys = append(keys, "left") + plan.MoveLeft() + } + if step%5 == 2 { + keys = append(keys, "rotateCW") + plan.RotateCW() + } + if step%7 == 3 { + keys = append(keys, "hold") + plan.HoldPiece() + } + if step%4 == 1 { + keys = append(keys, "soft", "soft") + plan.SoftDrop() + plan.SoftDrop() + } + keys = append(keys, "hard") + plan.HardDrop() + } + if len(keys) > 200 { + keys = keys[:200] + } + script := make([]replayStep, len(keys)) + for i, k := range keys { + script[i] = replayStep{key: k, dt: dts[i%len(dts)]} + } + return script +} + +func runReplayScript(g *Game, script []replayStep) { + for _, s := range script { + switch s.key { + case "left": + g.MoveLeft() + case "right": + g.MoveRight() + case "rotateCW": + g.RotateCW() + case "rotateCCW": + g.RotateCCW() + case "soft": + g.SoftDrop() + case "hold": + g.HoldPiece() + case "hard": + g.HardDrop() + } + g.Advance(s.dt) + } +} + +func TestReplayIsReproducible(t *testing.T) { + script := buildReplayScript() + a := New(8675309) + setupReplayBoard(a) + runReplayScript(a, script) + b := New(8675309) + setupReplayBoard(b) + runReplayScript(b, script) + + if a.Score != b.Score || a.Lines != b.Lines || a.Level != b.Level || a.Combo != b.Combo || a.Over != b.Over { + t.Fatalf("state diverged: a={Score:%d Lines:%d Level:%d Combo:%d Over:%v} b={Score:%d Lines:%d Level:%d Combo:%d Over:%v}", + a.Score, a.Lines, a.Level, a.Combo, a.Over, b.Score, b.Lines, b.Level, b.Combo, b.Over) + } + if len(a.Next) != len(b.Next) { + t.Fatalf("Next length diverged: %d vs %d", len(a.Next), len(b.Next)) + } + for i := range a.Next { + if a.Next[i] != b.Next[i] { + t.Fatalf("Next diverged at %d: %v vs %v", i, a.Next[i], b.Next[i]) + } + } + if (a.Hold == nil) != (b.Hold == nil) { + t.Fatalf("Hold nilness diverged: %v vs %v", a.Hold, b.Hold) + } + if a.Hold != nil && *a.Hold != *b.Hold { + t.Fatalf("Hold diverged: %v vs %v", *a.Hold, *b.Hold) + } + if a.Board.String() != b.Board.String() { + t.Fatalf("Board diverged:\n%s\nvs\n%s", a.Board.String(), b.Board.String()) + } + if a.Lines == 0 { + t.Fatalf("script cleared 0 lines before topping out; it never exercises LinesCleared/ComboChanged/LevelChanged/ClearRows") + } +} + +func TestDifferentSeedsDiverge(t *testing.T) { + script := buildReplayScript() + a := New(1) + setupReplayBoard(a) + runReplayScript(a, script) + b := New(2) + setupReplayBoard(b) + runReplayScript(b, script) + if a.Board.String() == b.Board.String() { + t.Fatalf("boards from different seeds matched") + } +} + +func TestEngineNeverReadsTheClock(t *testing.T) { + entries, err := os.ReadDir(".") + if err != nil { + t.Fatalf("ReadDir(.): %v", err) + } + for _, e := range entries { + name := e.Name() + if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + data, err := os.ReadFile(name) + if err != nil { + t.Fatalf("ReadFile(%s): %v", name, err) + } + if strings.Contains(string(data), "time.Now(") { + t.Errorf("%s calls time.Now(), the engine must never read the clock", name) + } + } +} diff --git a/tests/inline-eval/microtests/plan-boundary/repo/internal/game/events.go b/tests/inline-eval/microtests/plan-boundary/repo/internal/game/events.go new file mode 100644 index 000000000..9277f84ab --- /dev/null +++ b/tests/inline-eval/microtests/plan-boundary/repo/internal/game/events.go @@ -0,0 +1,43 @@ +package game + +type EventKind int + +const ( + PieceMoved EventKind = iota + PieceRotated + PieceSoftDropped + PieceHardDropped + PieceLocked + HoldUsed + LinesCleared + ComboChanged + LevelChanged + GameOver +) + +var eventKindNames = [...]string{ + "PieceMoved", + "PieceRotated", + "PieceSoftDropped", + "PieceHardDropped", + "PieceLocked", + "HoldUsed", + "LinesCleared", + "ComboChanged", + "LevelChanged", + "GameOver", +} + +func (k EventKind) String() string { + return eventKindNames[k] +} + +type Event struct { + Kind EventKind + Piece Piece + Rows []int + Cells [][]Cell + Count int + Distance int + ScoreDelta int +} diff --git a/tests/inline-eval/microtests/plan-boundary/repo/internal/game/game.go b/tests/inline-eval/microtests/plan-boundary/repo/internal/game/game.go new file mode 100644 index 000000000..f5b9171d0 --- /dev/null +++ b/tests/inline-eval/microtests/plan-boundary/repo/internal/game/game.go @@ -0,0 +1,283 @@ +package game + +import ( + "math/rand" + "time" +) + +const NextCount = 5 + +type Game struct { + Board Board + Active Piece + Hold *PieceKind + CanHold bool + Next []PieceKind + Bag *Bag + + Score, Lines, Level, Combo int + + GravityAccumulator time.Duration + LockAccumulator time.Duration + LockResets int + Over bool + + Seed int64 + rng *rand.Rand +} + +func New(seed int64) *Game { + rng := rand.New(rand.NewSource(seed)) + g := &Game{ + Seed: seed, + rng: rng, + Bag: NewBag(rng), + CanHold: true, + Level: LevelFor(0), + } + g.Next = make([]PieceKind, NextCount) + for i := range g.Next { + g.Next[i] = g.Bag.Next() + } + g.spawn() + return g +} + +func (g *Game) spawn() []Event { + kind := g.Next[0] + copy(g.Next, g.Next[1:]) + g.Next[len(g.Next)-1] = g.Bag.Next() + g.Active = SpawnPiece(kind) + g.resetFallState() + if g.Board.Collides(g.Active) { + g.Over = true + return []Event{{Kind: GameOver}} + } + return nil +} + +// resetFallState gives a newly active piece a fresh gravity and lock budget, +// independent of whatever the previous piece had banked. +func (g *Game) resetFallState() { + g.GravityAccumulator = 0 + g.LockAccumulator = 0 + g.LockResets = 0 +} + +func (g *Game) tryMove(dx, dy int) bool { + candidate := g.Active + candidate.X += dx + candidate.Y += dy + if g.Board.Collides(candidate) { + return false + } + g.Active = candidate + return true +} + +func (g *Game) grounded() bool { + p := g.Active + p.Y++ + return g.Board.Collides(p) +} + +func (g *Game) noteGroundedReset() { + if g.grounded() && g.LockResets < MaxLockResets { + g.LockAccumulator = 0 + g.LockResets++ + } +} + +func (g *Game) MoveLeft() []Event { + if g.Over { + return nil + } + if !g.tryMove(-1, 0) { + return nil + } + g.noteGroundedReset() + return []Event{{Kind: PieceMoved, Piece: g.Active}} +} + +func (g *Game) MoveRight() []Event { + if g.Over { + return nil + } + if !g.tryMove(1, 0) { + return nil + } + g.noteGroundedReset() + return []Event{{Kind: PieceMoved, Piece: g.Active}} +} + +func (g *Game) rotate(delta int) []Event { + if g.Over { + return nil + } + candidate := g.Active + candidate.Rotation = ((candidate.Rotation+delta)%4 + 4) % 4 + for _, k := range KickOffsets { + p := candidate + p.X += k.X + p.Y += k.Y + if !g.Board.Collides(p) { + g.Active = p + g.noteGroundedReset() + return []Event{{Kind: PieceRotated, Piece: g.Active}} + } + } + return nil +} + +func (g *Game) RotateCW() []Event { + return g.rotate(1) +} + +func (g *Game) RotateCCW() []Event { + return g.rotate(-1) +} + +const maxCatchUpSteps = 20 + +func (g *Game) Interval() time.Duration { + return DropInterval(g.Level) +} + +func (g *Game) Advance(dt time.Duration) []Event { + if g.Over { + return nil + } + var evs []Event + interval := g.Interval() + g.GravityAccumulator += dt + for steps := 0; g.GravityAccumulator >= interval; steps++ { + if steps >= maxCatchUpSteps { + g.GravityAccumulator = 0 + break + } + g.GravityAccumulator -= interval + if !g.tryMove(0, 1) { + g.GravityAccumulator = 0 + break + } + evs = append(evs, Event{Kind: PieceMoved, Piece: g.Active}) + g.LockAccumulator, g.LockResets = 0, 0 + } + if g.grounded() { + g.LockAccumulator += dt + if g.LockAccumulator >= LockDelay { + evs = append(evs, g.lock()...) + } + } else { + g.LockAccumulator = 0 + } + return evs +} + +func (g *Game) SoftDrop() []Event { + if g.Over { + return nil + } + if !g.tryMove(0, 1) { + return nil + } + g.Score += SoftDropPoints + return []Event{{Kind: PieceSoftDropped, ScoreDelta: SoftDropPoints, Distance: 1, Piece: g.Active}} +} + +func (g *Game) HoldPiece() []Event { + if g.Over || !g.CanHold { + return nil + } + activeKind := g.Active.Kind + if g.Hold == nil { + h := activeKind + g.Hold = &h + evs := []Event{{Kind: HoldUsed, Piece: g.Active}} + g.CanHold = false + return append(evs, g.spawn()...) + } + heldKind := *g.Hold + incoming := SpawnPiece(heldKind) + if g.Board.Collides(incoming) { + g.Over = true + return []Event{{Kind: GameOver}} + } + h := activeKind + g.Hold = &h + g.Active = incoming + g.resetFallState() + g.CanHold = false + return []Event{{Kind: HoldUsed, Piece: g.Active}} +} + +func (g *Game) GhostY() int { + y := g.Active.Y + for { + candidate := g.Active + candidate.Y = y + 1 + if g.Board.Collides(candidate) { + return y + } + y++ + } +} + +func (g *Game) HardDrop() []Event { + if g.Over { + return nil + } + startY := g.Active.Y + g.Active.Y = g.GhostY() + distance := g.Active.Y - startY + evs := []Event{{Kind: PieceHardDropped, Piece: g.Active, Distance: distance, ScoreDelta: HardDropPoints * distance}} + g.Score += HardDropPoints * distance + evs = append(evs, g.lock()...) + return evs +} + +func (g *Game) lock() []Event { + g.Board.Commit(g.Active) + evs := []Event{{Kind: PieceLocked, Piece: g.Active}} + + prevCombo, prevLevel := g.Combo, g.Level + + rows := g.Board.CompleteRows() + if len(rows) > 0 { + cellsBefore := make([][]Cell, len(rows)) + for i, r := range rows { + row := make([]Cell, Width) + for x := 0; x < Width; x++ { + row[x] = g.Board.At(x, r) + } + cellsBefore[i] = row + } + g.Combo++ + scoreDelta := LineScore(len(rows), g.Level) + ComboBonus(g.Combo, g.Level) + evs = append(evs, Event{ + Kind: LinesCleared, + Rows: rows, + Cells: cellsBefore, + Count: len(rows), + ScoreDelta: scoreDelta, + }) + g.Board.ClearRows(rows) + g.Score += scoreDelta + g.Lines += len(rows) + } else { + g.Combo = 0 + } + + if g.Combo != prevCombo { + evs = append(evs, Event{Kind: ComboChanged, Count: g.Combo}) + } + + g.Level = LevelFor(g.Lines) + if g.Level != prevLevel { + evs = append(evs, Event{Kind: LevelChanged, Count: g.Level}) + } + + evs = append(evs, g.spawn()...) + g.CanHold = true + return evs +} diff --git a/tests/inline-eval/microtests/plan-boundary/repo/internal/game/game_test.go b/tests/inline-eval/microtests/plan-boundary/repo/internal/game/game_test.go new file mode 100644 index 000000000..ea534fc45 --- /dev/null +++ b/tests/inline-eval/microtests/plan-boundary/repo/internal/game/game_test.go @@ -0,0 +1,155 @@ +package game + +import "testing" + +func TestNewGameInitialState(t *testing.T) { + seed := int64(42) + g := New(seed) + if g.Score != 0 || g.Lines != 0 || g.Combo != 0 { + t.Errorf("initial Score/Lines/Combo = %d/%d/%d, want 0/0/0", g.Score, g.Lines, g.Combo) + } + if g.Level != 1 { + t.Errorf("Level = %d, want 1", g.Level) + } + if len(g.Next) != NextCount { + t.Errorf("len(Next) = %d, want %d", len(g.Next), NextCount) + } + if g.Hold != nil { + t.Errorf("Hold = %v, want nil", g.Hold) + } + if !g.CanHold { + t.Errorf("CanHold = false, want true") + } + if g.Over { + t.Errorf("Over = true, want false") + } + if g.Seed != seed { + t.Errorf("Seed = %d, want %d", g.Seed, seed) + } + if g.Active.Rotation != 0 || g.Active.Y != 0 { + t.Errorf("Active = %+v, want Rotation 0, Y 0", g.Active) + } +} + +func TestMoveLeftAndRight(t *testing.T) { + g := New(1) + x := g.Active.X + evs := g.MoveLeft() + if len(evs) != 1 || evs[0].Kind != PieceMoved { + t.Fatalf("MoveLeft() = %v, want one PieceMoved event", evs) + } + if g.Active.X != x-1 { + t.Errorf("Active.X = %d, want %d", g.Active.X, x-1) + } + g.MoveRight() + g.MoveRight() + if g.Active.X != x+1 { + t.Errorf("Active.X = %d, want %d", g.Active.X, x+1) + } +} + +func TestBlockedMoveEmitsNothing(t *testing.T) { + g := New(1) + for i := 0; i < 20; i++ { + before := g.Active + evs := g.MoveLeft() + if evs == nil { + if g.Active != before { + t.Errorf("Active changed to %+v despite blocked move, want unchanged %+v", g.Active, before) + } + return + } + } + t.Fatalf("MoveLeft never blocked after 20 attempts") +} + +func TestRotationEmitsPieceRotated(t *testing.T) { + g := New(1) + g.Active = Piece{Kind: KindT, Rotation: 0, X: 4, Y: 10} + evs := g.RotateCW() + if len(evs) != 1 || evs[0].Kind != PieceRotated { + t.Fatalf("RotateCW() = %v, want one PieceRotated event", evs) + } + if g.Active.Rotation != 1 { + t.Errorf("Active.Rotation = %d, want 1", g.Active.Rotation) + } + + g.Active = Piece{Kind: KindT, Rotation: 0, X: 4, Y: 10} + g.RotateCCW() + if g.Active.Rotation != 3 { + t.Errorf("RotateCCW from rotation 0: Rotation = %d, want 3", g.Active.Rotation) + } +} + +func TestRotationWallKicks(t *testing.T) { + g := New(1) + g.Active = Piece{Kind: KindI, Rotation: 1, X: -1, Y: 5} + evs := g.RotateCW() + if evs == nil { + t.Fatalf("RotateCW() = nil, want a successful kicked rotation") + } + for _, c := range g.Active.Cells() { + if !g.Board.InBounds(c.X, c.Y) { + t.Errorf("cell %v out of bounds after rotation", c) + } + } +} + +func TestRotationFailsInTightPocket(t *testing.T) { + g := New(1) + for y := 0; y < Height; y++ { + for x := 0; x < Width; x++ { + if x == 5 { + continue + } + g.Board.Set(x, y, CellFor(KindT)) + } + } + g.Active = Piece{Kind: KindI, Rotation: 1, X: 3, Y: 8} + before := g.Active + evs := g.RotateCW() + if evs != nil { + t.Fatalf("RotateCW() = %v, want nil in tight pocket", evs) + } + if g.Active != before { + t.Errorf("Active = %+v, want unchanged %+v", g.Active, before) + } +} + +func TestRotationNeverEscapesTheCeiling(t *testing.T) { + g := New(1) + // Block every column but 5 for every row below the piece so every kick offset, + // including the (0,-1)/(-1,-1)/(1,-1) trio, is actually tried and rejected right + // at the ceiling, instead of succeeding trivially on the first (0,0) offset. + for y := 1; y < Height; y++ { + for x := 0; x < Width; x++ { + if x == 5 { + continue + } + g.Board.Set(x, y, CellFor(KindT)) + } + } + g.Active = Piece{Kind: KindI, Rotation: 1, X: 3, Y: 0} + boardBefore := g.Board + activeBefore := g.Active + + evs := g.RotateCW() + + if evs != nil { + for _, c := range g.Active.Cells() { + if c.Y < 0 { + t.Errorf("cell %v has Y < 0 after rotation", c) + } + } + if g.Board.Collides(g.Active) { + t.Errorf("RotateCW() accepted a colliding position: %+v", g.Active) + } + return + } + if g.Active != activeBefore { + t.Errorf("Active changed to %+v despite a rejected rotation, want unchanged %+v", g.Active, activeBefore) + } + if g.Board != boardBefore { + t.Errorf("Board mutated by RotateCW") + } +} diff --git a/tests/inline-eval/microtests/plan-boundary/repo/internal/game/hold_test.go b/tests/inline-eval/microtests/plan-boundary/repo/internal/game/hold_test.go new file mode 100644 index 000000000..726478167 --- /dev/null +++ b/tests/inline-eval/microtests/plan-boundary/repo/internal/game/hold_test.go @@ -0,0 +1,149 @@ +package game + +import ( + "testing" + "time" +) + +func TestFirstHoldStoresAndSpawnsNext(t *testing.T) { + g := New(1) + kind := g.Active.Kind + next := g.Next[0] + evs := g.HoldPiece() + if len(evs) == 0 || evs[0].Kind != HoldUsed { + t.Fatalf("HoldPiece() = %v, want first event HoldUsed", evs) + } + if g.Hold == nil || *g.Hold != kind { + t.Fatalf("Hold = %v, want %v", g.Hold, kind) + } + if g.Active.Kind != next { + t.Errorf("Active.Kind = %v, want %v", g.Active.Kind, next) + } + if want := SpawnPiece(next); g.Active != want { + t.Errorf("Active = %+v, want %+v", g.Active, want) + } + if g.CanHold { + t.Errorf("CanHold = true, want false") + } +} + +func TestSecondHoldIsBlocked(t *testing.T) { + g := New(1) + g.HoldPiece() + before := *g + evs := g.HoldPiece() + if evs != nil { + t.Fatalf("second HoldPiece() = %v, want nil", evs) + } + if g.Active != before.Active || *g.Hold != *before.Hold || g.CanHold != before.CanHold { + t.Errorf("state changed on blocked hold") + } +} + +func TestHoldSwapsAndKeepsSpawnRotation(t *testing.T) { + g := New(1) + firstKind := g.Active.Kind + g.HoldPiece() + g.HardDrop() + g.RotateCW() + g.RotateCW() + evs := g.HoldPiece() + if eventWithKind(evs, HoldUsed) == nil { + t.Fatalf("no HoldUsed event in %v", evs) + } + want := SpawnPiece(firstKind) + if g.Active != want { + t.Errorf("Active = %+v, want %+v", g.Active, want) + } +} + +func TestHoldAvailableAgainAfterLock(t *testing.T) { + g := New(1) + g.HoldPiece() + if g.CanHold { + t.Fatalf("CanHold = true after HoldPiece, want false") + } + g.HardDrop() + if !g.CanHold { + t.Errorf("CanHold = false after lock, want true") + } +} + +func TestBlockedSpawnEndsTheGame(t *testing.T) { + g := New(1) + g.Active.Y = 10 // keep the current piece clear of the rows about to be blocked + // Every spawn geometry lies within columns 3-6; block that band in both hidden + // rows without completing any full row (which would auto-clear before spawn). + for _, y := range []int{0, 1} { + for x := 3; x <= 6; x++ { + g.Board.Set(x, y, CellFor(KindT)) + } + } + evs := g.HardDrop() + if eventWithKind(evs, GameOver) == nil { + t.Fatalf("no GameOver event in %v", evs) + } + if !g.Over { + t.Fatalf("Over = false, want true") + } + if evs := g.Advance(1 * time.Second); evs != nil { + t.Errorf("Advance() after game over = %v, want nil", evs) + } +} + +func TestHoldIntoBlockedSpawnEndsTheGameCleanly(t *testing.T) { + g := New(1) + for x := 0; x < Width; x++ { + g.Board.Set(x, 0, CellFor(KindT)) + g.Board.Set(x, 1, CellFor(KindT)) + } + boardBefore := g.Board.String() + evs := g.HoldPiece() + if eventWithKind(evs, GameOver) == nil { + t.Fatalf("no GameOver event in %v", evs) + } + if !g.Over { + t.Errorf("Over = false, want true") + } + if g.Board.String() != boardBefore { + t.Errorf("board changed after blocked hold-spawn") + } +} + +func TestHoldSwapIntoBlockedSpawnEndsTheGameCleanly(t *testing.T) { + g := New(1) + firstKind := g.Active.Kind + g.HoldPiece() // Hold now holds firstKind; the empty-hold branch, not the swap branch. + g.HardDrop() // lock, restoring CanHold, so the next HoldPiece takes the swap branch. + + // Block every cell the incoming (held) piece's spawn would occupy, in both hidden rows. + incoming := SpawnPiece(firstKind) + size := incoming.BoxSize() + for y := 0; y < HiddenRows; y++ { + for x := incoming.X; x < incoming.X+size; x++ { + g.Board.Set(x, y, CellFor(KindZ)) + } + } + + boardBefore := g.Board.String() + holdBefore := *g.Hold + activeBefore := g.Active + + evs := g.HoldPiece() + + if eventWithKind(evs, GameOver) == nil { + t.Fatalf("no GameOver event in %v", evs) + } + if !g.Over { + t.Errorf("Over = false, want true") + } + if g.Board.String() != boardBefore { + t.Errorf("board changed after a blocked hold swap") + } + if *g.Hold != holdBefore { + t.Errorf("Hold changed after a blocked hold swap: %v, want unchanged %v", *g.Hold, holdBefore) + } + if g.Active != activeBefore { + t.Errorf("Active changed after a blocked hold swap: %+v, want unchanged %+v", g.Active, activeBefore) + } +} diff --git a/tests/inline-eval/microtests/plan-boundary/repo/internal/game/piece.go b/tests/inline-eval/microtests/plan-boundary/repo/internal/game/piece.go new file mode 100644 index 000000000..53a9b9fa5 --- /dev/null +++ b/tests/inline-eval/microtests/plan-boundary/repo/internal/game/piece.go @@ -0,0 +1,118 @@ +package game + +type PieceKind int + +const ( + KindI PieceKind = iota + KindJ + KindL + KindO + KindS + KindT + KindZ +) + +const KindCount = 7 + +var kindNames = [KindCount]string{"I", "J", "L", "O", "S", "T", "Z"} + +func (k PieceKind) String() string { + return kindNames[k] +} + +func (k PieceKind) Letter() byte { + return kindNames[k][0] +} + +type Point struct{ X, Y int } + +type Piece struct { + Kind PieceKind + Rotation int + X, Y int +} + +func (p Piece) Cells() [4]Point { + rel := rotationCells(p.Kind, p.Rotation) + var out [4]Point + for i, pt := range rel { + out[i] = Point{X: pt.X + p.X, Y: pt.Y + p.Y} + } + return out +} + +func (p Piece) BoxSize() int { + return boxSizeFor(p.Kind) +} + +func boxSizeFor(k PieceKind) int { + switch k { + case KindI: + return 4 + case KindO: + return 2 + default: + return 3 + } +} + +func SpawnPiece(k PieceKind) Piece { + size := boxSizeFor(k) + return Piece{Kind: k, Rotation: 0, X: (10 - size) / 2, Y: 0} +} + +var spawnGrids = map[PieceKind][]string{ + KindI: {"....", "XXXX", "....", "...."}, + KindJ: {"X..", "XXX", "..."}, + KindL: {"..X", "XXX", "..."}, + KindO: {"XX", "XX"}, + KindS: {".XX", "XX.", "..."}, + KindT: {".X.", "XXX", "..."}, + KindZ: {"XX.", ".XX", "..."}, +} + +var rotationTable [KindCount][4][4]Point + +func init() { + for k := PieceKind(0); k < KindCount; k++ { + grid := spawnGrids[k] + for r := 0; r < 4; r++ { + rotationTable[k][r] = extractCells(grid) + grid = rotateCW(grid) + } + } +} + +// rotationCells returns the box-relative cells for a kind at a rotation, +// before the piece's board position is added. +func rotationCells(k PieceKind, r int) [4]Point { + return rotationTable[k][((r%4)+4)%4] +} + +func extractCells(grid []string) [4]Point { + var cells [4]Point + i := 0 + for y, row := range grid { + for x, ch := range row { + if ch == 'X' { + cells[i] = Point{X: x, Y: y} + i++ + } + } + } + return cells +} + +// rotateCW rotates a square grid of glyphs 90 degrees clockwise within its own box. +func rotateCW(grid []string) []string { + n := len(grid) + out := make([]string, n) + for i := 0; i < n; i++ { + row := make([]byte, n) + for j := 0; j < n; j++ { + row[j] = grid[n-1-j][i] + } + out[i] = string(row) + } + return out +} diff --git a/tests/inline-eval/microtests/plan-boundary/repo/internal/game/piece_test.go b/tests/inline-eval/microtests/plan-boundary/repo/internal/game/piece_test.go new file mode 100644 index 000000000..76298abad --- /dev/null +++ b/tests/inline-eval/microtests/plan-boundary/repo/internal/game/piece_test.go @@ -0,0 +1,113 @@ +package game + +import "testing" + +func TestKindStrings(t *testing.T) { + cases := []struct { + k PieceKind + want string + }{ + {KindI, "I"}, {KindJ, "J"}, {KindL, "L"}, {KindO, "O"}, + {KindS, "S"}, {KindT, "T"}, {KindZ, "Z"}, + } + for _, c := range cases { + if got := c.k.String(); got != c.want { + t.Errorf("%v.String() = %q, want %q", c.k, got, c.want) + } + if got := c.k.Letter(); got != c.want[0] { + t.Errorf("%v.Letter() = %q, want %q", c.k, got, c.want[0]) + } + } +} + +func uniquePoints(pts [4]Point) map[Point]bool { + set := map[Point]bool{} + for _, p := range pts { + set[p] = true + } + return set +} + +func TestEveryRotationHasFourCells(t *testing.T) { + for k := PieceKind(0); k < KindCount; k++ { + for r := 0; r < 4; r++ { + cells := Piece{Kind: k, Rotation: r}.Cells() + if got := len(uniquePoints(cells)); got != 4 { + t.Errorf("kind %v rotation %d: %d unique cells, want 4", k, r, got) + } + } + } +} + +func sameCellSet(a, b [4]Point) bool { + sa, sb := uniquePoints(a), uniquePoints(b) + if len(sa) != len(sb) { + return false + } + for p := range sa { + if !sb[p] { + return false + } + } + return true +} + +func TestFourRotationsReturnToStart(t *testing.T) { + for k := PieceKind(0); k < KindCount; k++ { + start := rotationCells(k, 0) + grid := spawnGrids[k] + for i := 0; i < 4; i++ { + grid = rotateCW(grid) + } + got := extractCells(grid) + if !sameCellSet(got, start) { + t.Errorf("kind %v: cells after 4 rotations = %v, want %v", k, got, start) + } + } +} + +func TestIPieceRotationZeroAndOne(t *testing.T) { + want0 := [4]Point{{0, 1}, {1, 1}, {2, 1}, {3, 1}} + if got := (Piece{Kind: KindI, Rotation: 0, X: 0, Y: 0}).Cells(); got != want0 { + t.Errorf("I rotation 0 cells = %v, want %v", got, want0) + } + want1 := [4]Point{{2, 0}, {2, 1}, {2, 2}, {2, 3}} + if got := (Piece{Kind: KindI, Rotation: 1, X: 0, Y: 0}).Cells(); got != want1 { + t.Errorf("I rotation 1 cells = %v, want %v", got, want1) + } +} + +func TestOPieceIdenticalThroughRotation(t *testing.T) { + want := [4]Point{{0, 0}, {1, 0}, {0, 1}, {1, 1}} + for r := 0; r < 4; r++ { + got := (Piece{Kind: KindO, Rotation: r, X: 0, Y: 0}).Cells() + if !sameCellSet(got, want) { + t.Errorf("O rotation %d cells = %v, want set %v", r, got, want) + } + } +} + +func TestSpawnPositions(t *testing.T) { + if got, want := SpawnPiece(KindI), (Piece{Kind: KindI, Rotation: 0, X: 3, Y: 0}); got != want { + t.Errorf("SpawnPiece(KindI) = %+v, want %+v", got, want) + } + if got, want := SpawnPiece(KindO), (Piece{Kind: KindO, Rotation: 0, X: 4, Y: 0}); got != want { + t.Errorf("SpawnPiece(KindO) = %+v, want %+v", got, want) + } + if got, want := SpawnPiece(KindT), (Piece{Kind: KindT, Rotation: 0, X: 3, Y: 0}); got != want { + t.Errorf("SpawnPiece(KindT) = %+v, want %+v", got, want) + } +} + +func TestSpawnCellsStayInHiddenRows(t *testing.T) { + for k := PieceKind(0); k < KindCount; k++ { + for _, c := range SpawnPiece(k).Cells() { + if c.Y >= 2 { + t.Errorf("kind %v: spawn cell %v has Y >= 2", k, c) + } + if c.X < 0 || c.X >= 10 { + t.Errorf("kind %v: spawn cell %v has X out of [0,10)", k, c) + } + } + } +} diff --git a/tests/inline-eval/microtests/plan-boundary/repo/internal/game/rules.go b/tests/inline-eval/microtests/plan-boundary/repo/internal/game/rules.go new file mode 100644 index 000000000..313746fb0 --- /dev/null +++ b/tests/inline-eval/microtests/plan-boundary/repo/internal/game/rules.go @@ -0,0 +1,14 @@ +package game + +import "time" + +const ( + LockDelay = 500 * time.Millisecond + MaxLockResets = 15 + BaseInterval = 800 * time.Millisecond + MinInterval = 60 * time.Millisecond + IntervalFactor = 0.86 + LinesPerLevel = 10 +) + +var KickOffsets = [8]Point{{0, 0}, {-1, 0}, {1, 0}, {-2, 0}, {2, 0}, {0, -1}, {-1, -1}, {1, -1}} diff --git a/tests/inline-eval/microtests/plan-boundary/repo/internal/game/scoring.go b/tests/inline-eval/microtests/plan-boundary/repo/internal/game/scoring.go new file mode 100644 index 000000000..e8e343cdb --- /dev/null +++ b/tests/inline-eval/microtests/plan-boundary/repo/internal/game/scoring.go @@ -0,0 +1,46 @@ +package game + +import ( + "math" + "time" +) + +const ( + SoftDropPoints = 1 + HardDropPoints = 2 +) + +func LineScore(lines, level int) int { + var base int + switch lines { + case 1: + base = 100 + case 2: + base = 300 + case 3: + base = 500 + case 4: + base = 800 + } + return base * level +} + +func ComboBonus(combo, level int) int { + if combo < 2 { + return 0 + } + return 50 * (combo - 1) * level +} + +func LevelFor(lines int) int { + return lines/LinesPerLevel + 1 +} + +func DropInterval(level int) time.Duration { + ms := float64(BaseInterval/time.Millisecond) * math.Pow(IntervalFactor, float64(level-1)) + d := time.Duration(math.Round(ms)) * time.Millisecond + if d < MinInterval { + return MinInterval + } + return d +} diff --git a/tests/inline-eval/microtests/plan-boundary/repo/internal/game/scoring_test.go b/tests/inline-eval/microtests/plan-boundary/repo/internal/game/scoring_test.go new file mode 100644 index 000000000..ddf91689d --- /dev/null +++ b/tests/inline-eval/microtests/plan-boundary/repo/internal/game/scoring_test.go @@ -0,0 +1,72 @@ +package game + +import ( + "testing" + "time" +) + +func TestLineScoreTable(t *testing.T) { + cases := []struct { + lines, level, want int + }{ + {0, 1, 0}, {1, 1, 100}, {2, 1, 300}, {3, 1, 500}, {4, 1, 800}, + {1, 7, 700}, {2, 7, 2100}, {3, 7, 3500}, {4, 7, 5600}, + } + for _, c := range cases { + if got := LineScore(c.lines, c.level); got != c.want { + t.Errorf("LineScore(%d,%d) = %d, want %d", c.lines, c.level, got, c.want) + } + } +} + +func TestComboBonus(t *testing.T) { + cases := []struct { + combo, level, want int + }{ + {0, 5, 0}, {1, 5, 0}, {2, 5, 250}, {3, 2, 200}, + } + for _, c := range cases { + if got := ComboBonus(c.combo, c.level); got != c.want { + t.Errorf("ComboBonus(%d,%d) = %d, want %d", c.combo, c.level, got, c.want) + } + } +} + +func TestLevelFor(t *testing.T) { + cases := []struct { + lines, want int + }{ + {0, 1}, {9, 1}, {10, 2}, {19, 2}, {20, 3}, {127, 13}, + } + for _, c := range cases { + if got := LevelFor(c.lines); got != c.want { + t.Errorf("LevelFor(%d) = %d, want %d", c.lines, got, c.want) + } + } +} + +func TestDropIntervalCurveAndFloor(t *testing.T) { + if got, want := DropInterval(1), 800*time.Millisecond; got != want { + t.Errorf("DropInterval(1) = %v, want %v", got, want) + } + if got, want := DropInterval(2), 688*time.Millisecond; got != want { + t.Errorf("DropInterval(2) = %v, want %v", got, want) + } + if got, want := DropInterval(3), 592*time.Millisecond; got != want { + t.Errorf("DropInterval(3) = %v, want %v", got, want) + } + prev := DropInterval(1) + for level := 2; level <= 19; level++ { + cur := DropInterval(level) + if cur >= prev { + t.Errorf("DropInterval(%d) = %v, not strictly less than DropInterval(%d) = %v", level, cur, level-1, prev) + } + prev = cur + } + if got := DropInterval(19); got != MinInterval { + t.Errorf("DropInterval(19) = %v, want MinInterval %v", got, MinInterval) + } + if got := DropInterval(30); got != MinInterval { + t.Errorf("DropInterval(30) = %v, want MinInterval %v", got, MinInterval) + } +} diff --git a/tests/inline-eval/microtests/plan-boundary/repo/plans/2026-09-18-cosmic-tetris-1-engine.md b/tests/inline-eval/microtests/plan-boundary/repo/plans/2026-09-18-cosmic-tetris-1-engine.md new file mode 100644 index 000000000..a95352c4f --- /dev/null +++ b/tests/inline-eval/microtests/plan-boundary/repo/plans/2026-09-18-cosmic-tetris-1-engine.md @@ -0,0 +1,777 @@ +# Cosmic Tetris — Plan 1: Headless Game Engine + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build `internal/game` — a deterministic, clock-free falling-block engine with pieces, board, 7-bag, movement, wall kicks, gravity, lock delay, line clears, hold, scoring and game over, fully unit tested. + +**Architecture:** One package, no dependencies outside the standard library. The engine is a pure state machine: every mutator returns a `[]Event` describing what happened, and time enters only through `Advance(dt)`. Nothing in the package reads a clock or renders anything. FX and UI packages built in later plans observe events; they never call into engine internals. + +**Tech Stack:** Go 1.26, standard library only (`math`, `math/rand`, `time` for `time.Duration`). + +**Spec:** `design.md` (this plan implements §5–§13, §34, §35, §40, §42 Phase 1, §49.1, §49.2, §49.4 geometry, §49.6) + +## Global Constraints + +- Language: Go. Module path: `cosmic-tetris`. Go directive: `go 1.26`. +- UI libraries (later plans): `charm.land/bubbletea/v2`, `charm.land/lipgloss/v2`, `charm.land/bubbles/v2`. Verified available at v2.0.9 / v2.0.6 / v2.2.1. +- Repository layout is fixed by §33. Do not add packages beyond `cmd/cosmic-tetris`, `internal/game`, `internal/app`, `internal/render`, `internal/fx`, `internal/flavor`. Extra *files* inside those packages are allowed where a plan names them. +- No networking, no profiles, no achievements, no plugin system, no database (§2). +- `internal/game` never calls `time.Now()` (§49.2). Bubble Tea owns the clock. +- Game RNG and FX RNG are separate `*rand.Rand` instances and never share (§49.6). +- Logical board: width 10, height 22, visible rows 20, hidden spawn rows 2 (§5). +- Combo bonus is exactly `50 × (combo - 1) × level` (§49.1). +- Drop interval is `800ms × 0.86^(level-1)`, clamped at a 60ms floor (§11). +- Lock delay 500ms; max 15 lock resets (§12). +- Effects may never modify game state (§14, §44). + +## Review Focus + +Input classes the spec implies but does not describe, most likely to bite first. Each has a test in the task named. + +1. `Advance(dt)` with a `dt` far larger than one drop interval — a suspended process or a debugger pause must not spin through thousands of gravity steps or skip locking (Task 6). +2. A hold swap whose incoming piece cannot spawn — must reach game over with the board intact, never a half-committed piece (Task 8). +3. `New(seed)` with seed `0` and negative seeds — the bag must still yield all seven kinds; `0` is a real seed, not "pick one for me" (Task 3). +4. Rotation in the hidden spawn rows where the `(0,-1)` kick would push cells above row 0 — rotation must fail rather than write out of bounds (Task 5). +5. A line clear whose row set includes a hidden spawn row — collapse must preserve board height and never index out of range (Task 7). + +## Plan Set + +Run in this order. A ruling that changes a name, signature, or value a later plan consumes is applied to that plan's file before the next task starts. + +1. `plans/2026-09-18-cosmic-tetris-1-engine.md` — headless deterministic engine in `internal/game`. Consumes: nothing. +2. `plans/2026-09-18-cosmic-tetris-2-playable-terminal.md` — Bubble Tea app, canvas renderer, layout, HUD, hold/next/ghost, CLI flags, pause/help/game-over card, golden tests. Consumes Plan 1's `game.Game`, `Advance`, `Event`, `Board`, `Piece`, `GhostY`. +3. `plans/2026-09-18-cosmic-tetris-3-cosmic-foundation.md` — `internal/fx` (particles, starfield), animated border, piece trails, `internal/flavor` mission control. Consumes Plan 1's `Event`/`Cell` and Plan 2's `render.Canvas`, `render.Layout`, `render.Snapshot`, `app.Model`. +4. `plans/2026-09-18-cosmic-tetris-4-violence.md` — hard-drop impact, screen shake, line supernova, shockwaves, hyperdrive, four-line sequence, combo/level overlays. Consumes Plan 3's `fx.World` and the render FX layer. +5. `plans/2026-09-18-cosmic-tetris-5-polish.md` — boot sequence, game-over black hole, ASCII/no-FX guarantees, §45 details, README, definition-of-done sweep. Consumes everything above. + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `go.mod` | module `cosmic-tetris`, `go 1.26` | +| `internal/game/piece.go` | `PieceKind`, rotation tables, `Piece`, spawn geometry | +| `internal/game/board.go` | `Cell`, `Board`, collision, row completion, clear + collapse | +| `internal/game/bag.go` | 7-bag generator over the game RNG | +| `internal/game/scoring.go` | line values, combo bonus, level, drop interval | +| `internal/game/rules.go` | tuning constants and kick offsets | +| `internal/game/events.go` | `EventKind`, `Event` | +| `internal/game/game.go` | `Game` state, mutators, `Advance`, hold, lock, game over | + +--- + +### Task 1: Module and piece geometry + +**Files:** +- Create: `go.mod`, `internal/game/piece.go` +- Test: `internal/game/piece_test.go` + +**Interfaces:** +- Consumes: nothing. +- Produces: +```go +type PieceKind int +const (KindI PieceKind = iota; KindJ; KindL; KindO; KindS; KindT; KindZ) +const KindCount = 7 +func (k PieceKind) String() string // "I", "J", "L", "O", "S", "T", "Z" +func (k PieceKind) Letter() byte // 'I' … 'Z' + +type Point struct{ X, Y int } + +type Piece struct { + Kind PieceKind + Rotation int // 0..3 + X, Y int // board coords of the piece box's top-left corner +} +func (p Piece) Cells() [4]Point // absolute board coords, +Y is down +func (p Piece) BoxSize() int // 4 for I, 2 for O, 3 otherwise +func SpawnPiece(k PieceKind) Piece // Rotation 0, Y=0, X=spawnX(k) +``` + +Rotation data: rotation 0 of each kind is a literal glyph grid; rotations 1–3 are produced by rotating that grid clockwise inside its own box and are baked into a package-level table at init. Grids (fixed — do not adjust): + +```go +var spawnGrids = map[PieceKind][]string{ + KindI: {"....", "XXXX", "....", "...."}, + KindJ: {"X..", "XXX", "..."}, + KindL: {"..X", "XXX", "..."}, + KindO: {"XX", "XX"}, + KindS: {".XX", "XX.", "..."}, + KindT: {".X.", "XXX", "..."}, + KindZ: {"XX.", ".XX", "..."}, +} +``` + +Spawn X is `(Width - BoxSize) / 2` — 3 for I, 4 for O, 3 for the rest. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestKindStrings(t *testing.T) +// KindI.String() == "I" … KindZ.String() == "Z"; Letter() matches String()[0]. + +func TestEveryRotationHasFourCells(t *testing.T) +// for each of the 7 kinds, for r in 0..3: len(unique cells of Piece{Kind:k, Rotation:r}.Cells()) == 4 + +func TestFourRotationsReturnToStart(t *testing.T) +// cell set of rotation 0 == cell set of rotation 4%4 reached by rotating the table four times; +// i.e. rotationCells(k,0) equals rotationCells(k,0) after four clockwise applications. + +func TestIPieceRotationZeroAndOne(t *testing.T) +// Piece{KindI, 0, 0, 0}.Cells() == {{0,1},{1,1},{2,1},{3,1}} +// Piece{KindI, 1, 0, 0}.Cells() == {{2,0},{2,1},{2,2},{2,3}} + +func TestOPieceIdenticalThroughRotation(t *testing.T) +// all four rotations of KindO give the same cell set {{0,0},{1,0},{0,1},{1,1}} + +func TestSpawnPositions(t *testing.T) +// SpawnPiece(KindI) == Piece{KindI, 0, 3, 0}; SpawnPiece(KindO) == Piece{KindO, 0, 4, 0}; +// SpawnPiece(KindT) == Piece{KindT, 0, 3, 0} + +func TestSpawnCellsStayInHiddenRows(t *testing.T) +// for every kind: every cell of SpawnPiece(k).Cells() has Y < 2 and 0 <= X < 10 +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/game/ -run 'TestKind|TestEvery|TestFour|TestIPiece|TestOPiece|TestSpawn' -v` +Expected: FAIL — build error, undefined identifiers. + +- [ ] **Step 3: Create `go.mod`** + +`module cosmic-tetris` with `go 1.26`. + +- [ ] **Step 4: Implement `internal/game/piece.go`** + +Parse `spawnGrids` into `[KindCount][4][4]Point` at init via a `rotateCW([]string) []string` helper. `Cells()` adds `p.X, p.Y` to the table entry. Cell order within a rotation is row-major over the grid, so the test literals above hold. + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add go.mod internal/game/piece.go internal/game/piece_test.go +git commit -m "feat(game): piece kinds, rotation tables and spawn geometry" +``` + +--- + +### Task 2: Board — cells, collision, row clear and collapse + +**Files:** +- Create: `internal/game/board.go` +- Test: `internal/game/board_test.go` + +**Interfaces:** +- Consumes: `Piece`, `PieceKind`, `Point` (Task 1). +- Produces: +```go +const (Width = 10; Height = 22; VisibleRows = 20; HiddenRows = 2) + +type Cell uint8 +const CellEmpty Cell = 0 +func CellFor(k PieceKind) Cell // k+1 +func (c Cell) Empty() bool +func (c Cell) Kind() PieceKind // panics if Empty + +type Board struct{ Cells [Height][Width]Cell } +func (b *Board) At(x, y int) Cell +func (b *Board) Set(x, y int, c Cell) +func (b *Board) InBounds(x, y int) bool // 0<=x At(4,20)==At(5,20)==At(4,21)==At(5,21)==CellFor(KindO) + +func TestCompleteRowsFindsAllFullRows(t *testing.T) +// fill rows 19 and 21 completely, row 20 with 9 cells => CompleteRows() == []int{19,21} + +func TestClearRowsCollapsesAbove(t *testing.T) +// fill row 21 completely; put a single cell at (0,20); ClearRows([]int{21}) +// => At(0,21) == CellFor(...) (the lone cell fell), row 20 empty, row 0 empty + +func TestClearRowsHandlesHiddenRows(t *testing.T) +// fill rows 0 and 21; ClearRows([]int{0,21}); board still Height rows, +// no panic, board is empty afterwards + +func TestBoardStringShape(t *testing.T) +// empty board String() has Height lines each of Width '.'; a KindT cell renders 'T' +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/game/ -run TestOccupied -v` +Expected: FAIL — undefined `Board`. + +- [ ] **Step 3: Implement `internal/game/board.go`** + +`ClearRows` copies surviving rows downward from the bottom and zeroes the remainder; it must tolerate an unsorted or duplicate `rows` slice. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/board.go internal/game/board_test.go +git commit -m "feat(game): board with collision, row completion and collapse" +``` + +--- + +### Task 3: 7-bag piece generator + +**Files:** +- Create: `internal/game/bag.go` +- Test: `internal/game/bag_test.go` + +**Interfaces:** +- Consumes: `PieceKind`, `KindCount` (Task 1). +- Produces: +```go +type Bag struct{ /* unexported: rng *rand.Rand; queue []PieceKind */ } +func NewBag(rng *rand.Rand) *Bag +func (b *Bag) Next() PieceKind +``` + +Refill puts one of every kind in the bag and shuffles with `rng.Shuffle`. The bag's `rng` is the game RNG passed in by `Game` (§49.6); the bag never creates its own. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestEachBagContainsAllSevenExactlyOnce(t *testing.T) +// draw 14 kinds; first 7 and second 7 each contain every kind exactly once + +func TestSeededBagIsReproducible(t *testing.T) +// two bags from rand.New(rand.NewSource(8675309)) produce identical 30-kind sequences + +func TestBagWorksWithZeroAndNegativeSeeds(t *testing.T) +// for seed in {0, -1, math.MinInt64}: first 7 draws contain every kind exactly once +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/game/ -run TestEachBag -v` +Expected: FAIL — undefined `NewBag`. + +- [ ] **Step 3: Implement `internal/game/bag.go`** + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/bag.go internal/game/bag_test.go +git commit -m "feat(game): seeded 7-bag generator" +``` + +--- + +### Task 4: Scoring, level and timing rules + +**Files:** +- Create: `internal/game/scoring.go`, `internal/game/rules.go` +- Test: `internal/game/scoring_test.go` + +**Interfaces:** +- Consumes: `Point` (Task 1). +- Produces: +```go +// rules.go +const ( + LockDelay = 500 * time.Millisecond + MaxLockResets = 15 + BaseInterval = 800 * time.Millisecond + MinInterval = 60 * time.Millisecond + IntervalFactor = 0.86 + LinesPerLevel = 10 +) +var KickOffsets = [8]Point{{0,0},{-1,0},{1,0},{-2,0},{2,0},{0,-1},{-1,-1},{1,-1}} + +// scoring.go +func LineScore(lines, level int) int // 0/100/300/500/800 × level +func ComboBonus(combo, level int) int // 50 × (combo-1) × level, 0 when combo < 2 +func LevelFor(lines int) int // lines/LinesPerLevel + 1 +func DropInterval(level int) time.Duration // rounded to whole ms, floor MinInterval +const (SoftDropPoints = 1; HardDropPoints = 2) // per cell +``` + +`KickOffsets` are `(dx, dy)` in board coordinates where `+y` is down, so `{0,-1}` lifts the piece one row (§7). + +- [ ] **Step 1: Write the failing tests** + +```go +func TestLineScoreTable(t *testing.T) +// level 1: 0→0, 1→100, 2→300, 3→500, 4→800 +// level 7: 1→700, 2→2100, 3→3500, 4→5600 + +func TestComboBonus(t *testing.T) +// ComboBonus(0,5)==0; ComboBonus(1,5)==0; ComboBonus(2,5)==250; ComboBonus(3,2)==200 + +func TestLevelFor(t *testing.T) +// 0→1, 9→1, 10→2, 19→2, 20→3, 127→13 + +func TestDropIntervalCurveAndFloor(t *testing.T) +// DropInterval(1) == 800*time.Millisecond +// DropInterval(2) == 688*time.Millisecond +// DropInterval(3) == 592*time.Millisecond +// strictly decreasing for level 1..19 +// DropInterval(19) == MinInterval and DropInterval(30) == MinInterval +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/game/ -run 'TestLineScore|TestCombo|TestLevelFor|TestDropInterval' -v` +Expected: FAIL — undefined `LineScore`. + +- [ ] **Step 3: Implement `scoring.go` and `rules.go`** + +`DropInterval` computes `BaseInterval × IntervalFactor^(level-1)` in float milliseconds, rounds to the nearest millisecond, then applies the floor. (`800 × 0.86 = 688`, `× 0.86 = 591.68 → 592`.) + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/scoring.go internal/game/rules.go internal/game/scoring_test.go +git commit -m "feat(game): scoring, level progression and gravity curve" +``` + +--- + +### Task 5: Game state, events, movement and rotation + +**Files:** +- Create: `internal/game/events.go`, `internal/game/game.go` +- Test: `internal/game/game_test.go` + +**Interfaces:** +- Consumes: everything from Tasks 1–4. +- Produces: +```go +// events.go +type EventKind int +const ( + PieceMoved EventKind = iota + PieceRotated + PieceSoftDropped + PieceHardDropped + PieceLocked + HoldUsed + LinesCleared + ComboChanged + LevelChanged + GameOver +) +func (k EventKind) String() string + +type Event struct { + Kind EventKind + Piece Piece // the piece involved; for PieceLocked its resting place + Rows []int // LinesCleared: cleared row indices, ascending + Cells [][]Cell // LinesCleared: contents of those rows before clearing, same order + Count int // LinesCleared: line count. ComboChanged: new combo. LevelChanged: new level + Distance int // PieceSoftDropped / PieceHardDropped: cells descended + ScoreDelta int +} + +// game.go +type Game struct { + Board Board + Active Piece + Hold *PieceKind + CanHold bool + Next []PieceKind // always len NextCount + Bag *Bag + + Score, Lines, Level, Combo int + + GravityAccumulator time.Duration + LockAccumulator time.Duration + LockResets int + Over bool + + Seed int64 + rng *rand.Rand +} +const NextCount = 5 + +func New(seed int64) *Game +func (g *Game) MoveLeft() []Event +func (g *Game) MoveRight() []Event +func (g *Game) RotateCW() []Event +func (g *Game) RotateCCW() []Event +``` + +`New` seeds one `*rand.Rand`, builds the bag, fills `Next` to `NextCount`, and spawns the first piece. Movement and rotation return a single-element `[]Event` on success and `nil` on failure, and never mutate state on failure. A successful move or rotation while grounded resets `LockAccumulator` and increments `LockResets`, but only while `LockResets < MaxLockResets` (§12). + +- [ ] **Step 1: Write the failing tests** + +```go +func TestNewGameInitialState(t *testing.T) +// Score/Lines/Combo == 0; Level == 1; len(Next) == NextCount; Hold == nil; CanHold == true; +// Over == false; Seed == the seed passed in; Active is a spawn piece (Rotation 0, Y 0) + +func TestMoveLeftAndRight(t *testing.T) +// x := g.Active.X; evs := g.MoveLeft(); len(evs)==1; evs[0].Kind==PieceMoved; g.Active.X == x-1 +// MoveRight twice => X == x+1 + +func TestBlockedMoveEmitsNothing(t *testing.T) +// push the active piece to the left wall in a loop; the move that fails returns nil +// and leaves Active unchanged + +func TestRotationEmitsPieceRotated(t *testing.T) +// g.Active = Piece{KindT,0,4,10}; evs := g.RotateCW(); evs[0].Kind == PieceRotated; +// g.Active.Rotation == 1; RotateCCW from rotation 0 gives Rotation 3 + +func TestRotationWallKicks(t *testing.T) +// g.Active = Piece{KindI,1,-1,5} placed flush against the left wall such that rotation 2 +// would overlap x<0; RotateCW succeeds and the resulting piece has all cells in bounds + +func TestRotationFailsInTightPocket(t *testing.T) +// fill the board except a 1-wide vertical slot; put a vertical I in the slot; +// RotateCW returns nil, Rotation and X/Y unchanged + +func TestRotationNeverEscapesTheCeiling(t *testing.T) +// spawn state: g.Active = Piece{KindI,1,3,0}; RotateCW must either succeed with every cell +// at Y >= 0 or return nil — assert no cell has Y < 0 and the board is untouched +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/game/ -run 'TestNewGame|TestMove|TestBlocked|TestRotation' -v` +Expected: FAIL — undefined `New`. + +- [ ] **Step 3: Implement `events.go` and the movement half of `game.go`** + +Rotation tries `KickOffsets` in order and accepts the first non-colliding position (§7). Add unexported helpers `tryMove(dx, dy int) bool`, `grounded() bool`, `spawn() []Event`, `noteGroundedReset()`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/events.go internal/game/game.go internal/game/game_test.go +git commit -m "feat(game): game state, event stream, movement and kicked rotation" +``` + +--- + +### Task 6: Advance — gravity, soft drop, ghost and lock delay + +**Files:** +- Modify: `internal/game/game.go` +- Test: `internal/game/advance_test.go` + +**Interfaces:** +- Consumes: Task 5's `Game`, `Event`; Task 4's `DropInterval`, `LockDelay`, `MaxLockResets`. +- Produces: +```go +func (g *Game) Advance(dt time.Duration) []Event +func (g *Game) SoftDrop() []Event // one cell; PieceSoftDropped with ScoreDelta = SoftDropPoints +func (g *Game) GhostY() int // Y the active piece would rest at +func (g *Game) Interval() time.Duration // DropInterval(g.Level), exposed for the HUD +const maxCatchUpSteps = 20 +``` + +`Advance` is the only way time enters the engine. Algorithm (fixed — the tests do not fully determine it): + +```go +func (g *Game) Advance(dt time.Duration) []Event { + if g.Over { return nil } + var evs []Event + interval := g.Interval() + g.GravityAccumulator += dt + for steps := 0; g.GravityAccumulator >= interval; steps++ { + if steps >= maxCatchUpSteps { g.GravityAccumulator = 0; break } + g.GravityAccumulator -= interval + if !g.tryMove(0, 1) { g.GravityAccumulator = 0; break } + evs = append(evs, Event{Kind: PieceMoved, Piece: g.Active}) + g.LockAccumulator, g.LockResets = 0, 0 // a new row is a fresh lock budget + } + if g.grounded() { + g.LockAccumulator += dt + if g.LockAccumulator >= LockDelay { evs = append(evs, g.lock()...) } + } else { + g.LockAccumulator = 0 + } + return evs +} +``` + +- [ ] **Step 1: Write the failing tests** + +```go +func TestAdvanceBelowIntervalDoesNothing(t *testing.T) +// g := New(1); y := g.Active.Y; g.Advance(100*time.Millisecond); Active.Y == y, no events + +func TestAdvanceAtIntervalDropsOneRow(t *testing.T) +// g.Advance(800*time.Millisecond) => one PieceMoved, Active.Y == y+1 + +func TestAdvanceHugeDtIsBoundedAndLeavesPieceGrounded(t *testing.T) +// g.Advance(10*time.Second) returns in well under a second, emits at most maxCatchUpSteps+3 events, +// GravityAccumulator < g.Interval(), and the piece is either locked or resting on the floor + +func TestGhostY(t *testing.T) +// empty board: GhostY() == the lowest Y where the piece does not collide +// with a locked cell at (Active.X, 15) under the piece, GhostY() is above it + +func TestSoftDropScoresOnePoint(t *testing.T) +// evs := g.SoftDrop(); evs[0].Kind == PieceSoftDropped; evs[0].ScoreDelta == 1; +// g.Score == 1; Active.Y increased by 1 +// soft drop into the floor returns nil and does not score + +func TestGroundedPieceLocksAfterLockDelay(t *testing.T) +// drop the piece to the floor, then Advance(499ms) => no PieceLocked; +// one more Advance(1ms) => a PieceLocked event + +func TestMovementWhileGroundedResetsLockTimer(t *testing.T) +// grounded, Advance(400ms), MoveLeft(), Advance(400ms) => no PieceLocked yet + +func TestLockResetsAreCapped(t *testing.T) +// grounded; loop 20 times { Advance(400ms); MoveLeft() or MoveRight() alternating } +// => a PieceLocked event occurs within the loop; g.LockResets <= MaxLockResets +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/game/ -run TestAdvance -v` +Expected: FAIL — undefined `Advance`. + +- [ ] **Step 3: Implement `Advance`, `SoftDrop`, `GhostY`, `Interval` and a stub `lock()`** + +`lock()` for now commits the piece and emits `PieceLocked` plus a spawn; Task 7 adds clearing and scoring. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/game.go internal/game/advance_test.go +git commit -m "feat(game): elapsed-time gravity, soft drop, ghost and lock delay" +``` + +--- + +### Task 7: Hard drop, locking, line clears, combo and level + +**Files:** +- Modify: `internal/game/game.go` +- Test: `internal/game/clear_test.go` + +**Interfaces:** +- Consumes: Task 6's `Advance`/`lock`; Task 4's scoring functions; Task 2's `CompleteRows`/`ClearRows`. +- Produces: +```go +func (g *Game) HardDrop() []Event +``` + +Lock sequence, in this order (§12): commit piece → `PieceLocked` → detect complete rows → emit `LinesCleared` (carrying `Rows` and the pre-clear `Cells`, and `ScoreDelta = LineScore + ComboBonus`) → clear rows → update `Score`/`Lines`/`Combo` → emit `ComboChanged` when the combo value changed → emit `LevelChanged` when `LevelFor(Lines)` changed → spawn next → `CanHold = true`. + +`HardDrop` emits `PieceHardDropped{Distance, ScoreDelta: 2×Distance}` then locks immediately (no lock delay). + +- [ ] **Step 1: Write the failing tests** + +```go +func TestHardDropScoresTwoPerCell(t *testing.T) +// g := New(1); evs := g.HardDrop(); first event is PieceHardDropped with +// Distance == cells travelled and ScoreDelta == 2*Distance; the event list also contains PieceLocked + +func TestHardDropOfAGroundedPieceStillLocks(t *testing.T) +// piece already resting on the floor: HardDrop emits PieceHardDropped{Distance:0} and PieceLocked + +func TestSingleLineClearScoresAndCollapses(t *testing.T) +// construct a board with row 21 filled except x=0..? and drop a piece to complete it; +// LinesCleared event has Count 1, Rows []int{21}, len(Cells)==1 and Cells[0] holds the +// pre-clear row; ScoreDelta == 100*level; g.Lines == 1; row 21 is empty afterwards + +func TestFourLineClearScores800TimesLevel(t *testing.T) +// stack rows 18..21 nine wide, hard drop a vertical I into the tenth column +// => LinesCleared{Count:4}, ScoreDelta == 800*g.Level (+0 combo bonus at combo 1) + +func TestComboAccumulatesAndResets(t *testing.T) +// first clearing placement: Combo == 1, ComboChanged{Count:1}, no combo bonus +// second consecutive clearing placement: Combo == 2, ScoreDelta includes 50*1*level +// a placement that clears nothing: Combo == 0 and one ComboChanged{Count:0} +// a second non-clearing placement emits no ComboChanged (value did not change) + +func TestLevelIncreasesEveryTenLines(t *testing.T) +// drive Lines to 10 => LevelChanged{Count:2} emitted once and g.Level == 2; +// g.Interval() == DropInterval(2) + +func TestClearIncludingHiddenRowKeepsBoardIntact(t *testing.T) +// fill row 1 (hidden) and row 21 completely via Board.Set, then lock any piece to trigger +// the clear path => no panic, board still Height rows, both rows empty +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/game/ -run 'TestHardDrop|TestSingleLine|TestFourLine|TestCombo|TestLevel|TestClearIncluding' -v` +Expected: FAIL — undefined `HardDrop`. + +- [ ] **Step 3: Implement `HardDrop` and the full `lock()` sequence** + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/game.go internal/game/clear_test.go +git commit -m "feat(game): hard drop, lock sequence, line clears, combo and level" +``` + +--- + +### Task 8: Hold, game over and replay determinism + +**Files:** +- Modify: `internal/game/game.go` +- Test: `internal/game/hold_test.go`, `internal/game/determinism_test.go` + +**Interfaces:** +- Consumes: Tasks 5–7. +- Produces: +```go +func (g *Game) HoldPiece() []Event // HoldUsed, or nil when hold is unavailable +``` + +Hold rules (§9): `HoldPiece` swaps the active piece with the held kind, or stores the active kind and spawns the next when hold is empty. The incoming piece arrives at spawn rotation and spawn position. Hold is available once per piece: `CanHold` goes false on use and true on lock. If the incoming piece collides at spawn, the game is over (`Over = true`, `GameOver` event) and the board is left untouched. + +Spawn blocking (§12, §28): when `spawn()` produces a piece that collides, set `Over` and emit `GameOver`. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestFirstHoldStoresAndSpawnsNext(t *testing.T) +// kind := g.Active.Kind; next := g.Next[0]; evs := g.HoldPiece() +// evs[0].Kind == HoldUsed; *g.Hold == kind; g.Active.Kind == next; +// g.Active == SpawnPiece(next); g.CanHold == false + +func TestSecondHoldIsBlocked(t *testing.T) +// after one HoldPiece, a second returns nil and changes nothing + +func TestHoldSwapsAndKeepsSpawnRotation(t *testing.T) +// hold once, lock the piece, rotate the new active piece twice, hold again +// => the piece coming out of hold has Rotation 0 and SpawnPiece coordinates + +func TestHoldAvailableAgainAfterLock(t *testing.T) +// HoldPiece, HardDrop, then CanHold == true + +func TestBlockedSpawnEndsTheGame(t *testing.T) +// fill rows 0..3 completely via Board.Set, HardDrop the active piece +// => a GameOver event, g.Over == true, and Advance(1*time.Second) afterwards returns nil + +func TestHoldIntoBlockedSpawnEndsTheGameCleanly(t *testing.T) +// fill the hidden rows so any spawn collides, then HoldPiece +// => GameOver event, g.Over == true, board string unchanged from before the call +``` + +```go +func TestReplayIsReproducible(t *testing.T) +// script: a fixed []struct{ key string; dt time.Duration } of ~200 entries covering +// left/right/rotate/soft/hard/hold and varied dt (7ms, 16ms, 250ms, 900ms). +// Run it twice against New(8675309) and assert equal Score, Lines, Level, Combo, +// Over, Next, *Hold and Board.String(). + +func TestDifferentSeedsDiverge(t *testing.T) +// the same script against New(1) and New(2) produces different Board.String() + +func TestEngineNeverReadsTheClock(t *testing.T) +// walk internal/game/*.go (excluding _test.go) and fail if any file contains "time.Now(" +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/game/ -run 'TestHold|TestBlocked|TestReplay|TestDifferentSeeds|TestEngineNever' -v` +Expected: FAIL — undefined `HoldPiece`. + +- [ ] **Step 3: Implement `HoldPiece` and the game-over path in `spawn()`** + +- [ ] **Step 4: Run the whole suite** + +Run: `go test ./... -v && go vet ./...` +Expected: PASS, no vet findings. + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/game.go internal/game/hold_test.go internal/game/determinism_test.go +git commit -m "feat(game): hold, game over and deterministic replay" +``` + +--- + +### Task 9: Engine acceptance sweep + +**Files:** +- Test: `internal/game/acceptance_test.go` + +**Interfaces:** +- Consumes: the whole package. +- Produces: nothing new — this task is the Phase 1 gate from §42 ("tests must pass before proceeding"). + +- [ ] **Step 1: Write the test** + +```go +func TestLongRandomSessionStaysConsistent(t *testing.T) +// For seeds 1..20: drive 5000 pseudo-random inputs (from a separate local rand, so the +// game RNG is untouched) with 16ms Advance steps between them. After every step assert: +// - every cell of g.Active.Cells() is in bounds +// - g.Board has no complete row left uncleared +// - len(g.Next) == NextCount +// - g.Score, g.Lines, g.Level, g.Combo are all >= 0 and Level == LevelFor(g.Lines) +// - once g.Over is true, no further events are produced +// The loop must finish (games end) for every seed. +``` + +- [ ] **Step 2: Run it** + +Run: `go test ./internal/game/ -run TestLongRandomSession -v` +Expected: PASS (fix any invariant violation it surfaces in the owning file) + +- [ ] **Step 3: Commit** + +```bash +git add internal/game/acceptance_test.go +git commit -m "test(game): long random session invariants" +``` diff --git a/tests/inline-eval/microtests/plan-boundary/repo/plans/2026-09-18-cosmic-tetris-2-playable-terminal.md b/tests/inline-eval/microtests/plan-boundary/repo/plans/2026-09-18-cosmic-tetris-2-playable-terminal.md new file mode 100644 index 000000000..0fdcf4554 --- /dev/null +++ b/tests/inline-eval/microtests/plan-boundary/repo/plans/2026-09-18-cosmic-tetris-2-playable-terminal.md @@ -0,0 +1,787 @@ +# Cosmic Tetris — Plan 2: Playable Terminal + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Turn the Plan 1 engine into a genuinely good, fully playable terminal game: Bubble Tea app loop, cell-accurate canvas renderer, responsive layout, board/ghost/hold/next/HUD, pause, help, game-over card, CLI flags, and ANSI-stripped golden tests. + +**Architecture:** `internal/render` owns a `Canvas` of styled cells; every visual element draws into the canvas and `Canvas.String()` emits one styled string. `render.Render(Snapshot, Options)` is a pure function of a snapshot — it never mutates game state. `internal/app` is the Bubble Tea model: it owns the clock, turns key presses into engine calls immediately, and feeds elapsed time to `Game.Advance`. Render does not import app; app builds a `render.Snapshot`. + +**Tech Stack:** Go 1.26, `charm.land/bubbletea/v2` v2.0.9, `charm.land/lipgloss/v2` v2.0.6, `charm.land/bubbles/v2` v2.2.1 (key, help), `github.com/charmbracelet/x/ansi` (test-side ANSI stripping). + +**Spec:** `design.md` (§4, §5, §8, §9, §10, §13 HUD, §26, §27 line slot, §28 final card, §30, §31, §32 modes, §33, §34, §36, §37, §39, §41, §42 Phase 2, §46, §49.3, §49.4, §49.7) + +## Global Constraints + +- Language: Go. Module `cosmic-tetris`, `go 1.26`. +- `charm.land/bubbletea/v2`, `charm.land/lipgloss/v2`, `charm.land/bubbles/v2` — do not wrap Bubble Tea in a homegrown framework (§3). +- Bubble Tea v2 API as installed (verified): `tea.Model` is `Init() tea.Cmd`, `Update(tea.Msg) (tea.Model, tea.Cmd)`, `View() tea.View`. Full-screen is `v := tea.NewView(s); v.AltScreen = true`. Key presses arrive as `tea.KeyPressMsg`. Terminal size is `tea.WindowSizeMsg{Width, Height}`. Color support arrives as `tea.ColorProfileMsg{colorprofile.Profile}`. `key.Matches` is generic over `fmt.Stringer` and accepts a `tea.KeyPressMsg`. Lip Gloss colors are `lipgloss.Color(string) color.Color`. +- Repository layout is fixed by §33; extra files inside the named packages are allowed where this plan names them. +- Logical board: width 10, height 22, visible 20, hidden 2. One logical cell renders as **2 terminal columns × 1 row** (§5). +- Glyphs (§49.4): blocks `██` (`[]` in ASCII), ghost `░░` (`··` in ASCII). Pieces use filled glyphs with a bright foreground — never a foreground+background pairing. The active piece renders one step brighter than locked cells. +- Minimum usable terminal 40×24; below that show the too-small notice (§31). Never crash on resize. +- Rendering must not mutate game state (§37). Animations must never block input (§44). +- The §4 mockup is mood, not geometry; the ANSI-stripped goldens are the binding layout contract (§49.7). +- CLI surface is exactly: no flags, `--seed N`, `--ascii`, `--no-fx`, `--reduced-motion`, `--help` (§49.5). + +## Review Focus + +1. Terminal reported as 0×0, 1×1, or any size below 40×24 — render the notice, never panic or divide by zero (Task 2, Task 6). +2. Key presses arriving before the first `WindowSizeMsg`, i.e. while width and height are still 0 — the model must absorb them without panicking (Task 6). +3. A terminal far larger than any expected size (300×100) — regions center and still never overlap (Task 2). +4. A score wider than its HUD field (8+ digits) and a level past 99 — the HUD grows or truncates without pushing the board out of place (Task 5). +5. A resize that lands between `Update` and `View` — `View` must render only from the size stored on the model, so the frame is internally consistent (Task 6). + +## Plan Set + +Run in this order. A ruling that changes a name, signature, or value a later plan consumes is applied to that plan's file before the next task starts. + +1. `plans/2026-09-18-cosmic-tetris-1-engine.md` — headless deterministic engine in `internal/game`. Consumes: nothing. +2. `plans/2026-09-18-cosmic-tetris-2-playable-terminal.md` — Bubble Tea app, canvas renderer, layout, HUD, hold/next/ghost, CLI flags, pause/help/game-over card, golden tests. Consumes Plan 1's `game.Game`, `Tick`, `Event`, `Board`, `Piece`, `GhostY`. +3. `plans/2026-09-18-cosmic-tetris-3-cosmic-foundation.md` — `internal/fx` (particles, starfield), animated border, piece trails, `internal/flavor` mission control. Consumes Plan 1's `Event`/`Cell` and Plan 2's `render.Canvas`, `render.Layout`, `render.Snapshot`, `app.Model`. +4. `plans/2026-09-18-cosmic-tetris-4-violence.md` — hard-drop impact, screen shake, line supernova, shockwaves, hyperdrive, four-line sequence, combo/level overlays. Consumes Plan 3's `fx.World` and the render FX layer. +5. `plans/2026-09-18-cosmic-tetris-5-polish.md` — boot sequence, game-over black hole, ASCII/no-FX guarantees, §45 details, README, definition-of-done sweep. Consumes everything above. + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `internal/render/canvas.go` | styled cell grid, clipping writes, run-length styled output | +| `internal/render/layout.go` | `Rect`, `Layout`, `Compute(w,h)` responsive geometry and drop order | +| `internal/render/palette.go` | `Mode`, `Options`, glyphs, piece/HUD/border colors | +| `internal/render/board.go` | locked cells, ghost, active piece, board border | +| `internal/render/hud.go` | hold, next queue, stats, mission line, controls line, frame | +| `internal/render/overlay.go` | centered cards: pause, help, game over, too-small notice | +| `internal/render/render.go` | `Phase`, `Snapshot`, `Render` — the §37 pipeline | +| `internal/render/testdata/*.txt` | ANSI-stripped goldens | +| `internal/app/keys.go` | `KeyMap` (bubbles/key) and help groupings | +| `internal/app/messages.go` | `FrameMsg`, frame command | +| `internal/app/options.go` | CLI flag parsing, usage text | +| `internal/app/model.go` | `Model`, `New`, `Init`, `View` | +| `internal/app/update.go` | `Update`: resize, color profile, keys, frame clock | +| `cmd/cosmic-tetris/main.go` | flags → `app.New` → `tea.NewProgram(...).Run()` | + +`canvas.go` and `overlay.go` are additions to §33's file list. They are load-bearing: compositing FX over the board (Plans 3–4) and shifting the board by one cell for screen shake are only sane against a cell grid, and the four overlay cards share one centering routine. + +--- + +### Task 1: Canvas — the styled cell grid + +**Files:** +- Create: `internal/render/canvas.go` +- Test: `internal/render/canvas_test.go` + +**Interfaces:** +- Consumes: nothing. +- Produces: +```go +type Paint struct { + FG, BG color.Color // nil means "terminal default" + Bold, Faint bool +} + +type Canvas struct{ /* unexported: w, h int; runes []rune; paints []Paint */ } +func NewCanvas(w, h int) *Canvas +func (c *Canvas) Size() (w, h int) +func (c *Canvas) Set(x, y int, r rune, p Paint) // no-op when out of bounds +func (c *Canvas) SetString(x, y int, s string, p Paint) int // returns columns written +func (c *Canvas) Fill(r Rect, ch rune, p Paint) +func (c *Canvas) Blit(src *Canvas, dx, dy int) // clipped; skips src cells that are unset +func (c *Canvas) String() string +func (c *Canvas) Plain() string // unstyled, for tests +``` + +Unset cells hold rune 0 and render as a space; `Blit` treats them as transparent. `String()` walks each row, groups runs of cells with an equal `Paint`, renders each run through one `lipgloss.NewStyle()`, joins rows with `"\n"`, and trims trailing whitespace on each row. `SetString` writes one rune per column (callers pass `██` as two runes, which is what makes a logical cell 2 columns wide). + +- [ ] **Step 1: Write the failing tests** + +```go +func TestCanvasPlainRendersGrid(t *testing.T) +// c := NewCanvas(4,2); c.Set(0,0,'A',Paint{}); c.Set(3,1,'B',Paint{}) +// c.Plain() == "A\n B" (trailing spaces trimmed) + +func TestZeroSizedCanvasIsEmptyAndSafe(t *testing.T) +// NewCanvas(0,0).Plain() == ""; Set(0,0,'x',Paint{}) does not panic +// NewCanvas(-5,-5) behaves as 0x0 + +func TestWritesOutsideBoundsAreClipped(t *testing.T) +// c := NewCanvas(3,1); c.SetString(-2,0,"abcde",Paint{}) leaves "cde"; +// c.SetString(2,0,"xy",Paint{}) leaves "abx" untouched beyond the edge; no panic + +func TestBlockGlyphOccupiesTwoColumns(t *testing.T) +// c := NewCanvas(4,1); c.SetString(0,0,"██",Paint{}) returns 2 and Plain() == "██" + +func TestBlitIsTransparentForUnsetCells(t *testing.T) +// dst filled with '.', src 2x2 with only (1,1) set to '#': Blit(src,1,0) +// => only one '.' replaced, at (2,1) + +func TestStringCarriesStyleAndPlainStripsIt(t *testing.T) +// c.Set(0,0,'X',Paint{FG: lipgloss.Color("#ff00ff")}) +// ansi.Strip(c.String()) == c.Plain() and c.String() != c.Plain() +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/render/ -v` +Expected: FAIL — undefined `NewCanvas`. + +- [ ] **Step 3: Add the dependencies and implement `internal/render/canvas.go`** + +```bash +go get charm.land/lipgloss/v2@v2.0.6 github.com/charmbracelet/x/ansi@v0.11.8 +``` + +`Rect` is defined in Task 2; for this task declare `Fill` against it after Task 2 lands, or define `Rect` here and leave layout to Task 2 — implement `Rect` in `layout.go` and have Task 1 `Fill` take `Rect` once Task 2 exists. To keep Task 1 self-contained, put the `Rect` type in `canvas.go`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add go.mod go.sum internal/render/canvas.go internal/render/canvas_test.go +git commit -m "feat(render): styled cell canvas with clipping and transparent blit" +``` + +--- + +### Task 2: Responsive layout + +**Files:** +- Create: `internal/render/layout.go` +- Test: `internal/render/layout_test.go` + +**Interfaces:** +- Consumes: `Rect` (Task 1). +- Produces: +```go +const ( + MinWidth = 40 + MinHeight = 24 + BoardCols = game.Width * 2 // 20 + BoardRows = game.VisibleRows // 20 +) + +type Tier int +const (TierSmall Tier = iota; TierMedium; TierWide) + +type Layout struct { + Screen Rect + Tier Tier + TooSmall bool + + Frame Rect // full-screen title border; Empty when dropped + Border Rect // board border box: BoardCols+2 × BoardRows+2 + Board Rect // board interior: BoardCols × BoardRows + Hold Rect + Next Rect + Stats Rect + Mission Rect // Empty when dropped + Controls Rect + + NextCount int // 5 or 3 + ShowStatLabels bool +} + +func (r Rect) Empty() bool +func (r Rect) Intersects(o Rect) bool +func (r Rect) Contains(o Rect) bool +func Compute(w, h int) Layout +``` + +Rules (fixed): + +| Condition | Result | +|---|---| +| `w < MinWidth \|\| h < MinHeight` | `TooSmall: true`, only `Screen` set | +| `w >= 64 && h >= 28` | `TierWide`: HOLD + SCORE/LINES/LEVEL column left of the board, NEXT column right | +| `w >= 50` | `TierMedium`: one compact HUD column right of the board (HOLD, NEXT, stats stacked) | +| otherwise | `TierSmall`: one narrow HUD column right of the board | +| `h >= 28` | `Frame` set (full-screen title border) | +| `h >= 25` | `Mission` set (one row above `Controls`) | +| `h >= 26` | `ShowStatLabels: true` | +| `w >= 50 && h >= 26` | `NextCount: 5`, else `3` | + +`Controls` is always one row, the last row of the screen. The board box is horizontally centered in the space left after the HUD columns and vertically centered in the space left after frame/mission/controls. NEXT never stacks above or below the board (§49.3). + +- [ ] **Step 1: Write the failing tests** + +```go +func TestWideLayoutHasEveryRegion(t *testing.T) +// l := Compute(100,40): Tier==TierWide, !TooSmall, NextCount==5, ShowStatLabels, +// none of Frame/Border/Board/Hold/Next/Stats/Mission/Controls is Empty, +// Board.W==BoardCols, Board.H==BoardRows + +func TestMinimumLayoutDropsChromeInOrder(t *testing.T) +// l := Compute(40,24): !TooSmall, Tier==TierSmall, Frame.Empty(), Mission.Empty(), +// !ShowStatLabels, NextCount==3, Board.W==BoardCols, Board.H==BoardRows, +// Controls.Y == 23, l.Screen.Contains(l.Border) + +func TestMissionSurvivesAtTwentyFiveRows(t *testing.T) +// Compute(60,25): !Mission.Empty(), Frame.Empty(), !ShowStatLabels + +func TestBelowMinimumIsTooSmall(t *testing.T) +// Compute(39,24), Compute(40,23), Compute(0,0), Compute(1,1) all TooSmall + +func TestNoRegionsEverOverlap(t *testing.T) +// for w in 40..140 step 1, h in 24..60 step 1: every pair of non-empty regions +// (Border, Hold, Next, Stats, Mission, Controls) has !Intersects, and +// Screen.Contains each of them + +func TestHugeTerminalCentersTheBoard(t *testing.T) +// Compute(300,100): Board.X > 100 and Screen.Contains(Border) and Tier==TierWide +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/render/ -run 'TestWideLayout|TestMinimum|TestMission|TestBelow|TestNoRegions|TestHuge' -v` +Expected: FAIL — undefined `Compute`. + +- [ ] **Step 3: Implement `internal/render/layout.go`** + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/layout.go internal/render/layout_test.go +git commit -m "feat(render): responsive layout with pinned drop order" +``` + +--- + +### Task 3: Palette, modes and glyphs + +**Files:** +- Create: `internal/render/palette.go` +- Test: `internal/render/palette_test.go` + +**Interfaces:** +- Consumes: `game.PieceKind`, `Paint` (Task 1). +- Produces: +```go +type Mode int +const (ModeFull Mode = iota; ModeReduced; ModeASCII) +func ModeFor(p colorprofile.Profile, asciiFlag bool) Mode + +type Options struct { + Mode Mode + FXEnabled bool + ReducedMotion bool +} + +func BlockGlyph(m Mode) string // "██" / "██" / "[]" +func GhostGlyph(m Mode) string // "░░" / "░░" / "··" +func BoxChars(m Mode) BoxSet // ╔ ═ ╗ ║ ╚ ╝ in Unicode modes; + - | in ASCII +type BoxSet struct{ TL, T, TR, V, BL, BR rune } + +func PiecePaint(k game.PieceKind, active bool, m Mode) Paint +func GhostPaint(m Mode) Paint +func HUDPaint(m Mode) Paint +func LabelPaint(m Mode) Paint +func AccentPaint(m Mode) Paint +var BorderPalette = []string{"#7A3CFF", "#22E1FF", "#FF37E0", "#3B6BFF", "#FFFFFF"} // §25 +``` + +Piece hues (§26), used as the locked-cell foreground; active cells use a lighter step of the same hue: + +```text +I plasma cyan #22E1FF J deep electric blue #3B6BFF +L solar orange #FF8A2B O stellar gold #FFD23F +S alien green #49F27A T ultraviolet #9D4EFF +Z supernova red #FF3B6B +``` + +`ModeFull` uses these hex values directly; `ModeReduced` and `ModeASCII` round them through `colorprofile.Profile.Convert`. Only the `--ascii` flag selects `ModeASCII` (§32). + +- [ ] **Step 1: Write the failing tests** + +```go +func TestModeForProfile(t *testing.T) +// ModeFor(colorprofile.TrueColor,false)==ModeFull; ANSI256 and ANSI => ModeReduced; +// NoTTY/Ascii => ModeASCII; asciiFlag true forces ModeASCII from any profile + +func TestGlyphWidths(t *testing.T) +// for every mode: ansi.StringWidth(BlockGlyph(m))==2 and ansi.StringWidth(GhostGlyph(m))==2 + +func TestGhostGlyphsArePinned(t *testing.T) +// BlockGlyph(ModeFull)=="██"; BlockGlyph(ModeASCII)=="[]" +// GhostGlyph(ModeFull)=="░░"; GhostGlyph(ModeReduced)=="░░"; GhostGlyph(ModeASCII)=="··" + +func TestASCIIModeEmitsOnlyASCII(t *testing.T) +// every rune of BlockGlyph/GhostGlyph(ModeASCII) and every field of BoxChars(ModeASCII) is < 128 + +func TestActivePieceIsBrighterThanLocked(t *testing.T) +// for all 7 kinds: PiecePaint(k,true,ModeFull).FG != PiecePaint(k,false,ModeFull).FG +// and the active colour has a strictly higher luminance + +func TestEveryKindHasADistinctColour(t *testing.T) +// the 7 locked FG values are pairwise different +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/render/ -run 'TestMode|TestGlyph|TestGhost|TestASCII|TestActive|TestEveryKind' -v` +Expected: FAIL — undefined `ModeFor`. + +- [ ] **Step 3: Implement `internal/render/palette.go`** + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/palette.go internal/render/palette_test.go +git commit -m "feat(render): neon space palette, render modes and glyph sets" +``` + +--- + +### Task 4: Board, ghost, active piece and border + +**Files:** +- Create: `internal/render/board.go` +- Test: `internal/render/board_test.go` + +**Interfaces:** +- Consumes: Tasks 1–3. +- Produces: +```go +func DrawBoardBorder(c *Canvas, l Layout, o Options, p Paint) +func DrawLocked(c *Canvas, l Layout, b *game.Board, o Options) +func DrawGhost(c *Canvas, l Layout, g *game.Game, o Options) +func DrawActive(c *Canvas, l Layout, p game.Piece, o Options) +func CellOrigin(l Layout, x, y int) (col, row int) // logical cell -> canvas cell; y is a board row +``` + +`CellOrigin` maps logical `(x, y)` to `(l.Board.X + x*2, l.Board.Y + y - game.HiddenRows)`. Rows above `game.HiddenRows` are not drawn — a piece straddling the hidden rows shows only its visible part. Draw order is locked → ghost → active (§37), and the ghost writes only into cells that are empty on the board (§10). + +- [ ] **Step 1: Write the failing tests** + +```go +func TestEmptyBoardDrawsNothing(t *testing.T) +// canvas sized to a wide layout, DrawLocked with an empty board => Plain() is all blank + +func TestLockedCellLandsAtTheRightColumns(t *testing.T) +// board.Set(0,21,CellFor(KindT)) and board.Set(9,21,CellFor(KindI)); +// the last board row of Plain() has BlockGlyph at columns Board.X..Board.X+1 +// and Board.X+18..Board.X+19 and blanks between + +func TestGhostSitsAtTheLandingRow(t *testing.T) +// empty board, active I at spawn: DrawGhost puts GhostGlyph on the row +// CellOrigin(l,0,g.GhostY()+1).row (the piece's own cell row), not on row 0 + +func TestGhostNeverOverwritesLockedCells(t *testing.T) +// fill row 21 with locked cells, ghost resting on row 20: +// row 21 of Plain() contains only block glyphs, no ghost glyph + +func TestActivePieceDrawsOverGhost(t *testing.T) +// draw ghost then active for a piece one row above its landing spot: +// the active piece's rows hold block glyphs, not ghost glyphs + +func TestHiddenRowsAreNotRendered(t *testing.T) +// a piece at Y=0 (entirely in hidden rows) draws nothing + +func TestBorderBoxMatchesTheLayout(t *testing.T) +// DrawBoardBorder: Plain() row l.Border.Y starts with BoxChars.TL at l.Border.X and +// ends with TR at l.Border.X+l.Border.W-1; every interior row has V at both edges +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/render/ -run 'TestEmptyBoard|TestLockedCell|TestGhost|TestActive|TestHidden|TestBorderBox' -v` +Expected: FAIL — undefined `DrawLocked`. + +- [ ] **Step 3: Implement `internal/render/board.go`** + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/board.go internal/render/board_test.go +git commit -m "feat(render): board, ghost, active piece and machinery border" +``` + +--- + +### Task 5: HUD, render pipeline and goldens + +**Files:** +- Create: `internal/render/hud.go`, `internal/render/render.go`, `internal/render/golden_test.go`, `internal/render/testdata/` +- Test: `internal/render/hud_test.go`, `internal/render/render_test.go` + +**Interfaces:** +- Consumes: Tasks 1–4. +- Produces: +```go +type Phase int +const (PhaseBoot Phase = iota; PhasePlaying; PhasePaused; PhaseGameOver) + +type Snapshot struct { + Game *game.Game + Width int + Height int + Phase Phase + ShowHelp bool + Mission string // already-formatted mission text, "" hides the channel + Elapsed time.Duration // total elapsed play time, for animated chrome + Seed int64 +} + +func Render(s Snapshot, o Options) string + +// hud.go +func DrawFrame(c *Canvas, l Layout, o Options, seed int64) // title bar: "✦ COSMIC TETRIS" + "LOCAL UNIVERSE 7F3A" +func DrawHold(c *Canvas, l Layout, hold *game.PieceKind, o Options) +func DrawNext(c *Canvas, l Layout, next []game.PieceKind, o Options) +func DrawStats(c *Canvas, l Layout, g *game.Game, o Options) +func DrawMission(c *Canvas, l Layout, text string, o Options) +func DrawControls(c *Canvas, l Layout, o Options) +func UniverseLabel(seed int64) string // fmt.Sprintf("%04X", uint16(seed)) +``` + +`Render` builds a canvas of `s.Width × s.Height` and follows §37's order, skipping the FX steps (Plans 3–4 add them). When `Compute(w,h).TooSmall`, it renders only the §31 notice with the live `current:` and `needed:` lines. Stats are drawn as label+value when `ShowStatLabels`, value-only otherwise (§49.3). Score renders zero-padded to 8 digits and is left-truncated to the region width if it overflows; level and lines render zero-padded to 2 and 3 digits and grow when they exceed it. + +Golden helper (in `golden_test.go`): +```go +var update = flag.Bool("update", false, "rewrite golden files") +func assertGolden(t *testing.T, name, got string) // compares ansi.Strip(got) to testdata/.txt +func fixtureGame(t *testing.T) *game.Game // game.New(8675309) driven by one canned script +``` +`fixtureGame` is shared by every golden in this plan and Plans 3–5, so its script is fixed once here: 12 scripted placements producing a non-trivial stack, one single-line clear, score > 0, level 1. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestStatsShowValuesWithoutLabelsAtSmallSize(t *testing.T) +// l := Compute(40,24); DrawStats => Plain() contains the zero-padded score +// and does not contain "LINES" or "LEVEL" + +func TestStatsShowLabelsWhenThereIsRoom(t *testing.T) +// l := Compute(100,40) => Plain() contains "SCORE", "LINES", "LEVEL" + +func TestNextRendersTheRequestedCount(t *testing.T) +// wide layout: DrawNext with 5 kinds draws 5 previews (count non-blank preview groups); +// small layout: only l.NextCount == 3 previews are drawn and nothing spills outside l.Next + +func TestHoldEmptyDrawsOnlyTheLabel(t *testing.T) +// hold == nil => no block glyphs inside l.Hold + +func TestHugeScoreDoesNotDisturbTheBoard(t *testing.T) +// g.Score = 1234567890; Render at 100x40; the 20 board rows of the output are +// byte-identical to the same render with Score = 0 (board untouched) + +func TestRenderIsPureAndDeterministic(t *testing.T) +// before := g.Board.String() + fmt.Sprint(g.Score, g.Lines, g.Level, g.Combo, g.Active) +// Render(...) twice => identical strings; the `before` fingerprint is unchanged + +func TestEveryRenderedLineFitsTheTerminal(t *testing.T) +// for w in 40..120 step 7, h in 24..48 step 5: every line of ansi.Strip(Render(...)) +// has ansi.StringWidth <= w and the line count is <= h + +func TestTooSmallNotice(t *testing.T) +// Render at 34x19 contains "THIS UNIVERSE IS TOO SMALL", "current: 34 × 19", +// "needed: approximately 40 × 24" +``` + +```go +func TestGoldenWideLayout(t *testing.T) // 100x40, ModeFull, FX off +func TestGoldenMediumLayout(t *testing.T) // 72x30 +func TestGoldenSmallLayout(t *testing.T) // 40x24 +func TestGoldenTooSmall(t *testing.T) // 34x19 +// each: assertGolden(t, "", Render(snapshotFrom(fixtureGame(t), w, h), Options{Mode: ModeFull})) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/render/ -run 'TestStats|TestNext|TestHold|TestHugeScore|TestRenderIs|TestEveryRendered|TestTooSmall|TestGolden' -v` +Expected: FAIL — undefined `Render`. + +- [ ] **Step 3: Implement `hud.go` and `render.go`, then create the goldens** + +Run `go test ./internal/render/ -run TestGolden -update` once, then read each `testdata/*.txt` and confirm by eye that nothing overlaps, the board is 20×20, and the HUD reads cleanly. Fix the layout rather than the golden if it looks wrong. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/hud.go internal/render/render.go internal/render/golden_test.go internal/render/hud_test.go internal/render/render_test.go internal/render/testdata +git commit -m "feat(render): HUD, render pipeline and layout goldens" +``` + +--- + +### Task 6: Bubble Tea app, keys, CLI and a runnable binary + +**Files:** +- Create: `internal/app/keys.go`, `internal/app/messages.go`, `internal/app/options.go`, `internal/app/model.go`, `internal/app/update.go`, `cmd/cosmic-tetris/main.go` +- Test: `internal/app/options_test.go`, `internal/app/update_test.go` + +**Interfaces:** +- Consumes: Plan 1's `game` package; Task 5's `render.Render`, `render.Snapshot`, `render.Phase`, `render.Options`, `render.ModeFor`. +- Produces: +```go +// options.go +type Options struct { + Seed int64 + SeedSet bool + ASCII bool + NoFX bool + ReducedMotion bool +} +var ErrHelpRequested = errors.New("help requested") +func ParseFlags(args []string) (Options, error) // args excludes argv[0] +func Usage() string + +// keys.go +type KeyMap struct { + Left, Right, SoftDrop, RotateCW, RotateCCW, HardDrop, + Hold, Pause, Restart, Help, Quit key.Binding +} +func DefaultKeyMap() KeyMap +func (k KeyMap) ShortHelp() []key.Binding +func (k KeyMap) FullHelp() [][]key.Binding + +// messages.go +type FrameMsg struct{ Now time.Time } +const FrameInterval = 16 * time.Millisecond // ~60Hz (§36) +const MaxFrameDelta = 250 * time.Millisecond // clamp after a suspend +func FrameCmd() tea.Cmd // tea.Tick(FrameInterval, ...) + +// model.go +type Model struct { + Game *game.Game + Opts Options + Render render.Options + Width, Height int + Phase render.Phase + ShowHelp bool + Keys KeyMap + Help help.Model + LastFrame time.Time + Elapsed time.Duration + Seed int64 +} +func New(o Options) *Model +func (m *Model) Init() tea.Cmd +func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) +func (m *Model) View() tea.View +func (m *Model) Snapshot() render.Snapshot +``` + +Bindings (§8): left `left`/`h`/`a`; right `right`/`l`/`d`; soft drop `down`/`j`/`s`; rotate CW `up`/`k`/`x`/`w`; rotate CCW `z`; hard drop `space` ("YEET"); hold `c`; pause `p`; restart `r`; help `?`; quit `q`/`esc`. Help strings use the §39 copy. + +`Update` rules: +- `tea.WindowSizeMsg` → store `Width`/`Height` only. +- `tea.ColorProfileMsg` → `m.Render.Mode = render.ModeFor(msg.Profile, m.Opts.ASCII)`. +- `tea.KeyPressMsg` → act immediately, never wait for a tick (§8, §36). Quit returns `tea.Quit`; help toggles `ShowHelp`; pause toggles `PhasePlaying`/`PhasePaused`; restart rebuilds `m.Game = game.New(m.Seed)` and resets `Phase`. Movement keys are ignored unless `Phase == PhasePlaying`. +- `FrameMsg` → `dt := min(msg.Now.Sub(m.LastFrame), MaxFrameDelta)` (0 when `LastFrame` is zero), store `LastFrame`, then: when playing, `m.Elapsed += dt` and `m.Game.Tick(dt)`; when paused, `m.Elapsed += dt / 10` and no `Tick` (§30). If `m.Game.Over`, set `Phase = PhaseGameOver`. Always return `FrameCmd()`. +- `View()` returns `v := tea.NewView(render.Render(m.Snapshot(), m.Render)); v.AltScreen = true`. + +Repeated movement while a key is held (§8) comes from terminal auto-repeat: each repeat is another `tea.KeyPressMsg`, handled immediately. No DAS timer. + +`main.go` parses flags, prints `Usage()` and exits 0 on `ErrHelpRequested`, prints the error and exits 2 on any other parse error, seeds from `time.Now().UnixNano()` when `!SeedSet`, and runs the program. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestParseFlags(t *testing.T) +// {} => zero Options, SeedSet false +// {"--seed","1234"} => Seed 1234, SeedSet true +// {"--seed","0"} => Seed 0, SeedSet true (0 is a real seed) +// {"--seed","-7"} => Seed -7, SeedSet true +// {"--ascii"} => ASCII; {"--no-fx"} => NoFX; {"--reduced-motion"} => ReducedMotion +// {"--seed","abc"} => error (not ErrHelpRequested) +// {"--help"} => errors.Is(err, ErrHelpRequested) +// {"--wat"} => error +// Usage() mentions every one of the five flags + +func TestKeyBindingsCoverTheSpec(t *testing.T) +// DefaultKeyMap: for each of "left","h","a","right","l","d","down","j","s","up","k","x","w", +// "z"," ","c","p","r","?","q","esc" exactly one binding claims it + +func TestKeyPressMovesThePieceImmediately(t *testing.T) +// m := New(Options{Seed:1, SeedSet:true}); m.Width, m.Height = 100, 40 +// x := m.Game.Active.X; feed tea.KeyPressMsg for "left" => m.Game.Active.X == x-1 +// with no FrameMsg in between + +func TestKeysBeforeFirstWindowSizeDoNotPanic(t *testing.T) +// fresh model (Width==0, Height==0): feed "left","space","c","p","?" then call View() +// => no panic, View().Content is non-empty + +func TestResizeToDegenerateSizesDoesNotPanic(t *testing.T) +// feed WindowSizeMsg {0,0}, {1,1}, {39,23}, {200,60}, {34,19} in sequence, calling View() +// after each => no panic; at {34,19} the content contains "TOO SMALL" + +func TestFrameTicksGravity(t *testing.T) +// feed two FrameMsgs one second apart => Active.Y increased; m.Elapsed == 1s + +func TestFirstFrameHasZeroDelta(t *testing.T) +// a single FrameMsg on a fresh model leaves Active.Y unchanged + +func TestGiantFrameDeltaIsClamped(t *testing.T) +// two FrameMsgs one hour apart => m.Elapsed increased by MaxFrameDelta, not an hour + +func TestPauseFreezesTheGame(t *testing.T) +// press "p" => Phase == PhasePaused; two FrameMsgs one second apart leave +// Board.String(), Score and Active unchanged; m.Elapsed grew by 100ms (dt/10) +// press "p" again => PhasePlaying + +func TestRestartRebuildsTheSameSeed(t *testing.T) +// hard drop a few pieces, press "r" => Score==0, Lines==0, Phase==PhasePlaying, +// Board.String() is empty, and m.Game.Next equals a fresh game.New(seed).Next + +func TestQuitReturnsTeaQuit(t *testing.T) +// pressing "q" returns a cmd whose result is a tea.QuitMsg; same for "esc" + +func TestGameOverPhaseIsEntered(t *testing.T) +// fill the board via m.Game.Board.Set, hard drop => after the next FrameMsg, +// Phase == PhaseGameOver and further movement keys change nothing +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/app/ -v` +Expected: FAIL — package does not exist. + +- [ ] **Step 3: Add the dependencies and implement the app package plus `main.go`** + +```bash +go get charm.land/bubbletea/v2@v2.0.9 charm.land/bubbles/v2@v2.2.1 +``` + +- [ ] **Step 4: Run the tests and play the game** + +Run: `go test ./... -v && go vet ./... && go run ./cmd/cosmic-tetris --seed 1234` +Expected: tests PASS; the game is playable — pieces fall, move, rotate, hold, ghost shows, lines clear, resizing works, `p` pauses, `q` quits. + +- [ ] **Step 5: Commit** + +```bash +git add internal/app cmd/cosmic-tetris go.mod go.sum +git commit -m "feat(app): Bubble Tea loop, key map, CLI flags and playable binary" +``` + +--- + +### Task 7: Pause, help and game-over overlays + +**Files:** +- Create: `internal/render/overlay.go` +- Modify: `internal/render/render.go` (call overlays at the end of the pipeline), `internal/render/golden_test.go` (three more goldens) +- Test: `internal/render/overlay_test.go` + +**Interfaces:** +- Consumes: Tasks 1–5; `app.KeyMap` copy lives in app, so overlay help text is passed in as lines. +- Produces: +```go +func DrawCard(c *Canvas, l Layout, title string, lines []string, o Options) // centered box, clipped to Screen +func DrawPause(c *Canvas, l Layout, o Options) +func DrawHelp(c *Canvas, l Layout, lines []string, o Options) +func DrawGameOver(c *Canvas, l Layout, g *game.Game, o Options) +``` + +Copy is fixed by the spec: pause is `TEMPORAL SUSPENSION` / `SPACE IS PAUSED` / `p resume` (§30). Game over is `UNIVERSE EXPIRED`, `SCORE`, `LINES`, `LEVEL`, `r REBOOT UNIVERSE`, `q ACCEPT COSMIC DEATH`, subtitle `CAUSE: EXCESSIVE GEOMETRY` (§28). Help is `FLIGHT MANUAL` with the §39 rows; `Snapshot.HelpLines []string` carries them from `app` (built from `KeyMap.FullHelp()` via `bubbles/help`), so `render` owns no key vocabulary. Add `HelpLines []string` to `Snapshot`. + +Cards never draw outside `l.Screen` and shrink their box to fit when the terminal is small. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestPauseOverlayCopy(t *testing.T) +// Render with Phase PhasePaused contains "TEMPORAL SUSPENSION", "SPACE IS PAUSED", "p resume" + +func TestGameOverCardShowsFinalNumbers(t *testing.T) +// g.Score=483200; g.Lines=127; g.Level=13; Phase PhaseGameOver => +// output contains "UNIVERSE EXPIRED", "483,200", "127", "13", +// "r REBOOT UNIVERSE", "q ACCEPT COSMIC DEATH" + +func TestHelpOverlayListsEveryBinding(t *testing.T) +// ShowHelp true with the app-supplied lines => output contains "FLIGHT MANUAL" and +// one row per binding + +func TestOverlaysStayInsideTheScreen(t *testing.T) +// for each of pause/help/game over, at 40x24 and 100x40: every line of the output +// has width <= w and the line count <= h + +func TestOverlaysDoNotChangeTheBoardGeometry(t *testing.T) +// the board's 20 rows keep width BoardCols in every overlay state (cards may cover +// them, but no row grows or shrinks) + +func TestGoldenPause(t *testing.T) // 100x40 +func TestGoldenGameOver(t *testing.T) // 100x40 +func TestGoldenHelp(t *testing.T) // 100x40 +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/render/ -run 'TestPause|TestGameOver|TestHelp|TestOverlays|TestGolden' -v` +Expected: FAIL — undefined `DrawPause`. + +- [ ] **Step 3: Implement `overlay.go`, wire it into `Render`, wire `HelpLines` in `app`** + +In `app`, build `HelpLines` from `m.Help` + `m.Keys.FullHelp()` once per render. + +- [ ] **Step 4: Create the goldens and run everything** + +Run: `go test ./internal/render/ -run TestGolden -update && go test ./... -v` +Expected: PASS; inspect the three new `testdata` files by eye. + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/overlay.go internal/render/render.go internal/render/overlay_test.go internal/render/golden_test.go internal/render/testdata internal/app +git commit -m "feat(render): pause, help and game-over overlays with goldens" +``` + +--- + +### Task 8: Phase 2 acceptance + +**Files:** +- Test: `internal/app/acceptance_test.go` + +**Interfaces:** +- Consumes: the whole app. +- Produces: nothing new — this is §42's "at this point it should already be a genuinely good game" gate. + +- [ ] **Step 1: Write the test** + +```go +func TestFullSessionThroughGameOverNeverPanics(t *testing.T) +// m := New(Options{Seed: 42, SeedSet: true}); WindowSizeMsg{100,40}; ColorProfileMsg TrueColor. +// Loop up to 20000 iterations: every iteration feed one FrameMsg 16ms later and, every +// third iteration, one pseudo-random key from the full key set (excluding q/esc), then call +// View() and assert the content is non-empty. Every 500 iterations feed a WindowSizeMsg +// cycling through {100,40},{72,30},{40,24},{34,19},{0,0}. +// Assert the loop reaches Phase == PhaseGameOver, and that no iteration panicked. +``` + +- [ ] **Step 2: Run it** + +Run: `go test ./internal/app/ -run TestFullSession -v` +Expected: PASS + +- [ ] **Step 3: Commit** + +```bash +git add internal/app/acceptance_test.go +git commit -m "test(app): full session with resizes and random input" +``` diff --git a/tests/inline-eval/microtests/plan-boundary/repo/plans/2026-09-18-cosmic-tetris-3-cosmic-foundation.md b/tests/inline-eval/microtests/plan-boundary/repo/plans/2026-09-18-cosmic-tetris-3-cosmic-foundation.md new file mode 100644 index 000000000..065328e85 --- /dev/null +++ b/tests/inline-eval/microtests/plan-boundary/repo/plans/2026-09-18-cosmic-tetris-3-cosmic-foundation.md @@ -0,0 +1,784 @@ +# Cosmic Tetris — Plan 3: Cosmic Foundation + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build `internal/fx` as an independent spectacle simulation and wire it into the renderer: particle physics, three-layer starfield, energy-reactive animated board border, piece ion trails, and the `internal/flavor` mission-control channel. + +**Architecture:** `GameState → events → FXWorld → Renderer`, one direction only. `fx` imports `game` for event and piece types and never imports `render` or `app`; `render` imports `fx` to draw it. `fx.World` holds its own `*rand.Rand`, seeded separately from the game, so particle randomness can never shift piece order. `Observe` takes events plus a read-only `GameView` value — the FX package never holds a `*game.Game`, which makes §14's "may never modify GameState" structural rather than a promise. + +**Tech Stack:** Go 1.26, `charm.land/lipgloss/v2`, `charm.land/bubbletea/v2`, standard library `math`/`math/rand`. + +**Spec:** `design.md` (§9 quantum storage, §14, §15, §17, §23, §25, §26, §27, §32 reduced modes, §33, §35, §37, §38, §42 Phase 3, §44, §49.6) + +## Global Constraints + +- Language: Go. Module `cosmic-tetris`, `go 1.26`. +- `internal/fx` must not import `internal/render`, `internal/app`, or `internal/flavor`; it may import `internal/game`. `internal/flavor` may import `internal/game` and `internal/fx`. +- FX may observe game events and never modify game state (§14, §44). +- Game RNG and FX RNG are separate `*rand.Rand` instances and never share (§35, §49.6). +- No goroutine per particle or per frame; no filesystem work during gameplay; no synchronous logging per frame (§38). +- A few hundred particles must be trivial; reuse slices (§38). Hard cap `MaxParticles = 400`. +- Effects never obscure the active piece, never delay gameplay, never alter it (§44). +- Board readability is sacred: the background must never make the board harder to read (§15, §21). +- ASCII mode emits only ASCII runes (§32). Every FX glyph needs an ASCII fallback. + +## Review Focus + +1. FX enabled versus disabled changing the game outcome — the same input script must produce an identical board, score and next queue either way (Task 2). +2. A resize while thousands of particle-seconds of state exist — off-screen stars and particles must be culled or re-seeded with no index panic (Task 3, Task 4). +3. An event burst in a single frame (lock + 4 lines + combo + level, ten events at once) — the mission line must show one message and hold it, not thrash (Task 7). +4. A long session (ten simulated minutes) — star, particle and trail slices must stay bounded (Task 1, Task 8). +5. Background versus foreground legibility — no star, particle or trail may replace a locked cell or an active-piece cell in the rendered output (Task 4, Task 6). + +## Plan Set + +Run in this order. A ruling that changes a name, signature, or value a later plan consumes is applied to that plan's file before the next task starts. + +1. `plans/2026-09-18-cosmic-tetris-1-engine.md` — headless deterministic engine in `internal/game`. Consumes: nothing. +2. `plans/2026-09-18-cosmic-tetris-2-playable-terminal.md` — Bubble Tea app, canvas renderer, layout, HUD, hold/next/ghost, CLI flags, pause/help/game-over card, golden tests. Consumes Plan 1's `game.Game`, `Advance`, `Event`, `Board`, `Piece`, `GhostY`. +3. `plans/2026-09-18-cosmic-tetris-3-cosmic-foundation.md` — `internal/fx` (particles, starfield), animated border, piece trails, `internal/flavor` mission control. Consumes Plan 1's `Event`/`Cell` and Plan 2's `render.Canvas`, `render.Layout`, `render.Snapshot`, `app.Model`. +4. `plans/2026-09-18-cosmic-tetris-4-violence.md` — hard-drop impact, screen shake, line supernova, shockwaves, hyperdrive, four-line sequence, combo/level overlays. Consumes Plan 3's `fx.World` and the render FX layer. +5. `plans/2026-09-18-cosmic-tetris-5-polish.md` — boot sequence, game-over black hole, ASCII/no-FX guarantees, §45 details, README, definition-of-done sweep. Consumes everything above. + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `internal/fx/particle.go` | `Particle`, the §23 integrator, spawn caps | +| `internal/fx/events.go` | `GameView`, event→FX reaction table, `Energy` | +| `internal/fx/world.go` | `World`: state, `Step`, `Observe`, `Resize`, FX RNG | +| `internal/fx/starfield.go` | `Star`, three layers, density, drift, wrap, level speed | +| `internal/fx/trail.go` | `Trail`: short-lived ion trails behind moving pieces; `Quantum`: the §9 hold effect | +| `internal/render/fx.go` | draw stars / particles / trails; `ASCIISafe` | +| `internal/render/border.go` | energy-reactive animated border colour | +| `internal/flavor/messages.go` | the message tables and event→category mapping | +| `internal/flavor/channel.go` | `Channel`: one line at a time, with hold and idle timers | + +`internal/fx/trail.go`, `internal/render/fx.go`, `internal/render/border.go` and `internal/flavor/channel.go` are additions to §33's file list, each a distinct responsibility inside a package §33 already names. + +--- + +### Task 1: Particle and the terminal-space integrator + +**Files:** +- Create: `internal/fx/particle.go` +- Test: `internal/fx/particle_test.go` + +**Interfaces:** +- Consumes: nothing. +- Produces: +```go +type Class int +const (ClassStar Class = iota; ClassDebris; ClassSpark; ClassEmber) + +type Particle struct { + X, Y float64 // canvas cell coordinates (column, row), fractional + VX, VY float64 // cells per second + AX, AY float64 // cells per second squared + Life float64 // seconds remaining + MaxLife float64 // seconds + Glyph rune + Brightness float64 // 0..1 + Class Class +} + +const ( + ParticleGravity = 14.0 // cells/s² downward, the default AY for debris + ParticleDrag = 0.05 // fraction of velocity surviving one second + MaxParticles = 400 +) + +func (p *Particle) Step(dt float64) +func (p Particle) Alive(w, h int) bool // Life > 0 and inside the viewport with a 2-cell margin +func (p Particle) Fade() float64 // Life / MaxLife, clamped 0..1 +``` + +Integration order is §23's, exactly: `position += velocity × dt`, then `velocity += acceleration × dt`, then `velocity *= pow(ParticleDrag, dt)`, then `life -= dt`. No collision detection. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestStepIntegratesPositionBeforeDrag(t *testing.T) +// p := Particle{VX:10, Life:2, MaxLife:2}; p.Step(1.0) +// => p.X == 10 (position used the pre-drag velocity) +// => p.VX == 10*ParticleDrag (0.5), within 1e-9 +// => p.Life == 1 + +func TestGravityAccelerates(t *testing.T) +// p := Particle{AY: ParticleGravity, Life:2, MaxLife:2}; p.Step(1.0) +// => p.VY == ParticleGravity*ParticleDrag, within 1e-9; p.Y == 0 + +func TestDeadWhenLifeRunsOut(t *testing.T) +// Life 0.5, Step(0.5) => !Alive(80,24) + +func TestDeadWhenOutsideViewport(t *testing.T) +// Particle{X:-5, Life:1, MaxLife:1}.Alive(80,24) == false +// Particle{X:200, ...}, Particle{Y:-5, ...}, Particle{Y:100, ...} all false +// Particle{X:0, Y:0, ...} true (edge cells count as inside) + +func TestFadeIsNormalised(t *testing.T) +// Life 1, MaxLife 2 => Fade() == 0.5; Life 3, MaxLife 2 => 1; Life -1 => 0 +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -v` +Expected: FAIL — package does not exist. + +- [ ] **Step 3: Implement `internal/fx/particle.go`** + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/fx/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/particle.go internal/fx/particle_test.go +git commit -m "feat(fx): terminal-space particle integrator" +``` + +--- + +### Task 2: FXWorld, event observation and engine isolation + +**Files:** +- Create: `internal/fx/world.go`, `internal/fx/events.go` +- Test: `internal/fx/world_test.go`, `internal/fx/isolation_test.go` + +**Interfaces:** +- Consumes: Task 1; `game.Event`, `game.EventKind`, `game.Piece`. +- Produces: +```go +type Options struct { + Enabled bool + ReducedMotion bool +} + +type GameView struct { + Level, Combo, Score, Lines int + Active game.Piece + Over bool +} +func ViewOf(g *game.Game) GameView // the only bridge; copies values, keeps no pointer + +type World struct { + Opts Options + W, H int + Stars []Star + Particles []Particle + Trails []Trail + Energy float64 // 0..1, the board border's "energy state" + Elapsed time.Duration + View GameView + // unexported: rng *rand.Rand +} + +func NewWorld(seed int64, w, h int, o Options) *World +func (w *World) Resize(width, height int) +func (w *World) Step(dt time.Duration) +func (w *World) Observe(evs []game.Event, v GameView) +func (w *World) Spawn(p Particle) // drops the oldest particle when at MaxParticles +func (w *World) Rand() *rand.Rand // FX RNG, for FX use only + +const ( + EnergyDecayPerSecond = 0.55 // multiplicative survival fraction per second +) +var EnergyBump = map[game.EventKind]float64{ // added on each event, Energy clamped to 1 + game.PieceLocked: 0.10, + game.PieceHardDropped: 0.25, + game.LinesCleared: 0.30, // × line count + game.LevelChanged: 0.50, + game.ComboChanged: 0.10, // × combo + game.HoldUsed: 0.08, +} +``` + +`Step` advances `Elapsed`, decays `Energy`, steps the starfield, steps and compacts particles in place (filter without reallocating), and ages trails. `Observe` records `v` into `w.View`, bumps `Energy`, and dispatches to per-event reactions (Task 6 adds trails; Plan 4 adds the rest). When `Opts.Enabled` is false, `Observe` returns immediately and `Step` only advances `Elapsed`. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestNewWorldSeedsStarsAndNothingElse(t *testing.T) +// w := NewWorld(7, 80, 24, Options{Enabled:true}) +// len(w.Stars) == StarCount(80,24); len(w.Particles) == 0; w.Energy == 0 + +func TestFXDisabledWorldStaysEmpty(t *testing.T) +// Options{Enabled:false}: len(Stars)==0; Observe(lots of events) leaves Particles empty +// and Energy at 0; Step(1s) still advances Elapsed + +func TestEnergyBumpsAndDecays(t *testing.T) +// Observe([]game.Event{{Kind: game.LinesCleared, Count: 2}}, GameView{Level:1}) +// => Energy == 0.60 (0.30 × 2), within 1e-9 +// Step(1s) => Energy == 0.60 * EnergyDecayPerSecond +// a 4-line clear at Energy 0.9 clamps to 1.0, never above + +func TestSpawnRespectsTheCap(t *testing.T) +// Spawn MaxParticles+50 particles => len(Particles) == MaxParticles and the +// most recently spawned particle is present + +func TestStepCullsDeadParticles(t *testing.T) +// spawn 10 with Life 0.1 and 10 with Life 5 => Step(200ms) leaves exactly 10 + +func TestResizeKeepsStarsInsideTheViewport(t *testing.T) +// w := NewWorld(7,120,40,...); Step a few seconds; w.Resize(40,24) +// => len(Stars)==StarCount(40,24) and every star has 0<=X<40, 0<=Y<24 +// Resize(0,0) then Step(1s) does not panic; Resize back to 80x24 re-seeds stars + +func TestLongSessionStaysBounded(t *testing.T) +// 10 simulated minutes at 16ms steps with an event every 10 frames: +// len(Particles) <= MaxParticles, len(Stars) == StarCount, len(Trails) <= 64 throughout +``` + +```go +func TestObserveNeverTouchesGameState(t *testing.T) +// g := game.New(99); before := fingerprint(g) (board string + score/lines/level/combo/active/next) +// evs := g.HardDrop(); w.Observe(evs, ViewOf(g)); w.Step(16ms) +// => fingerprint(g) equals the value taken right after HardDrop + +func TestFXDoesNotChangeTheGameOutcome(t *testing.T) +// run the same canned (key, dt) script twice through app-free helpers: +// once with a World{Enabled:true} observing every event, once with Enabled:false, +// both games seeded game.New(8675309) +// => identical Board.String(), Score, Lines, Level, Combo, Next + +func TestFXRNGIsIndependentOfTheGameRNG(t *testing.T) +// two Worlds with the same fx seed observing the event streams of two *different* +// game seeds produce the same number of Rand() draws for the same event sequence; +// and two Worlds with different fx seeds over the same event stream differ +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -v` +Expected: FAIL — undefined `NewWorld`. + +- [ ] **Step 3: Implement `world.go` and `events.go`** + +`StarCount` and `Star` land in Task 3; stub them minimally here (or land Task 3 first if the reviewer prefers) — the `world_test.go` assertions above reference `StarCount`, so implement `starfield.go`'s `StarCount`/`Star`/seed helper as part of this task and leave drift to Task 3. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/fx/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/world.go internal/fx/events.go internal/fx/world_test.go internal/fx/isolation_test.go +git commit -m "feat(fx): FX world, event observation, energy and engine isolation" +``` + +--- + +### Task 3: Three-layer starfield + +**Files:** +- Create: `internal/fx/starfield.go` (completing Task 2's stub) +- Test: `internal/fx/starfield_test.go` + +**Interfaces:** +- Consumes: Task 2's `World`. +- Produces: +```go +type Star struct { + X, Y float64 + Layer int // 0 far, 1 mid, 2 near + Glyph rune + Bright float64 // 0..1 +} + +var LayerSpeeds = [3]float64{0.8, 2.0, 5.0} // cells/s at level 1 +var LayerGlyphs = [3][]rune{{'.'}, {'·', '˚'}, {'✦', '✧', '*'}} // §15 +var LayerBright = [3]float64{0.30, 0.60, 1.0} +var LayerWeights = [3]float64{0.55, 0.30, 0.15} // share of the star budget + +func StarCount(w, h int) int // (w*h)/45, floored at 12, capped at 240 +func SpeedForLevel(level int) float64 // 1 + 0.06*(level-1), capped at 2.5 +func (w *World) StarSpeed() float64 // SpeedForLevel(w.View.Level); Plan 4 multiplies in hyperdrive +func (w *World) stepStars(dt float64) +func (w *World) seedStars() +``` + +Stars drift downward (§15). A star leaving the bottom wraps to `Y -= float64(h)` with a fresh random `X` and glyph, so the population is constant. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestStarCountScalesWithArea(t *testing.T) +// StarCount(80,24) == 42; StarCount(10,2) == 12 (floor); StarCount(400,100) == 240 (cap) + +func TestLayerDistribution(t *testing.T) +// a seeded 80x24 world has at least one star in each of the three layers, and +// layer 0 is the most populous + +func TestStarsDriftDownward(t *testing.T) +// record every star's Y, Step(500ms), assert every star moved down by +// LayerSpeeds[layer]*0.5*SpeedForLevel(1) or wrapped + +func TestStarsWrapInsteadOfDisappearing(t *testing.T) +// Step(60s) => len(Stars) unchanged and every star is inside the viewport + +func TestHigherLevelMovesStarsFaster(t *testing.T) +// SpeedForLevel(1) == 1.0; SpeedForLevel(7) == 1.36 within 1e-9; SpeedForLevel(40) == 2.5 +// a level-10 world displaces its stars strictly further than a level-1 world +// over the same 500ms, from the same fx seed + +func TestReducedMotionKeepsStarsDrifting(t *testing.T) +// Options{Enabled:true, ReducedMotion:true}: stars still move (only hyperdrive, +// shake and shockwaves are suppressed, §49.5) + +func TestStarGlyphsComeFromTheLayerTables(t *testing.T) +// every star's Glyph is in LayerGlyphs[star.Layer] +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -run TestStar -v` +Expected: FAIL — undefined `LayerSpeeds`. + +- [ ] **Step 3: Implement `internal/fx/starfield.go`** + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/fx/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/starfield.go internal/fx/starfield_test.go +git commit -m "feat(fx): three-layer starfield with level-scaled drift" +``` + +--- + +### Task 4: Render the starfield and particles + +**Files:** +- Create: `internal/render/fx.go` +- Modify: `internal/render/render.go` (add `FX *fx.World` to `Snapshot`, insert §37 steps 2, 6 and 9), `internal/render/board.go` (leave empty board cells unset so space shows through) +- Test: `internal/render/fx_test.go`, `internal/render/golden_test.go` + +**Interfaces:** +- Consumes: Plan 2's `Canvas`, `Layout`, `Options`, `Paint`; Tasks 1–3. +- Produces: +```go +func DrawStarfield(c *Canvas, l Layout, w *fx.World, o Options) +func DrawParticles(c *Canvas, l Layout, w *fx.World, o Options) +func ASCIISafe(r rune) rune +func StarPaint(s fx.Star, insideBoard bool, m Mode) Paint +func ParticlePaint(p fx.Particle, m Mode) Paint +``` + +Rules: +- Starfield draws first, across the whole canvas, into unset cells only — it never overwrites anything already drawn, and the board draws after it. +- Inside `l.Board`, only layers 0 and 1 are drawn and brightness is halved, so the board stays the most readable thing on screen (§15, §21). Stars inside the board align to even columns so they never split a 2-column cell. +- `DrawLocked` leaves empty board cells unset (previously blank), which is what lets space show through the playfield as in §4. +- Particles draw after the board (§37 step 6 board-local, step 9 global), also into unset cells only, except `ClassDebris`, which may overwrite empty board cells but never a cell holding a block or ghost glyph. +- `ASCIISafe` maps every non-ASCII glyph this project emits to an ASCII stand-in: `✦→* ✧→+ ˚→' ·→. ░→: ▒→; ▓→# █→# ○→o ◌→o ◯→O ●→@ ☄→> ✪→*`. `ModeASCII` routes every glyph through it. +- When `o.FXEnabled` is false or `s.FX` is nil, both draw functions return immediately. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestStarfieldDrawsIntoEmptySpace(t *testing.T) +// canvas at a wide layout, DrawStarfield with a seeded world +// => the number of non-blank cells is > 0 and <= len(w.Stars) + +func TestStarfieldNeverOverwritesLockedCells(t *testing.T) +// fill board rows 18..21, draw stars, then locked, then stars again in that order: +// every cell of the bottom four board rows holds a block glyph, never a star glyph + +func TestStarfieldNeverOverwritesTheActivePiece(t *testing.T) +// after the full pipeline, every cell of Active.Cells() renders BlockGlyph + +func TestStarsInsideTheBoardAreDimAndNear(t *testing.T) +// every star drawn within l.Board has Layer <= 1 and an even column offset from l.Board.X + +func TestParticlesDrawAfterTheBoard(t *testing.T) +// a ClassDebris particle placed over an empty board cell appears in the output; +// one placed over a locked cell does not + +func TestASCIISafeCoversEveryGlyphWeEmit(t *testing.T) +// for every rune in LayerGlyphs, the debris glyph set, the ghost/block glyphs and BoxChars: +// ASCIISafe(r) < 128 + +func TestFXDisabledRendersExactlyLikePlanTwo(t *testing.T) +// Render with Options{FXEnabled:false} and a non-nil World produces output identical +// to Render with FX nil — assert against testdata/wide.txt + +func TestRenderWithFXIsDeterministic(t *testing.T) +// same world (fx seed 4242, stepped 30 frames of 16ms) rendered twice => identical strings + +func TestGoldenWideWithStarfield(t *testing.T) +// 100x40, fx seed 4242, 30 frames stepped, ModeFull => testdata/wide-fx.txt +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/render/ -run 'TestStarfield|TestStars|TestParticles|TestASCII|TestFXDisabled|TestRenderWithFX' -v` +Expected: FAIL — undefined `DrawStarfield`. + +- [ ] **Step 3: Implement `internal/render/fx.go` and the `render.go` / `board.go` changes** + +- [ ] **Step 4: Create the new golden and run everything** + +Run: `go test ./internal/render/ -run TestGolden -update && go test ./... -v` +Expected: PASS. Read `testdata/wide-fx.txt` and confirm the board is still legible with stars behind it. + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/fx.go internal/render/render.go internal/render/board.go internal/render/fx_test.go internal/render/golden_test.go internal/render/testdata +git commit -m "feat(render): composite starfield and particles behind the board" +``` + +--- + +### Task 5: Animated board border + +**Files:** +- Create: `internal/render/border.go` +- Modify: `internal/render/board.go` (`DrawBoardBorder` gains `elapsed` and `energy` parameters and colours each border cell through `BorderPaintAt`, replacing Plan 2's single `Paint` argument; update its callers) +- Test: `internal/render/border_test.go` + +**Interfaces:** +- Consumes: Plan 2's `BorderPalette`, `BoxChars`, `Paint`. +- Produces: +```go +func BorderPaintAt(elapsed time.Duration, energy float64, i, n int, m Mode) Paint +func DrawBoardBorder(c *Canvas, l Layout, o Options, elapsed time.Duration, energy float64) +const BorderCyclePeriod = 12 * time.Second // calm palette drift (§25: "the shift should be subtle") +``` + +`BorderPaintAt` colours border cell `i` of `n`, walking the box clockwise from the top-left. Phase is `elapsed/BorderCyclePeriod + energy*elapsed/(1.5s)`, plus a per-cell offset of `energy × i/n` — so at rest the whole border is one slowly drifting colour, and at high energy a gradient visibly travels around it (§25). Colours are linear RGB interpolations between adjacent `BorderPalette` entries, parsed once at init. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestBorderColourDriftsOverTime(t *testing.T) +// BorderPaintAt(0,0,0,84,ModeFull).FG != BorderPaintAt(6*time.Second,0,0,84,ModeFull).FG + +func TestBorderIsUniformAtRest(t *testing.T) +// energy 0: BorderPaintAt(t,0,i,84,...) is the same colour for i = 0, 20, 40, 83 + +func TestHighEnergyMakesAGradient(t *testing.T) +// energy 1: at least 8 distinct colours appear across i = 0..83 + +func TestBorderCycleIsPeriodic(t *testing.T) +// BorderPaintAt(0,0,0,84,...) == BorderPaintAt(BorderCyclePeriod,0,0,84,...) + +func TestBorderColoursStayInThePalette(t *testing.T) +// every colour produced over a 12s sweep lies on a segment between two adjacent +// BorderPalette entries (each channel within [min,max] of that pair) + +func TestASCIIBorderUsesASCIIBoxChars(t *testing.T) +// DrawBoardBorder in ModeASCII: every rune of the border rows is < 128 +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/render/ -run TestBorder -v` +Expected: FAIL — undefined `BorderPaintAt`. + +- [ ] **Step 3: Implement `internal/render/border.go` and update `DrawBoardBorder` callers** + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS (regenerate goldens only if the border characters changed, never to hide a layout break) + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/border.go internal/render/board.go internal/render/border_test.go +git commit -m "feat(render): energy-reactive animated board border" +``` + +--- + +### Task 6: Piece ion trails and quantum storage + +**Files:** +- Create: `internal/fx/trail.go` +- Modify: `internal/fx/world.go` (`Observe` spawns trails and the hold streak, `Step` ages them), `internal/render/fx.go` (`DrawTrails`, `DrawQuantumStorage`), `internal/render/render.go` (pipeline step 6) +- Test: `internal/fx/trail_test.go`, `internal/fx/quantum_test.go`, `internal/render/trail_test.go` + +**Interfaces:** +- Consumes: Task 2's `World`; `game.Point`, `game.PieceKind`. +- Produces: +```go +type Trail struct { + Cells []game.Point // logical board cells the piece just vacated + Kind game.PieceKind + Age float64 // seconds + Life float64 // seconds +} +const ( + TrailLife = 0.14 // 140ms, inside §17's 100–160ms window + MaxTrails = 64 +) +func (t Trail) Stage() int // 0,1,2 → ▓▓ ▒▒ ░░ ; -1 when expired +func (w *World) spawnTrail(p game.Piece) + +type Quantum struct { + Out game.PieceKind // the piece being compressed away + In game.PieceKind // the piece flashing into existence + Age float64 +} +const QuantumLife = 0.12 // §9: ~120ms +func (q Quantum) Stage() int // 0 compressed, 1 streaked sideways, 2 gone; -1 expired +func (w *World) Quantum() *Quantum // nil when none + +// render +func DrawTrails(c *Canvas, l Layout, g *game.Game, w *fx.World, o Options) +func DrawQuantumStorage(c *Canvas, l Layout, w *fx.World, o Options) +var TrailGlyphs = [3]string{"▓▓", "▒▒", "░░"} // ASCII: "##", ";;", ".." +``` + +`Observe` spawns a trail from the piece's previous cells on `PieceMoved` and `PieceRotated`. Trails carry the vacated cells, so they never sit under the piece itself. `Step` ages them and drops expired ones, capped at `MaxTrails`. + +`HoldUsed` starts a `Quantum` (§9): inside the HOLD region, the outgoing piece compresses vertically (stage 0), streaks sideways out of the box with `TrailGlyphs` shoulders (stage 1), and vanishes (stage 2), while the incoming piece flashes in at full brightness for the same 120ms. Gameplay does not wait for it — the engine has already swapped the pieces, and `DrawQuantumStorage` draws only inside `l.Hold`. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestMoveSpawnsATrailOfFourCells(t *testing.T) +// Observe([]game.Event{{Kind: game.PieceMoved, Piece: p}}, view) +// => one Trail with len(Cells) == 4 and Kind == p.Kind + +func TestTrailExpiresWithinTheSpecWindow(t *testing.T) +// Step(100ms) => the trail is still present; Step(another 100ms) => Trails is empty +// Stage() walks 0 → 1 → 2 → -1 across its life + +func TestTrailsAreCapped(t *testing.T) +// 200 PieceMoved events in one Observe => len(Trails) <= MaxTrails + +func TestNoTrailsWhenFXDisabled(t *testing.T) +// Options{Enabled:false} => Observe spawns none + +func TestTrailsNeverCoverTheActivePieceOrLockedCells(t *testing.T) +// render a frame where a trail overlaps both the active piece and a locked cell: +// those cells hold BlockGlyph in the output, not a trail glyph + +func TestTrailGlyphsAreASCIISafeInASCIIMode(t *testing.T) +// every rune of the trail cells in a ModeASCII render is < 128 +``` + +```go +func TestHoldStartsQuantumStorage(t *testing.T) +// Observe([]game.Event{{Kind: game.HoldUsed, Piece: outgoing}}, view with Active = incoming) +// => Quantum() != nil with Out == outgoing.Kind and In == view.Active.Kind + +func TestQuantumStagesAndLifetime(t *testing.T) +// Stage() is 0 at Age 0.01, 1 at 0.06, 2 at 0.11; Step(120ms) => Quantum() == nil + +func TestQuantumDoesNotDelayGameplay(t *testing.T) +// app level: press "c", then feed 8 frames of 16ms => the new active piece descended +// on schedule and a second "c" is still correctly refused (hold once per piece) + +func TestQuantumDrawsOnlyInsideTheHoldRegion(t *testing.T) +// every cell the quantum effect writes lies within l.Hold; the board region is untouched +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -run 'TestTrail|TestHoldStarts|TestQuantum' -v && go test ./internal/render/ -run TestTrail -v` +Expected: FAIL — undefined `Trail`. + +- [ ] **Step 3: Implement `trail.go`, the quantum state, the `world.go` hooks, `DrawTrails` and `DrawQuantumStorage`** + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./... -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/trail.go internal/fx/world.go internal/fx/trail_test.go internal/fx/quantum_test.go internal/render/fx.go internal/render/render.go internal/render/trail_test.go +git commit -m "feat(fx): short-lived piece ion trails and quantum storage hold effect" +``` + +--- + +### Task 7: Mission control + +**Files:** +- Create: `internal/flavor/messages.go`, `internal/flavor/channel.go` +- Test: `internal/flavor/messages_test.go`, `internal/flavor/channel_test.go` + +**Interfaces:** +- Consumes: `game.Event`, `fx.GameView`. +- Produces: +```go +type Category int +const ( + CatBoot Category = iota + CatIdle + CatLock + CatClear + CatTetris + CatCombo + CatLevel + CatHold + CatHardDrop + CatGameOver + CatRare +) + +var Messages = map[Category][]string{...} +func ForEvent(ev game.Event, v fx.GameView) (Category, bool) +func Pick(cat Category, rng *rand.Rand, last string) string // never returns `last` +func Format(text string) string // "☄ MISSION CONTROL: " + text + +type Channel struct{ /* unexported */ } +const ( + MinHold = 2500 * time.Millisecond // §27: "give them time to breathe" + IdlePrompt = 20 * time.Second + RareChance = 0.02 +) +func NewChannel(seed int64) *Channel +func (c *Channel) Observe(evs []game.Event, v fx.GameView) +func (c *Channel) NoteInput() +func (c *Channel) Step(dt time.Duration) +func (c *Channel) Text() string // formatted line, "" when there is nothing to say +``` + +Copy comes verbatim from the spec: §27 for `CatLock`/`CatClear`/`CatIdle` (`NOMINALISH`, `GRAVITY REMAINS MOSTLY LEGAL`, `TETROMINO INJECTION SUCCESSFUL`, `STRUCTURAL VIBES: QUESTIONABLE`, `LOCAL UNIVERSE STABLE*`, `* DEFINITION OF STABLE UNDER REVIEW`, `MOON NOTIFIED`, `ORBITAL OSHA HAS ENTERED THE CHAT`, `WE HAVE EXCEEDED THE RECOMMENDED NUMBER OF BLOCKS`, `PHYSICS TEAM SAYS KEEP GOING`), §21 for `CatCombo` (`COMBO 5 // UNAUTHORIZED ORBITAL MANEUVER`, `COMBO 6 // STRUCTURAL REALITY FAILURE`, `COMBO 7 // NASA DENIES EVERYTHING`, with the combo number substituted), §22 for `CatLevel` (`GRAVITY TAX INCREASED`, `LOCAL PHYSICS UPDATED WITHOUT CONSENT`, `PLEASE SECURE ALL LOOSE TETROMINOES`), §45 for `CatRare` (`DID YOU KNOW YOU'RE IN A TERMINAL?`) and `CatIdle` (`CAPTAIN?`). + +Priority when several events arrive in one frame, highest first: `CatGameOver`, `CatTetris`, `CatLevel`, `CatCombo`, `CatHardDrop`, `CatClear`, `CatHold`, `CatLock`. A new message replaces the current one only when the current one has been held `MinHold` or the new category outranks it. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestEveryCategoryHasMessages(t *testing.T) +// every Category constant has at least two entries except CatBoot (>=1) + +func TestForEventMapping(t *testing.T) +// LinesCleared Count 1 => CatClear; Count 4 => CatTetris +// ComboChanged Count 5 => CatCombo; Count 0 => no message (false) +// LevelChanged => CatLevel; HoldUsed => CatHold; PieceHardDropped => CatHardDrop +// GameOver => CatGameOver; PieceMoved => false + +func TestComboMessageCarriesTheNumber(t *testing.T) +// Pick(CatCombo, rng, "") for a combo of 5 renders text containing "COMBO 5" + +func TestPickNeverRepeatsTheLastLine(t *testing.T) +// 200 Picks with `last` threaded through never return the same line twice in a row + +func TestBurstOfEventsYieldsOneHeldMessage(t *testing.T) +// Observe with 10 events (lock + 4-line clear + combo 5 + level up) in one call +// => exactly one Text(); Step(2.4s) with more low-rank events arriving keeps that +// same text; Step(0.2s) more and then a CatClear event replaces it + +func TestHigherPriorityInterruptsImmediately(t *testing.T) +// a CatLock message at age 100ms is replaced by a CatTetris event in the next Observe + +func TestIdlePromptAfterTwentySeconds(t *testing.T) +// NewChannel then Step(19s) => no "CAPTAIN?"; Step(2s) more => Text() contains "CAPTAIN?" +// NoteInput() resets the idle timer + +func TestChannelIsSeedDeterministic(t *testing.T) +// two channels with seed 5 fed the same events and steps produce identical Text() sequences + +func TestFormatPrefix(t *testing.T) +// Format("NOMINALISH") == "☄ MISSION CONTROL: NOMINALISH" +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/flavor/ -v` +Expected: FAIL — package does not exist. + +- [ ] **Step 3: Implement `messages.go` and `channel.go`** + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/flavor/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/flavor +git commit -m "feat(flavor): mission control message tables and pacing channel" +``` + +--- + +### Task 8: Wire FX and mission control into the app + +**Files:** +- Modify: `internal/app/model.go`, `internal/app/update.go`, `internal/render/render.go` (`Snapshot.Mission` already exists; pass `FX`) +- Test: `internal/app/fx_test.go` + +**Interfaces:** +- Consumes: Tasks 2–7. +- Produces: +```go +// Model gains: +FX *fx.World +Mission *flavor.Channel +FXSeed int64 // Seed ^ 0x5F3759DF — derived once, never shared with the game RNG +``` + +Wiring rules: +- `New` builds `fx.NewWorld(FXSeed, 0, 0, fx.Options{Enabled: !o.NoFX, ReducedMotion: o.ReducedMotion})` and `flavor.NewChannel(FXSeed)`. +- `tea.WindowSizeMsg` also calls `m.FX.Resize(w, h)`. +- Every engine call's returned events go to `m.FX.Observe(evs, fx.ViewOf(m.Game))` and `m.Mission.Observe(evs, fx.ViewOf(m.Game))`; key presses additionally call `m.Mission.NoteInput()`. +- `FrameMsg` steps `m.FX` and `m.Mission` with the same clamped `dt` used for `Advance`. While paused, FX steps at `dt/10` (§30: background stars keep drifting slowly) and the mission channel does not step. +- `Snapshot()` fills `FX: m.FX` and `Mission: m.Mission.Text()`. +- `--no-fx` leaves the game fully playable with an empty `World` (§32). + +- [ ] **Step 1: Write the failing tests** + +```go +func TestModelFeedsEventsToFX(t *testing.T) +// press space (hard drop) => m.FX.Energy > 0 and m.Mission.Text() != "" + +func TestFXSeedIsNotTheGameSeed(t *testing.T) +// New(Options{Seed: 1234, SeedSet: true}): m.FXSeed != m.Seed + +func TestNoFXFlagLeavesTheGamePlayable(t *testing.T) +// New(Options{NoFX:true}): a 200-frame session with keys still clears a line, +// m.FX.Particles stays empty, and View() is non-empty + +func TestPausedStarsKeepDriftingSlowly(t *testing.T) +// pause, record star Y values, feed 1s of frames => stars moved, board unchanged + +func TestSameScriptSameOutcomeWithAndWithoutFX(t *testing.T) +// the canned script from Plan 2's tests, run on New(Options{Seed:5,SeedSet:true}) and +// New(Options{Seed:5,SeedSet:true,NoFX:true}) => identical Board.String(), Score, Lines, Level + +func TestThirtySecondsOfPlayShowsTheCosmicBaseline(t *testing.T) +// 30 simulated seconds at 16ms with scripted hard drops; over the session assert: +// - at least one frame's output changed in the starfield region between consecutive frames +// - the border colour changed at least twice +// - at least one frame contained a trail glyph +// - m.Mission.Text() was non-empty for at least 10 seconds' worth of frames +// This is §43's first-30-seconds requirement, mechanised. +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/app/ -run 'TestModelFeeds|TestFXSeed|TestNoFX|TestPausedStars|TestSameScript|TestThirtySeconds' -v` +Expected: FAIL — `Model` has no field `FX`. + +- [ ] **Step 3: Implement the wiring** + +- [ ] **Step 4: Run everything and play it** + +Run: `go test ./... -v && go vet ./... && go run ./cmd/cosmic-tetris --seed 1234` +Expected: tests PASS; the terminal is visibly alive at rest — stars drift, the border shifts colour, pieces leave trails, mission control comments. + +- [ ] **Step 5: Commit** + +```bash +git add internal/app internal/render/render.go +git commit -m "feat(app): wire FX world and mission control into the frame loop" +``` diff --git a/tests/inline-eval/microtests/plan-boundary/repo/plans/2026-09-18-cosmic-tetris-4-violence.md b/tests/inline-eval/microtests/plan-boundary/repo/plans/2026-09-18-cosmic-tetris-4-violence.md new file mode 100644 index 000000000..0177e8a1b --- /dev/null +++ b/tests/inline-eval/microtests/plan-boundary/repo/plans/2026-09-18-cosmic-tetris-4-violence.md @@ -0,0 +1,632 @@ +# Cosmic Tetris — Plan 4: Violence + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the spectacle excessive: hard-drop impact, screen shake, the three-phase line-clear supernova, shockwaves, hyperdrive, and the simultaneous four-line event — plus combo escalation, the level-up card and the HUD pulse. + +**Architecture:** Every effect is another aged field on `fx.World` with a trigger in `Observe` and a draw function in `render`. Gameplay never waits: the engine has already cleared the rows and spawned the next piece while the supernova plays, so each effect carries its own snapshot of what it needs (the pre-clear row contents, the cells a hard drop crossed). Screen shake is applied by compositing the board layer into its own canvas and blitting it at an offset clipped to the board box, so shake can never bleed into the HUD. + +**Tech Stack:** Go 1.26, `charm.land/lipgloss/v2`, standard library `math`. + +**Spec:** `design.md` (§16, §17 hard-drop trail, §18, §19, §20, §21, §22, §23, §24, §25 event pulse, §38, §42 Phase 4, §43, §44, §49.5) + +## Global Constraints + +- Language: Go. Module `cosmic-tetris`, `go 1.26`. +- `internal/fx` must not import `internal/render` or `internal/app`. +- FX may observe game events and never modify game state (§14, §44). +- Never delay gameplay for animation; never make controls lag; animations never block input (§44, §47). +- Screen shake never exceeds roughly one terminal cell (§44). +- Particles never permanently alter the rendered board (§44). +- Never obscure the active piece (§44). +- `MaxParticles = 400` stays the hard cap (§38, Plan 3). +- `--reduced-motion` suppresses screen shake, hyperdrive acceleration and shockwaves, and leaves colour, trails and particles alone (§49.5). +- Board readability remains sacred at every combo level (§21). + +## Review Focus + +1. `--reduced-motion` — shake, hyperdrive and shockwaves must be fully off while trails, particles and colour still run (Task 7). +2. Shake offset larger than one cell, or shaken board content bleeding into the HUD columns (Task 1). +3. A banner or level card covering the active piece or the top of the stack (Task 6). +4. The 220ms supernova gating gameplay — the next piece must keep falling, and input must keep working, during the animation (Task 3). +5. A four-line clear that also levels up and lands at combo 5 in a single frame — every overlay must compose at once without exceeding any cap or panicking (Task 6, Task 7). + +## Plan Set + +Run in this order. A ruling that changes a name, signature, or value a later plan consumes is applied to that plan's file before the next task starts. + +1. `plans/2026-09-18-cosmic-tetris-1-engine.md` — headless deterministic engine in `internal/game`. Consumes: nothing. +2. `plans/2026-09-18-cosmic-tetris-2-playable-terminal.md` — Bubble Tea app, canvas renderer, layout, HUD, hold/next/ghost, CLI flags, pause/help/game-over card, golden tests. Consumes Plan 1's `game.Game`, `Advance`, `Event`, `Board`, `Piece`, `GhostY`. +3. `plans/2026-09-18-cosmic-tetris-3-cosmic-foundation.md` — `internal/fx` (particles, starfield), animated border, piece trails, `internal/flavor` mission control. Consumes Plan 1's `Event`/`Cell` and Plan 2's `render.Canvas`, `render.Layout`, `render.Snapshot`, `app.Model`. +4. `plans/2026-09-18-cosmic-tetris-4-violence.md` — hard-drop impact, screen shake, line supernova, shockwaves, hyperdrive, four-line sequence, combo/level overlays. Consumes Plan 3's `fx.World` and the render FX layer. +5. `plans/2026-09-18-cosmic-tetris-5-polish.md` — boot sequence, game-over black hole, ASCII/no-FX guarantees, §45 details, README, definition-of-done sweep. Consumes everything above. + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `internal/fx/shake.go` | deterministic shake pattern and offset | +| `internal/fx/impact.go` | hard-drop ion beam and debris eruption | +| `internal/fx/lineflash.go` | the three-phase supernova state | +| `internal/fx/shockwave.go` | expanding rings | +| `internal/fx/hyperdrive.go` | the §16 speed timeline and star-density boost | +| `internal/fx/banner.go` | four-line banner, level card, HUD pulse, combo tiers | +| `internal/render/boardlayer.go` | board layer canvas + shake blit, clipped to the board box | +| `internal/render/spectacle.go` | draw supernova, shockwaves, star streaks, banners, level card | + +All are new files inside packages §33 already names, each one effect's state or drawing. + +--- + +### Task 1: Screen shake + +**Files:** +- Create: `internal/fx/shake.go`, `internal/render/boardlayer.go` +- Modify: `internal/render/render.go` (route board drawing through the board layer) +- Test: `internal/fx/shake_test.go`, `internal/render/boardlayer_test.go` + +**Interfaces:** +- Consumes: Plan 3's `World`, `Options`; Plan 2's `Canvas`, `Layout`. +- Produces: +```go +type Shake struct{ Age, Life float64 } +var ShakePattern = [5]game.Point{{X: 0, Y: 1}, {X: -1, Y: 0}, {X: 1, Y: 0}, {X: 0, Y: -1}, {X: 0, Y: 0}} // §18 +const (ShakeLife = 0.08; BigShakeLife = 0.14) +func (w *World) TriggerShake(life float64) +func (w *World) ShakeOffset() (dx, dy int) // (0,0) when inactive or ReducedMotion + +// render +func DrawBoardLayer(dst *Canvas, l Layout, s Snapshot, o Options) +``` + +`ShakeOffset` indexes `ShakePattern` by `int(Age / Life * len(ShakePattern))`, clamped to the last entry. `DrawBoardLayer` renders the border, board interior, ghost, active piece and board-local FX into a canvas of `l.Border` size, then blits it into `dst` at `l.Border.X+dx, l.Border.Y+dy` with the blit clipped to the `l.Border` rectangle — content shifted outside the box is dropped rather than drawn over the HUD. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestShakeWalksThePinnedPattern(t *testing.T) +// TriggerShake(ShakeLife); sample ShakeOffset() at Age 0, 0.02, 0.04, 0.06, 0.079 +// => (0,1), (-1,0), (1,0), (0,-1), (0,0) in that order + +func TestShakeEndsAfterEightyMilliseconds(t *testing.T) +// TriggerShake(ShakeLife); Step(80ms) => ShakeOffset() == (0,0) and the shake is inactive + +func TestShakeNeverExceedsOneCell(t *testing.T) +// step through 1000 sub-steps of both ShakeLife and BigShakeLife: +// |dx| <= 1 and |dy| <= 1 at every sample (§44) + +func TestReducedMotionDisablesShake(t *testing.T) +// Options{Enabled:true, ReducedMotion:true}: TriggerShake => ShakeOffset() == (0,0) + +func TestShakenBoardNeverBleedsIntoTheHUD(t *testing.T) +// for each of the five pattern offsets: render a full frame at 100x40 with a filled board; +// every HUD region (Hold, Next, Stats) contains exactly what it contains with no shake + +func TestShakeKeepsTheBoardBoxSize(t *testing.T) +// with shake active, the rows of the output at l.Border.Y..Border.Y+H-1 are never wider +// than l.Border.W within that x-range +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -run TestShake -v` +Expected: FAIL — undefined `ShakePattern`. + +- [ ] **Step 3: Implement `shake.go`, `boardlayer.go` and the `render.go` rewiring** + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./... -v` +Expected: PASS (goldens unchanged — with no shake active the offset is (0,0)) + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/shake.go internal/fx/shake_test.go internal/render/boardlayer.go internal/render/render.go internal/render/boardlayer_test.go +git commit -m "feat(fx): deterministic one-cell screen shake on a clipped board layer" +``` + +--- + +### Task 2: Hard-drop impact + +**Files:** +- Create: `internal/fx/impact.go` +- Modify: `internal/fx/world.go` (`Observe` dispatches `PieceHardDropped`) +- Test: `internal/fx/impact_test.go`, `internal/render/impact_test.go` + +**Interfaces:** +- Consumes: Task 1's `TriggerShake`; Plan 3's `Spawn`, `Trail`, `Energy`. +- Produces: +```go +var DebrisGlyphs = []rune{'·', '*', '✦', '+'} // §18 +const ( + ImpactBeamLife = 0.18 + DebrisPerCell = 3 + DebrisBase = 6 + DebrisMax = 40 + DebrisUpMin = 6.0 // cells/s + DebrisUpMax = 14.0 + DebrisSpreadMax = 9.0 // cells/s horizontal at the edge of the contact area +) +func (w *World) impact(ev game.Event) +func (w *World) spawnBeam(cells []game.Point, life float64) +``` + +`impact` does four things (§18): a vertical ion beam through every cell the piece crossed (`spawnBeam`, `ImpactBeamLife`, capped by `MaxTrails`); `min(DebrisBase + DebrisPerCell*ev.Distance, DebrisMax)` debris particles from the contact row with horizontal velocity signed by their offset from the contact centre, upward initial `VY`, and `AY = ParticleGravity`; `TriggerShake(ShakeLife)`; and the `EnergyBump` for `PieceHardDropped`, which the border flash reads. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestImpactSpawnsBeamDebrisAndShake(t *testing.T) +// Observe a PieceHardDropped{Piece: p, Distance: 12} +// => at least one Trail with Life == ImpactBeamLife covering 12 rows of crossed cells +// => len(Particles) == min(6+3*12, DebrisMax) == 40 +// => ShakeOffset() != (0,0) +// => Energy == 0.25 + +func TestDebrisCountScalesWithDistanceAndCaps(t *testing.T) +// Distance 1 => 9 particles; Distance 4 => 18; Distance 20 => DebrisMax + +func TestDebrisFliesOutwardAndFalls(t *testing.T) +// particles left of the contact centre have VX < 0, right have VX > 0; +// every particle has VY < 0 initially (upward) and AY == ParticleGravity + +func TestDebrisGlyphsAreFromTheSpecSet(t *testing.T) +// every spawned particle's Glyph is in DebrisGlyphs + +func TestZeroDistanceHardDropStillReacts(t *testing.T) +// Distance 0 => DebrisBase particles, shake triggered, no panic, no beam cells + +func TestNoImpactWhenFXDisabled(t *testing.T) +// Options{Enabled:false} => nothing spawns, no shake + +func TestHardDropBrightensTheBorder(t *testing.T) // render side +// the border colour one frame after a hard drop differs from the resting colour, +// and moves toward the hot-white end of BorderPalette +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -run TestImpact -v` +Expected: FAIL — undefined `DebrisGlyphs`. + +- [ ] **Step 3: Implement `impact.go` and the `Observe` dispatch** + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./... -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/impact.go internal/fx/world.go internal/fx/impact_test.go internal/render/impact_test.go +git commit -m "feat(fx): hard-drop ion beam, debris eruption and shake" +``` + +--- + +### Task 3: Line-clear supernova + +**Files:** +- Create: `internal/fx/lineflash.go`, `internal/render/spectacle.go` +- Modify: `internal/fx/world.go` (`Observe` dispatches `LinesCleared`), `internal/render/render.go` +- Test: `internal/fx/lineflash_test.go`, `internal/render/spectacle_test.go` + +**Interfaces:** +- Consumes: `game.Event.Rows` and `game.Event.Cells` (the pre-clear row contents from Plan 1). +- Produces: +```go +type LineFlash struct { + Rows []int + Cells [][]game.Cell + Age float64 + // unexported: debrisSpawned bool +} +const (FlashPhaseAEnd = 0.07; FlashPhaseBEnd = 0.15; FlashLife = 0.22) // §19: ~220ms total +func (f LineFlash) Phase() int // 0 critical mass, 1 supernova, 2 collapse, -1 expired +func (w *World) Flashes() []LineFlash + +// render +func DrawLineFlash(c *Canvas, l Layout, w *fx.World, g *game.Game, o Options) +var FlashRamp = [4]string{"██", "▓▓", "▒▒", "░░"} // ASCII: "[]", "##", ";;", ".." +const FlashCoreGlyph = "✦✦" +``` + +Phases (§19), drawn at the cleared rows' original positions over the already-collapsed board: +- **A — critical mass:** the row degrades from its edges inward: outer cells step down the `FlashRamp`, the centre stays solid. +- **B — supernova:** a bright `FlashCoreGlyph` band expands from the centre outward with `FlashRamp` shoulders on either side. +- **C — collapse:** the row renders empty, and on entering this phase exactly once, debris particles spawn — one per cleared cell, `VX = (x - centre) × 2.5` cells/s, upward `VY`, `AY = ParticleGravity` (§19: particles inherit horizontal velocity from their position relative to centre). + +The flash never draws over a cell occupied by the active piece (§44). + +- [ ] **Step 1: Write the failing tests** + +```go +func TestFlashLifetimeAndPhases(t *testing.T) +// Observe a LinesCleared{Rows:[]int{21}, Cells: one row, Count:1} +// Phase() == 0 at Age 0 and 0.06; == 1 at 0.08 and 0.14; == 2 at 0.16 and 0.21; +// after Step(220ms) the flash is gone (Flashes() empty) + +func TestDebrisSpawnsExactlyOnceOnPhaseC(t *testing.T) +// step to Age 0.16 in 10 sub-steps => the debris count after the first phase-C step +// equals the count after five more phase-C steps + +func TestDebrisInheritsHorizontalVelocityFromPosition(t *testing.T) +// cleared row of 10 cells: the leftmost debris has the most negative VX, the +// rightmost the most positive, and a centre cell is near zero + +func TestFlashCarriesPreClearColours(t *testing.T) +// the LineFlash's Cells match the event's Cells, so the animation can colour the row +// after the board has already collapsed + +func TestFourLinesMakeOneFlashOfFourRows(t *testing.T) +// LinesCleared{Count:4} => exactly one LineFlash with len(Rows)==4 + +func TestGameplayContinuesDuringTheAnimation(t *testing.T) +// app-level: clear a line, then feed 14 frames of 16ms (the whole 220ms) while holding +// no keys => the newly spawned piece descends normally, key presses still move it, +// and the engine's board is already collapsed from frame one + +func TestFlashNeverCoversTheActivePiece(t *testing.T) +// place the active piece over a cleared row's position: those cells render BlockGlyph + +func TestGoldenSupernovaMidPhaseB(t *testing.T) +// fixed fx seed, a 2-line clear, stepped to Age 0.10, 100x40 => testdata/supernova.txt +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -run TestFlash -v` +Expected: FAIL — undefined `LineFlash`. + +- [ ] **Step 3: Implement `lineflash.go`, `DrawLineFlash` and the pipeline hook** + +- [ ] **Step 4: Create the golden and run everything** + +Run: `go test ./internal/render/ -run TestGolden -update && go test ./... -v` +Expected: PASS; read `testdata/supernova.txt` and confirm it reads as an explosion, not as garbage. + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/lineflash.go internal/fx/world.go internal/fx/lineflash_test.go internal/render/spectacle.go internal/render/render.go internal/render/spectacle_test.go internal/render/testdata +git commit -m "feat(fx): three-phase line-clear supernova" +``` + +--- + +### Task 4: Shockwaves + +**Files:** +- Create: `internal/fx/shockwave.go` +- Modify: `internal/render/spectacle.go` +- Test: `internal/fx/shockwave_test.go` + +**Interfaces:** +- Consumes: Plan 3's `World`. +- Produces: +```go +type Shockwave struct{ CX, CY, Age float64 } +const (ShockLife = 0.30; MaxShockwaves = 3; ShockMaxRadius = 14.0; ShockAspect = 2.0) +func (s Shockwave) Radius() float64 // eased out: ShockMaxRadius * (1 - (1-t)²), t = Age/ShockLife +func (w *World) TriggerShockwave(cx, cy float64) +func (w *World) Shockwaves() []Shockwave + +// render +var ShockRamp = [4]rune{'·', '○', '◌', '◯'} // §24; ASCII: '.', 'o', 'o', 'O' +func DrawShockwaves(c *Canvas, l Layout, w *fx.World, o Options) +``` + +Rings are faked in terminal space: a cell `(x, y)` is on the ring when `hypot((x-CX)/ShockAspect, y-CY)` is within 0.6 of `Radius()`, which compensates for the 2:1 cell aspect. Glyph comes from `ShockRamp` indexed by age. Shockwaves are used sparingly (§24) — only by four-line clears, combo ≥ 4 and level-up (wired in Task 6) — suppressed under `ReducedMotion`, and capped at `MaxShockwaves` (oldest dropped). + +- [ ] **Step 1: Write the failing tests** + +```go +func TestRadiusGrowsAndDies(t *testing.T) +// TriggerShockwave(10,10): Radius() at Age 0 is 0, strictly increases across +// 10 samples, and reaches ShockMaxRadius at ShockLife; Step(300ms) => Shockwaves() empty + +func TestReducedMotionSuppressesShockwaves(t *testing.T) +// ReducedMotion: TriggerShockwave => Shockwaves() empty + +func TestShockwavesAreCapped(t *testing.T) +// trigger 6 => len(Shockwaves()) == MaxShockwaves + +func TestRingCellsFollowTheEllipse(t *testing.T) +// at Age 0.15, the drawn cells all satisfy the ring predicate within tolerance, +// and the ring is about twice as wide as it is tall + +func TestNoShockwaveWhenFXDisabled(t *testing.T) +// Options{Enabled:false} => nothing +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -run 'TestRadius|TestReducedMotionSuppresses|TestShockwaves|TestRing|TestNoShockwave' -v` +Expected: FAIL — undefined `Shockwave`. + +- [ ] **Step 3: Implement `shockwave.go` and `DrawShockwaves`** + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./... -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/shockwave.go internal/fx/shockwave_test.go internal/render/spectacle.go +git commit -m "feat(fx): faked-geometry radial shockwaves" +``` + +--- + +### Task 5: Hyperdrive + +**Files:** +- Create: `internal/fx/hyperdrive.go` +- Modify: `internal/fx/starfield.go` (`StarSpeed` multiplies in the hyperdrive factor and the density boost), `internal/render/fx.go` (near-layer stars render as streaks while stretching) +- Test: `internal/fx/hyperdrive_test.go`, `internal/render/hyperdrive_test.go` + +**Interfaces:** +- Consumes: Plan 3's `StarSpeed`, `Stars`, `StarCount`. +- Produces: +```go +type Hyper struct{ Age float64; Active bool } +const ( + HyperLife = 1.10 + HyperPeakFactor = 8.0 + LargeCombo = 4 // §16 "large combo" +) +func (w *World) TriggerHyperdrive() +func (w *World) StarSpeedMultiplier() float64 // §16 timeline; 1.0 when inactive or ReducedMotion +func (w *World) Stretching() bool // true in the 50–150ms stretch window +func (w *World) BoostStarDensity(amount, seconds float64) +func (w *World) StarTarget() int // StarCount(W,H) scaled by the live density boost + +// render +var StreakGlyphs = [2]rune{'│', '┃'} // ASCII: '|', '!' +``` + +Timeline (§16), as `StarSpeedMultiplier()`: + +| Age | Value | +|---|---| +| 0 – 50ms | `0` (stars pause) | +| 50 – 100ms | `0.2`, with `Stretching() == true` | +| 100 – 500ms | linear ramp `0.2 → HyperPeakFactor` | +| 500 – 800ms | `HyperPeakFactor` (peak) | +| 800 – 1100ms | linear decay `HyperPeakFactor → 1.0` | +| past 1100ms | `1.0`, inactive | + +Triggers (§16): a four-line clear, a combo of `LargeCombo` or more, and a new session high score — `World` tracks the best score it has seen and fires only when a later score beats a non-zero best. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestHyperdriveTimeline(t *testing.T) +// TriggerHyperdrive then sample StarSpeedMultiplier() at Age +// 0.00 → 0; 0.04 → 0; 0.07 → 0.2; 0.30 → between 0.2 and HyperPeakFactor (monotone); +// 0.60 → HyperPeakFactor; 0.95 → between 1 and HyperPeakFactor; 1.20 → 1.0 and inactive + +func TestStretchWindow(t *testing.T) +// Stretching() is false at Age 0.02, true at 0.07 and 0.12, false at 0.30 + +func TestTriggersAreTheSpecifiedThree(t *testing.T) +// Observe LinesCleared{Count:4} => active +// Observe ComboChanged{Count:4} => active; Count:3 => not active +// GameView Score 5000 after a previous best of 4000 => active; +// the very first score seen does not trigger + +func TestReducedMotionKeepsStarsAtNormalSpeed(t *testing.T) +// ReducedMotion: TriggerHyperdrive => StarSpeedMultiplier() == 1.0 always, Stretching() false + +func TestStarSpeedCombinesLevelAndHyperdrive(t *testing.T) +// level 10 world with hyperdrive at peak: StarSpeed() == SpeedForLevel(10)*HyperPeakFactor + +func TestDensityBoostAddsAndThenRemovesStars(t *testing.T) +// BoostStarDensity(0.4, 3.0): StarTarget() > StarCount(W,H); after Step(3s), +// StarTarget() == StarCount(W,H) and len(Stars) matches it again + +func TestNearStarsStreakWhileStretching(t *testing.T) // render side +// during the stretch window, layer-2 stars render a StreakGlyphs rune; +// outside it, they render their normal glyph +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -run 'TestHyper|TestStretch|TestTriggers|TestReducedMotionKeeps|TestStarSpeed|TestDensity' -v` +Expected: FAIL — undefined `TriggerHyperdrive`. + +- [ ] **Step 3: Implement `hyperdrive.go` and the starfield/render changes** + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./... -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/hyperdrive.go internal/fx/starfield.go internal/fx/hyperdrive_test.go internal/render/fx.go internal/render/hyperdrive_test.go +git commit -m "feat(fx): hyperdrive timeline, star streaks and density boost" +``` + +--- + +### Task 6: Four-line sequence, combo escalation and the level-up card + +**Files:** +- Create: `internal/fx/banner.go` +- Modify: `internal/fx/world.go` (`Observe` composes the big events), `internal/render/spectacle.go` (banner, level card, HUD pulse) +- Test: `internal/fx/banner_test.go`, `internal/render/banner_test.go` + +**Interfaces:** +- Consumes: Tasks 1–5. +- Produces: +```go +type Banner struct{ Text string; Age float64 } +const BannerLife = 0.70 // §20 + +var TetrisBanners = []string{ // §20, verbatim + "✦ EVENT HORIZON ✦", + "QUADRUPLE COSMIC INCIDENT", + "FOUR ROWS HAVE LEFT THE CHAT", + "SPACE-TIME HAS FILED A COMPLAINT", +} + +type LevelCard struct{ Level int; Subtitle string; Age float64 } +const LevelCardLife = 1.20 // §22: slides/fades away without pausing the game +var LevelSubtitles = []string{ // §22, verbatim + "GRAVITY TAX INCREASED", + "LOCAL PHYSICS UPDATED WITHOUT CONSENT", + "PLEASE SECURE ALL LOOSE TETROMINOES", +} + +func ComboTier(combo int) int // 0 for <2; 1 at 2; 2 at 3; 3 at 4; 4 at 5+ +func (w *World) HUDPulse() float64 // 0 below tier 3; a 0..1 sine otherwise (§21) +func (w *World) Banner() *Banner // nil when none +func (w *World) LevelCard() *LevelCard // nil when none +func (w *World) tetris(ev game.Event) // the §20 simultaneous bundle + +// render +func DrawBanner(c *Canvas, l Layout, w *fx.World, g *game.Game, o Options) +func DrawLevelCard(c *Canvas, l Layout, w *fx.World, o Options) +``` + +`tetris` fires all of §20 at once: `TriggerHyperdrive()`, `TriggerShake(BigShakeLife)`, a border gradient pulse (`Energy` to 1.0), a particle eruption (a `DebrisMax`-sized burst from the cleared band), `HUDPulse` via the combo tier, `BoostStarDensity(0.4, 3.0)`, and a `Banner` picked from `TetrisBanners` with the FX RNG. + +Combo tiers (§21): tier 1 spawns a few `ClassSpark` particles, tier 2 spawns `ClassEmber` meteors that drift across the board, tier 3 turns on `HUDPulse`, tier 4 adds a `TriggerShockwave` at the board centre. + +`DrawBanner` centres the text in the board's top three visible rows and skips any cell already holding a block, ghost or active-piece glyph — so the banner can never obscure the active piece or the stack (§44). `DrawLevelCard` draws the §22 box, sliding in from the right edge of the board area and fading out over `LevelCardLife`. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestFourLineClearFiresEverythingAtOnce(t *testing.T) +// one Observe of LinesCleared{Count:4} => +// Banner() != nil and its Text is one of TetrisBanners +// StarSpeedMultiplier() reflects an active hyperdrive +// ShakeOffset() != (0,0) +// Energy == 1.0 +// len(Particles) > 20 +// StarTarget() > StarCount(W,H) + +func TestBannerLifetime(t *testing.T) +// Step(690ms) => Banner() != nil; Step(20ms) more => nil + +func TestComboTiers(t *testing.T) +// ComboTier(0)==0; (1)==0; (2)==1; (3)==2; (4)==3; (5)==4; (12)==4 +// Observe ComboChanged{Count:2} spawns ClassSpark particles; +// Count:3 spawns ClassEmber; Count:4 => HUDPulse() > 0; Count:5 => a shockwave exists + +func TestLevelCardCopyAndLifetime(t *testing.T) +// Observe LevelChanged{Count:8} => LevelCard().Level == 8 and Subtitle is in LevelSubtitles +// Step(1.2s) => LevelCard() == nil + +func TestLevelCardDoesNotPauseTheGame(t *testing.T) +// app level: a level-up mid-session, then 40 frames => the piece kept descending and +// key presses kept moving it during the card's lifetime + +func TestBannerNeverCoversTheActivePieceOrStack(t *testing.T) +// active piece placed in the top three visible rows with a banner showing: +// every active-piece cell renders BlockGlyph in the output, and no locked cell is +// replaced by banner text + +func TestSimultaneousTetrisLevelAndComboFive(t *testing.T) +// one Observe carrying LinesCleared{Count:4} + ComboChanged{Count:5} + LevelChanged{Count:5} +// => no panic, Banner() != nil, LevelCard() != nil, len(Particles) <= MaxParticles, +// len(Shockwaves()) <= MaxShockwaves, Energy == 1.0, and a full Render at 100x40 succeeds +// with every line within the terminal width + +func TestGoldenFourLineClear(t *testing.T) +// fixed fx seed, LinesCleared{Count:4}, stepped to Age 0.12, 100x40 => testdata/tetris.txt +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -run 'TestFourLine|TestBanner|TestCombo|TestLevelCard|TestSimultaneous' -v` +Expected: FAIL — undefined `TetrisBanners`. + +- [ ] **Step 3: Implement `banner.go`, the `Observe` composition and the two draw functions** + +- [ ] **Step 4: Create the golden, run everything, and look at it** + +Run: `go test ./internal/render/ -run TestGolden -update && go test ./... -v && go run ./cmd/cosmic-tetris --seed 1234` +Expected: PASS; a four-line clear in the live binary produces §43's reaction. + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/banner.go internal/fx/world.go internal/fx/banner_test.go internal/render/spectacle.go internal/render/banner_test.go internal/render/testdata +git commit -m "feat(fx): four-line event bundle, combo escalation and level-up card" +``` + +--- + +### Task 7: Reduced motion, intensity caps and performance + +**Files:** +- Modify: `internal/fx/world.go` (single intensity gate), `internal/app/update.go` (flag plumb-through already exists) +- Test: `internal/fx/reducedmotion_test.go`, `internal/fx/bench_test.go`, `internal/app/violence_test.go` + +**Interfaces:** +- Consumes: Tasks 1–6. +- Produces: +```go +func (o Options) AllowsMotion() bool // !ReducedMotion — the single gate every motion effect asks +``` + +Every motion trigger (`TriggerShake`, `TriggerHyperdrive`, `TriggerShockwave`) consults this one predicate, so §49.5's ten-line promise stays ten lines. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestReducedMotionMatrix(t *testing.T) +// Options{Enabled:true, ReducedMotion:true}, observe a four-line clear + combo 5 + level up: +// suppressed: ShakeOffset()==(0,0); StarSpeedMultiplier()==1.0; Shockwaves() empty +// still running: len(Particles) > 0; len(Trails) > 0 after a move; Energy > 0; +// Banner() != nil; LevelCard() != nil; stars still drift + +func TestNoFXSuppressesEverything(t *testing.T) +// Options{Enabled:false}: the same observation leaves every collection empty and Energy 0 + +func TestParticleCapHoldsUnderSustainedViolence(t *testing.T) +// 60 seconds of simulated frames with a four-line clear every 10 frames: +// len(Particles) <= MaxParticles, len(Trails) <= MaxTrails, +// len(Shockwaves()) <= MaxShockwaves, len(Stars) <= 240 at every frame + +func BenchmarkStepAndRenderUnderLoad(b *testing.B) +// a 100x40 world at MaxParticles with a live supernova, shockwave and hyperdrive: +// one fx.Step(16ms) + one render.Render per iteration + +func TestFrameBudgetUnderLoad(t *testing.T) +// the same load, 120 frames, measured: the mean Step+Render wall time is under 8ms +// (skip under -short and on -race) + +func TestNoGoroutinesAreSpawned(t *testing.T) +// runtime.NumGoroutine() before and after 600 frames of heavy FX is unchanged (§38) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -run 'TestReducedMotionMatrix|TestNoFXSuppresses|TestParticleCap|TestFrameBudget|TestNoGoroutines' -v` +Expected: FAIL — undefined `AllowsMotion`. + +- [ ] **Step 3: Route every motion trigger through `AllowsMotion` and fix any cap violations the tests find** + +- [ ] **Step 4: Run everything** + +Run: `go test ./... -v && go test ./internal/fx/ -bench . -run XXX && go vet ./...` +Expected: PASS; the benchmark reports a per-frame cost well inside a 16ms budget. + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx internal/app +git commit -m "feat(fx): single reduced-motion gate, intensity caps and frame budget tests" +``` diff --git a/tests/inline-eval/microtests/plan-boundary/repo/plans/2026-09-18-cosmic-tetris-5-polish.md b/tests/inline-eval/microtests/plan-boundary/repo/plans/2026-09-18-cosmic-tetris-5-polish.md new file mode 100644 index 000000000..72a57f6fb --- /dev/null +++ b/tests/inline-eval/microtests/plan-boundary/repo/plans/2026-09-18-cosmic-tetris-5-polish.md @@ -0,0 +1,456 @@ +# Cosmic Tetris — Plan 5: Absurd Polish + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Finish the game: the boot sequence, the game-over black hole collapse, guaranteed ASCII and no-FX modes, the §45 tiny details, the README, and the §43/§47 acceptance sweep. + +**Architecture:** Two new app phases get their own aged FX state and draw function, following the pattern established in Plans 3–4 (`fx` holds the timeline, `render` draws it, `app` owns the clock). The mode guarantees are enforced by tests that walk every rune of every view rather than by trusting call sites. + +**Tech Stack:** Go 1.26, `charm.land/bubbletea/v2`, `charm.land/bubbles/v2` (spinner), `charm.land/lipgloss/v2`. + +**Spec:** `design.md` (§28, §29, §30, §32, §39, §42 Phase 5, §43, §45, §46, §47, §48, §49.4, §49.5) + +## Global Constraints + +- Language: Go. Module `cosmic-tetris`, `go 1.26`. +- `internal/fx` must not import `internal/render` or `internal/app`. +- CLI surface is exactly: no flags, `--seed N`, `--ascii`, `--no-fx`, `--reduced-motion`, `--help`. Nothing else (§46, §49.5). +- ASCII mode emits only ASCII runes — glyphs, box drawing, flavor text and banners included (§32). +- The boring modes (`--no-fx`, `--ascii`) must still be a good game (§32, §47). +- Effects never modify game state; animations never block input (§44, §47). +- Boot drama lasts about one second and any key skips it; no menu (§29). +- The codebase should stay small enough to understand in an afternoon (§48). Do not add architecture beyond the files this plan names. + +## Review Focus + +1. A key pressed inside the very first boot frame — must skip cleanly into play, with no double initialisation and no lost key (Task 1). +2. Game over arriving while a banner, hyperdrive or supernova is mid-flight — the collapse must still start and finish, never wedge (Task 2). +3. `r` pressed partway through the collapse — restart must work from every sub-phase, including the final card (Task 2). +4. ASCII mode rendering a non-ASCII flavor message, banner or particle glyph — the ASCII guarantee has to cover text, not just blocks (Task 3). +5. A resize below the minimum while boot, help or the collapse is on screen — the too-small notice replaces the overlay without panicking (Task 3). + +## Plan Set + +Run in this order. A ruling that changes a name, signature, or value a later plan consumes is applied to that plan's file before the next task starts. + +1. `plans/2026-09-18-cosmic-tetris-1-engine.md` — headless deterministic engine in `internal/game`. Consumes: nothing. +2. `plans/2026-09-18-cosmic-tetris-2-playable-terminal.md` — Bubble Tea app, canvas renderer, layout, HUD, hold/next/ghost, CLI flags, pause/help/game-over card, golden tests. Consumes Plan 1's `game.Game`, `Advance`, `Event`, `Board`, `Piece`, `GhostY`. +3. `plans/2026-09-18-cosmic-tetris-3-cosmic-foundation.md` — `internal/fx` (particles, starfield), animated border, piece trails, `internal/flavor` mission control. Consumes Plan 1's `Event`/`Cell` and Plan 2's `render.Canvas`, `render.Layout`, `render.Snapshot`, `app.Model`. +4. `plans/2026-09-18-cosmic-tetris-4-violence.md` — hard-drop impact, screen shake, line supernova, shockwaves, hyperdrive, four-line sequence, combo/level overlays. Consumes Plan 3's `fx.World` and the render FX layer. +5. `plans/2026-09-18-cosmic-tetris-5-polish.md` — boot sequence, game-over black hole, ASCII/no-FX guarantees, §45 details, README, definition-of-done sweep. Consumes everything above. + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `internal/render/boot.go` | the §29 boot screen and its reveal timeline | +| `internal/fx/collapse.go` | the §28 game-over collapse timeline and cell physics | +| `internal/render/collapse.go` | SIGNAL LOST, inward fall, black-hole art | +| `internal/flavor/messages.go` (modify) | the §45 occasional lines | +| `internal/fx/shootingstar.go` | the §45 idle shooting star | +| `README.md` | what it is, how to run it, flags, keys | + +--- + +### Task 1: Boot sequence + +**Files:** +- Create: `internal/render/boot.go` +- Modify: `internal/app/model.go` (start in `PhaseBoot`, add the spinner), `internal/app/update.go` (boot timing and skip), `internal/render/render.go` (boot branch), `internal/render/golden_test.go` +- Test: `internal/render/boot_test.go`, `internal/app/boot_test.go` + +**Interfaces:** +- Consumes: Plan 2's `Canvas`, `Layout`, `Snapshot.Elapsed`, `PhaseBoot`. +- Produces: +```go +const BootDuration = 1100 * time.Millisecond +func DrawBoot(c *Canvas, l Layout, elapsed time.Duration, spinner string, o Options) +``` + +Reveal timeline, all text verbatim from §29: + +| Elapsed | Revealed | +|---|---| +| 0ms | `✦`, `C O S M I C`, `T E T R I S` | +| 150ms | `INITIALIZING LOCAL UNIVERSE...` + spinner | +| 350ms | `gravity ........ OK` | +| 550ms | `spacetime ...... OK` | +| 750ms | `tetrominoes .... QUESTIONABLE` | +| 950ms | `UNIVERSE ONLINE` | +| 1100ms | boot ends, play starts | + +App rules: `New` sets `Phase = PhaseBoot`. While booting, `FrameMsg` accumulates `Elapsed` and steps the FX world (stars drift behind the boot text) but never calls `Advance`. Any `tea.KeyPressMsg` during boot switches to `PhasePlaying` and is consumed — except `q`/`esc`, which still quit. Reaching `BootDuration` switches to `PhasePlaying` and resets `Elapsed` to 0 so gameplay chrome starts from zero. The spinner is `bubbles/v2/spinner` (§3: use Bubbles only where it helps — boot spinner is the named case). + +- [ ] **Step 1: Write the failing tests** + +```go +func TestBootRevealTimeline(t *testing.T) +// DrawBoot at 0ms contains "C O S M I C" and "T E T R I S" but not "gravity" +// at 400ms contains "gravity ........ OK" but not "spacetime" +// at 800ms contains "tetrominoes .... QUESTIONABLE" but not "UNIVERSE ONLINE" +// at 1000ms contains "UNIVERSE ONLINE" + +func TestBootFitsSmallTerminals(t *testing.T) +// at 40x24 and 100x40, every line of the boot output is within the terminal width +// and the line count is within the height + +func TestModelStartsInBoot(t *testing.T) +// New(...).Phase == PhaseBoot; the game does not advance: two FrameMsgs 900ms apart +// leave Active.Y unchanged + +func TestAnyKeySkipsBoot(t *testing.T) +// a single tea.KeyPressMsg for "c" during boot => Phase == PhasePlaying, +// Elapsed == 0, and the hold was NOT used (the key is consumed by the skip) + +func TestKeyInTheVeryFirstBootFrameSkipsCleanly(t *testing.T) +// key press before any FrameMsg => PhasePlaying, game intact, View() non-empty, +// and a following FrameMsg advances gravity normally + +func TestQuitStillWorksDuringBoot(t *testing.T) +// "q" during boot returns a cmd producing tea.QuitMsg + +func TestBootEndsOnItsOwn(t *testing.T) +// frames totalling 1.2s with no key => Phase == PhasePlaying and Elapsed restarted at ~100ms + +func TestGoldenBoot(t *testing.T) // 100x40 at 800ms, FX off => testdata/boot.txt +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/render/ -run TestBoot -v && go test ./internal/app/ -run 'TestModelStarts|TestAnyKey|TestKeyInThe|TestQuitStill|TestBootEnds' -v` +Expected: FAIL — undefined `DrawBoot`. + +- [ ] **Step 3: Implement `boot.go` and the app changes, then migrate the earlier app tests** + +Plans 2–4's app tests assume the model starts playing. Add a test helper `skipBoot(t *testing.T, m *Model)` in `internal/app/helpers_test.go` that sets `Phase = PhasePlaying`, and call it in every existing app test that drives gameplay. Do not change production behaviour to keep old tests green. + +- [ ] **Step 4: Create the golden and run everything** + +Run: `go test ./internal/render/ -run TestGolden -update && go test ./... -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/boot.go internal/render/render.go internal/render/boot_test.go internal/render/golden_test.go internal/render/testdata internal/app +git commit -m "feat(render): one second of excessive boot drama, skippable" +``` + +--- + +### Task 2: Game-over black hole collapse + +**Files:** +- Create: `internal/fx/collapse.go`, `internal/render/collapse.go` +- Modify: `internal/fx/world.go` (`Observe` triggers on `GameOver`), `internal/app/update.go` (game-over input rules), `internal/render/render.go` +- Test: `internal/fx/collapse_test.go`, `internal/render/collapse_test.go`, `internal/app/gameover_test.go` + +**Interfaces:** +- Consumes: Plan 1's `GameOver` event and `Board`; Plan 3's `World`. +- Produces: +```go +type CollapseCell struct { + X, Y float64 + VX, VY float64 + Kind game.PieceKind +} +type Collapse struct { + Age float64 + Cells []CollapseCell + Active bool +} +const ( + CollapseFreezeEnd = 0.30 // §28: 0–300ms everything freezes, "SIGNAL LOST" + CollapseFallEnd = 0.90 // 300–900ms blocks fall inward + CollapseHoleEnd = 1.30 // 900–1300ms board collapses into a black hole +) +func (w *World) TriggerCollapse(b *game.Board) +func (w *World) CollapseStage() int // 0 freeze, 1 falling, 2 black hole, 3 finished, -1 inactive + +// render +func DrawCollapse(c *Canvas, l Layout, w *fx.World, o Options) +var BlackHoleArt = []string{ // §28, verbatim + " · ", + " ˚ ", + " \\ | / ", + "--- ● --- ", + " / | \\ ", + " * ", +} +``` + +`TriggerCollapse` snapshots every occupied board cell into a `CollapseCell` whose velocity points at the board centre, with speed proportional to distance, so stage 1 looks like the stack falling inward. Stage 2 replaces the board with the black-hole art plus a swirl of `ClassEmber` particles. Stage 3 hands over to Plan 2's `DrawGameOver` card, which gains the `CAUSE: EXCESSIVE GEOMETRY` subtitle (§28). + +App rules: entering `PhaseGameOver` calls `TriggerCollapse(&m.Game.Board)` exactly once. During the collapse, `r` restarts and `q`/`esc` quit; every other key is ignored. FX keeps stepping so the collapse animates; the engine is not advanced. The collapse runs to completion even if a banner, hyperdrive or supernova was live — those keep decaying independently. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestCollapseStageTimeline(t *testing.T) +// TriggerCollapse(board): CollapseStage() is 0 at Age 0 and 0.29; 1 at 0.31 and 0.89; +// 2 at 0.91 and 1.29; 3 at 1.31 + +func TestCollapseSnapshotsEveryOccupiedCell(t *testing.T) +// a board with 37 occupied cells => len(Cells) == 37, each carrying its Kind + +func TestCollapseCellsFallInward(t *testing.T) +// cells left of centre have VX > 0, right have VX < 0, above the centre row have VY > 0 + +func TestCollapseIgnoresFXDisabled(t *testing.T) +// Options{Enabled:false}: TriggerCollapse leaves CollapseStage() == -1 so the app +// falls straight through to the game-over card + +func TestCollapseCompletesWhileOtherEffectsAreLive(t *testing.T) +// trigger a four-line bundle, then TriggerCollapse in the same frame: +// stepping 1.4s reaches stage 3 and no collection exceeds its cap + +func TestSignalLostThenBlackHole(t *testing.T) // render side +// stage 0 output contains "SIGNAL LOST"; stage 2 output contains "●" and the +// diagonal rays; stage 3 output contains "UNIVERSE EXPIRED" and "CAUSE: EXCESSIVE GEOMETRY" + +func TestRestartWorksFromEveryCollapseStage(t *testing.T) // app level +// for each stage 0..3: reach game over, step into that stage, press "r" +// => Phase == PhasePlaying, Score 0, empty board, CollapseStage() == -1 + +func TestOnlyRestartAndQuitDuringCollapse(t *testing.T) +// during stage 1, pressing left/right/space/c/p changes nothing about the game + +func TestCollapseTriggersOnce(t *testing.T) +// several frames after game over => len(Collapse.Cells) never grows +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -run TestCollapse -v` +Expected: FAIL — undefined `TriggerCollapse`. + +- [ ] **Step 3: Implement `collapse.go`, `DrawCollapse` and the app rules** + +- [ ] **Step 4: Create the golden and run everything** + +Add `TestGoldenBlackHole` (100×40 at collapse Age 1.0, fixed fx seed) to the golden set, then: + +Run: `go test ./internal/render/ -run TestGolden -update && go test ./... -v` +Expected: PASS; read `testdata/blackhole.txt` and confirm the universe looks properly dead. + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/collapse.go internal/fx/world.go internal/fx/collapse_test.go internal/render/collapse.go internal/render/render.go internal/render/collapse_test.go internal/render/testdata internal/app +git commit -m "feat(fx): game-over collapse into a simulated black hole" +``` + +--- + +### Task 3: ASCII and no-FX guarantees + +**Files:** +- Modify: `internal/render/fx.go` (route every glyph through `ASCIISafe` in `ModeASCII`), `internal/render/spectacle.go`, `internal/render/collapse.go`, `internal/render/boot.go`, `internal/flavor/messages.go` (ASCII-safe variants), `internal/render/golden_test.go` +- Test: `internal/render/ascii_test.go` + +**Interfaces:** +- Consumes: Plan 3's `ASCIISafe`. +- Produces: +```go +func ASCIIText(s string) string // maps every non-ASCII rune in a string via ASCIISafe +``` + +Every text-emitting path in `ModeASCII` passes through `ASCIIText`: mission control lines, banners (`✦ EVENT HORIZON ✦`), the boot `✦`, the black-hole art, the level card, the HUD title. Glyph paths already use `ASCIISafe`. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestEveryViewIsPureASCIIInASCIIMode(t *testing.T) +// For each state — boot, playing, paused, help, four-line clear mid-banner, each +// collapse stage, game-over card, too-small notice — and at 40x24 and 100x40: +// every rune of ansi.Strip(Render(..., Options{Mode: ModeASCII, FXEnabled: true})) is < 128 + +func TestASCIIModeKeepsTheBoardGeometry(t *testing.T) +// in ModeASCII the board rows are still BoardCols wide and blocks render "[]", ghosts ".." + +func TestNoFXModeIsStillAGoodGame(t *testing.T) +// Options{FXEnabled:false} across the same states: output is non-empty, contains the +// board, HUD, next queue and controls, and contains no star, particle or trail glyph + +func TestResizeBelowMinimumReplacesEveryOverlay(t *testing.T) +// for boot, help, paused, each collapse stage and the game-over card: +// Render at 34x19 shows "THIS UNIVERSE IS TOO SMALL" and nothing else, with no panic + +func TestGoldenASCIIWide(t *testing.T) // 100x40, ModeASCII, FX on, fixed fx seed +func TestGoldenASCIISmall(t *testing.T) // 40x24, ModeASCII +func TestGoldenNoFXWide(t *testing.T) // 100x40, ModeFull, FXEnabled false +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/render/ -run 'TestEveryView|TestASCIIMode|TestNoFXMode|TestResizeBelow' -v` +Expected: FAIL — non-ASCII runes leak through banners and flavor text. + +- [ ] **Step 3: Add `ASCIIText` and route every text path through it** + +- [ ] **Step 4: Create the goldens and run everything** + +Run: `go test ./internal/render/ -run TestGolden -update && go test ./... -v && go run ./cmd/cosmic-tetris --ascii --seed 1234 && go run ./cmd/cosmic-tetris --no-fx --seed 1234` +Expected: PASS; both boring modes play well and look intentional rather than broken. + +- [ ] **Step 5: Commit** + +```bash +git add internal/render internal/flavor +git commit -m "feat(render): guaranteed ASCII and no-FX modes" +``` + +--- + +### Task 4: The tiny details + +**Files:** +- Create: `internal/fx/shootingstar.go` +- Modify: `internal/flavor/messages.go`, `internal/flavor/channel.go` (score-rollover detection), `internal/fx/world.go` (idle tracking) +- Test: `internal/fx/shootingstar_test.go`, `internal/flavor/details_test.go` + +**Interfaces:** +- Consumes: Plan 3's `Channel`, `Messages`, `ForEvent`. +- Produces: +```go +// fx +const (ShootingStarChancePerSecond = 0.15; ShootingStarSpeed = 30.0) +func (w *World) maybeShootingStar(dt float64) // called from Step; one ClassStar streak + // crossing diagonally, life ~0.5s + +// flavor — new single-line categories (§45) +const ( + CatKineticRod Category = iota + 100 // "KINETIC ROD DEPLOYED" + CatCubeSecured // "CUBE ADJACENT OBJECT SECURED" + CatRollover // "NUMBER BECAME BIGGER" +) +func (c *Channel) noteScore(score int) bool // true when the score crossed a power of ten >= 10000 +``` + +`ForEvent` gains three cases, each ranking above `CatLock` and below `CatTetris`: +- `PieceHardDropped` whose `Piece.Kind == game.KindI` and `Piece.Rotation` is 1 or 3 → `CatKineticRod`. +- `HoldUsed` whose `Piece.Kind == game.KindO` → `CatCubeSecured`. +- a `GameView.Score` that crossed a power of ten at or above 10000 since the last observation → `CatRollover`. + +`CatRare` (`DID YOU KNOW YOU'RE IN A TERMINAL?`) and the idle `CAPTAIN?` already exist from Plan 3. Plan 3's `TestEveryCategoryHasMessages` exempts `CatBoot`; extend that exemption to these three single-line categories. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestShootingStarIsRareAndShortLived(t *testing.T) +// 60 seconds of 16ms steps with a fixed fx seed: between 3 and 25 shooting stars spawn; +// each lives under 1s; none exists after a further 2s of stepping + +func TestShootingStarTravelsDiagonally(t *testing.T) +// a spawned shooting star has both VX != 0 and VY != 0 and Class == ClassStar + +func TestNoShootingStarWhenFXDisabled(t *testing.T) + +func TestKineticRodOnVerticalIHardDrop(t *testing.T) +// ForEvent(PieceHardDropped with Piece{KindI, Rotation:1}) => CatKineticRod +// Rotation 0 (horizontal) => not CatKineticRod +// a KindT vertical hard drop => not CatKineticRod + +func TestCubeSecuredOnHoldingAnO(t *testing.T) +// ForEvent(HoldUsed with Piece{KindO}) => CatCubeSecured; KindS => CatHold + +func TestScoreRolloverFiresOncePerDecade(t *testing.T) +// noteScore: 9_999 → false; 10_000 → true; 10_500 → false; 99_999 → false; +// 100_000 → true; 1_000 → false (below the 10_000 threshold) +// and the channel's Text() then contains "NUMBER BECAME BIGGER" + +func TestRareLinesStayRare(t *testing.T) +// 1000 clear events with a fixed seed: the CatRare line appears at least once and +// fewer than 60 times (RareChance 0.02) + +func TestDetailMessagesAreOccasionalNotConstant(t *testing.T) +// across a 3-minute simulated session, no single message occupies more than 25% +// of the frames in which the mission line was non-empty +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -run TestShooting -v && go test ./internal/flavor/ -v` +Expected: FAIL — undefined `CatKineticRod`. + +- [ ] **Step 3: Implement the shooting star and the three flavor cases** + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./... -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx internal/flavor +git commit -m "feat(flavor): shooting stars, kinetic rods, cube adjacency and number growth" +``` + +--- + +### Task 5: README and the definition-of-done sweep + +**Files:** +- Create: `README.md` +- Test: `internal/render/flicker_test.go`, `internal/app/done_test.go` + +**Interfaces:** +- Consumes: everything. +- Produces: the §47 gate. No new production API. + +`README.md` covers: what Cosmic Tetris is (one paragraph, §48's framing), `go run ./cmd/cosmic-tetris`, `go build`, the five flags with one line each, the §8 key table, the three rendering modes, the architecture map from §33 with one line per package, and how to run the tests (`go test ./...`, `-update` for goldens). + +- [ ] **Step 1: Write the failing tests** + +```go +func TestRenderIsStableForIdenticalState(t *testing.T) +// the same Snapshot and Options rendered twice is byte-identical, for playing, paused, +// boot, help, collapse and game-over states — the renderer contributes no flicker (§47) + +func TestNoFilesystemOrLoggingDuringGameplay(t *testing.T) +// a 600-frame session with heavy FX writes nothing to a redirected log.Default() output +// and opens no files (assert log output buffer stays empty) (§38) + +func TestDefinitionOfDoneChecklist(t *testing.T) +// one table-driven test, one subtest per §47 bullet that can be asserted in code: +// playable start → game over; hold; ghost; next queue; deterministic piece generation; +// isolated game and FX RNGs; correct line clearing; gravity increases; pause; restart; +// ASCII fallback; no-FX mode; resize; effects never modify game state; +// animations never block input. +// Each subtest drives the app model and asserts the behaviour; none may be skipped. +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/render/ -run TestRenderIsStable -v && go test ./internal/app/ -run 'TestNoFilesystem|TestDefinitionOfDone' -v` +Expected: FAIL — undefined tests / unmet assertions + +- [ ] **Step 3: Fix whatever the checklist surfaces, then write `README.md`** + +- [ ] **Step 4: Run the full gate** + +Run: +```bash +gofmt -l . && go vet ./... && go test ./... && go test -race ./internal/game/ ./internal/fx/ && go build ./... +``` +Expected: `gofmt` prints nothing, vet is clean, all tests pass, race detector clean, build succeeds. + +- [ ] **Step 5: Run the §43 coolness acceptance test by hand** + +Launch `go run ./cmd/cosmic-tetris --seed 8675309` and confirm, writing the result into the commit message: +- within 30 seconds of normal play: moving starfield, animated board border, piece trails, hard-drop impact, particles, mission-control commentary; +- on the first completed line: supernova clear animation, debris, border reaction; +- a four-line clear produces the §43 reaction; +- game over collapses the universe into a black hole; +- resizing the terminal mid-game never breaks or crashes it; +- `--ascii`, `--no-fx` and `--reduced-motion` each still play well. + +- [ ] **Step 6: Commit** + +```bash +git add README.md internal/render/flicker_test.go internal/app/done_test.go +git commit -m "docs: README and definition-of-done sweep" +``` diff --git a/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/README.txt b/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/README.txt new file mode 100644 index 000000000..58e74aee8 --- /dev/null +++ b/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/README.txt @@ -0,0 +1,18 @@ +Plan-boundary micro-test, Sonnet 5 via Bedrock, single-shot sessions on a +repo where plan 1 (engine) is built with Game.Advance and plan 2 still says +Game.Tick. Each session is told plan 1 is done and asked to set up plan 2 +and stop before its Task 1, under one of: no instruction (X0), the +per-ruling "plans touched" slot (X1), a prose boundary scan (X2), the +plan-boundary script (X3, run after). + +Result: a ceiling. Every rep in every arm (17/17 with output; one X0 rep +produced no output) found the Tick/Advance mismatch and edited plan 2; the +two "Tick" mentions left are Bubble Tea's own tea.Tick and a test name. +A fresh session asked to "set up plan 2" checks the names on its own. The +failure this was meant to reproduce (0/2 slot, partial edits) happened +inside long sessions mid-flow, after a final review and a fix pass, which +a single-shot cannot stage. So the micro-test does not discriminate, and +the boundary-script gate is judged by the full plan-set run instead. The +interviews of the two failing sessions are the evidence for its form. + +X3 (script arm): same ceiling; see the rows in summary.txt and the outputs. diff --git a/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X0-control-1.md b/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X0-control-1.md new file mode 100644 index 000000000..2805b021e --- /dev/null +++ b/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X0-control-1.md @@ -0,0 +1,9 @@ +I did the setup/review step for plan 2 (executing-plans Step 1) and stopped before Task 1's first test. + +**Checked:** every `game.*` symbol plan 2 relies on against the actual committed engine (`Game`, `Advance`, `Event`, `Board`, `Piece`, `GhostY`, `Width`, `VisibleRows`, `HiddenRows`, etc.) — all present with matching signatures; `go.mod`/`go.sum` state; every design.md section plan 2 cites (§4–§49.x) exists and matches; board hidden-row indexing lines up with the `CellOrigin` formula in Task 4. + +**Changed:** found a real drafting bug — plan 2 said `Tick` (in the Plan Set consumed-symbols line and in Task 6's `Update` rules, `m.Game.Tick(dt)`), but the engine method is `Advance`, and plan 1's own text plus plan 2's Architecture paragraph already agree on `Advance`. Fixed both spots in `plans/2026-09-18-cosmic-tetris-2-playable-terminal.md`. Also created `plans/LEDGER.md` recording this review and the correction (there was no prior ledger convention, so I started one, matching the "rulings applied to the plan file" language the plans themselves use). + +**Not fixed, flagged instead:** the Bubble Tea v2 API shapes plan 2 asserts (`tea.KeyPressMsg`, `colorprofile.Profile`, etc.) can't be checked yet — none of those modules are in `go.sum` until Task 1/6 run `go get`. Noted in the ledger to re-verify once fetched, before writing code against them. + +No Go was written. Ready for Task 1, Step 1 (write the failing canvas tests) on your go-ahead. diff --git a/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X0-control-2.md b/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X0-control-2.md new file mode 100644 index 000000000..0d738de30 --- /dev/null +++ b/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X0-control-2.md @@ -0,0 +1,7 @@ +Setup for Plan 2 is done. Summary: + +**Checked:** baseline `go build`/`go test` on main (green, no deps yet); module proxy reachability and that `lipgloss/v2@v2.0.6` exists on it; every `game.*` symbol Plan 2's Task 1–8 claim to consume, cross-referenced against `internal/game/*.go` and design.md. + +**Changed:** fixed a real bug in the plan file itself — two references to a nonexistent `game.Game.Tick(dt)` (Plan Set §2, and Task 6's `FrameMsg` rule) corrected to the engine's actual method, `Advance(dt)` (confirmed in `internal/game/game.go:146` and design.md §49.2). Wrote `plans/LEDGER.md` recording that fix plus one open, non-blocking item to flag when Task 3/6 start: the plan never states the import path for the `colorprofile` package it uses in `ModeFor`/`tea.ColorProfileMsg`. + +Task 1 (Canvas) itself checked out clean — no other concerns. Stopping here, before writing its first test. diff --git a/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X0-control-3.md b/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X0-control-3.md new file mode 100644 index 000000000..7343e76b4 --- /dev/null +++ b/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X0-control-3.md @@ -0,0 +1,10 @@ +The diff is clean and self-consistent. Setup for Plan 2 is done and I'm stopping before Task 1's first test, as instructed. + +**What I checked:** read plan 2 in full plus every design.md section it cites (§4–§49.7), cross-checked plan 2's claimed Plan-1 interfaces (`game.Game`, `Event`, `Board`, `Piece`, `GhostY`) against the actual `internal/game` source, and traced every producer/consumer pair across plan 2's 8 tasks. Also verified all five pinned module versions resolve in the local module cache. + +**What I changed** (`plans/2026-09-18-cosmic-tetris-2-playable-terminal.md`, no Go written): +- **Ruling:** replaced two `Tick`/`m.Game.Tick(dt)` references with `Advance`/`m.Game.Advance(dt)` — Plan 1 never produced `Tick`; `Advance(dt) []Event` is what it actually built, matching design.md §49.2. +- **Ruling:** Task 3 (`palette.go`) uses `colorprofile.Profile` but had no `go get` for `github.com/charmbracelet/colorprofile`, and the Tech Stack line never named it — added `go get github.com/charmbracelet/colorprofile@v0.4.3` to Task 3 (the exact version bubbletea/bubbles already pin, so no later bump), plus updated the Tech Stack line and Task 3's commit `git add`. +- **Ruling:** resolved a self-contradiction about where `Rect` lives (Task 1's prose vs. the File Structure table) — `Rect` stays in `canvas.go` (Task 1); reworded both spots to say so plainly. + +Full detail and cost-if-wrong for each ruling is in the ledger at `.superpowers/sdd/2026-09-18-cosmic-tetris-2-playable-terminal/progress.md`. Next step is Task 1 Step 1: write the failing canvas tests. diff --git a/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X0-control-4.md b/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X0-control-4.md new file mode 100644 index 000000000..f5a5dc83b --- /dev/null +++ b/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X0-control-4.md @@ -0,0 +1,9 @@ +Setup for Plan 2 is done. Summary: + +**Checked:** every `internal/game` symbol Plan 2's Task interfaces consume (`Game`, `Board`, `Piece`, `PieceKind`, `GhostY`, `Advance`, `Event`, dimension consts, `CellFor`, score/lines/level/combo/over/hold/next fields) against the actual Plan-1 code — all present and matching. Cross-checked the tech-stack import paths, adaptive-layout rules, Bubble Tea event model, render pipeline order, and key bindings against design.md §3/§8/§31/§36/§37/§49.3 — consistent. Confirmed no TUI dependencies are in go.mod yet (expected; Task 1/6 add them) and confirmed there's no existing ledger convention in the repo. + +**Changed:** +- Fixed a stale reference: Plan 2 said `game.Game.Tick`/`Tick` in its Plan-Set line and Task 6's `Update` rule, but Plan 1 shipped `Advance(dt)` (also what design.md §49.2 and Plan 1's own file already say). Corrected both spots in the plan file per its own "apply the ruling before the next task starts" rule. +- Wrote `plans/2026-09-18-cosmic-tetris-2-playable-terminal.ledger.md` recording what was checked, the fix above, and one open question I did *not* resolve by guessing: Task 2's fixed layout table (`Mission` at h≥25, `ShowStatLabels` at h≥26) reads backwards from design.md §49.3's prose ("title, then mission, then stats labels" drop order), but the plan's own Step-1 test is self-consistent with the table as written. Worth a quick human call before/during Task 2. + +No Go code was written; Task 1's first test is next, pending your go-ahead. diff --git a/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X0-control-5.md b/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X0-control-5.md new file mode 100644 index 000000000..72de36ad7 --- /dev/null +++ b/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X0-control-5.md @@ -0,0 +1,9 @@ +Setup for Plan 2 is done. Summary: + +**Checked:** confirmed `go test ./...` passes cleanly on the committed Plan 1 engine; read every exported signature in `internal/game/*.go` and cross-checked it against everything Plan 2's tasks consume; verified the four pinned module versions (lipgloss/v2@v2.0.6, bubbletea/v2@v2.0.9, bubbles/v2@v2.2.1, x/ansi@v0.11.8) resolve via the proxy without touching `go.mod`/`go.sum`. + +**Found and fixed:** Plan 2 called Plan 1's time-advance method `Tick` in two places (its consumes-list and the `app.Update`/`FrameMsg` rule), but the actual, committed method is `Advance(dt) []Event` — matching what Plan 1's own header already said. Corrected both spots in `plans/2026-09-18-cosmic-tetris-2-playable-terminal.md`. No other drift found. + +**Wrote:** `plans/ledger.md` recording that ruling and the setup checks, per the plan set's "apply rulings before the next task starts" instruction. + +Stopping here, before Plan 2 Task 1's first failing test (`internal/render/canvas_test.go`). No Go was written. diff --git a/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X0-control-6.md b/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X0-control-6.md new file mode 100644 index 000000000..974039ef0 --- /dev/null +++ b/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X0-control-6.md @@ -0,0 +1,11 @@ +Setup for Plan 2 is done. I'm stopping here, before Task 1's first test. + +What I checked: +- Read the full Plan 2 file and the relevant `design.md` sections it cites (§8, §31, §36, §37, §46, §49.x) — the layout tiers, render pipeline order, CLI surface, and copy all line up with the plan text. +- Cross-checked every Plan-1 symbol Plan 2 claims to consume (`game.Game`, `Board`, `Piece`, `Event`, `GhostY`, `Width`, `VisibleRows`, `HiddenRows`, `PieceKind`, `CellFor`, `New`, `HardDrop`, `HoldPiece`, etc.) against the actual `internal/game` source — all present with matching signatures. +- Verified the four new module paths/versions the plan will `go get` (`charm.land/lipgloss/v2@v2.0.6`, `charm.land/bubbletea/v2@v2.0.9`, `charm.land/bubbles/v2@v2.2.1`, `github.com/charmbracelet/x/ansi@v0.11.8`) actually resolve. + +What I changed (per the plan set's own governance rule — "a ruling that changes a name... is applied to that plan's file before the next task starts"): +- Plan 1's engine method is `Advance(dt time.Duration) []Event`, but Plan 2 twice referenced a nonexistent `Tick` method (in the Plan Set summary line and in Task 6's `FrameMsg` update rule). Fixed both references to `Advance` in `plans/2026-09-18-cosmic-tetris-2-playable-terminal.md`. + +No Go code was written. Ready for Task 1 (Canvas) whenever you want to proceed. diff --git a/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X1-ruling-slot-1.md b/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X1-ruling-slot-1.md new file mode 100644 index 000000000..98d764038 --- /dev/null +++ b/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X1-ruling-slot-1.md @@ -0,0 +1,11 @@ +Setup for Plan 2 is done. Summary: + +**Checked:** +- Plan 1 is complete and its actual API (`internal/game`: `Game`, `Board`, `Piece`, `PieceKind`, `Event`, `GhostY`, `Width`/`Height`/`VisibleRows`/`HiddenRows`, `Advance`) against everything Plan 2 declares it consumes. +- `go.mod` module name (`cosmic-tetris`) and Go version (`1.26`) against Plan 2's Global Constraints — match, and `go1.26.1` is installed. +- Plan 2's cited design.md sections (§8 controls, §26 colors, §28 game-over card, §30 pause, §31 adaptive layout/too-small notice, §32 modes, §33 architecture, §36/§37 event model and render pipeline) against the plan's copy strings, draw order, and file layout — all consistent. +- No pre-existing `plans/ledger.md` or "Ruling:" convention existed yet, so I started one. + +**Found and fixed:** Plan 2 called a nonexistent `game.Game.Tick(dt)` (Plan Set consumes-line and the Task 6 `FrameMsg` rule) — the committed engine's method is `Advance`, which Plan 2's own Architecture paragraph already named correctly. This would have failed to compile at Task 6. Edited both spots in `plans/2026-09-18-cosmic-tetris-2-playable-terminal.md` to say `Advance`, and confirmed no other plan file references `Tick`. Logged the ruling in new `plans/ledger.md`. + +No Go was written. Stopping here, before Task 1's failing-tests step. diff --git a/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X1-ruling-slot-2.md b/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X1-ruling-slot-2.md new file mode 100644 index 000000000..b82284b20 --- /dev/null +++ b/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X1-ruling-slot-2.md @@ -0,0 +1,9 @@ +Setup for Plan 2 is done. Summary: + +**Checked:** every `game.*`/`g.*` symbol Plan 2 references against the actual `internal/game` source (not just Plan 1's text) — constants, `Board`, `Piece`, `PieceKind`, `GhostY`, `Game` fields all match exactly. Verified the four pinned Charm module versions actually resolve and confirmed their real APIs (`tea.Model`/`View`/`KeyPressMsg`/`WindowSizeMsg`/`ColorProfileMsg`, `lipgloss.Color`, `bubbles/key.Matches`, `bubbles/help`, `colorprofile.Profile.Convert`) match what Plan 2's Global Constraints assert. Checked Plan 2's 8 tasks for internal interface consistency, and cross-checked its Plan Set line against Plans 1/3/4/5 and `design.md`. + +**Found one defect:** Plan 2 contradicted itself — its architecture line correctly says `Game.Advance`, but its Plan Set line and the Task 6 `Update` spec said `m.Game.Tick(dt)`, a method that doesn't exist (the real, and Plan 1/3/4/5-consistent, name is `Advance`). Fixed both spots in `plans/2026-09-18-cosmic-tetris-2-playable-terminal.md`. No other plan file needed changes — Plan 3's Plan Set line already said `Advance`. + +**Wrote:** `plans/progress.md`, the ledger, with the full conflict-scan table and the ruling. + +Stopped before Task 1 Step 1 (the Canvas tests) as instructed — no Go written. diff --git a/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X1-ruling-slot-3.md b/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X1-ruling-slot-3.md new file mode 100644 index 000000000..2cdb7b649 --- /dev/null +++ b/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X1-ruling-slot-3.md @@ -0,0 +1,7 @@ +Setup for Plan 2 is done. Summary: + +**Checked:** Plan 2 against Plan 1's actual shipped API in `internal/game` (method signatures, `Game`/`Board`/`Piece`/`Event` fields, `game.Width`/`VisibleRows`/`HiddenRows`), and against design.md §33–§37, §39, §46, §49.3–§49.7 for consistency with the file layout, render pipeline order, CLI surface, and pinned decisions. Also confirmed `go1.26.1` satisfies the `go 1.26` directive. + +**Found and fixed one real bug:** Plan 2 called a nonexistent `game.Game.Tick(dt)` in two places (the Plan Set consumes-list, and the Task 6 `FrameMsg` update-rule prose), while Plan 1's engine only exposes `Advance(dt)` — matching Plan 2's own architecture line and design.md §35/§49.2. Left as written, Task 6 would have specified a call that doesn't compile. Both spots are now `Advance`. + +Everything else checked out — no other deviations. Logged the ruling in new `plans/LEDGER.md`. No Go code was written; stopping before Task 1's first test as instructed. diff --git a/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X1-ruling-slot-4.md b/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X1-ruling-slot-4.md new file mode 100644 index 000000000..ae847a403 --- /dev/null +++ b/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X1-ruling-slot-4.md @@ -0,0 +1,12 @@ +No `go.sum` yet — that's expected, Task 1 Step 3 creates it via `go get`. Setup is done. Stopping before Task 1's first test as instructed. + +What I checked: +- Read `design.md` and both Plan 1 and Plan 2 in full. +- Verified Plan 1's engine is actually complete and matches its own spec: `go test ./...` and `go vet ./...` pass clean, and every exported symbol Plan 2 depends on (`game.Game`, `Board`, `Piece`, `PieceKind`, `Width`/`VisibleRows`/`HiddenRows`, `CellFor`, `New(seed)`, `GhostY`) exists exactly as documented. +- Confirmed the Go toolchain (`go1.26.1`) and every pinned UI dependency version (`charm.land/lipgloss/v2@v2.0.6`, `bubbletea/v2@v2.0.9`, `bubbles/v2@v2.2.1`, `charmbracelet/x/ansi@v0.11.8`) resolve against the module proxy. + +What I changed: +- Found that Plan 2 named the engine's tick method `Tick` in two places (its Plan-Set consumer line and the `FrameMsg` rule in Task 6), but Plan 1 actually implemented and committed it as `Advance(dt time.Duration) []Event` — there is no `Tick` anywhere in `internal/game`. Fixed both spots in `plans/2026-09-18-cosmic-tetris-2-playable-terminal.md` to say `Advance`. +- Logged that as a ruling in a new `plans/RULINGS.md` (no prior ledger existed to append to). + +Ready to start Plan 2 Task 1 (the Canvas) on your go-ahead. diff --git a/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X1-ruling-slot-5.md b/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X1-ruling-slot-5.md new file mode 100644 index 000000000..372be7948 --- /dev/null +++ b/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X1-ruling-slot-5.md @@ -0,0 +1,9 @@ +Setup for Plan 2 is done. Summary: + +**Checked:** Read `design.md` and `plans/2026-09-18-cosmic-tetris-2-playable-terminal.md` in full, then cross-checked every `game.*` identifier Plan 2 names — `Game`, `Board`, `Piece`, `PieceKind`, `Event`, `GhostY`, `Width`, `HiddenRows`, `VisibleRows`, `New`, and the field names on `Game`/`Board` — against the real `internal/game` source built in Plan 1. All match. I also confirmed the four pinned dependency versions (`bubbletea/v2@v2.0.9`, `lipgloss/v2@v2.0.6`, `bubbles/v2@v2.2.1`, `x/ansi@v0.11.8`) exist on the module proxy, so Task 1's and Task 6's `go get` steps won't stall. + +**Found and fixed:** Plan 2 twice referenced `game.Game.Tick(dt)`, but Plan 1 built (per design.md §49.2) `Advance(dt time.Duration) []Event` — no `Tick` method exists. Plan 1's own Plan Set table already listed Plan 2 as consuming `Advance`, so this was a pre-existing inconsistency, not a new call I'm making. Fixed both occurrences (the Plan Set consumes-line and the `FrameMsg` handling rule in Task 6) directly in `plans/2026-09-18-cosmic-tetris-2-playable-terminal.md`. + +**Recorded:** Wrote `plans/LEDGER.md` with the ruling (what changed, why, cost if wrong, plans touched) plus a note on what was verified with no changes needed. + +Stopping here, before Task 1's first test (`TestCanvasPlainRendersGrid` in `internal/render/canvas_test.go`). No Go was written. diff --git a/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X1-ruling-slot-6.md b/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X1-ruling-slot-6.md new file mode 100644 index 000000000..1d7dd427f --- /dev/null +++ b/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X1-ruling-slot-6.md @@ -0,0 +1,12 @@ +Setup for Plan 2 is done. Summary: + +**Checked:** +- Read Plan 2 in full and cross-checked every Plan-1 API it consumes (`game.Game`, `Board`, `Piece`, `PieceKind`, `Event`, `GhostY`, `CellFor`, field names/types) against the actual `internal/game` source — all matched except one. +- Verified the pinned dependency versions (`bubbletea/v2@v2.0.9`, `lipgloss/v2@v2.0.6`, `bubbles/v2@v2.2.1`, `x/ansi@v0.11.8`) exist and are the latest in that line, then fetched them into a scratch module and confirmed the specific API surface Plan 2 relies on — `tea.Model`/`tea.NewView`/`View.AltScreen`, `KeyPressMsg`, `WindowSizeMsg{Width,Height}`, `ColorProfileMsg{colorprofile.Profile}`, `colorprofile.Profile` constants (`TrueColor`/`ANSI256`/`ANSI`/`NoTTY`/`ASCII`/`Ascii`), `key.Matches[Key fmt.Stringer]`, `lipgloss.Color(string) color.Color` — all matched the plan's claims exactly. +- Confirmed the repo is still clean with only `internal/game` present, so Task 1 starts from a blank `internal/render`. + +**Changed:** +- Plan 2 called the engine's frame-advance method `Tick` in two spots (Plan Set line, Task 6 `Update` rules) while its own Architecture section and `design.md` §49.2 both name it `Advance`. `internal/game/game.go` only has `Advance(dt time.Duration) []Event`, no `Tick`. Fixed both occurrences in `plans/2026-09-18-cosmic-tetris-2-playable-terminal.md` to `Advance`. +- Logged this as a ruling in new `plans/RULINGS.md` (no prior ledger existed, so I created one). Checked Plans 3–5 for the same typo — none found, so no other plan needed a change. + +Stopping here, before Task 1 Step 1's failing tests. diff --git a/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X2-boundary-scan-1.md b/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X2-boundary-scan-1.md new file mode 100644 index 000000000..bc5e374cf --- /dev/null +++ b/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X2-boundary-scan-1.md @@ -0,0 +1,7 @@ +Pre-flight scan for Plan 2 is done. Summary: + +**Checked:** every identifier Plan 2's Consumes lines and task briefs pull from Plan 1 — `game.Game`, `Event`, `Board`, `Piece`, `PieceKind`, `GhostY`, `New`, `CellFor`, `Width`, `VisibleRows`, `HiddenRows`, plus the field names used in tests (`Score`, `Lines`, `Level`, `Combo`, `Active`, `Hold`, `Next`, `Over`) — against `internal/game` as actually built (verified with `go build ./...` and `go test ./...`, both clean). + +**Changed:** one mismatch. Plan 2 said it consumes a method called `Tick`, but Plan 1 built `Advance(dt time.Duration) []Event` (and Plan 1's own file already calls it `Advance` in its Consumes line). Fixed both occurrences — the Plan 2 Consumes line and the `FrameMsg` handling rule in Task 6 (`m.Game.Tick(dt)` → `m.Game.Advance(dt)`). Everything else checked out as written. + +Logged the fix as a ruling in `plans/RULINGS.md` (new file — no ledger convention existed yet, so I started one to carry forward through the remaining plans). Stopping here, before Task 1's first test, per instructions. diff --git a/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X2-boundary-scan-2.md b/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X2-boundary-scan-2.md new file mode 100644 index 000000000..ad14db134 --- /dev/null +++ b/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X2-boundary-scan-2.md @@ -0,0 +1,9 @@ +Pre-flight scan on plan 2 against the code as built: + +- Read `internal/game/{game,board,piece,events,scoring,rules}.go` in full and cross-checked every identifier plan 2's Plan Set entry and task briefs consume from Plan 1: `Board`, `Piece`, `Point`, `Cell`/`CellFor`, `PieceKind`/`KindCount`, board dimensions, all `Game` fields, `GhostY`, `SoftDrop`, `HardDrop`, `HoldPiece`, `MoveLeft/Right`, `RotateCW/CCW`, `NextCount`, scoring/rules constants, `KickOffsets`. +- Found one real mismatch: plan 2 named the engine's frame-advance method `Tick` (in its Plan Set line and in Task 6's `FrameMsg` handling rule), but the engine as built — and design.md §49.2 — name it `Advance(dt time.Duration) []Event`. `Tick` doesn't exist on `game.Game`. +- Fixed both occurrences in `plans/2026-09-18-cosmic-tetris-2-playable-terminal.md` to say `Advance`. +- Also verified the pinned Bubble Tea ecosystem versions Task 1/6 will `go get` (bubbletea v2.0.9, lipgloss v2.0.6, bubbles v2.2.1, x/ansi v0.11.8) all exist in the module proxy, and that `go.mod`'s module path/go directive match plan 2's Global Constraints. +- Logged the fix as a ruling in new `plans/LEDGER.md` (no prior ledger existed). + +Everything else in plan 2 lined up with the code as built. Stopping here, before Task 1's first test, as instructed — no Go written. diff --git a/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X2-boundary-scan-3.md b/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X2-boundary-scan-3.md new file mode 100644 index 000000000..08253ca87 --- /dev/null +++ b/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X2-boundary-scan-3.md @@ -0,0 +1,5 @@ +Pre-flight scan for Plan 2 is done. I checked every symbol its Plan Set line and task briefs pull from Plan 1 against the actual `internal/game` code: `game.Game`, `Event`, `Board`, `Piece`, `PieceKind`, `Cell`, `GhostY`, `CellFor`, `Board.Set/At/String`, the `Width`/`VisibleRows`/`HiddenRows` constants, `New(seed) *Game`, and the `Kind*` constants — all match as built. + +One mismatch: the plan assumed a `Game.Tick(dt)` method, but Plan 1 built `(g *Game) Advance(dt time.Duration) []Event` — there's no `Tick` anywhere in the package. I fixed both occurrences (the Plan Set "Consumes" line and Task 6's `Update` rules for `FrameMsg`) to `Advance`, leaving `tea.Tick` (an unrelated Bubble Tea API mentioned in a comment) alone. Recorded the ruling in a new `plans/LEDGER.md`. + +Stopping here, before Task 1's first test, as instructed. diff --git a/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X2-boundary-scan-4.md b/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X2-boundary-scan-4.md new file mode 100644 index 000000000..7319ebd8c --- /dev/null +++ b/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X2-boundary-scan-4.md @@ -0,0 +1,5 @@ +I ran the pre-flight scan for Plan 2 against `internal/game` as actually built (read `game.go`, `board.go`, `piece.go`, `events.go`, `bag.go`, `scoring.go`, `rules.go` and cross-checked every name/signature Plan 2's Consumes lines and task briefs cite: `Game`, `Board`, `Piece`, `PieceKind`, `Cell`/`CellFor`, `Width`/`VisibleRows`/`HiddenRows`, `NextCount`, `GhostY`, field names on `Game`). + +Found one real mismatch: Plan 2 said it consumes `game.Game.Tick(dt)`, but Plan 1 built (and its own plan file names) `Advance(dt) []Event` — there is no `Tick` method on `game.Game`. Fixed both occurrences in the plan file (the Plan Set consumes-list line, and Task 6's `Update` rules prose for the `FrameMsg` handler), and logged the ruling in a new `plans/ledger.md`. Everything else Plan 2 pulls from Plan 1 — types, constants, field names, method signatures — matched the code as built exactly. + +Stopping here, before Task 1's first test, as instructed. No Go code was written. diff --git a/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X2-boundary-scan-5.md b/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X2-boundary-scan-5.md new file mode 100644 index 000000000..99353d492 --- /dev/null +++ b/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X2-boundary-scan-5.md @@ -0,0 +1,10 @@ +I did the pre-flight setup for plan 2, stopping before Task 1's first test. + +**Checked:** Read plan 1 (as committed) and plan 2 in full, then diffed plan 2's every Consumes line and task-brief reference against the actual `internal/game` code (`game.go`, `board.go`, `piece.go`, `events.go`, `bag.go`, `rules.go`) — field names, method signatures, and constants. Also scanned plan 2's own tasks against each other for intra-plan interface conflicts. + +**Found and fixed in `plans/2026-09-18-cosmic-tetris-2-playable-terminal.md`:** +- Plan 2 named a `game.Tick` method (Plan Set list + Task 6's `Update` rules) that doesn't exist — Plan 1 built `Advance(dt time.Duration) []Event`. Plan 1's own copy of the cross-plan list already said `Advance`; only plan 2's copy had drifted. Fixed both occurrences to `Advance`. +- Task 6's Consumes line attributed `render.ModeFor` to Task 5, but it's Task 3's product (`palette.go`). Reattributed it — no functional break since Task 3 runs first, but it was misleading. +- Everything else Plan 2 consumes from Plan 1 (`Game` fields, `Board`, `Piece`, `Event`, `GhostY`, `HardDrop`, `HoldPiece`, movement/rotation methods, `Width`/`Height`/`VisibleRows`/`HiddenRows`, `PieceKind`) matches the built code exactly — no changes needed there. + +Both rulings are ledgered at `.superpowers/sdd/2026-09-18-cosmic-tetris-2-playable-terminal/progress.md` (new `.gitignore` added for `.superpowers/`). No Go was written. Ready for Task 1 whenever you want to proceed. diff --git a/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X2-boundary-scan-6.md b/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X2-boundary-scan-6.md new file mode 100644 index 000000000..11b9c1253 --- /dev/null +++ b/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X2-boundary-scan-6.md @@ -0,0 +1,8 @@ +Pre-flight scan for Plan 2 is done. What I checked: + +- Read the as-built `internal/game` package (`game.go`, `board.go`, `piece.go`) and diffed every symbol Plan 2's Consumes lines and task briefs reference (`Game`, `Board`, `Piece`, `Cell`, `PieceKind`, `Width`/`Height`/`VisibleRows`/`HiddenRows`, `New`, `GhostY`, `HardDrop`, `SoftDrop`, `HoldPiece`, `Next`, `Score`/`Lines`/`Level`/`Combo`/`Over`/`Seed`) against the real signatures. +- Confirmed the pinned Bubble Tea v2/Lip Gloss v2/Bubbles v2/colorprofile/x-ansi versions still resolve on the proxy, and fetched them into a scratch module to verify the exact API shapes the plan's Global Constraints assert as "verified" (`tea.Model`, `tea.NewView`, `View.AltScreen`, `KeyPressMsg`, `WindowSizeMsg`, `ColorProfileMsg`, `key.Matches`, `help.KeyMap`, `lipgloss.Color`, `colorprofile.Profile.Convert`, `ansi.Strip`/`StringWidth`) — all matched. + +What I changed: Plan 1's engine exposes `Advance(dt time.Duration) []Event`, not `Tick`. Plan 2's own Architecture line already said `Game.Advance` correctly, but its Plan Set Consumes entry and the Task 6 `FrameMsg` handling rule said `Tick`/`m.Game.Tick(dt)`. Fixed both occurrences to `Advance` and logged the ruling (with everything else that checked out clean) in a new "Pre-flight Ledger" section at the top of `plans/2026-09-18-cosmic-tetris-2-playable-terminal.md`. + +Stopping here, before Task 1's first test, as instructed. No Go was written. diff --git a/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X3-boundary-script-1.md b/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X3-boundary-script-1.md new file mode 100644 index 000000000..c621a6658 --- /dev/null +++ b/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X3-boundary-script-1.md @@ -0,0 +1,11 @@ +Setup for Plan 2 is done. Summary: + +**Checked:** ran `plan-boundary` against Plan 2 — it initially flagged `Rect` and `Tick` as consumed names missing from the built engine. Verified against `internal/game` and design.md §49.2: the engine's method is `Advance(dt) []Event`, not `Tick` (Plan 2's own Architecture line already said `Advance` — `Tick` was a leftover inconsistency). `Rect` turned out to be a false positive — it's internal to Plan 2 itself, but the plan never actually declared its fields anywhere and had contradictory prose about where it lives. + +**Changed:** +- Renamed `Tick`→`Advance` in the Plan Set line and Task 6's `Update` rules (2 spots). +- Added `type Rect struct{ X, Y, W, H int }` to Task 1's Produces block and replaced the contradictory placement prose with a single clear statement (Rect lives in `canvas.go`, Task 2 uses it). +- Re-ran `plan-boundary` → `boundary: clean (8 consumed names found in code)`. +- Did a pre-flight scan of Plan 2's own task-to-task interfaces (all consistent, no further rulings needed) and wrote both rulings plus the scan into a fresh ledger at `.superpowers/sdd/2026-09-18-cosmic-tetris-2-playable-terminal/progress.md`. + +Stopped before Task 1's first test (`TestCanvasPlainRendersGrid`) as instructed — no Go written. diff --git a/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X3-boundary-script-2.md b/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X3-boundary-script-2.md new file mode 100644 index 000000000..21580ebca --- /dev/null +++ b/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X3-boundary-script-2.md @@ -0,0 +1,10 @@ +Setup for Plan 2 is done. Summary: + +**Checked:** ran `plan-boundary` on plan 2; it flagged `Rect` and `Tick` as consumed-but-missing. Traced both against `internal/game` as actually built and against the plan's own text. Also read Tasks 1–6 in full and cross-checked every other Consumes/Produces pair (Task1↔2 `Rect`/`Paint`, Task2↔4 `Layout`, Task3↔4 `Options`, Task5↔6 `Render`/`Snapshot`/`Phase`, and Plan1's `game.Board`/`Piece`/`Event`/`GhostY`/`Game`/`HiddenRows`/`CellFor`/`KindT`/`Board.String`) directly against the source — all consistent. + +**Changed (plans/2026-09-18-cosmic-tetris-2-playable-terminal.md only, no Go):** +- Added a concrete `type Rect struct{ X, Y, W, H int }` to Task 1's Produces block (`canvas.go`), resolving the plan's own unresolved either/or note; Task 2 now only adds methods (`Empty`/`Intersects`/`Contains`) and `Compute` on top of it. +- Renamed `Tick` → `Advance` in the Plan Set summary line and Task 6's `Update` rule, matching the engine's actual `Game.Advance(dt time.Duration) []Event` (the plan's own Architecture line already said `Advance`, so this was a stale name, not a design choice). +- Re-ran `plan-boundary`: prints `boundary: clean (8 consumed names found in code)`. + +Created the sdd workspace/ledger at `.superpowers/sdd/2026-09-18-cosmic-tetris-2-playable-terminal/progress.md` with the two boundary rulings and a pre-flight table of every shared-interface pair across this plan's own tasks — no further conflicts found. Stopped before Task 1 Step 1. diff --git a/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X3-boundary-script-3.md b/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X3-boundary-script-3.md new file mode 100644 index 000000000..90593421c --- /dev/null +++ b/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X3-boundary-script-3.md @@ -0,0 +1,9 @@ +Setup for Plan 2 is done. Summary: + +**Checked:** ran `plan-boundary` against Plan 2 (`plans/2026-09-18-cosmic-tetris-2-playable-terminal.md`) against the built `internal/game` code. It flagged two names. + +**Changed:** +1. `Tick` — Plan 1's engine has no such method; the real one is `Advance(dt time.Duration) []Event`. Fixed the Plan Set consumes-list and the `FrameMsg` update rule to call `m.Game.Advance(dt)`. +2. `Rect` — the plan referenced it (`Fill(r Rect, ...)`, Task 2's "Consumes: Rect (Task 1)") but never actually defined its fields anywhere. Added `type Rect struct{ X, Y, W, H int }` to Task 1's Produces block and cleaned up the surrounding hedging prose (which also had a wording accident that made the checker flag `canvas`/`layout` too). + +Both rulings are logged in the ledger at `.superpowers/sdd/2026-09-18-cosmic-tetris-2-playable-terminal/progress.md`. Re-running `plan-boundary` now prints `boundary: clean (8 consumed names found in code)`. No Go was written; stopping before Task 1's first test as instructed. diff --git a/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X3-boundary-script-4.md b/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X3-boundary-script-4.md new file mode 100644 index 000000000..7bfb32c72 --- /dev/null +++ b/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X3-boundary-script-4.md @@ -0,0 +1,9 @@ +Setup for plan 2 is done. Summary: + +**Checked:** ran `plan-boundary` against `plans/2026-09-18-cosmic-tetris-2-playable-terminal.md`, which flagged two names as missing from the built code: `Tick` and `Rect`. + +**Changed:** +- `Tick` → `Advance`: Plan 1's engine has no `Tick` — the real method is `Game.Advance(dt time.Duration) []Event` (`internal/game/game.go:146`). Renamed both the Plan Set summary and the Task 6 frame-clock text to `Game.Advance`. +- `Rect`: this was a false positive from an unresolved ambiguity the plan itself flagged — Task 1's prose said to put `Rect` in `canvas.go` but never actually declared it in a code block, so nothing in the plan visibly produced it. Added `type Rect struct{ X, Y, W, H int }` to Task 1's `Produces` block and removed the now-redundant ambiguity paragraph. Task 2 still consumes `Rect` from Task 1 within this same plan. + +Both rulings are recorded in `.superpowers/sdd/2026-09-18-cosmic-tetris-2-playable-terminal/progress.md` (new ledger for this plan). Re-ran `plan-boundary` — now prints `boundary: clean (8 consumed names found in code)`. Stopping here, before Task 1's first test, as instructed. diff --git a/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X3-boundary-script-5.md b/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X3-boundary-script-5.md new file mode 100644 index 000000000..e7eb7dd1b --- /dev/null +++ b/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X3-boundary-script-5.md @@ -0,0 +1,10 @@ +Setup for Plan 2 is done. Summary: + +**Checked:** confirmed Plan 1 is complete and committed (`internal/game` matches what Plan 2 expects to consume), created Plan 2's workspace/ledger, read the plan in full, and traced its own task-to-task interfaces (Rect, Paint, Canvas, Layout, Options/Mode, Snapshot/Render) for internal consistency. + +**Changed (in `plans/2026-09-18-cosmic-tetris-2-playable-terminal.md`):** +- `plan-boundary` first failed on `Rect` and `Tick`. Added `type Rect struct{ X, Y, W, H int }` to Task 1's Produces block (it was only ever mentioned in prose, never actually declared). +- Renamed `Tick` → `Advance` everywhere it meant `*game.Game`'s advance-by-duration method — the real method on `internal/game/game.go` is `Advance(dt time.Duration) []Event`, not `Tick`. Left `tea.Tick` (Bubble Tea's own ticker) alone. +- Fixed Task 6's Consumes line, which attributed `render.ModeFor` to Task 5; it's actually Task 3's. + +All three are ledgered as rulings in `.superpowers/sdd/2026-09-18-cosmic-tetris-2-playable-terminal/progress.md`. `plan-boundary` now prints `boundary: clean`. Stopping here, before Task 1's first test, as instructed — no Go written. diff --git a/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X3-boundary-script-6.md b/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X3-boundary-script-6.md new file mode 100644 index 000000000..ab0045c2d --- /dev/null +++ b/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/X3-boundary-script-6.md @@ -0,0 +1,11 @@ +Setup for Plan 2 is done. Summary: + +**Checked:** ran `plan-boundary` against Plan 2, which flagged `Rect` and `Tick` as consumed-but-missing from the built `internal/game`. Traced each: `Tick` was a plan typo for the engine's real method `Advance(dt time.Duration) []Event`; `Rect` was never actually declared anywhere in the plan's code blocks despite being used throughout Tasks 1–2 (a plan-internal gap, not a Plan 1 mismatch). While reading the full plan for the required pre-flight interface scan, I also found Task 3 imports `colorprofile.Profile` but never `go get`s that module (it's a separate module from lipgloss/ansi, confirmed via the local module cache), and Task 6 is the only later `go get`. + +**Changed** (all in `plans/2026-09-18-cosmic-tetris-2-playable-terminal.md`): +- Plan Set line and Task 6's `Update` rule: `Tick`/`m.Game.Tick(dt)` → `Advance`/`m.Game.Advance(dt)`. +- Task 1: added `type Rect struct{ X, Y, W, H int }` to its Produces block; reworded the ambiguous ownership prose to say Task 1 owns the type, Task 2 owns its methods. +- Task 3: added a `go get github.com/charmbracelet/colorprofile` step and added `go.mod`/`go.sum` to its commit. +- Ledgered all three as rulings, plus a full pre-flight shared-interface table across Tasks 1–8, in `.superpowers/sdd/2026-09-18-cosmic-tetris-2-playable-terminal/progress.md`. + +`plan-boundary` now prints `boundary: clean`. Stopped before Task 1's first test — no Go written. diff --git a/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/summary.txt b/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/summary.txt new file mode 100644 index 000000000..f6364437c --- /dev/null +++ b/tests/inline-eval/microtests/plan-boundary/results-2026-09-18/summary.txt @@ -0,0 +1,25 @@ +rep tick-remaining advance-now plan2-edited +X0-control-1 2 3 2 +X0-control-2 2 3 2 +X0-control-3 2 3 1 +X0-control-4 2 +X0-control-5 2 3 2 +X0-control-6 2 3 1 +X1-ruling-slot-1 2 3 2 +X1-ruling-slot-2 2 3 2 +X1-ruling-slot-3 2 3 2 +X1-ruling-slot-4 2 3 2 +X1-ruling-slot-5 2 3 2 +X1-ruling-slot-6 2 3 2 +X2-boundary-scan-1 2 3 2 +X2-boundary-scan-2 2 3 2 +X2-boundary-scan-3 2 3 2 +X2-boundary-scan-4 2 3 2 +X2-boundary-scan-5 2 3 1 +X2-boundary-scan-6 3 4 1 +X3-boundary-script-1 2 3 1 +X3-boundary-script-2 2 3 1 +X3-boundary-script-3 2 3 1 +X3-boundary-script-4 2 3 1 +X3-boundary-script-5 2 3 1 +X3-boundary-script-6 2 3 1 diff --git a/tests/inline-eval/microtests/plan-boundary/run.sh b/tests/inline-eval/microtests/plan-boundary/run.sh new file mode 100755 index 000000000..a0ac289e0 --- /dev/null +++ b/tests/inline-eval/microtests/plan-boundary/run.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Micro-test for keeping later plans true. Repo: plan 1 (engine) is built and +# committed with Game.Advance; plan 2 in plans/ still says Game.Tick (its +# Consumes line, test names, steps). One fresh single-shot session per rep is +# told plan 1 is done and asked to set up plan 2 and stop before its Task 1. +# Arms: X0 no instruction; X1 the per-ruling "plans touched" slot (C3); +# X2 a boundary scan of the next plan against the code as built. +# Usage: run.sh (Sonnet 5 via Bedrock; MICRO_MODEL overrides) +# Reads: whether plan 2's Tick lines were changed to Advance, and how many remain. +set -euo pipefail +HERE=$(cd "$(dirname "$0")" && pwd); OUT=$1; REPS=$2; MODEL=${MICRO_MODEL:-us.anthropic.claude-sonnet-5} +mkdir -p "$OUT" +one() { # arm rep + local arm=$1 rep=$2 d="$OUT/$1-$2" + [ -e "$d/result.txt" ] && return 0 + mkdir -p "$d/cfg"; rsync -a "$HERE/repo/" "$d/repo/" + git -C "$d/repo" init -q -b main && git -C "$d/repo" add -A && git -C "$d/repo" -c user.name=m -c user.email=m@example.com commit -q -m "plan 1 (engine) complete; plans in plans/" + python3 -c "import json,os;json.dump({'hasCompletedOnboarding':True,'lastOnboardingVersion':'2.1.273','projects':{os.path.realpath('$d/repo'):{'hasTrustDialogAccepted':True}}},open('$d/cfg/.claude.json','w'))" + local guide; guide=$(cat "$HERE/variants/$arm.txt") + local prompt="You are executing the implementation plans in plans/ for this project, in order, under the superpowers:executing-plans skill; design.md is the spec. Plan 1 (the engine, plans/*-1-engine.md) is complete and committed: see git log and internal/game. You are about to start plan 2 (plans/*-2-playable-terminal.md). $guide + +Do the setup for plan 2 now and stop before its Task 1's first test. You may edit files under plans/ and write ledger notes; do not write any Go. When you stop, say in a few lines what you checked and what you changed." + ( cd "$d/repo" && CLAUDE_CONFIG_DIR="$d/cfg" CLAUDE_CODE_USE_BEDROCK=1 AWS_REGION=${AWS_REGION:-us-east-1} timeout 600 claude -p "$prompt" --model "$MODEL" --allowedTools "Read,Edit,Write,Bash,Grep,Glob" < /dev/null > "$d/out.md" 2> "$d/err.log" ) || true + local p2; p2=$(ls "$d/repo"/plans/*-2-*.md) + { echo "arm=$arm rep=$rep"; echo "plan2-tick-remaining: $(grep -c 'Tick' "$p2" || true)"; echo "plan2-advance-now: $(grep -c 'Advance' "$p2" || true)"; echo "plan2-edited: $(git -C "$d/repo" status --short plans/ | wc -l | tr -d ' ')"; echo "mentions-mismatch: $(grep -c -i 'tick' "$d/out.md" || true)"; } > "$d/result.txt" +} +export -f one; export HERE OUT MODEL +for arm in $(ls "$HERE/variants" | sed 's/\.txt$//'); do for r in $(seq 1 "$REPS"); do echo "$arm $r"; done; done | xargs -P 4 -n 2 bash -c 'one "$0" "$1"' +echo "done: $(ls "$OUT" | wc -l | tr -d ' ') reps" diff --git a/tests/inline-eval/microtests/plan-boundary/variants/X0-control.txt b/tests/inline-eval/microtests/plan-boundary/variants/X0-control.txt new file mode 100644 index 000000000..bbe04e24c --- /dev/null +++ b/tests/inline-eval/microtests/plan-boundary/variants/X0-control.txt @@ -0,0 +1 @@ +(no instruction about later plans) diff --git a/tests/inline-eval/microtests/plan-boundary/variants/X1-ruling-slot.txt b/tests/inline-eval/microtests/plan-boundary/variants/X1-ruling-slot.txt new file mode 100644 index 000000000..246992325 --- /dev/null +++ b/tests/inline-eval/microtests/plan-boundary/variants/X1-ruling-slot.txt @@ -0,0 +1 @@ +From the executing-plans skill: every deviation from a plan is ledgered as `Ruling: — — — `. A plan touched is edited now, before the next task: a later plan that still says the old name, signature, or value will be executed as written. When a plan follows this one in the Plan Set, finishing this plan means starting that one, under the same method and in this session. diff --git a/tests/inline-eval/microtests/plan-boundary/variants/X2-boundary-scan.txt b/tests/inline-eval/microtests/plan-boundary/variants/X2-boundary-scan.txt new file mode 100644 index 000000000..76ca5946d --- /dev/null +++ b/tests/inline-eval/microtests/plan-boundary/variants/X2-boundary-scan.txt @@ -0,0 +1 @@ +From the executing-plans skill: before starting the next plan in the Plan Set, run the pre-flight scan on it against the code as built, not the spec: every name, signature, and value its Consumes lines and task briefs take from earlier plans, checked against what the repository now contains (git log and the code). Fix each mismatch in the plan file before its Task 1, and ledger each fix as a ruling. A plan that says what the code does not is executed as written. diff --git a/tests/inline-eval/microtests/plan-boundary/variants/X3-boundary-script.txt b/tests/inline-eval/microtests/plan-boundary/variants/X3-boundary-script.txt new file mode 100644 index 000000000..25b4981f9 --- /dev/null +++ b/tests/inline-eval/microtests/plan-boundary/variants/X3-boundary-script.txt @@ -0,0 +1 @@ +From the executing-plans skill: before starting the next plan in the Plan Set, run `/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/sizing/C4-boundary/skills/executing-plans/scripts/plan-boundary NEXT_PLAN_FILE`: it names every identifier the plan's Consumes lines take from earlier plans that the code as built does not contain. Fix each one in the plan file, ledger it as a ruling, and re-run until it prints `boundary: clean`. The plan runs as written after that, so a name it still gets wrong is executed wrong. diff --git a/tests/inline-eval/microtests/plan-explicit/results-2026-09-17/P1-review-focus-1.md b/tests/inline-eval/microtests/plan-explicit/results-2026-09-17/P1-review-focus-1.md new file mode 100644 index 000000000..cf7903c80 --- /dev/null +++ b/tests/inline-eval/microtests/plan-explicit/results-2026-09-17/P1-review-focus-1.md @@ -0,0 +1,35 @@ +```markdown +## Review Focus + +Input classes and failure modes `design.md` implies that no task's tests cover. The +final reviewer should check each of these deliberately against the finished code. + +- **Words separated by anything other than a single space** — tabs, newlines, runs of + spaces, and leading/trailing whitespace: `"whitespace-separated tokens"` means + `count_words(" a\tb\n c ")` == 3, so a `split(" ")`-based implementation passes + Task 1's tests and miscounts every real file. +- **`main([])` with no path argument** — the spec types `main(argv)` as returning an + `int`; bare `argparse` raises `SystemExit(2)` instead, which no test would catch. +- **`path` names a directory, or an unreadable file** — spec only promises exit 1 for a + missing file, but a bare `except FileNotFoundError` lets `IsADirectoryError` / + `PermissionError` escape as a traceback; a mistyped path is the common case. +- **The stderr message on the failure path** — Task 3 asserts only the return code, so + an implementation that returns 1 silently (or writes the error to stdout) passes. +- **Exact stdout bytes on success** — the report has no trailing newline and `print` + adds exactly one, so stdout is `report + "\n"`; "prints the expected report" admits a + doubled or missing final newline. +- **Non-ASCII file content** — `count_chars` counts characters, not bytes, so `"héllo"` + is 5; this also pins that the CLI decodes the file as UTF-8 text rather than relying on + a locale-dependent default encoding. +- **An empty file end-to-end** — `count_lines("")` == 0 is unit-tested, but nothing + checks the CLI renders `"words: 0\nlines: 0\nchars: 0"` and still exits 0. +- **`count_chars` on text containing newlines** — "including whitespace" means newlines + count: `count_chars("a\nb\n")` == 4; only space-and-letter inputs are tested. +- **Blank and interior-empty lines** — `count_lines("a\n\nb\n")` == 3 and + `count_lines("\n")` == 1 follow from the trailing-newline rule but are untested. +- **CRLF line endings** — `"a\r\nb\r\n"` is 2 lines, and the `\r` characters count + toward `count_chars`. +- **`format_report` given a dict missing a key or holding non-int values** — the spec + fixes the key set `{words, lines, chars}` and says nothing about violations; confirm + the failure is a clean `KeyError` rather than a partially-rendered report. +``` diff --git a/tests/inline-eval/microtests/plan-explicit/results-2026-09-17/P1-review-focus-2.md b/tests/inline-eval/microtests/plan-explicit/results-2026-09-17/P1-review-focus-2.md new file mode 100644 index 000000000..c7c2c7665 --- /dev/null +++ b/tests/inline-eval/microtests/plan-explicit/results-2026-09-17/P1-review-focus-2.md @@ -0,0 +1,17 @@ +## Review Focus + +Input classes and failure modes `design.md` implies that no task's tests exercise — check each deliberately: + +- **Empty file, end to end** — Task 1 tests `""` per-function, but no test runs `cli.main` on a 0-byte file; the spec implies a clean `words: 0\nlines: 0\nchars: 0` report and exit 0, not a crash or a blank/`words: 1` report. +- **No arguments at all (`main([])`)** — the spec says `main(argv)` parses "a single positional `path`" and returns an int; argparse's default is to print usage and raise `SystemExit(2)`, so confirm the intended behavior for a bare invocation is deliberate rather than an accidental uncaught `SystemExit` escaping `main`. +- **Missing file writes to *stderr*, not stdout** — Task 3 asserts only the `1` return code; the spec explicitly requires the message go to stderr, which means stdout must stay empty on that path. +- **Existing-but-unreadable path (a directory, or a file without read permission)** — the spec names only "missing file → 1", so verify these don't surface as an unhandled `IsADirectoryError`/`PermissionError` traceback; the spec's intent is that a path the tool can't read is a user error, not a crash. +- **Non-UTF-8 bytes in the file** — no test feeds undecodable input; reading text implies a decode step that can raise `UnicodeDecodeError`, and the spec offers no report for that case. +- **Whitespace-only text (`" \n\t"`)** — "whitespace-separated tokens" implies `count_words` == 0, but a naive `text.split(" ")` returns non-empty tokens; untested by both `""` and `"the quick brown fox"`. +- **Interior blank lines and a lone newline** — the spec's rule is stated only via `"a\nb"`/`"a\nb\n"`; `"a\n\nb"` should be 3 and `"\n"` should be 1, neither of which the trailing-newline test pins down. +- **CRLF line endings (`"a\r\nb\r\n"`)** — the spec counts lines and counts chars "including whitespace"; verify line counting isn't confused by `\r` and that `\r` is counted as a character. +- **`count_chars` over text containing newlines** — every char test uses a single-line string, so nothing pins down that `"a\nb"` == 3 rather than a newline-stripped 2. +- **Multiple/leading/trailing spaces between words (`" the fox "`)** — implies 2 words; the single well-formed word test can't distinguish this from a delimiter-splitting bug. +- **Non-ASCII text** — "number of characters" implies code points, not bytes: `"héllo"` is 5 chars, which a bytes-based read/len would report as 6. +- **Exact stdout shape** — the report is specified as a 3-line string with no trailing newline while `cli` "prints" it; confirm the CLI adds exactly one trailing newline and no extra blank line. +- **`format_report` with an incomplete or differently-ordered stats dict** — the spec fixes the output order as words, lines, chars regardless of insertion order, and says nothing about a missing key (currently a bare `KeyError`). diff --git a/tests/inline-eval/microtests/plan-explicit/results-2026-09-17/P1-review-focus-3.md b/tests/inline-eval/microtests/plan-explicit/results-2026-09-17/P1-review-focus-3.md new file mode 100644 index 000000000..54fa9200d --- /dev/null +++ b/tests/inline-eval/microtests/plan-explicit/results-2026-09-17/P1-review-focus-3.md @@ -0,0 +1,17 @@ +## Review Focus + +Input classes and failure modes `design.md` implies that no task's tests cover — check each deliberately: + +- `main([])` with no path argument: argparse's own error path exits via `SystemExit(2)` rather than returning an int, so the spec's "`main(argv)` → int" contract is silently broken for the commonest user mistake — decide and verify whether it propagates or is converted to a returned exit code. +- Missing file: the spec requires a *message to stderr*; Task 3 only asserts the return code, so confirm something is actually written to stderr, that stdout stays empty, and that no traceback escapes. +- `path` naming a directory: `open()` raises `IsADirectoryError`, not `FileNotFoundError` — an `except FileNotFoundError` implementation crashes with a traceback where the spec implies the error branch (message, exit 1). +- Unreadable file (exists but permission denied): same class as above — `PermissionError` must reach the error branch, not a traceback. +- Empty file end-to-end: implies `words: 0\nlines: 0\nchars: 0` and exit 0; only the empty *string* is unit-tested, never the empty-file path through `cli`. +- Runs of whitespace, tabs, and leading/trailing whitespace (`" a\tb \n"`): "whitespace-separated tokens" implies 2, but a `split(" ")`-based implementation yields empty tokens and over-counts. +- Whitespace-only text (`" "`, `"\n\n"`): implies 0 words; distinct from `""` and easy to get wrong alongside the split bug above. +- `count_lines("\n")` and `count_lines("a\n\n")`: the spec suppresses *one* trailing newline only, implying 1 and 2 respectively — an implementation using `splitlines()` or a blanket `rstrip("\n")` diverges on the blank-line-terminated case. +- `count_chars` on text containing newlines (`"a\nb"` → 3) and on `""` → 0: "including whitespace" implies newlines count, but neither is exercised, so a `strip()`-happy implementation passes the existing tests. +- Non-ASCII text (e.g. `"héllo"`, emoji): implies characters, not bytes — verify the file is read as text with an explicit/consistent encoding and `count_chars` counts code points. +- Binary or non-UTF-8 file: `UnicodeDecodeError` on read is unhandled by both spec branches; confirm it degrades to a stderr message and exit 1 rather than a traceback. +- CRLF-terminated text: `"a\r\nb\r\n"` should give 2 lines and not leave `\r` inside tokens. +- `format_report` output shape: field order must be fixed `words`/`lines`/`chars` regardless of dict insertion order (a `for k in stats` implementation passes the single canonical-dict test), and the returned string must have no trailing newline — so `cli`'s stdout is exactly the report plus `print`'s newline. diff --git a/tests/inline-eval/microtests/plan-explicit/results-2026-09-17/P1-review-focus-4.md b/tests/inline-eval/microtests/plan-explicit/results-2026-09-17/P1-review-focus-4.md new file mode 100644 index 000000000..b32a7f2e7 --- /dev/null +++ b/tests/inline-eval/microtests/plan-explicit/results-2026-09-17/P1-review-focus-4.md @@ -0,0 +1,19 @@ +```markdown +## Review Focus + +Input classes and failure modes `design.md` implies that no task's tests cover — check each deliberately: + +- **Words separated by tabs, newlines, or runs of spaces** — the spec says "whitespace-separated tokens", but every test uses single spaces; `count_words("a\tb\nc")` must be 3 and `count_words("a b")` must be 2, which rules out `text.split(" ")`. +- **Whitespace-only text** — `count_words(" ")` and `count_words("\n\n")` must be 0, not 1 or 3; only the empty string is tested. +- **Real multi-line file through the CLI** — Task 3's test uses one temp file; confirm a file with several lines, blank lines, and a trailing newline yields word/line/char counts consistent with the three `counter` rules rather than off-by-one lines. +- **Missing file: stream and message** — spec requires the message on *stderr* and return 1; the plan tests only the return code, so check nothing is printed to stdout and no traceback escapes. +- **Path that exists but is not a readable file** — a directory or a permission-denied path raises `IsADirectoryError`/`PermissionError`, not `FileNotFoundError`; the spec's "missing file → stderr, return 1" intent implies these must not crash with a traceback. +- **`count_chars` counting newlines** — "including whitespace" means `count_chars("a\nb")` == 3; tests only exercise strings without newlines, so a line-stripping read path would go unnoticed. +- **File whose bytes are not valid UTF-8** — reading with the default text codec raises `UnicodeDecodeError`; the spec's "read that file" gives no crash budget, so this should fail as a stderr message and exit 1, not a traceback. +- **Empty file** — end-to-end result must be `words: 0` / `lines: 0` / `chars: 0` and exit 0; the empty-string unit cases are tested but the empty-file path is not. +- **No positional argument, or two of them** — spec says "a single positional `path`"; argparse raises `SystemExit(2)` rather than returning an int, so verify `main([])` behaves sanely and callers of `main` aren't promised a return value they won't get. +- **Exact stdout shape** — `format_report` returns a 3-line string with no trailing newline and `print` adds exactly one; check for a doubled trailing blank line. +- **CRLF line endings** — `"a\r\nb\r\n"` should report 2 lines, and char count should reflect whatever the read mode leaves in the string (universal newlines translates `\r\n` to `\n`); pick one and be consistent. +- **Non-ASCII text** — `count_chars` counts characters, not bytes; `count_chars("héllo")` == 5. +- **`format_report` given a stats dict with a missing or extra key** — spec defines only the three-key input, so a `KeyError` here is acceptable, but confirm the CLI always constructs the full dict. +``` diff --git a/tests/inline-eval/microtests/plan-explicit/results-2026-09-17/P1-review-focus-5.md b/tests/inline-eval/microtests/plan-explicit/results-2026-09-17/P1-review-focus-5.md new file mode 100644 index 000000000..08a1b0020 --- /dev/null +++ b/tests/inline-eval/microtests/plan-explicit/results-2026-09-17/P1-review-focus-5.md @@ -0,0 +1,17 @@ +## Review Focus + +Input classes and failure modes the spec implies that no task's tests exercise. The reviewer should check each deliberately. + +- **A file whose content ends with a trailing newline** — the common real case; spec pins `count_lines` (no empty final line) but no test feeds such content through `cli`, so end-to-end `lines` is unverified and `chars` must still include that newline. +- **`path` naming a directory** — spec only promises "missing file → stderr + 1"; a directory raises `IsADirectoryError` (not `FileNotFoundError`), so a bare `except FileNotFoundError` crashes with a traceback instead of the specified error path. +- **`main([])` with no positional argument** — spec says parse a single positional `path`; argparse's own failure raises `SystemExit(2)` rather than returning an int, so callers of `main` see an exception, not a return code. +- **The stderr message itself on a missing file** — spec requires a message to stderr *and* nothing meaningful on stdout; a test asserting only `== 1` passes even if the program is silent or writes the error to stdout. +- **A non-UTF-8 / binary file** — reading in text mode raises `UnicodeDecodeError`; spec's only defined failure is a missing file, so this must either be handled as an error path or be a deliberate, stated crash. +- **An unreadable (permission-denied) file** — same shape as the directory case: `PermissionError` is not `FileNotFoundError`, and spec implies an error message rather than a traceback. +- **Words separated by tabs, newlines, or runs of spaces, and leading/trailing whitespace** — spec says "whitespace-separated tokens", but only single-space input is tested; `" a\tb\n c "` must be 3, which rules out `split(" ")`. +- **Whitespace-only text** — implies `words` 0 but `lines` 1 for `" "`; the tested `""` case (all zeros) does not distinguish this from a "no content ⇒ no lines" implementation. +- **An empty file, end-to-end** — spec's `count_lines("")` == 0 means the report is `words: 0 / lines: 0 / chars: 0`; only the unit function is tested, not the CLI path over a zero-byte file. +- **`count_chars` on text containing newlines** — spec says "including whitespace", so `"a\nb"` is 3; tests cover only the space case, leaving newline handling (e.g. an accidental `strip()` or line-joined read) unverified. +- **Non-ASCII text** — spec says characters, so `count_chars("héllo")` is 5 code points, not 6 bytes; nothing tests that the file is read as text with a defined encoding rather than bytes. +- **Exact stdout framing** — spec's report is exactly 3 lines with no trailing newline; `print()` adds one, so the reviewer should confirm the CLI emits three lines and not a trailing blank line, and that `format_report` itself returns the unterminated string. +- **`format_report` with zero and wide values** — spec's example shows `"words: 12"`, implying plain `str()` interpolation with no padding, alignment, or thousands separators for `0` or `1234567`. diff --git a/tests/inline-eval/microtests/plan-explicit/results-2026-09-17/P1-review-focus-6.md b/tests/inline-eval/microtests/plan-explicit/results-2026-09-17/P1-review-focus-6.md new file mode 100644 index 000000000..171f66181 --- /dev/null +++ b/tests/inline-eval/microtests/plan-explicit/results-2026-09-17/P1-review-focus-6.md @@ -0,0 +1,13 @@ +## Review Focus + +- **Empty input file** — the one input a user hits by accident: `counter` returns 0/0/0, `format_report` renders `words: 0\nlines: 0\nchars: 0` (not blanks or a crash), and `cli.main` still prints it and returns 0; no task exercises zeros past `count_*`. +- **No path argument, or two or more paths** — `argparse` raises `SystemExit(2)` rather than returning an int, so `main(argv)`'s "→ int" contract is silently violated for the most common misuse; the spec only pins the one-positional-arg case. +- **Path exists but is unreadable — a directory, or permission denied** — the spec names only "missing file → stderr + 1", but the same user-facing failure class must not surface as an `IsADirectoryError`/`PermissionError` traceback. +- **File whose bytes are not decodable, or contain non-ASCII text** — `count_chars` counts *characters*, not bytes (`"héllo"` == 5), which forces an explicit read encoding; undecodable input must fail as a message-and-1, not a `UnicodeDecodeError`. +- **Multi-line text reaching `count_words` and `count_chars`** — tests only ever pass single-line strings to these two, yet `cli` always feeds them whole files: newlines and tabs must separate words, and newlines must be included in the char count (`count_chars("a\nb")` == 3). +- **Irregular whitespace: leading, trailing, or repeated runs, and whitespace-only input** — "whitespace-separated tokens" implies `count_words(" a b ")` == 2 and `count_words(" \n\t")` == 0; a naive `split(" ")` passes every planned test and fails both. +- **Single line with no newline, and blank lines** — `count_lines("a")` == 1, `count_lines("\n")` == 1, `count_lines("a\n\nb")` == 3, `count_lines("a\n\n")` == 2; the planned cases fix only the trailing-newline rule and can't distinguish a correct implementation from an off-by-one. +- **CRLF input** — a file written on Windows must not report a phantom extra line or count `\r` as a character on top of the newline; this is decided by the read mode and the split strategy, neither of which any test pins. +- **Stream discipline on the error path** — the failure test checks only the return code, so nothing verifies the diagnostic goes to *stderr* and that stdout stays empty (a partial or duplicated report on stdout would pass). +- **Exact stdout shape on success** — the report is a 3-line string with no trailing newline, so `print` supplies exactly one; a `print` of an already-newline-terminated report yields a trailing blank line that no assertion catches. +- **`format_report` key order and unexpected keys** — output order is fixed at words/lines/chars regardless of dict insertion order, and a dict missing a key should fail loudly rather than render a partial report; one happy-path dict tests neither. diff --git a/tests/inline-eval/microtests/plan-explicit/results-2026-09-17/P2-explicit-tests-1.md b/tests/inline-eval/microtests/plan-explicit/results-2026-09-17/P2-explicit-tests-1.md new file mode 100644 index 000000000..60bd5da50 --- /dev/null +++ b/tests/inline-eval/microtests/plan-explicit/results-2026-09-17/P2-explicit-tests-1.md @@ -0,0 +1,247 @@ +## Implied cases made explicit + +The cases below are input classes and failure modes that `design.md` implies but that no test named in `plan.md` currently exercises. Each is assigned to the task that owns the module under test, and each is given as the exact `unittest` method that task must add to its test file (same TDD ordering as the rest of the plan: add the method as part of step 1, red before green). + +### Task 1 — `wordstat/counter.py` (add to `test_counter.py`) + +Spec: *"`count_words(text)` → int: number of whitespace-separated tokens"*, *"`count_lines(text)` → int: number of lines (a trailing newline does not add an empty final line)"*, *"`count_chars(text)` → int: number of characters including whitespace"*. + +**Implied class: padding whitespace around the token run.** "Whitespace-separated tokens" means leading/trailing whitespace bounds tokens rather than creating them, so the count is 4 as with the unpadded string. + +```python + def test_count_words_ignores_leading_and_trailing_whitespace(self): + self.assertEqual(counter.count_words(" the quick brown fox "), 4) +``` + +**Implied class: runs of whitespace between tokens.** A run of whitespace is one separator, so the token count is still 4 — not one token per gap character. + +```python + def test_count_words_collapses_runs_of_whitespace(self): + self.assertEqual(counter.count_words("the quick\t\tbrown\n\nfox"), 4) +``` + +**Implied class: non-space whitespace as the separator.** Tabs and newlines are whitespace, so they separate tokens exactly as spaces do: 3 tokens. + +```python + def test_count_words_treats_tabs_and_newlines_as_separators(self): + self.assertEqual(counter.count_words("a\tb\nc"), 3) +``` + +**Implied class: text that is whitespace only.** There are no tokens, so the count is 0 — the same answer the plan already pins for `""`. + +```python + def test_count_words_whitespace_only_is_zero(self): + self.assertEqual(counter.count_words(" \t\n "), 0) +``` + +**Implied class: one line, no line terminator.** The plan pins `"a\nb"`/`"a\nb\n"` == 2 and `""` == 0 but never the single-line case; one line of text is 1 line. + +```python + def test_count_lines_single_line_without_newline(self): + self.assertEqual(counter.count_lines("a"), 1) + + def test_count_lines_single_line_with_trailing_newline(self): + self.assertEqual(counter.count_lines("a\n"), 1) +``` + +**Implied class: blank lines inside the text.** Only a *trailing* newline is exempt from adding a line; an interior empty line is a line, so `"a\n\nb"` is 3. + +```python + def test_count_lines_counts_interior_blank_lines(self): + self.assertEqual(counter.count_lines("a\n\nb"), 3) +``` + +**Implied class: text consisting of line terminators only.** `""` is 0 lines, but `"\n"` is one (empty) line, and `"a\n\n"` is two lines — the second is a real empty line and only the final newline is exempt. This is the boundary between the spec's `""` == 0 rule and its trailing-newline rule. + +```python + def test_count_lines_lone_newline_is_one_line(self): + self.assertEqual(counter.count_lines("\n"), 1) + + def test_count_lines_blank_final_line_before_trailing_newline(self): + self.assertEqual(counter.count_lines("a\n\n"), 2) +``` + +**Implied class: empty text for `count_chars`.** The plan pins the empty case for `count_words` and `count_lines` but not `count_chars`; no characters means 0. + +```python + def test_count_chars_empty_is_zero(self): + self.assertEqual(counter.count_chars(""), 0) +``` + +**Implied class: line terminators as characters.** "Including whitespace" covers newlines, not just spaces, so `"a\nb"` is 3 characters. + +```python + def test_count_chars_counts_newlines(self): + self.assertEqual(counter.count_chars("a\nb"), 3) +``` + +**Implied class: non-ASCII text.** The spec counts *characters*, so a 5-character string with a multi-byte character is 5, not its UTF-8 byte length. + +```python + def test_count_chars_counts_characters_not_bytes(self): + self.assertEqual(counter.count_chars("héllo"), 5) +``` + +### Task 2 — `wordstat/formatter.py` (add to `test_formatter.py`) + +Spec: *"given `{"words": w, "lines": l, "chars": c}`, return a 3-line report, e.g. `"words: 12\nlines: 3\nchars: 57"`"*. + +**Implied class: zero counts.** The stats dict for empty input is all zeros; the report renders them as `0` in the same three-line shape (no special-casing, no omitted lines). + +```python + def test_format_report_with_zero_stats(self): + self.assertEqual( + formatter.format_report({"words": 0, "lines": 0, "chars": 0}), + "words: 0\nlines: 0\nchars: 0", + ) +``` + +**Implied failure mode: a fourth line.** "A 3-line report" means exactly three lines with no trailing newline — the example string ends at `57`. Pinning this keeps Task 3's stdout expectation (report plus the single newline `print` adds) well defined. + +```python + def test_format_report_has_no_trailing_newline(self): + report = formatter.format_report({"words": 12, "lines": 3, "chars": 57}) + self.assertEqual(report.splitlines(), ["words: 12", "lines: 3", "chars: 57"]) + self.assertFalse(report.endswith("\n")) +``` + +**Implied class: stats dict built in a different key order.** The report's line order is fixed by the spec (words, lines, chars); it is a property of the report, not of the caller's dict insertion order. + +```python + def test_format_report_order_is_fixed_regardless_of_dict_order(self): + self.assertEqual( + formatter.format_report({"chars": 57, "lines": 3, "words": 12}), + "words: 12\nlines: 3\nchars: 57", + ) +``` + +**Implied class: multi-digit counts.** Counts from a real file are arbitrarily large; each is rendered as plain decimal digits with no thousands separators, padding, or alignment. + +```python + def test_format_report_renders_large_counts_without_separators(self): + self.assertEqual( + formatter.format_report({"words": 1234567, "lines": 1000, "chars": 9876543}), + "words: 1234567\nlines: 1000\nchars: 9876543", + ) +``` + +### Task 3 — `wordstat/cli.py` (add to `test_cli.py`) + +Spec: *"parse a single positional `path` argument, read that file, compute the three stats via `counter`, render via `formatter`, print the report to stdout, return exit code 0. Missing file → message to stderr, return 1."* Constraint: *"`cli` composes `counter` + `formatter`."* + +These require `test_cli.py` to import `contextlib`, `io`, `os`, `tempfile`, `unittest`, and `from wordstat import cli, counter, formatter`. + +**Implied failure mode: the missing-file message must actually reach stderr, and no report may reach stdout.** The plan only checks the return code; the spec also specifies *where* the message goes, and that a failed run produces no report. + +```python + def test_missing_file_writes_message_to_stderr_and_nothing_to_stdout(self): + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = cli.main(["/no/such/file"]) + self.assertEqual(code, 1) + self.assertEqual(out.getvalue(), "") + self.assertNotEqual(err.getvalue().strip(), "") +``` + +**Implied class: an existing but empty file.** This is a successful run, not an error: the three stats are all 0, the report is printed to stdout, and the exit code is 0. + +```python + def test_empty_file_reports_all_zero_and_returns_0(self): + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "empty.txt") + with open(path, "w", encoding="utf-8") as handle: + handle.write("") + out = io.StringIO() + with contextlib.redirect_stdout(out): + code = cli.main([path]) + self.assertEqual(code, 0) + self.assertEqual(out.getvalue(), "words: 0\nlines: 0\nchars: 0\n") +``` + +**Implied class: a file ending in a newline (the ordinary text-file case).** End to end, the trailing newline does not add a line (`lines: 2`) but is counted as a character (`chars: 6`), and stdout is the 3-line report followed by exactly one newline from printing it. + +```python + def test_file_with_trailing_newline_prints_report_once(self): + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "sample.txt") + with open(path, "w", encoding="utf-8") as handle: + handle.write("a b\nc\n") + out = io.StringIO() + with contextlib.redirect_stdout(out): + code = cli.main([path]) + self.assertEqual(code, 0) + self.assertEqual(out.getvalue(), "words: 3\nlines: 2\nchars: 6\n") +``` + +**Implied class: a file containing non-ASCII text.** Reading the file yields text, so `chars` is the number of characters (12), not the number of UTF-8 bytes (15). + +```python + def test_non_ascii_file_counts_characters_not_bytes(self): + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "unicode.txt") + with open(path, "w", encoding="utf-8") as handle: + handle.write("héllo wörld\n") + out = io.StringIO() + with contextlib.redirect_stdout(out): + code = cli.main([path]) + self.assertEqual(code, 0) + self.assertEqual(out.getvalue(), "words: 2\nlines: 1\nchars: 12\n") +``` + +**Implied failure mode: no `path` given.** `path` is a required positional argument, so an empty argv is a usage error: `argparse` exits with status 2 after writing usage to stderr, rather than reading anything or returning 0. + +```python + def test_no_path_argument_is_a_usage_error(self): + err = io.StringIO() + with contextlib.redirect_stderr(err): + with self.assertRaises(SystemExit) as caught: + cli.main([]) + self.assertEqual(caught.exception.code, 2) + self.assertIn("usage", err.getvalue().lower()) +``` + +**Implied failure mode: more than one path given.** The spec allows a *single* positional argument, so a second one is a usage error with status 2, not a second file silently ignored or a second report. + +```python + def test_extra_positional_argument_is_a_usage_error(self): + with contextlib.redirect_stderr(io.StringIO()): + with self.assertRaises(SystemExit) as caught: + cli.main(["one.txt", "two.txt"]) + self.assertEqual(caught.exception.code, 2) +``` + +**Implied failure mode: a path that exists but cannot be read as a file.** The spec says `main(argv)` returns an `int` exit code, and that an unusable path produces a message on stderr and code 1. A directory path is the reachable instance of "cannot read that file" that is not `FileNotFoundError`; it must behave like the missing-file case rather than letting an exception escape `main`. + +```python + def test_directory_path_reports_error_and_returns_1(self): + with tempfile.TemporaryDirectory() as tmp: + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = cli.main([tmp]) + self.assertEqual(code, 1) + self.assertEqual(out.getvalue(), "") + self.assertNotEqual(err.getvalue().strip(), "") +``` + +**Implied constraint: `cli` composes `counter` + `formatter`.** No test asserts that the printed report *is* `formatter.format_report` over the three `counter` values for the file's contents, rather than an independently formatted or independently counted string. + +```python + def test_output_is_formatter_over_counter_for_the_file_contents(self): + content = "one two\nthree\n" + expected = formatter.format_report( + { + "words": counter.count_words(content), + "lines": counter.count_lines(content), + "chars": counter.count_chars(content), + } + ) + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "compose.txt") + with open(path, "w", encoding="utf-8") as handle: + handle.write(content) + out = io.StringIO() + with contextlib.redirect_stdout(out): + code = cli.main([path]) + self.assertEqual(code, 0) + self.assertEqual(out.getvalue(), expected + "\n") +``` diff --git a/tests/inline-eval/microtests/plan-explicit/results-2026-09-17/P2-explicit-tests-2.md b/tests/inline-eval/microtests/plan-explicit/results-2026-09-17/P2-explicit-tests-2.md new file mode 100644 index 000000000..1de1c8d47 --- /dev/null +++ b/tests/inline-eval/microtests/plan-explicit/results-2026-09-17/P2-explicit-tests-2.md @@ -0,0 +1,238 @@ +## Implied cases made explicit + +Each case below is an input class or failure mode that `design.md` states or entails, but that no test in the current plan exercises. Each is assigned to the task that owns the behavior, with the exact `unittest` method to add to that task's failing-test step (step 1 of the task), and the expected behavior stated in the spec's own terms. + +--- + +### Task 1 — `wordstat/counter.py` (add to `test_counter.py`) + +**1.1 Runs of whitespace, and leading/trailing whitespace, do not create tokens** + +Spec basis: `count_words(text)` is "number of whitespace-separated tokens" — separators are whitespace runs, not single characters, so `" the quick "` has 2 tokens. + +```python + def test_count_words_collapses_whitespace_runs(self): + self.assertEqual(counter.count_words(" the quick "), 2) +``` + +**1.2 Tabs and newlines are word separators, not just spaces** + +Spec basis: tokens are *whitespace*-separated, so every whitespace character separates; `"a\tb\nc"` is 3 tokens. + +```python + def test_count_words_separates_on_any_whitespace(self): + self.assertEqual(counter.count_words("a\tb\nc"), 3) +``` + +**1.3 Whitespace-only text has no words** + +Spec basis: whitespace-only text contains no tokens, so the count is 0 — the same answer as `""`. + +```python + def test_count_words_whitespace_only_is_zero(self): + self.assertEqual(counter.count_words(" \n\t "), 0) +``` + +**1.4 Text with no newline at all is one line** + +Spec basis: `count_lines(text)` is "number of lines"; `"abc"` is a single line. The plan pins `"a\nb"`, `"a\nb\n"` and `""` but never the no-newline non-empty case, which is the boundary between the 0 case and the 2 cases. + +```python + def test_count_lines_no_newline_is_one(self): + self.assertEqual(counter.count_lines("abc"), 1) +``` + +**1.5 Interior blank lines are counted** + +Spec basis: only a *trailing* newline is exempt from adding a final line; an embedded blank line is a line, so `"a\n\nb"` is 3. + +```python + def test_count_lines_counts_interior_blank_lines(self): + self.assertEqual(counter.count_lines("a\n\nb"), 3) +``` + +**1.6 A trailing newline after a blank line still does not add a final empty line** + +Spec basis: `"a\n\n"` is the lines `"a"` and `""`, and the trailing newline does not add an empty final line — 2, by the same rule that makes `"a\nb\n"` equal 2. + +```python + def test_count_lines_trailing_newline_after_blank_line(self): + self.assertEqual(counter.count_lines("a\n\n"), 2) +``` + +**1.7 Newlines count as characters** + +Spec basis: `count_chars(text)` is "number of characters *including whitespace*" — newlines are whitespace and are counted, so `"a\nb"` is 3. + +```python + def test_count_chars_includes_newlines(self): + self.assertEqual(counter.count_chars("a\nb"), 3) +``` + +**1.8 Empty text has zero characters** + +Spec basis: the empty text has no characters, so the count is 0 (matching `count_words("")` and `count_lines("")`). + +```python + def test_count_chars_empty_is_zero(self): + self.assertEqual(counter.count_chars(""), 0) +``` + +**1.9 Characters are counted as characters, not bytes** + +Spec basis: `count_chars` counts *characters*, so `"héllo"` is 5 even though it is 6 bytes when UTF-8 encoded. + +```python + def test_count_chars_counts_characters_not_bytes(self): + self.assertEqual(counter.count_chars("héllo"), 5) +``` + +--- + +### Task 2 — `wordstat/formatter.py` (add to `test_formatter.py`) + +**2.1 Zero stats render as zeros** + +Spec basis: `format_report(stats)` returns a 3-line report for the given `{"words", "lines", "chars"}` dict; the all-zero dict (what an empty file produces) is a valid input and renders with `0` in each field. + +```python + def test_format_report_zero_stats(self): + self.assertEqual( + formatter.format_report({"words": 0, "lines": 0, "chars": 0}), + "words: 0\nlines: 0\nchars: 0", + ) +``` + +**2.2 The report is exactly 3 lines, with no trailing newline** + +Spec basis: the return value is "a 3-line report", e.g. `"words: 12\nlines: 3\nchars: 57"` — three lines joined by two newlines, with no newline after the last field. + +```python + def test_format_report_has_three_lines_and_no_trailing_newline(self): + report = formatter.format_report({"words": 12, "lines": 3, "chars": 57}) + self.assertEqual(report.count("\n"), 2) + self.assertFalse(report.endswith("\n")) +``` + +**2.3 Field order is fixed by the report format, not by the dict** + +Spec basis: the report format is words, then lines, then chars. The input is a dict, so the caller's key order must not affect the output. + +```python + def test_format_report_order_independent_of_dict_order(self): + self.assertEqual( + formatter.format_report({"chars": 57, "lines": 3, "words": 12}), + "words: 12\nlines: 3\nchars: 57", + ) +``` + +--- + +### Task 3 — `wordstat/cli.py` (add to `test_cli.py`) + +These use `tempfile` for the input file and `contextlib.redirect_stdout` / `redirect_stderr` with `io.StringIO` to capture output; add `import contextlib`, `import io`, `import os`, `import tempfile` to the test module. + +**3.1 The report goes to stdout followed by a single newline** + +Spec basis: `main(argv)` "print[s] the report to stdout" — printing the 3-line report yields exactly the report plus one terminating newline, and nothing else. + +```python + def test_main_prints_report_with_single_trailing_newline(self): + with tempfile.TemporaryDirectory() as d: + path = os.path.join(d, "sample.txt") + with open(path, "w") as fh: + fh.write("the quick brown fox\njumps\n") + out = io.StringIO() + with contextlib.redirect_stdout(out): + code = cli.main([path]) + self.assertEqual(code, 0) + self.assertEqual(out.getvalue(), "words: 5\nlines: 2\nchars: 26\n") +``` + +**3.2 An empty file reports zeros and succeeds** + +Spec basis: an empty file is a readable file, so `main` computes the three stats via `counter` (0 words, 0 lines, 0 chars), renders via `formatter`, and returns exit code 0 — the empty file is not a failure. + +```python + def test_main_empty_file_reports_zeros(self): + with tempfile.TemporaryDirectory() as d: + path = os.path.join(d, "empty.txt") + open(path, "w").close() + out = io.StringIO() + with contextlib.redirect_stdout(out): + code = cli.main([path]) + self.assertEqual(code, 0) + self.assertEqual(out.getvalue(), "words: 0\nlines: 0\nchars: 0\n") +``` + +**3.3 A missing file writes to stderr and prints nothing to stdout** + +Spec basis: "Missing file → message to stderr, return 1." The plan asserts only the return code; the destination of the message (stderr, and *not* stdout) is the other half of the stated behavior. The message's wording is not specified, so only its presence is asserted. + +```python + def test_main_missing_file_writes_message_to_stderr_only(self): + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = cli.main(["/no/such/file"]) + self.assertEqual(code, 1) + self.assertEqual(out.getvalue(), "") + self.assertNotEqual(err.getvalue().strip(), "") +``` + +**3.4 A missing `path` argument is an argparse usage error, not a `1` return** + +Spec basis: `main(argv)` "parse[s] a single positional `path` argument" using `argparse`. A required positional that is absent is a usage error, so `argparse` exits with its usage code (2) rather than returning — this is distinct from the "missing file → 1" path. + +```python + def test_main_without_path_argument_is_usage_error(self): + err = io.StringIO() + with contextlib.redirect_stderr(err): + with self.assertRaises(SystemExit) as cm: + cli.main([]) + self.assertEqual(cm.exception.code, 2) +``` + +**3.5 More than one positional argument is an argparse usage error** + +Spec basis: the CLI takes a *single* positional `path`; extra positionals are unrecognized, so `argparse` reports a usage error and exits 2. + +```python + def test_main_rejects_extra_positional_arguments(self): + err = io.StringIO() + with contextlib.redirect_stderr(err): + with self.assertRaises(SystemExit) as cm: + cli.main(["a.txt", "b.txt"]) + self.assertEqual(cm.exception.code, 2) +``` + +**3.6 A directory path is handled as an unreadable file, not an unhandled crash** + +Spec basis: `main` must "read that file" and, when it cannot, emit a message to stderr and return 1. A path that exists but is a directory cannot be read as a file, so it takes the same failure path as a missing file. + +```python + def test_main_directory_path_reports_failure(self): + with tempfile.TemporaryDirectory() as d: + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = cli.main([d]) + self.assertEqual(code, 1) + self.assertEqual(out.getvalue(), "") + self.assertNotEqual(err.getvalue().strip(), "") +``` + +**3.7 End-to-end character counting reads text, not bytes** + +Spec basis: `main` reads the file and computes the three stats "via `counter`", and `count_chars` counts characters — so a UTF-8 file whose contents are `"héllo\n"` reports 6 chars (5 letters plus the newline), not 7 bytes. + +```python + def test_main_counts_characters_not_bytes(self): + with tempfile.TemporaryDirectory() as d: + path = os.path.join(d, "unicode.txt") + with open(path, "w", encoding="utf-8") as fh: + fh.write("héllo\n") + out = io.StringIO() + with contextlib.redirect_stdout(out): + code = cli.main([path]) + self.assertEqual(code, 0) + self.assertEqual(out.getvalue(), "words: 1\nlines: 1\nchars: 6\n") +``` diff --git a/tests/inline-eval/microtests/plan-explicit/results-2026-09-17/P2-explicit-tests-3.md b/tests/inline-eval/microtests/plan-explicit/results-2026-09-17/P2-explicit-tests-3.md new file mode 100644 index 000000000..22458f7f1 --- /dev/null +++ b/tests/inline-eval/microtests/plan-explicit/results-2026-09-17/P2-explicit-tests-3.md @@ -0,0 +1,240 @@ +## Implied cases made explicit + +Each case below is an input class or failure mode that `design.md` requires but no test in `plan.md` currently exercises. Add each test to the named task's test file, in that task's step 1 (write the failing test first). + +--- + +### Task 1 — `wordstat/counter.py` (add to `test_counter.py`) + +Spec lines covered: "number of whitespace-separated tokens"; "number of lines (a trailing newline does not add an empty final line)"; "number of characters including whitespace". + +**Implied case 1a — words are separated by *runs* of *any* whitespace, not by single spaces.** The spec says "whitespace-separated tokens", so repeated spaces, leading/trailing whitespace, tabs and newlines are all separators and none of them produce empty tokens. + +```python + def test_count_words_collapses_runs_of_mixed_whitespace(self): + # "whitespace-separated tokens": tabs and newlines separate too, and + # repeated/leading/trailing whitespace yields no empty tokens. + self.assertEqual(counter.count_words(" the quick brown\tfox\n"), 4) +``` + +**Implied case 1b — whitespace-only text contains no tokens.** A string with whitespace but no non-whitespace characters has zero whitespace-separated tokens, exactly like `""`. + +```python + def test_count_words_whitespace_only_is_zero(self): + self.assertEqual(counter.count_words(" \t\n "), 0) +``` + +**Implied case 1c — a single token with no separator at all.** The boundary between the covered `""` (0) and `"the quick brown fox"` (4) cases. + +```python + def test_count_words_single_token(self): + self.assertEqual(counter.count_words("word"), 1) +``` + +**Implied case 1d — text with no trailing newline is one line.** The spec's examples are all multi-line; a file whose content is unterminated is a single line. + +```python + def test_count_lines_no_newline_is_one_line(self): + self.assertEqual(counter.count_lines("abc"), 1) +``` + +**Implied case 1e — a blank line inside the text is a line; only the final trailing newline is the one that "does not add an empty final line".** In `"a\n\n"` the first newline terminates line `"a"` and the second terminates an empty line, and that second newline is the trailing one, so the count is 2 — the same rule that makes `"a\nb\n"` equal 2. + +```python + def test_count_lines_counts_a_blank_line_before_the_trailing_newline(self): + self.assertEqual(counter.count_lines("a\n\n"), 2) +``` + +**Implied case 1f — a lone newline is one (empty) line.** Distinguishes "no content" (`""` → 0, already covered) from "one empty line terminated by a newline". + +```python + def test_count_lines_lone_newline_is_one_empty_line(self): + self.assertEqual(counter.count_lines("\n"), 1) +``` + +**Implied case 1g — newlines are characters.** `count_chars` counts "characters including whitespace", and a newline is whitespace, so it is counted. + +```python + def test_count_chars_includes_newlines(self): + self.assertEqual(counter.count_chars("a\nb"), 3) +``` + +**Implied case 1h — empty text has zero characters.** The empty-input class is specified for words and lines but not for chars. + +```python + def test_count_chars_empty_is_zero(self): + self.assertEqual(counter.count_chars(""), 0) +``` + +**Implied case 1i — characters, not bytes.** The spec says "number of characters", so a multi-byte character counts once. + +```python + def test_count_chars_counts_characters_not_bytes(self): + # "héllo" is 5 characters (6 bytes as UTF-8). + self.assertEqual(counter.count_chars("héllo"), 5) +``` + +--- + +### Task 2 — `wordstat/formatter.py` (add to `test_formatter.py`) + +Spec line covered: "given `{"words": w, "lines": l, "chars": c}`, return a 3-line report, e.g. `"words: 12\nlines: 3\nchars: 57"`". + +**Implied case 2a — zero stats.** An empty input file produces zeros (see case 3b), so the formatter must render them in the same shape rather than omitting or special-casing them. + +```python + def test_format_report_renders_zero_stats(self): + self.assertEqual( + formatter.format_report({"words": 0, "lines": 0, "chars": 0}), + "words: 0\nlines: 0\nchars: 0", + ) +``` + +**Implied case 2b — exactly 3 lines, with no trailing newline.** The spec calls it "a 3-line report" and its example string ends at `57`, so the returned string contains two newlines and does not end with one. (The CLI, not the formatter, supplies the final newline — see case 3a.) + +```python + def test_format_report_is_exactly_three_lines_with_no_trailing_newline(self): + report = formatter.format_report({"words": 12, "lines": 3, "chars": 57}) + self.assertEqual(len(report.splitlines()), 3) + self.assertEqual(report.count("\n"), 2) + self.assertFalse(report.endswith("\n")) +``` + +**Implied case 2c — line order is fixed by the report, not by the dict.** The spec fixes the report order as words, lines, chars; a `stats` dict built in another order must render identically. + +```python + def test_format_report_order_is_independent_of_dict_insertion_order(self): + stats = {} + stats["chars"] = 57 + stats["lines"] = 3 + stats["words"] = 12 + self.assertEqual( + formatter.format_report(stats), "words: 12\nlines: 3\nchars: 57" + ) +``` + +--- + +### Task 3 — `wordstat/cli.py` (add to `test_cli.py`) + +Spec line covered: "parse a single positional `path` argument, read that file, compute the three stats via `counter`, render via `formatter`, print the report to stdout, return exit code 0. Missing file → message to stderr, return 1." + +These tests assume the file-writing helper below in `test_cli.py`: + +```python + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + + def write(self, content): + path = os.path.join(self._tmp.name, "sample.txt") + with open(path, "w", encoding="utf-8") as fh: + fh.write(content) + return path +``` + +**Implied case 3a — stdout is the formatter's report followed by exactly one newline.** The spec says the report is *printed* to stdout, so the captured stdout is `format_report(...)` plus the newline `print` adds — nothing more, and no extra blank line. + +```python + def test_main_prints_report_to_stdout_with_single_trailing_newline(self): + path = self.write("the quick brown fox\njumps\n") + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = cli.main([path]) + self.assertEqual(code, 0) + self.assertEqual(out.getvalue(), "words: 5\nlines: 2\nchars: 26\n") + self.assertEqual(err.getvalue(), "") +``` + +**Implied case 3b — empty file.** Reading an empty file yields `""`, whose three stats are all 0 per Task 1, and this is still success (exit code 0), not an error. + +```python + def test_main_on_empty_file_reports_zeros_and_succeeds(self): + path = self.write("") + out = io.StringIO() + with contextlib.redirect_stdout(out): + code = cli.main([path]) + self.assertEqual(code, 0) + self.assertEqual(out.getvalue(), "words: 0\nlines: 0\nchars: 0\n") +``` + +**Implied case 3c — the printed report is what `counter` + `formatter` produce for that content.** The spec requires the CLI to *compose* the two modules rather than compute or format stats itself; this pins the composition to the real functions instead of to hand-written numbers. + +```python + def test_main_composes_counter_and_formatter(self): + content = "alpha beta\ngamma\n" + path = self.write(content) + expected = formatter.format_report( + { + "words": counter.count_words(content), + "lines": counter.count_lines(content), + "chars": counter.count_chars(content), + } + ) + out = io.StringIO() + with contextlib.redirect_stdout(out): + code = cli.main([path]) + self.assertEqual(code, 0) + self.assertEqual(out.getvalue(), expected + "\n") +``` + +**Implied case 3d — the file is read as text, so `chars` counts characters and not bytes.** Follows from case 1i once the content comes from a file: a file holding multi-byte characters must be decoded before counting. + +```python + def test_main_reads_file_as_text_so_chars_are_characters(self): + path = self.write("héllo wörld\n") # 12 characters, 14 UTF-8 bytes + out = io.StringIO() + with contextlib.redirect_stdout(out): + code = cli.main([path]) + self.assertEqual(code, 0) + self.assertEqual(out.getvalue(), "words: 2\nlines: 1\nchars: 12\n") +``` + +**Implied case 3e — a missing file writes a message to stderr and prints no report.** The spec's failure mode is "message to stderr, return 1"; the existing test only checks the return code, so the message (non-empty stderr) and the absence of a report on stdout are unexercised. The path is named in the message so the user knows which file failed. + +```python + def test_main_missing_file_writes_message_to_stderr_and_prints_nothing(self): + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = cli.main(["/no/such/file"]) + self.assertEqual(code, 1) + self.assertEqual(out.getvalue(), "") + self.assertNotEqual(err.getvalue().strip(), "") + self.assertIn("/no/such/file", err.getvalue()) +``` + +**Implied case 3f — a missing file must not raise.** "Missing file → ... return 1" means the error is reported by return value, so `main` returns normally rather than propagating `FileNotFoundError` or exiting. + +```python + def test_main_missing_file_does_not_raise(self): + err = io.StringIO() + with contextlib.redirect_stderr(err): + try: + code = cli.main(["/no/such/file"]) + except (OSError, SystemExit) as exc: + self.fail("main() must return 1 for a missing file, not raise %r" % (exc,)) + self.assertEqual(code, 1) +``` + +**Implied case 3g — the `path` argument is required.** The spec says `main(argv)` parses "a single positional `path` argument"; with no argument, `argparse` reports the usage error and exits with its standard code 2. + +```python + def test_main_requires_a_path_argument(self): + err = io.StringIO() + with contextlib.redirect_stderr(err): + with self.assertRaises(SystemExit) as ctx: + cli.main([]) + self.assertEqual(ctx.exception.code, 2) +``` + +**Implied case 3h — only one positional argument is accepted.** The counterpart of 3g for the "single" half of "a single positional `path` argument": extra positionals are a usage error, not silently ignored. + +```python + def test_main_rejects_extra_positional_arguments(self): + path = self.write("a\n") + err = io.StringIO() + with contextlib.redirect_stderr(err): + with self.assertRaises(SystemExit) as ctx: + cli.main([path, path]) + self.assertEqual(ctx.exception.code, 2) +``` diff --git a/tests/inline-eval/microtests/plan-explicit/results-2026-09-17/P2-explicit-tests-4.md b/tests/inline-eval/microtests/plan-explicit/results-2026-09-17/P2-explicit-tests-4.md new file mode 100644 index 000000000..28009542c --- /dev/null +++ b/tests/inline-eval/microtests/plan-explicit/results-2026-09-17/P2-explicit-tests-4.md @@ -0,0 +1,221 @@ +## Implied cases made explicit + +Below are the input classes and failure modes that `design.md` states or directly implies, but that no task's tests in the current plan exercise. Each is assigned to the task that owns the behavior, with the exact test to add and the expected behavior stated in the spec's own terms. + +--- + +### Task 1 — `wordstat/counter.py` (add to `test_counter.py`, step 1) + +**1a. Whitespace-only text has no tokens.** +Spec: `count_words(text)` is "the number of whitespace-separated tokens." A string made only of whitespace contains no tokens, so the count is 0 — the same as `""`. + +```python + def test_count_words_whitespace_only_is_zero(self): + self.assertEqual(counter.count_words(" "), 0) + self.assertEqual(counter.count_words("\n\t \n"), 0) +``` + +**1b. Runs of whitespace, and leading/trailing whitespace, do not create tokens.** +Spec: tokens are "whitespace-separated", so a separator of any width separates exactly two tokens and padding at the ends separates nothing. + +```python + def test_count_words_collapses_whitespace_runs(self): + self.assertEqual(counter.count_words(" the quick "), 2) +``` + +**1c. Whitespace means all whitespace, not just the space character.** +Spec: "whitespace-separated tokens" — newlines and tabs are whitespace and therefore separate tokens. + +```python + def test_count_words_separates_on_newlines_and_tabs(self): + self.assertEqual(counter.count_words("a\nb\tc"), 3) +``` + +**1d. Text with no newline at all is one line.** +Spec: `count_lines(text)` is "the number of lines". Non-empty text with no line terminator is a single line; only `""` has no lines. + +```python + def test_count_lines_single_line_without_newline(self): + self.assertEqual(counter.count_lines("a"), 1) +``` + +**1e. Blank interior lines are lines.** +Spec: `count_lines` counts lines; the exemption is only for the *final* empty line implied by a trailing newline, so an empty line in the middle counts like any other. + +```python + def test_count_lines_counts_blank_interior_lines(self): + self.assertEqual(counter.count_lines("a\n\nb"), 3) +``` + +**1f. Only the trailing newline is absorbed, not a trailing blank line.** +Spec: "a trailing newline does not add an empty final line." In `"a\n\n"` the first newline terminates line `"a"` and the second terminates an empty second line, so there are 2 lines — by the same rule that makes `"a\nb\n"` 2, not 3. + +```python + def test_count_lines_trailing_blank_line_counts(self): + self.assertEqual(counter.count_lines("a\n\n"), 2) +``` + +**1g. A lone newline is one (empty) line.** +Spec: same rule as `"a\n"` → 1. The newline terminates one empty line and adds no extra final line, so the answer is 1, distinguishing this input from `""` → 0. + +```python + def test_count_lines_lone_newline_is_one(self): + self.assertEqual(counter.count_lines("\n"), 1) +``` + +**1h. Newlines are characters.** +Spec: `count_chars(text)` is "the number of characters **including whitespace**" — line terminators are whitespace characters and are counted. + +```python + def test_count_chars_includes_newlines(self): + self.assertEqual(counter.count_chars("a\nb"), 3) + self.assertEqual(counter.count_chars("a\n"), 2) +``` + +**1i. Empty text has no characters.** +Spec: `count_chars` counts characters; `""` has none. (The plan pins the empty-input case for `count_words` and `count_lines` but not for `count_chars`.) + +```python + def test_count_chars_empty_is_zero(self): + self.assertEqual(counter.count_chars(""), 0) +``` + +**1j. Characters, not bytes.** +Spec: "number of characters". A non-ASCII character is one character regardless of how many bytes encode it. + +```python + def test_count_chars_counts_characters_not_bytes(self): + self.assertEqual(counter.count_chars("héllo"), 5) +``` + +--- + +### Task 2 — `wordstat/formatter.py` (add to `test_formatter.py`, step 1) + +**2a. Zero stats render as zeros.** +Spec: `format_report(stats)` renders `{"words": w, "lines": l, "chars": c}` as a 3-line report. Zero is a legal value of each stat (it is what `counter` returns for empty text), and it must be printed as `0`, not omitted or blanked. + +```python + def test_format_report_zero_stats(self): + self.assertEqual( + formatter.format_report({"words": 0, "lines": 0, "chars": 0}), + "words: 0\nlines: 0\nchars: 0", + ) +``` + +**2b. Exactly three lines, with no trailing newline.** +Spec: "return a 3-line report, e.g. `"words: 12\nlines: 3\nchars: 57"`" — the example ends after the last stat, so the returned string carries no trailing newline and splits into exactly 3 lines. (Task 3 depends on this: `print` supplies the final newline.) + +```python + def test_format_report_is_three_lines_without_trailing_newline(self): + report = formatter.format_report({"words": 1, "lines": 1, "chars": 1}) + self.assertFalse(report.endswith("\n")) + self.assertEqual(len(report.split("\n")), 3) +``` + +**2c. Line order is fixed by the report, not by the dict.** +Spec: the report is `words`, then `lines`, then `chars`. The argument is a stats dict, so the order in which a caller happens to build it must not change the report. + +```python + def test_format_report_order_independent_of_dict_order(self): + stats = {} + stats["chars"] = 57 + stats["lines"] = 3 + stats["words"] = 12 + self.assertEqual( + formatter.format_report(stats), "words: 12\nlines: 3\nchars: 57" + ) +``` + +--- + +### Task 3 — `wordstat/cli.py` (add to `test_cli.py`, step 1) + +**3a. The missing-file message goes to stderr, and stdout stays empty.** +Spec: "Missing file → message to stderr, return 1." The plan asserts only the return code; the stream and the absence of a report on stdout are equally part of the stated behavior. + +```python + def test_main_missing_file_writes_message_to_stderr_only(self): + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = cli.main(["/no/such/file"]) + self.assertEqual(code, 1) + self.assertEqual(out.getvalue(), "") + self.assertNotEqual(err.getvalue().strip(), "") +``` + +**3b. An empty file is a valid input, reported as all zeros.** +Spec: `main` reads the file, computes the three stats via `counter`, renders via `formatter`, prints to stdout, returns 0. An existing but empty file is not a missing file, so it takes the success path; `counter` gives 0 words, 0 lines, 0 chars. + +```python + def test_main_empty_file_reports_zeros_and_returns_zero(self): + with tempfile.TemporaryDirectory() as d: + path = os.path.join(d, "empty.txt") + with open(path, "w", encoding="utf-8") as fh: + fh.write("") + out = io.StringIO() + with contextlib.redirect_stdout(out): + code = cli.main([path]) + self.assertEqual(code, 0) + self.assertEqual(out.getvalue(), "words: 0\nlines: 0\nchars: 0\n") +``` + +**3c. The report reaches stdout as one printed report — three lines and nothing more.** +Spec: "print the report to stdout" — the printed output is the formatter's 3-line report plus the newline `print` adds, with no extra framing. + +```python + def test_main_prints_report_and_nothing_else(self): + with tempfile.TemporaryDirectory() as d: + path = os.path.join(d, "sample.txt") + with open(path, "w", encoding="utf-8") as fh: + fh.write("the quick brown fox\njumps\n") + out = io.StringIO() + with contextlib.redirect_stdout(out): + code = cli.main([path]) + self.assertEqual(code, 0) + self.assertEqual(out.getvalue(), "words: 5\nlines: 2\nchars: 26\n") +``` + +**3d. The `path` argument is required.** +Spec: "parse a single positional `path` argument." With no argument there is nothing to read, so `argparse` rejects the invocation rather than `main` returning 0; the plan never exercises the arg parser's own failure path. + +```python + def test_main_requires_path_argument(self): + err = io.StringIO() + with contextlib.redirect_stderr(err): + with self.assertRaises(SystemExit) as ctx: + cli.main([]) + self.assertNotEqual(ctx.exception.code, 0) +``` + +**3e. A path that exists but cannot be read as a file still exits 1, not with a traceback.** +Spec: `main` returns an exit code — 0 on success, 1 when the file cannot be read. A directory path is not a missing file, but it is not a readable text file either, so it must land in the same reported-failure branch (message to stderr, return 1) rather than propagating an exception out of `main`. + +```python + def test_main_unreadable_path_returns_one(self): + with tempfile.TemporaryDirectory() as d: + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = cli.main([d]) + self.assertEqual(code, 1) + self.assertEqual(out.getvalue(), "") + self.assertNotEqual(err.getvalue().strip(), "") +``` + +**3f. File contents are read as text, so `chars` counts characters.** +Spec: `main` reads the file and computes the three stats via `counter`; `count_chars` counts characters, so a UTF-8 file of non-ASCII text must report its character count, not its byte count. + +```python + def test_main_reads_file_as_text(self): + with tempfile.TemporaryDirectory() as d: + path = os.path.join(d, "utf8.txt") + with open(path, "w", encoding="utf-8") as fh: + fh.write("héllo wörld\n") + out = io.StringIO() + with contextlib.redirect_stdout(out): + code = cli.main([path]) + self.assertEqual(code, 0) + self.assertEqual(out.getvalue(), "words: 2\nlines: 1\nchars: 12\n") +``` + +*Imports these tests assume in `test_cli.py` (stdlib only, per the design's constraint): `contextlib`, `io`, `os`, `tempfile`, `unittest`, and `from wordstat import cli`.* diff --git a/tests/inline-eval/microtests/plan-explicit/results-2026-09-17/P2-explicit-tests-5.md b/tests/inline-eval/microtests/plan-explicit/results-2026-09-17/P2-explicit-tests-5.md new file mode 100644 index 000000000..d826e52cf --- /dev/null +++ b/tests/inline-eval/microtests/plan-explicit/results-2026-09-17/P2-explicit-tests-5.md @@ -0,0 +1,198 @@ +## Implied cases made explicit + +The additions below close the gap between what `design.md` requires and what the plan's tests currently exercise. Each is assigned to the task that owns the module, and each is written as a test method to be added to that task's step 1 (the failing-test step), in the plan's existing unittest style. + +--- + +### Task 1 — `wordstat/counter.py` (add to `test_counter.py`) + +**1. `count_words` with leading, trailing, and repeated whitespace.** +The spec defines words as "whitespace-separated tokens"; the plan only tests single-space separation, so runs of whitespace and padding are unexercised. Expected behavior: only tokens are counted, so surrounding and repeated whitespace contribute nothing. + +```python +def test_count_words_ignores_surrounding_and_repeated_whitespace(self): + self.assertEqual(counter.count_words(" the quick brown fox "), 4) +``` + +**2. `count_words` with non-space whitespace as separators.** +"Whitespace-separated" covers tabs and newlines, not just spaces. Expected behavior: tabs and newlines separate tokens exactly as spaces do. + +```python +def test_count_words_splits_on_tabs_and_newlines(self): + self.assertEqual(counter.count_words("the\tquick\nbrown\r\nfox"), 4) +``` + +**3. `count_words` on whitespace-only text.** +The plan tests `""` but not text that is non-empty yet contains no tokens. Expected behavior: there are no whitespace-separated tokens, so the count is 0. + +```python +def test_count_words_whitespace_only_is_zero(self): + self.assertEqual(counter.count_words(" \n\t "), 0) +``` + +**4. `count_lines` on a lone newline.** +The spec's rule "a trailing newline does not add an empty final line" has an untested boundary: text that is nothing but a trailing newline. Expected behavior: `"\n"` is one (empty) line, the same way `"a\n"` is one line. + +```python +def test_count_lines_lone_newline_is_one_line(self): + self.assertEqual(counter.count_lines("\n"), 1) + self.assertEqual(counter.count_lines("a\n"), 1) +``` + +**5. `count_lines` with interior blank lines.** +Only the *trailing* newline is exempt from producing a line; blank lines inside the text are lines. Expected behavior: an empty line between two content lines is counted. + +```python +def test_count_lines_counts_interior_blank_lines(self): + self.assertEqual(counter.count_lines("a\n\nb"), 3) + self.assertEqual(counter.count_lines("a\n\nb\n"), 3) +``` + +**6. `count_chars` counts newlines.** +The spec says "including whitespace", which covers line separators; the plan only tests a space. Expected behavior: newline characters are counted like any other character. + +```python +def test_count_chars_includes_newlines(self): + self.assertEqual(counter.count_chars("a\nb"), 3) + self.assertEqual(counter.count_chars("a\nb\n"), 4) +``` + +**7. `count_chars` on empty text.** +`count_words` and `count_lines` both have an empty-input case in the plan; `count_chars` does not. Expected behavior: 0 characters. + +```python +def test_count_chars_empty_is_zero(self): + self.assertEqual(counter.count_chars(""), 0) +``` + +**8. `count_chars` counts characters, not bytes.** +The spec says "number of characters"; non-ASCII text distinguishes characters from encoded bytes. Expected behavior: each non-ASCII character counts once. + +```python +def test_count_chars_counts_characters_not_bytes(self): + self.assertEqual(counter.count_chars("héllo"), 5) + self.assertEqual(counter.count_chars("naïve café"), 10) +``` + +--- + +### Task 2 — `wordstat/formatter.py` (add to `test_formatter.py`) + +**9. Zero-valued stats.** +The CLI must be able to report on an empty file, so the all-zero stats dict is a required input class; the plan only tests one non-zero example. Expected behavior: the same 3-line report shape with zeros. + +```python +def test_format_report_zero_stats(self): + self.assertEqual( + formatter.format_report({"words": 0, "lines": 0, "chars": 0}), + "words: 0\nlines: 0\nchars: 0", + ) +``` + +**10. Report shape: exactly three lines, no trailing newline.** +The spec requires "a 3-line report"; the plan asserts one literal string but never pins the shape as a property, which is what `cli` relies on when printing. Expected behavior: three lines, in the order words, lines, chars, with no trailing newline. + +```python +def test_format_report_is_three_lines_without_trailing_newline(self): + report = formatter.format_report({"words": 12, "lines": 3, "chars": 57}) + self.assertFalse(report.endswith("\n")) + self.assertEqual( + report.split("\n"), + ["words: 12", "lines: 3", "chars: 57"], + ) +``` + +**11. Field order is fixed, independent of dict insertion order.** +The spec fixes the report's line order; the input is a dict, whose iteration order can differ from the spec's order. Expected behavior: the report is always words, then lines, then chars. + +```python +def test_format_report_order_independent_of_dict_order(self): + self.assertEqual( + formatter.format_report({"chars": 57, "words": 12, "lines": 3}), + "words: 12\nlines: 3\nchars: 57", + ) +``` + +--- + +### Task 3 — `wordstat/cli.py` (add to `test_cli.py`) + +These use `tempfile`, `io.StringIO`, and `contextlib.redirect_stdout` / `redirect_stderr` — stdlib only, consistent with the plan's constraint. + +**12. Report is printed to stdout followed by exactly one newline.** +The spec says `main` prints the report to stdout; the plan says "prints the expected report" without pinning the exact stream contents. Expected behavior: stdout is the formatter's report plus the single newline from printing it, and the return code is 0. + +```python +def test_main_prints_report_to_stdout_and_returns_zero(self): + with tempfile.TemporaryDirectory() as d: + path = os.path.join(d, "sample.txt") + with open(path, "w") as f: + f.write("the quick brown fox\njumps\n") + out = io.StringIO() + with contextlib.redirect_stdout(out): + code = cli.main([path]) + self.assertEqual(code, 0) + self.assertEqual(out.getvalue(), "words: 5\nlines: 2\nchars: 26\n") +``` + +**13. Empty file end to end.** +An empty input file is a valid file and the composition of all three zero-valued stats; no task currently exercises it through `main`. Expected behavior: the all-zero report on stdout and exit code 0. + +```python +def test_main_empty_file_reports_zeros(self): + with tempfile.TemporaryDirectory() as d: + path = os.path.join(d, "empty.txt") + open(path, "w").close() + out = io.StringIO() + with contextlib.redirect_stdout(out): + code = cli.main([path]) + self.assertEqual(code, 0) + self.assertEqual(out.getvalue(), "words: 0\nlines: 0\nchars: 0\n") +``` + +**14. Trailing newline in the real file does not add a line.** +The spec's trailing-newline rule for `count_lines` must survive the read-a-file path, where the trailing byte is easy to mishandle. Expected behavior: a file whose content ends in a newline reports the same line count as one that does not, while the character count differs by one. + +```python +def test_main_trailing_newline_does_not_add_a_line(self): + with tempfile.TemporaryDirectory() as d: + with_nl = os.path.join(d, "with.txt") + without_nl = os.path.join(d, "without.txt") + with open(with_nl, "w") as f: + f.write("a\nb\n") + with open(without_nl, "w") as f: + f.write("a\nb") + out_with, out_without = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out_with): + self.assertEqual(cli.main([with_nl]), 0) + with contextlib.redirect_stdout(out_without): + self.assertEqual(cli.main([without_nl]), 0) + self.assertEqual(out_with.getvalue(), "words: 2\nlines: 2\nchars: 4\n") + self.assertEqual(out_without.getvalue(), "words: 2\nlines: 2\nchars: 3\n") +``` + +**15. Missing file writes a message to stderr and nothing to stdout.** +The spec says "Missing file → message to stderr, return 1"; the plan only asserts the return code, leaving the message and the silence on stdout unexercised. Expected behavior: a non-empty message on stderr, empty stdout, no exception, return 1. + +```python +def test_main_missing_file_writes_message_to_stderr(self): + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = cli.main(["/no/such/file"]) + self.assertEqual(code, 1) + self.assertEqual(out.getvalue(), "") + self.assertNotEqual(err.getvalue().strip(), "") +``` + +**16. The positional `path` argument is required.** +The spec says `main` parses "a single positional `path` argument"; invoking it with no argument is an implied input class that the plan never covers. Expected behavior: argparse rejects the invocation rather than the code reading a file — it exits with a non-zero status and writes to stderr. + +```python +def test_main_requires_path_argument(self): + err = io.StringIO() + with contextlib.redirect_stderr(err): + with self.assertRaises(SystemExit) as ctx: + cli.main([]) + self.assertNotEqual(ctx.exception.code, 0) + self.assertNotEqual(err.getvalue().strip(), "") +``` diff --git a/tests/inline-eval/microtests/plan-explicit/results-2026-09-17/P2-explicit-tests-6.md b/tests/inline-eval/microtests/plan-explicit/results-2026-09-17/P2-explicit-tests-6.md new file mode 100644 index 000000000..13e871c0b --- /dev/null +++ b/tests/inline-eval/microtests/plan-explicit/results-2026-09-17/P2-explicit-tests-6.md @@ -0,0 +1,155 @@ +## Implied cases made explicit + +Each entry names the task that owns the case, the exact test method to add to that task's test file (step 1 of the task, written failing first, same `unittest` style as the rest of the plan), and the behavior the spec requires. + +### Task 1 — `wordstat/counter.py` (add to `test_counter.py`) + +The spec defines `count_words` as "number of whitespace-separated tokens", `count_lines` with the rule "a trailing newline does not add an empty final line", and `count_chars` as "number of characters including whitespace". The plan's tests only exercise single-space, one-newline, and ASCII inputs. Add: + +**Whitespace runs, tabs, newlines, and leading/trailing whitespace as separators** — tokens are whitespace-separated, so any run of whitespace of any kind is one separator and edge whitespace produces no empty tokens. + +```python + def test_count_words_collapses_whitespace_runs(self): + self.assertEqual(counter.count_words(" the quick\tbrown\nfox "), 4) + self.assertEqual(counter.count_words("a\n\nb"), 2) +``` + +**Whitespace-only text has no tokens** — same input class as `""` (0), but non-empty. + +```python + def test_count_words_whitespace_only_is_zero(self): + self.assertEqual(counter.count_words(" \t\n "), 0) +``` + +**A single line, with and without a trailing newline** — the trailing newline does not add an empty final line, so both are 1 line. + +```python + def test_count_lines_single_line(self): + self.assertEqual(counter.count_lines("a"), 1) + self.assertEqual(counter.count_lines("a\n"), 1) +``` + +**Blank lines are lines** — a blank interior line counts, and `"\n"` is one (empty) line, not zero and not two, because only the *trailing* newline is non-additive. + +```python + def test_count_lines_counts_blank_lines(self): + self.assertEqual(counter.count_lines("a\n\nb"), 3) + self.assertEqual(counter.count_lines("\n"), 1) + self.assertEqual(counter.count_lines("a\n\n"), 2) +``` + +**Newlines are characters** — `count_chars` includes whitespace, and the trailing-newline rule of `count_lines` does not apply here. + +```python + def test_count_chars_includes_newlines(self): + self.assertEqual(counter.count_chars("a\nb"), 3) + self.assertEqual(counter.count_chars("a\nb\n"), 4) + self.assertEqual(counter.count_chars(""), 0) +``` + +**Non-ASCII text counts characters, not bytes** — "number of characters". + +```python + def test_count_chars_counts_characters_not_bytes(self): + self.assertEqual(counter.count_chars("naïve"), 5) + self.assertEqual(counter.count_chars("héllo wörld"), 11) +``` + +### Task 2 — `wordstat/formatter.py` (add to `test_formatter.py`) + +The spec fixes the shape as a "3-line report" over the keys `words`, `lines`, `chars`. The plan's single test uses one dict with distinct multi-digit values in spec order. Add: + +**Zero stats** — the empty-file case that Task 3 will feed through; the report still has all three lines. + +```python + def test_format_report_zero_stats(self): + self.assertEqual( + formatter.format_report({"words": 0, "lines": 0, "chars": 0}), + "words: 0\nlines: 0\nchars: 0", + ) +``` + +**Exactly three lines, no trailing newline** — the report is a 3-line string; the newline after the last field is the caller's (`cli` prints it). + +```python + def test_format_report_is_three_lines_without_trailing_newline(self): + report = formatter.format_report({"words": 12, "lines": 3, "chars": 57}) + self.assertEqual(len(report.splitlines()), 3) + self.assertFalse(report.endswith("\n")) +``` + +**Field order is fixed by the spec, not by the dict** — `words`, then `lines`, then `chars`, whatever order the mapping was built in. + +```python + def test_format_report_field_order_independent_of_dict_order(self): + stats = {} + stats["chars"] = 57 + stats["lines"] = 3 + stats["words"] = 12 + self.assertEqual( + formatter.format_report(stats), "words: 12\nlines: 3\nchars: 57" + ) +``` + +### Task 3 — `wordstat/cli.py` (add to `test_cli.py`) + +The spec requires: print the report to stdout and return 0; missing file → *message to stderr* and return 1; parse *a single positional* `path`. The plan's tests check the printed report and the exit code 1 only. Add (using `io.StringIO` with `contextlib.redirect_stdout` / `redirect_stderr`, and `tempfile` as the plan's existing test already does): + +**Missing file writes to stderr and leaves stdout clean** — the failure path's output goes to stderr, not stdout, and the exit code is 1. + +```python + def test_missing_file_reports_to_stderr_not_stdout(self): + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + rc = cli.main(["/no/such/file"]) + self.assertEqual(rc, 1) + self.assertEqual(out.getvalue(), "") + self.assertIn("/no/such/file", err.getvalue()) +``` + +**The success path prints the report to stdout** — stdout is exactly the formatter's 3-line report plus the newline from printing it, and nothing goes to stderr. + +```python + def test_report_printed_to_stdout(self): + path = self._write_temp("the quick brown fox\njumps\n") + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + rc = cli.main([path]) + self.assertEqual(rc, 0) + self.assertEqual(out.getvalue(), "words: 5\nlines: 2\nchars: 26\n") + self.assertEqual(err.getvalue(), "") +``` + +**An empty file is a success, reporting zeros** — reading succeeds, so the exit code is 0 and the three stats are all 0. + +```python + def test_empty_file_reports_zeros(self): + path = self._write_temp("") + out = io.StringIO() + with contextlib.redirect_stdout(out): + rc = cli.main([path]) + self.assertEqual(rc, 0) + self.assertEqual(out.getvalue(), "words: 0\nlines: 0\nchars: 0\n") +``` + +**The composed counts obey `counter`'s own rules** — `cli` computes the stats *via* `counter`, so a file whose last line ends in a newline reports that trailing newline as a character but not as an extra line. + +```python + def test_trailing_newline_counted_as_char_not_extra_line(self): + path = self._write_temp("a b\nc\n") + out = io.StringIO() + with contextlib.redirect_stdout(out): + rc = cli.main([path]) + self.assertEqual(rc, 0) + self.assertEqual(out.getvalue(), "words: 3\nlines: 2\nchars: 6\n") +``` + +**`path` is required** — the spec's single positional argument is mandatory, so `argparse` rejects an empty `argv` by exiting with status 2 rather than returning 0 or 1. + +```python + def test_missing_path_argument_is_an_argparse_error(self): + with contextlib.redirect_stderr(io.StringIO()): + with self.assertRaises(SystemExit) as ctx: + cli.main([]) + self.assertEqual(ctx.exception.code, 2) +``` diff --git a/tests/inline-eval/microtests/plan-explicit/results-2026-09-17/README.txt b/tests/inline-eval/microtests/plan-explicit/results-2026-09-17/README.txt new file mode 100644 index 000000000..eaf12a807 --- /dev/null +++ b/tests/inline-eval/microtests/plan-explicit/results-2026-09-17/README.txt @@ -0,0 +1,9 @@ +Planner-side micro-test on Opus 5 via Bedrock (claude -p, fresh config +dir), given design.md and the wordstat plan, 6 reps each. +P1: write the Review Focus section (list of implied cases) decode named 6/6 +P2: implied cases become explicit tests in the owning task decode test 4/6 + (14-24 tests generated per rep; the two misses spent the budget on + other cases: whitespace, blank lines, chars-not-bytes) +The plan is where implied becomes explicit, and the list has better +recall than jumping straight to tests. Two steps (list, then a test per +line) is the shape to try next. diff --git a/tests/inline-eval/microtests/plan-explicit/variants/P1-review-focus.txt b/tests/inline-eval/microtests/plan-explicit/variants/P1-review-focus.txt new file mode 100644 index 000000000..87f0af494 --- /dev/null +++ b/tests/inline-eval/microtests/plan-explicit/variants/P1-review-focus.txt @@ -0,0 +1 @@ +You are the author of plan.md for the wordstat package; design.md is the spec, and the spec is a vision document: it says what the software must do, not everything the software will meet. Read design.md and plan.md first. The plan is otherwise complete. Write ONLY the plan's '## Review Focus' section, following this instruction from the writing-plans skill: Input classes and failure modes the spec implies but no task's tests exercise — one line each, naming the input or condition and the behavior the spec implies for it, ordered by how likely each is to bite a user. Written here, once, with the spec in front of you; the final reviewer checks each deliberately instead of rediscovering them. Output the section as markdown and nothing else. \ No newline at end of file diff --git a/tests/inline-eval/microtests/plan-explicit/variants/P2-explicit-tests.txt b/tests/inline-eval/microtests/plan-explicit/variants/P2-explicit-tests.txt new file mode 100644 index 000000000..6bad32544 --- /dev/null +++ b/tests/inline-eval/microtests/plan-explicit/variants/P2-explicit-tests.txt @@ -0,0 +1 @@ +You are the author of plan.md for the wordstat package; design.md is the spec, and the spec is a vision document: it says what the software must do, not everything the software will meet. Read design.md and plan.md first. The plan is otherwise complete. Every input class or failure mode that design.md implies but no task's tests exercise must become explicit in the plan: for each one, name the task that owns it and write the exact test (unittest, matching the plan's style) that the task must add, with its expected behavior stated in the spec's own terms. Output ONLY a markdown section titled '## Implied cases made explicit' containing those additions, grouped by task, and nothing else. Do not write implementation code. \ No newline at end of file diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/README.txt b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/README.txt new file mode 100644 index 000000000..95c70574c --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/README.txt @@ -0,0 +1,28 @@ +Review Focus wording micro-test, Opus 5 via Bedrock (claude -p, fresh config +dir), given design.md + a complete plan.md, output the section only. 6 reps +per arm x fixture (wordstat 3 tasks, ledgerlite 6 tasks). summary.txt has the +counts; override-read.txt is a subagent's line-by-line read against the spec +(its OVERRIDE class is too strict: it counts reasonable-person extensions +such as directory -> exit 1; the real contradictions are ~3 reps, all on +ledgerlite's blank-line-before-closing-balance sentence). + +Baseline failure: under the current wording (T0) Opus writes 11-20 lines +and earmarks nearly every one for a test; in full plans that is 68-87 tests +for the six-task design. + +T0 current (list, then a test per line) 11-13 / 13-20 items, ~all tests +T1 per-line ruling with a cost criterion 12-18 / 19-26 items (worse) +T2 per-line ruling, planner's free choice 13-20 / 19-26 items (worse) +T3 one line per input the program takes 11-18 / 12-27 items (worse: sub-cases per input) +T4 "the five most likely to bite" 5-6 / 5-10 items (respected 11/12) + +Recall of the undecodable-input case: named in every rep of T0-T3 on both +fixtures; under T4 6/6 on wordstat, 0/6 on ledgerlite, where the five slots +went to the malformed-row exit path, amount precision, unreadable --rules, +header-only CSV, and --opening: the planted defects and the spec's stated +but untested paths, which is a defensible ranking for a bank-CSV tool. + +Reading: a ruling slot or a cost field is an invitation to enumerate; a +recipe keyed to inputs multiplies by sub-cases. Only a stated count moved +the volume. The form that survives is a count, and the number is a +judgment call (fixed, or scaled to the plan) — see the next micro-test. diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T0-current-ledgerlite-1.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T0-current-ledgerlite-1.md new file mode 100644 index 000000000..3a6f0ecea --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T0-current-ledgerlite-1.md @@ -0,0 +1,20 @@ +## Review Focus + +Input classes and failure modes the spec implies that no task's tests exercise. Each line names the condition, the behavior a reasonable person expects, and the test that pins it. + +- **Malformed row through the CLI** — the spec's exit code 2 is never exercised anywhere: a bad row must print `ledgerlite: :: ` to stderr, leave stdout completely empty, and return 2 (not 1, and not a `ParseError` traceback). → Task 6, `test_malformed_row_returns_2_and_prints_nothing`: write a CSV whose second row is `2026-13-40,1.00,x`, assert `rc == 2`, `out.getvalue() == ""`, and `err.getvalue().startswith(f"ledgerlite: {csv_path}:2: ")`. +- **Amount with more than two fractional digits** — `1.005` is malformed (`ParseError`, then exit 2) while `1.5` and `1.50` are fine; Task 2's interface omits this case from its list of malformed reasons, so it will be missed unless tested. → Task 2, `test_over_two_fractional_digits_is_malformed`: `parse_csv("date,amount,description\n2026-03-04,1.005,x\n")` raises `ParseError` with `line == 2`, and the same CSV with `1.5` and `1.50` parses to `Decimal("1.5")` / `Decimal("1.50")`. +- **No `--rules` and no `--opening`** — the simplest invocation, `report FILE`, must categorize nothing, print a single `uncategorized:` line, and start the balance at `0`. → Task 6, `test_defaults_no_rules_zero_opening`: `main(["report", csv_path])` returns 0 and prints `uncategorized: -7.50\n... \n\nclosing balance: \n` with no `food`/`housing` lines. +- **A file with only a header row** — zero transactions is legal (`test_header_only_is_empty` proves the parser accepts it), so the report must still print: closing balance equals opening, and the category block is empty rather than crashing on `min`/`max` or emitting a stray line. → Task 5, `test_format_report_no_categories`: `format_report({}, Decimal("100"))` equals `"\nclosing balance: 100.00"`, and Task 6, `test_header_only_prints_opening_balance`: `main(["report", header_only, "--opening", "100"])` returns 0 with output ending `closing balance: 100.00\n`. +- **`--rules` pointing at a file that cannot be read** — the spec only promises a message for TRANSACTIONS, but a missing rules path must not raise `FileNotFoundError` out of `main`; the same `ledgerlite: cannot read : ` / exit 1 shape is the reasonable answer. → Task 6, `test_unreadable_rules_file_returns_1`: `main(["report", csv_path, "--rules", "/no/such/rules.txt"])` returns 1, stdout empty, stderr starts `ledgerlite: cannot read /no/such/rules.txt`. +- **A rules line with no `=`** — `parse_rules` will `IndexError`/`ValueError` on `just some text`; it must either skip the line or raise a message the CLI can report, never crash with an unhandled exception; and `coffee=food=extra` must split on the first `=` only. → Task 3, `test_rules_line_without_equals` and `test_rules_line_splits_on_first_equals`: assert the chosen behavior for `"coffee=food\nnonsense\n"` and that `"a=b=c"` yields `("a", "b=c")`. +- **Wrong column count and a non-numeric amount** — both are named in Task 2's interface but neither is tested, and a row like `2026-03-04,-7.50` will silently `IndexError` instead of raising `ParseError`. → Task 2, `test_wrong_column_count_raises` and `test_non_numeric_amount_raises`: `parse_csv` on `"...\n2026-03-04,-7.50\n"` and on `"...\n2026-03-04,abc,x\n"` each raise `ParseError` with `line == 2` and a non-empty `reason`. +- **Missing or misspelled header** — Task 2's step 3 says "validate the header" but nothing says what happens; a file whose first line is already a data row must be rejected as `ParseError` at line 1, not silently swallowed as a header and one transaction lost. → Task 2, `test_bad_header_raises_at_line_1`: `parse_csv("2026-03-04,-7.50,x\n")` raises `ParseError` with `line == 1`. +- **Trailing or interior blank line in the CSV** — bank exports commonly end with `\n\n`; a blank line read as a zero-column row would reject the whole file over nothing, so blank lines must be skipped. → Task 2, `test_blank_lines_are_skipped`: `parse_csv("date,amount,description\n\n2026-03-04,-7.50,x\n\n")` returns exactly one transaction. +- **Description containing a comma or quotes** — `"COFFEE, SHOP"` is one field, not two, and must not trip the column-count check; this also confirms `csv.reader` is doing the splitting rather than `str.split(",")`. → Task 2, `test_quoted_description_with_comma`: `parse_csv('date,amount,description\n2026-03-04,-7.50,"COFFEE, SHOP"\n')[0].description == "COFFEE, SHOP"`. +- **`--opening` that is not a decimal number** — `--opening abc` must produce a diagnostic and a nonzero return, not a `decimal.InvalidOperation` traceback. → Task 6, `test_bad_opening_value_errors_cleanly`: `main(["report", csv_path, "--opening", "abc"])` returns nonzero (or raises `SystemExit` with a nonzero code) with stdout empty and a `ledgerlite: ...` message on stderr. +- **`main` called with missing or unknown arguments** — the contract is `main(argv) -> int`, but bare argparse raises `SystemExit` instead of returning, so an embedding caller (and the test suite) gets an exception for `main([])`. → Task 6, `test_no_arguments_returns_nonzero`: assert `main([])` returns a nonzero int, or, if `SystemExit` is the deliberate choice, assert it explicitly with `assertRaises(SystemExit)` so the contract is on record. +- **Rule substring given in mixed or upper case** — every test uses lowercase substrings; `Coffee=food` must still match `COFFEE SHOP`, since case-insensitivity is a property of the match, not of the rules file. → Task 3, `test_rule_substring_case_insensitive`: `categorize([Transaction(..., "COFFEE SHOP")], [("Coffee", "food")])[0].category == "food"`. +- **A rule whose category is literally `uncategorized`** — its total must merge with the no-rule bucket into one line, and that line must still sort last rather than alphabetically. → Task 5, `test_explicit_uncategorized_category_merges_and_sorts_last`: totals over one transaction with `category="uncategorized"` and one with `category=None` produce a single `uncategorized` key with the combined amount, and `format_report` places it after a `zoo` category. +- **A category whose amounts cancel exactly** — the total must print `0.00`, never `-0.00`; `Decimal` preserves the sign of negative zero through `quantize`. → Task 5, `test_format_amount_negative_zero`: `format_amount(Decimal("-0.00")) == "0.00"` and `format_amount(Decimal("-7.50") + Decimal("7.50")) == "0.00"`. +- **UTF-8 BOM or CRLF line endings** — a `utf-8-sig` export makes the header `\ufeffdate,...` and would be rejected as a bad header with a baffling message; both are the normal shape of a file exported on Windows and must parse. → Task 2, `test_bom_and_crlf_are_tolerated`: `parse_csv("\ufeffdate,amount,description\r\n2026-03-04,-7.50,x\r\n")` returns one transaction with `description == "x"`. diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T0-current-ledgerlite-2.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T0-current-ledgerlite-2.md new file mode 100644 index 000000000..112f0a312 --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T0-current-ledgerlite-2.md @@ -0,0 +1,24 @@ +## Review Focus + +These are input classes and conditions the spec implies but no task's tests currently exercise, ordered by how likely each is to bite. Each line names the expected behavior and the task/test that should pin it. + +- **A malformed row anywhere in the file** — the spec's whole exit-2 path is untested end to end: stderr gets `ledgerlite: :: `, stdout stays completely empty, and the return code is 2, not 1. → Task 6, `test_cli.py::CliTests.test_malformed_row_returns_2_and_prints_nothing` (also forces the `parse_csv` signature question: Task 2 produces `parse_csv(text)`, Task 6's Interfaces say `parse_csv(path)`, and only the CLI knows ``). +- **An amount with more than two fractional digits** (`1.005`) — malformed, while `1.5` and `1.50` are both fine; this rule is in the spec but absent from Task 2's Interfaces and tests, so it will simply not be implemented. → Task 2, `test_parse.py::ParseTests.test_three_fractional_digits_is_malformed` (with `1.5`/`1.50`/`2` accepted in the same test). +- **`--rules` omitted** — every transaction is uncategorized, so the report is a single `uncategorized:` line; the CLI test always passes `--rules`. → Task 6, `test_cli.py::CliTests.test_without_rules_all_uncategorized`. +- **`--opening` omitted** — defaults to `0`, so the closing balance is the plain sum; never exercised. → Task 6, `test_cli.py::CliTests.test_opening_defaults_to_zero`. +- **A row with the wrong column count** (two fields, or four because a description contains an unquoted comma) — malformed with that row's 1-based line number, header counted as line 1. → Task 2, `test_parse.py::ParseTests.test_wrong_column_count_raises_with_line`. +- **An amount that is not a decimal number at all** (`abc`, empty, `1,000`, `$5`) — malformed, with the offending line number, not a `decimal.InvalidOperation` traceback. → Task 2, `test_parse.py::ParseTests.test_non_numeric_amount_raises_with_line`. +- **A missing, misspelled, or reordered header row, and a zero-byte file** — rejected as malformed at line 1, not silently treated as an empty ledger or as data. → Task 2, `test_parse.py::ParseTests.test_bad_or_missing_header_is_malformed`. +- **An unreadable `--rules` path** — the spec defines the error only for TRANSACTIONS, but its silence is not permission to crash: expect a `ledgerlite: cannot read : ` line on stderr and a non-zero exit, never a traceback. → Task 6, `test_cli.py::CliTests.test_missing_rules_file_reports_cannot_read`. +- **A rules line with no `=`** (a comment, or stray text) — `parse_rules` skips blank lines but the spec's grammar admits nothing else; expect it skipped or rejected deterministically, not a `ValueError` from unpacking. → Task 3, `test_rules.py::RulesTests.test_line_without_equals_is_ignored`. +- **A CSV where the only rows are the header** — closing balance equals the opening amount and the report has no category lines: a blank line then `closing balance: `, with no leading blank confusion. → Task 5, `test_report.py::ReportTests.test_empty_totals_layout`, and Task 6, `test_cli.py::CliTests.test_header_only_file_reports_opening_balance`. +- **A UTF-8 BOM before `date`, and non-ASCII descriptions** — bank exports routinely carry both; the header must still validate and decoding must not depend on the platform's default locale. → Task 6, `test_cli.py::CliTests.test_bom_and_non_ascii_descriptions` (read with `encoding="utf-8-sig"`). +- **`NaN` or `Infinity` as an amount** — `Decimal` accepts both, so they pass parsing and then poison the sum and blow up `quantize`; expect them rejected as malformed like any other non-number. → Task 2, `test_parse.py::ParseTests.test_nan_and_infinity_amounts_are_malformed`. +- **A rule whose substring is uppercase** (`COFFEE=food` against `coffee shop`) — matching is case-insensitive in both directions; the tests only fold the description. → Task 3, `test_rules.py::RulesTests.test_uppercase_rule_substring_matches`. +- **A description quoted because it contains a comma or a doubled quote** — free text, so the field is one description and the row is not miscounted. → Task 2, `test_parse.py::ParseTests.test_quoted_description_with_comma`. +- **A category total that is negative zero** (`-0.00` in the file, or amounts cancelling) — prints `0.00`; the spec reserves the leading `-` for negatives, and `quantize`/`:.2f` will emit `-0.00` unless suppressed. → Task 5, `test_report.py::ReportTests.test_negative_zero_formats_as_zero`. +- **A directory or a permission-denied file as TRANSACTIONS** — same `cannot read` path as a missing file, exit 1, since `IsADirectoryError`/`PermissionError` are easy to miss if only `FileNotFoundError` is caught. → Task 6, `test_cli.py::CliTests.test_directory_path_reports_cannot_read`. +- **`--opening` given a non-decimal or over-precise value** (`abc`, `1.005`) — a usage error with a message and a non-zero exit, not a traceback and not a silent `0`. → Task 6, `test_cli.py::CliTests.test_invalid_opening_is_a_usage_error`. +- **No arguments, or an unknown subcommand** — `main(argv)` is specified to return an `int`; argparse's own exit is a `SystemExit(2)` that both escapes the contract and collides with the spec's "malformed row" meaning of 2. → Task 6, `test_cli.py::CliTests.test_no_args_returns_int_not_systemexit`. +- **Fields padded with spaces** (` 2026-03-04`, ` -7.50`, `coffee = food`) — decide once and pin it: either trim before parsing, or reject the date/amount as malformed and keep the rule substring/category verbatim; today `Decimal` tolerates the padding while `date.fromisoformat` does not, so the same file half-parses. → Task 2, `test_parse.py::ParseTests.test_padded_fields`, and Task 3, `test_rules.py::RulesTests.test_spaces_around_equals`. +- **A blank line in the middle of the CSV** — either skipped or reported as a malformed row at its own line number, with the numbering of every later row unchanged. → Task 2, `test_parse.py::ParseTests.test_blank_line_mid_file_line_numbering`. diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T0-current-ledgerlite-3.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T0-current-ledgerlite-3.md new file mode 100644 index 000000000..816f5b9b2 --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T0-current-ledgerlite-3.md @@ -0,0 +1,22 @@ +```markdown +## Review Focus + +Input classes the spec implies that no task's tests currently reach. Each line names the condition, the behavior a reasonable person would expect, and the test that should pin it — in the owning task's own step style, since the owning task owns the code. + +- **A malformed row anywhere in the file** — the spec's exit code 2 path (`ledgerlite: :: ` on stderr, rc 2, *nothing* on stdout) is documented in full and exercised by nothing; Task 2 only checks that `ParseError.line` is right, and Task 6 only tests rc 0 and rc 1, so a CLI that lets `ParseError` escape as a traceback passes the whole suite. → Task 6, `test_cli.py::CliTests.test_malformed_row_returns_2`: write a CSV whose third line is `2026-03-04,x,y`, assert `rc == 2`, `out.getvalue() == ""`, and that stderr starts with `f"ledgerlite: {csv_path}:3: "`. +- **An amount with more than two fractional digits (`1.005`)** — the spec names this as malformed and gives `1.5`/`1.50` as the passing contrast, but Task 2's Interfaces list omits the rule entirely, so it will be implemented only if the reviewer notices the spec line. → Task 2, `test_parse.py::ParseTests.test_too_many_fractional_digits_is_malformed`: assert `ParseError` with `line == 2` for `1.005`, and that `1.5` and `1.50` both parse to `Decimal("1.5")` and `Decimal("1.50")`. +- **A non-numeric amount (`abc`, empty string)** — `Decimal("abc")` raises `decimal.InvalidOperation`, an `ArithmeticError` and *not* a `ValueError`, so the obvious `except ValueError` around date-and-amount parsing catches the date case and lets the amount case escape uncaught. → Task 2, `test_parse.py::ParseTests.test_non_numeric_amount_raises_parse_error`: `assertRaises(ParseError)` for both `,abc,` and `,,` rows, asserting `line`. +- **A rule whose substring is uppercase in the rules file (`COFFEE=food`)** — matching is case-insensitive on the description, which means both sides must be folded; Task 3's test uses a lowercase substring against a mixed-case description, so lowercasing only the description passes. → Task 3, `test_rules.py::RulesTests.test_rule_substring_case_is_ignored`: `categorize([Transaction(..., "coffee shop")], [("COFFEE", "food")])[0].category == "food"`. +- **An amount Decimal accepts but a bank statement never means: `nan`, `Infinity`, `1e2`** — all three construct successfully, so `nan` silently poisons every total and closing balance (or blows up as `TypeError` if the two-decimal check compares `as_tuple().exponent`, which is `'n'`); a reasonable person expects them rejected as malformed. → Task 2, `test_parse.py::ParseTests.test_nan_and_exponent_amounts_are_malformed`: `assertRaises(ParseError)` for `nan`, `Infinity`, and `1e2`. +- **`report` run with no `--rules` at all** — the spec makes rules optional and says every transaction is then uncategorized, but every Task 6 test passes `--rules`, so a `None` rules path is never walked. → Task 6, `test_cli.py::CliTests.test_no_rules_puts_everything_in_uncategorized`: `main(["report", csv_path])` returns 0 and stdout is exactly `"uncategorized: 1592.50\n\nclosing balance: 1592.50\n"`. +- **`--opening` given a non-numeric value, and `--opening` with cents (`--opening 1000.10`)** — Task 6's test passes `100`, which parses identically as `int`, `float`, or `Decimal`; a `float`-typed argument silently violates the never-float constraint, and a bad value should be a clean error rather than an `InvalidOperation` traceback. → Task 6, `test_cli.py::CliTests.test_opening_is_decimal_and_validated`: assert the closing balance line for `--opening 1000.10` is exact to the cent, and that `--opening abc` exits non-zero with a message on stderr and nothing on stdout. +- **Wrong column count, including a description containing an unquoted comma** — Task 2's Interfaces list this as malformed but no test covers it, and it is the failure a hand-edited CSV produces most often; a *quoted* `"COFFEE, LTD"` is a legal three-column row and must still parse. → Task 2, `test_parse.py::ParseTests.test_column_count`: a four-field row raises `ParseError` with the right `line`, while `2026-03-04,-7.50,"COFFEE, LTD"` parses with `description == "COFFEE, LTD"`. +- **An empty file, or a file whose header is missing or misspelled** — the spec guarantees the header row, and Task 2's Step 3 says to validate it, but only the header-only case is tested; an empty file must not become an `IndexError` and a data-row-as-first-line must not be parsed as a header and dropped. → Task 2, `test_parse.py::ParseTests.test_missing_or_bad_header`: `assertRaises(ParseError)` with `line == 1` for `""` and for `"date,amount,desc\n"`. +- **A rules line with no `=`, and a category or substring containing `=`** — the spec's grammar splits at the first `=`; `line.split("=")` unpacking raises `ValueError` on both a bare word and a two-`=` line, and a stray blank-ish line like ` ` should not become a rule matching every description. → Task 3, `test_rules.py::RulesTests.test_malformed_and_multi_equals_rule_lines`: `parse_rules("coffee\n")` and `parse_rules(" \n")` are handled without raising and yield no bogus empty-substring rule; `parse_rules("co=ffee=food\n") == [("co", "ffee=food")]`. +- **`--rules` pointing at a file that cannot be read** — the spec spells out the `cannot read` message only for TRANSACTIONS and is silent here, which is not permission for a `FileNotFoundError` traceback; the same message and rc 1 is what a reasonable person expects. → Task 6, `test_cli.py::CliTests.test_unreadable_rules_returns_1`: `main(["report", csv_path, "--rules", "/no/such/rules.txt"])` returns 1, stdout empty, stderr starts with `"ledgerlite: cannot read /no/such/rules.txt"`. +- **TRANSACTIONS naming a directory, or holding undecodable bytes** — Task 6 tests only the missing-file case, so `except FileNotFoundError` passes while `IsADirectoryError`, `PermissionError`, and `UnicodeDecodeError` all reach the user as tracebacks instead of the spec's rc 1 message. → Task 6, `test_cli.py::CliTests.test_unreadable_kinds_return_1`: parametrize over a `tempfile.TemporaryDirectory()` path and a file containing `b"\xff\xfe\x00"`, asserting rc 1 and a `"ledgerlite: cannot read "` prefix for each. +- **A file with a header and no transactions** — the spec fixes the closing balance (the opening amount) but not the shape of a report with zero category lines; Task 4 covers the arithmetic and Task 5 never formats an empty `totals`, so the blank-line-and-closing-balance layout is unspecified in code. → Task 5, `test_report.py::ReportTests.test_empty_totals_is_blank_line_then_closing`: `format_report({}, Decimal("100")) == "\nclosing balance: 100.00"`, pinned end-to-end by Task 6 asserting the same stdout for a header-only CSV with `--opening 100`. +- **A literal `-0.00` amount, and a category whose amounts net to zero** — `-0.00` is a legal two-decimal amount that `f"{...:.2f}"` and `quantize` both render as `-0.00`, printing a leading `-` for a value that is not negative; a category summing to zero must still get its line. → Task 5, `test_report.py::ReportTests.test_negative_zero_prints_unsigned`: `format_amount(Decimal("-0.00")) == "0.00"`, and `totals_by_category` over `+7.50`/`-7.50` in one category yields a `"food: 0.00"` line. +- **A rule that assigns the literal category `uncategorized`** — the spec reserves that name for the no-rule bucket and requires it last, so an explicit rule using it must merge into the same bucket and keep the last position rather than producing two lines or sorting alphabetically. → Task 5, `test_report.py::ReportTests.test_explicit_uncategorized_merges_and_sorts_last`: totals built from one `category="uncategorized"` transaction and one `category=None` transaction produce a single summed `uncategorized` line printed after `zoo`. +- **A UTF-8 BOM or CRLF line endings on a spreadsheet-exported CSV** — `splitlines()` absorbs CRLF, but a leading `\ufeff` makes the first header cell `\ufeffdate` and rejects the whole file with a header error that reads as nonsense to the user; accepting it is what a reasonable person expects. → Task 2, `test_parse.py::ParseTests.test_bom_and_crlf_are_accepted`: `parse_csv("\ufeffdate,amount,description\r\n2026-03-04,-7.50,COFFEE\r\n")` returns one transaction with `description == "COFFEE"`. +``` diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T0-current-ledgerlite-4.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T0-current-ledgerlite-4.md new file mode 100644 index 000000000..a0c9ba9c4 --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T0-current-ledgerlite-4.md @@ -0,0 +1,17 @@ +## Review Focus + +Input classes the spec implies that no task's tests currently exercise. Each line names the input, the behavior a reasonable person would expect, and the task that owns the code plus the test that would pin it. + +- **A malformed row reaching the CLI** — exit code 2, `ledgerlite: :: ` on stderr, and nothing at all on stdout; no task tests exit 2, so the entire rc-2 path is currently unwritten. → Task 6, `test_malformed_row_returns_2_and_prints_nothing` +- **An amount with more than two fractional digits (`1.005`)** — malformed, while `1.5` and `1.50` are accepted; the spec names this case explicitly but Task 2's interface lists only column count, date, and non-decimal amounts, so the check is likely to be missing entirely. → Task 2, `test_over_two_fractional_digits_is_malformed` +- **A rule whose substring is written in uppercase (`COFFEE=food`)** — matches a lowercase description; matching is case-insensitive on *both* sides, and lowering only the description passes Task 3's existing test while failing this one. → Task 3, `test_rule_substring_case_is_ignored_too` +- **`--rules` pointing at a file that cannot be read** — the same `ledgerlite: cannot read : ` and exit 1 as an unreadable TRANSACTIONS, not a traceback; the spec's error text names only TRANSACTIONS and its silence here is not permission to crash. → Task 6, `test_missing_rules_file_returns_1` +- **A file with a wrong header, or an empty file with no header at all** — malformed at line 1 with exit 2; Task 2's Step 3 says "validate the header" but no test makes it do so, and an empty file must not be read as an empty report. → Task 2, `test_wrong_or_missing_header_raises_at_line_1` +- **A transactions file with only a header** — exit 0, no category lines, and `closing balance: `; the report shape when totals are empty is undefined by both the spec's example and Task 5's tests. → Task 5, `test_format_report_with_no_categories`; Task 6, `test_header_only_file_reports_opening_balance` +- **A row with the wrong column count, and a row whose amount is not a decimal number at all (`abc`, empty)** — malformed with the offending 1-based line number; Task 2 only tests the bad-date branch of three. → Task 2, `test_wrong_column_count_and_bad_amount_raise_with_line` +- **`--opening` given a fractional or negative value (`0.10`, `-250.00`)** — parsed as `Decimal`, never `float`, and reflected exactly in the closing balance; nothing currently tests any opening value but the integer `100`. → Task 6, `test_opening_is_decimal_and_accepts_negative_fractional` +- **A quoted description containing a comma (`"COFFEE, SHOP"`)** — one three-column row, not a wrong-column-count rejection; the row is well-formed CSV and rejecting it would refuse ordinary bank exports. → Task 2, `test_quoted_description_with_comma` +- **A trailing blank line, or a blank line between rows** — a trailing blank line is tolerated; a blank line mid-file is a decision the implementation will make silently either way, so pin the one the reviewer should see. → Task 2, `test_blank_lines_are_not_rows` +- **A rules line with no `=`, or with several (`a=b=c`), or an empty substring (`=food`)** — a line with no `=` is skipped rather than crashing; `a=b=c` splits on the first `=`; Task 3 tests only blank-line skipping. → Task 3, `test_parse_rules_handles_missing_and_extra_equals` +- **An amount that `Decimal` accepts but a bank statement never contains (`NaN`, `Infinity`, `1e5`)** — malformed, since the spec defines the amount as a decimal number with up to two fractional digits; `Decimal("NaN")` otherwise parses cleanly and silently poisons every total it touches. → Task 2, `test_nan_infinity_and_exponent_amounts_are_malformed` +- **A rule whose category is literally `uncategorized`** — it merges with the no-category bucket and still sorts last; harmless but worth pinning so the sort key is not accidentally applied to the wrong name. → Task 5, `test_category_named_uncategorized_sorts_last` diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T0-current-ledgerlite-5.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T0-current-ledgerlite-5.md new file mode 100644 index 000000000..82409fc31 --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T0-current-ledgerlite-5.md @@ -0,0 +1,19 @@ +## Review Focus + +Input classes and failure modes the spec implies that no task's tests exercise. Each line names the task that owns the code and the test that should pin it. + +- **A malformed row must reject the whole file: exit 2, `ledgerlite: :: ` on stderr, nothing on stdout.** No test in any task covers exit code 2 — the spec's second-largest behavior is entirely unexercised, and note the message needs the *path*, which `ParseError` (line, reason) does not carry, so the CLI must supply it. → **Task 6**, `test_malformed_row_returns_2_and_prints_path_line_reason`: write a CSV whose third line is `2026-03-04,nope,x`, assert `rc == 2`, `out.getvalue() == ""`, and `err.getvalue().startswith(f"ledgerlite: {csv_path}:3: ")`. +- **An amount with more than two fractional digits (`1.005`) is malformed, while `1.5` and `1.50` are fine.** The spec calls this out explicitly; Task 2's Interfaces omit it from the ParseError list, so it will simply not be implemented. → **Task 2**, `test_over_two_fractional_digits_is_malformed`: assert `ParseError` for `1.005` (line 2) and that `1.5`, `1.50`, `1200` all parse, with `parse_csv` returning `Decimal("1.5")` unrounded. +- **Task 2 hands out `parse_csv(text)` but Task 6 consumes `parse_csv(path)`.** Whoever implements Task 6 against the stated interface passes a path into a text parser and gets one `Transaction` from a one-line "CSV", or an `OSError` raised inside the parser instead of at the read. Resolve to text-in, with the CLI owning `open()` so it owns both error messages. → **Task 6**, `test_cli_reads_file_then_parses_text`: already forced by the two end-to-end tests once the signature is fixed; assert `main` handles the read error itself (see the `--rules` line below). +- **Wrong column count — a short row, an extra unquoted comma, or a blank line in the middle of the file.** Named in Task 2's Interfaces but never tested; a blank mid-file line reaches `csv.reader` as `[]` and an unguarded implementation raises `IndexError` instead of `ParseError`. → **Task 2**, `test_wrong_column_count_and_blank_line_raise_parse_error`: three subtests — `2026-03-04,1.00\n` (2 cols), `2026-03-04,1.00,a,b\n` (4 cols), and `\n` — each raising `ParseError` with the right `line`. +- **A missing, misspelled, or reordered header row, and a zero-byte file.** Task 2 Step 3 says "validate the header" with nothing asserting it; without validation, `amount,date,description` silently parses as data (or crashes), and an empty file yields an empty report instead of an error. → **Task 2**, `test_header_must_be_exact`: `ParseError` with `line == 1` for `""`, for `amount,date,description\n`, and for a file whose first row is data. +- **`--opening` omitted, negative, or non-numeric.** The default of `0` is never tested, nothing pins the value to `Decimal` rather than `float`, and `--opening abc` should be a clean error rather than a `decimal.InvalidOperation` traceback. → **Task 6**, `test_opening_default_and_bad_value`: run without `--opening` and assert `closing balance: 1592.50`; run with `--opening -5.25` and assert the arithmetic; run with `--opening abc` and assert a nonzero return with a `ledgerlite: ` message on stderr and no traceback. +- **`--rules` given but unreadable.** The spec's `cannot read` clause is written for TRANSACTIONS; silence about the rules file is not permission to raise `FileNotFoundError` out of `main`. → **Task 6**, `test_unreadable_rules_returns_1`: `main(["report", csv_path, "--rules", "/no/such/rules.txt"])` returns 1, stdout empty, stderr starts with `ledgerlite: cannot read /no/such/rules.txt`. +- **A rules line with no `=`, or with `=` inside the category (`a=b=c`), or with surrounding whitespace.** `parse_rules` is tested only for blank lines; a line without `=` raises `ValueError` on unpacking, and `a=b=c` should split once (`("a", "b=c")`), not throw. → **Task 3**, `test_parse_rules_malformed_and_multiple_equals`: assert `parse_rules("coffee=food\nnoequals\n")` either skips or raises deliberately — pick one and assert it — and that `parse_rules("a=b=c\n") == [("a", "b=c")]`. +- **No transactions at all (header-only CSV): what does the report look like?** The spec fixes the closing balance (the opening amount) but leaves the category block empty; `format_report({}, ...)` must not emit a stray leading blank line or crash. → **Task 5**, `test_format_report_with_no_categories`: assert `format_report({}, Decimal("100")) == "closing balance: 100.00"`, and → **Task 6**, `test_header_only_csv_prints_opening_balance`: `rc == 0`, stdout `"closing balance: 100.00\n"`. +- **Strings `Decimal()` accepts but a bank statement never means: `NaN`, `Infinity`, `1E-3`, `+7.50`, ` 7.50 `.** `Decimal("NaN")` constructs happily and then poisons every sum and comparison, so "is not a decimal number" cannot be implemented as bare `try: Decimal(s)`. → **Task 2**, `test_nan_and_exponent_amounts_are_malformed`: `ParseError` for `nan`, `Infinity`, and `1E-3`; decide and assert whether `+7.50` and whitespace-padded amounts are accepted. +- **`main([])`, `main(["--help"])`, and an unknown subcommand.** `argparse` raises `SystemExit`, so `main` returns nothing and a caller expecting an `int` gets an exception through the call. → **Task 6**, `test_no_subcommand_returns_exit_code`: assert `main([])` returns a nonzero `int` (or that `SystemExit` is caught and converted), with usage on stderr and stdout empty. +- **A rule whose substring is uppercase (`COFFEE=food`) against a lowercase description.** Task 3 tests case-insensitivity in only one direction (lowercase rule, mixed-case description), which an implementation that lowercases just the description passes. → **Task 3**, `test_match_is_case_insensitive_both_sides`: `categorize([Transaction(..., "corner coffee")], [("COFFEE", "food")])[0].category == "food"`. +- **A rule that maps to the literal category `uncategorized`.** Rule-matched rows and no-rule rows must merge into a single total on a single line, still printed last. → **Task 5**, `test_explicit_uncategorized_merges_and_stays_last`: totals for a mix of `category="uncategorized"` and `category=None` rows collapse to one key, and `format_report` puts it after an alphabetically-later name like `zoo`. +- **A CSV with CRLF line endings or a UTF-8 BOM, as exported by real banks.** A BOM makes the header `\ufeffdate,...` and fails header validation, turning a valid file into exit 2. → **Task 2**, `test_crlf_and_bom_are_accepted`: same CSV with `\r\n` terminators and with a leading `\ufeff` both parse to the same transactions. +- **A total or opening amount that is negative zero (`Decimal("-0.00")`, or `-0.001` rounded).** `quantize` preserves the sign, printing `-0.00` where the spec's examples say `0.00`. → **Task 5**, `test_format_amount_negative_zero_and_rounding`: `format_amount(Decimal("-0.00")) == "0.00"`, and assert the chosen rounding for a value with three fractional digits reaching the formatter. diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T0-current-ledgerlite-6.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T0-current-ledgerlite-6.md new file mode 100644 index 000000000..4c31f1bf1 --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T0-current-ledgerlite-6.md @@ -0,0 +1,22 @@ +```markdown +## Review Focus + +Input classes and failure modes `design.md` implies that no task's tests currently exercise. Each line names the condition, the behavior a reasonable person expects, and the task that owns the code plus the test to add there. + +- **A malformed row reaching the CLI** — the whole documented exit-2 path (stderr `ledgerlite: :: `, empty stdout, return 2) has no test in any task; Task 2 tests `ParseError` but nothing tests that the CLI catches it → Task 6, `test_malformed_row_returns_2_and_prints_nothing`. +- **An amount with more than two fractional digits (`1.005`)** — malformed per the spec, while `1.5` and `1.50` are fine; Task 2's interface lists only "not a decimal number", so this rule is unimplemented as written → Task 2, `test_amount_with_three_fractional_digits_raises`. +- **A trailing blank line in the CSV** — real exports end with one; `csv.reader` over `splitlines()` yields `[]` for it, which a naive column-count check reports as a malformed row instead of ignoring → Task 2, `test_trailing_blank_line_is_ignored`. +- **A row with the wrong column count (`2026-03-04,1.00`)** — named in Task 2's interface as a malformed-row cause but never tested, in either direction (too few or too many columns) → Task 2, `test_wrong_column_count_raises_with_line`. +- **A wrong, missing, or reordered header row (`amount,date,description`, or a data row first)** — Task 2 Step 3 says "validate the header" but no interface contract or test says what happens; expect a line-1 `ParseError`, never a silently-dropped or silently-parsed first row → Task 2, `test_bad_header_raises_on_line_1`. +- **A header-only CSV run end to end** — the spec's "opening amount if there are none" case produces a report with no category lines; `format_report({}, ...)` and the CLI path for it are both untested, and a leading blank line or a stray `uncategorized: 0.00` would be wrong → Task 5, `test_format_report_with_no_categories`; Task 6, `test_header_only_csv_prints_only_closing_balance`. +- **`--opening` that is not a decimal number, or has more than two fractional digits (`--opening abc`, `--opening 1.005`)** — expect a diagnostic and a non-zero return from `main`, not a `decimal.InvalidOperation` traceback; also pins that `--opening` is parsed as `Decimal`, never `float` → Task 6, `test_bad_opening_is_rejected_without_traceback`. +- **`--rules` naming an unreadable file** — the spec's cannot-read message is written for TRANSACTIONS only; expect the same `ledgerlite: cannot read : ` and return 1, not a traceback and not silently treating every row as uncategorized → Task 6, `test_unreadable_rules_file_returns_1`. +- **`nan`, `Infinity`, or `1_0` in the amount column** — `Decimal()` accepts all three, so a bare `Decimal(field)` admits values that break sorting, summing, and formatting; expect them rejected as malformed rows → Task 2, `test_nan_and_infinity_amounts_are_malformed`. +- **A rules line with no `=`, or with two (`a=b=c`)** — the spec gives one rule form and is silent on the rest; expect a no-`=` line skipped rather than an unpacking `ValueError`, and `a=b=c` split on the first `=` into `("a", "b=c")` → Task 3, `test_parse_rules_handles_missing_and_extra_equals`. +- **A rule whose substring is uppercase (`COFFEE=food`) against a lowercase description** — Task 3 only tests lowercase substrings against a mixed-case description, so a one-sided `.lower()` passes today; case-insensitivity must hold on both sides → Task 3, `test_match_is_case_insensitive_on_both_sides`. +- **A rule whose category is literally `uncategorized`** — it collides with the reserved name for unmatched rows; expect the two to sum into one line that is still listed last → Task 5, `test_rule_named_uncategorized_merges_and_stays_last`. +- **A description containing a comma or an embedded newline inside quotes** — the spec calls description free text; quoting works under `csv.reader`, but an embedded newline splits under `splitlines()` and surfaces as a bogus wrong-column-count error → Task 2, `test_quoted_description_with_comma_and_newline`. +- **A UTF-8 BOM on the first line** — common in bank exports; the BOM attaches to the `date` header and fails header validation with a confusing reason unless the file is read as `utf-8-sig` → Task 6, `test_bom_prefixed_csv_is_read`. +- **`main([])` or an unknown subcommand** — `main` is typed to return an `int`, but bare argparse raises `SystemExit`; expect a usage message on stderr and an integer return → Task 6, `test_no_args_returns_int_not_systemexit`. +- **A `-0.00` amount, or a category summing to negative zero** — the spec prints zero as `0.00`, so `quantize` alone would emit `-0.00` → Task 5, `test_negative_zero_formats_as_zero`. +``` diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T0-current-wordstat-1.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T0-current-wordstat-1.md new file mode 100644 index 000000000..291c88b4f --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T0-current-wordstat-1.md @@ -0,0 +1,17 @@ +## Review Focus + +Input classes and failure modes `design.md` implies that no task's tests currently exercise. Each line names the condition, the behavior a reasonable user would expect, and the task + test that should pin it. + +- **Runs of whitespace, tabs, and leading/trailing spaces** — `count_words(" the\tquick \n brown ")` is 4, not 8 or 9; "whitespace-separated tokens" means `str.split()` semantics, and a `split(" ")` implementation passes every current test while failing this. → Task 1, step 1: add `test_count_words_collapses_whitespace_runs` asserting `count_words(" the\tquick \n brown ") == 4`. +- **Whitespace-only text** — `count_words(" \n\t")` is 0, not 1; the empty-string case tested today doesn't catch a bare-`split(" ")` bug here either. → Task 1, step 1: add `test_count_words_whitespace_only_is_zero`. +- **Blank interior and repeated trailing newlines** — `count_lines("a\n\nb")` is 3 and `count_lines("a\nb\n\n")` is 3 (only *one* trailing newline is absorbed); a `text.strip().count("\n") + 1` implementation passes the current three cases and fails both. → Task 1, step 1: add `test_count_lines_blank_lines_and_double_trailing_newline`. +- **Missing file writes to stderr and leaves stdout empty** — the spec says "message to stderr, return 1", but the current test asserts only the return code, so a silent failure or a traceback-to-stdout implementation passes. → Task 3, step 1: extend `test_main_missing_file_returns_1` to capture both streams and assert stderr is non-empty and stdout is empty. +- **`main([])` with no path argument** — argparse raises `SystemExit(2)` rather than returning an int, so any caller treating `main` as a function that returns an exit code crashes; expected is a usage message on stderr and a non-zero exit, and the plan should decide which. → Task 3, step 1: add `test_main_no_args_exits_nonzero` asserting `SystemExit` with a non-zero code (or a returned non-zero, per the choice made). +- **Path names a directory, or an existing file that can't be read** — `IsADirectoryError` / `PermissionError` should land in the same bucket as a missing file (message to stderr, return 1), not surface as an uncaught traceback; catching only `FileNotFoundError` is the natural first implementation. → Task 3, step 1: add `test_main_directory_path_returns_1` using `tempfile.TemporaryDirectory()`. +- **File whose bytes aren't valid UTF-8** — reading in text mode raises `UnicodeDecodeError`; expected is the same stderr-message-and-1 path (or an explicit documented decoding choice such as `errors="replace"`), never a traceback. → Task 3, step 1: add `test_main_undecodable_file_returns_1` writing `b"\xff\xfe\x00bad"` to a temp file. +- **`format_report` given the keys in a different insertion order** — output must always be words, then lines, then chars; iterating `stats.items()` produces the right string for the test's literal dict and the wrong one for `{"chars": 57, "lines": 3, "words": 12}`. → Task 2, step 1: add `test_format_report_fixed_key_order` passing a dict built chars-first and asserting the same `"words: 12\nlines: 3\nchars: 57"`. +- **`count_chars` on text containing newlines** — "including whitespace" includes `\n`, so `count_chars("a\nb") == 3` and `count_chars("a\nb\n") == 4`; today only the space case is tested, so an implementation that strips newlines passes. → Task 1, step 1: add `test_count_chars_counts_newlines`. +- **Non-ASCII text** — `count_chars("héllo")` is 5 characters, not 6 bytes; this only breaks if the CLI reads bytes instead of text, which the missing-file tests wouldn't reveal. → Task 1, step 1: add `test_count_chars_multibyte_is_characters`; Task 3, step 1: extend the happy-path test to use content with an accented character. +- **Empty file, end to end** — `main([empty_path])` prints `"words: 0\nlines: 0\nchars: 0"` and returns 0; the zero cases are covered per-function but never composed, and an implementation that short-circuits on falsy text would print nothing. → Task 3, step 1: add `test_main_empty_file_reports_zeros`. +- **Exactly one trailing newline on stdout** — `format_report` returns no trailing newline and `cli` prints it once, so stdout is the report plus a single `"\n"`, not a blank line after it. → Task 2, step 1: add `test_format_report_has_no_trailing_newline`; Task 3, step 1: assert the captured stdout equals `report + "\n"` exactly rather than using a substring or `.strip()` comparison. +- **CRLF line endings** — `"a\r\nb"` is 2 lines, and `count_chars` counts the `\r`; if the CLI opens with `newline=""` these agree with the counter's own view, and if it doesn't, universal newlines silently changes `chars`. Pick one and say so. → Task 1, step 1: add `test_count_lines_crlf`; Task 3, step 1: add `test_main_crlf_file_matches_counter` writing CRLF bytes and asserting the report matches the counter functions applied to the intended text. diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T0-current-wordstat-2.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T0-current-wordstat-2.md new file mode 100644 index 000000000..5c3b0399a --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T0-current-wordstat-2.md @@ -0,0 +1,19 @@ +```markdown +## Review Focus + +Input classes the spec implies but no task's tests pin down, most likely to bite first. Each line names the test that should own it; a line left unaddressed is one the final reviewer checks by hand. + +- **Runs of whitespace and whitespace-only text** — `count_words("a b")` is 2 and `count_words(" ")` is 0; the spec says "whitespace-separated tokens", so a naive `text.split(" ")` returning empty tokens (3 and 1) is wrong. *Task 1 — add `test_count_words_collapses_runs_and_blank_input` to `test_counter.py`.* +- **Non-space whitespace as a separator** — `count_words("a\tb\nc")` is 3; tabs and newlines separate tokens just as spaces do. *Task 1 — add `test_count_words_splits_on_tabs_and_newlines` to `test_counter.py`.* +- **Newline-only and blank interior lines** — `count_lines("\n")` is 1, `count_lines("a\n\nb")` is 3, `count_lines("\n\n")` is 2; the trailing-newline rule suppresses only the final empty line, never a genuine blank one. *Task 1 — add `test_count_lines_blank_lines_count` to `test_counter.py`.* +- **Empty file end to end** — `main([empty_path])` prints `words: 0\nlines: 0\nchars: 0` and returns 0; the zero-length case must reach the report rather than crash or short-circuit. *Task 3 — add `test_main_empty_file_reports_zeros` to `test_cli.py`.* +- **Report field order independent of dict order** — `format_report` given `{"chars": 57, "lines": 3, "words": 12}` still yields `"words: 12\nlines: 3\nchars: 57"`; the spec fixes the report's order, not the caller's dict. *Task 2 — add `test_format_report_ignores_key_order` to `test_formatter.py`.* +- **No trailing newline in the formatted report** — `format_report(...)` ends at `"chars: 57"` with no `"\n"`, so `main`'s `print` leaves stdout with exactly one final newline and no blank line. *Task 2 — add `test_format_report_has_no_trailing_newline` to `test_formatter.py`; assert the stdout form in Task 3's `test_main_prints_single_trailing_newline` in `test_cli.py`.* +- **Newlines counted as characters** — `count_chars("a\nb")` is 3; "including whitespace" covers line breaks, so the count must not be taken per-line or with newlines stripped. *Task 1 — add `test_count_chars_includes_newlines` to `test_counter.py`.* +- **Non-ASCII text** — `count_chars("héllo")` is 5 (characters, not UTF-8 bytes) and `main` reads the file as UTF-8 text so an accented word neither miscounts nor raises. *Task 1 — add `test_count_chars_counts_characters_not_bytes` to `test_counter.py`; Task 3 — add `test_main_reads_utf8_file` to `test_cli.py`.* +- **Path exists but is not a readable file** — `main([dir_path])` (and an unreadable file) returns 1 with a message on stderr, like the missing-file case, instead of propagating `IsADirectoryError`/`PermissionError` as a traceback. *Task 3 — add `test_main_directory_path_returns_1` to `test_cli.py`.* +- **Failure output goes to stderr, stdout stays clean** — for a missing path, the message appears on stderr and stdout is empty; the spec's "message to stderr" is unverified when only the exit code is asserted. *Task 3 — extend `test_cli.py`'s missing-file case into `test_main_missing_file_writes_stderr_only`.* +- **Wrong argument count** — `main([])` and `main([p1, p2])` exit via argparse's usage error (`SystemExit` code 2) rather than raising `IndexError` or silently using the first path; the spec's contract is exactly one positional. *Task 3 — add `test_main_requires_exactly_one_path` to `test_cli.py`.* +- **CRLF input** — a file written with `\r\n` endings reports the same line count as its `\n` twin, since `main` opens in text mode with universal newlines. *Task 3 — add `test_main_crlf_file_line_count` to `test_cli.py`.* +- **Stats dict missing a key** — `format_report({"words": 1})` raises `KeyError` rather than printing a partial or `None`-filled report; the spec defines the function only for all three keys and callers should fail loudly. *Task 2 — add `test_format_report_missing_key_raises` to `test_formatter.py`.* +``` diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T0-current-wordstat-3.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T0-current-wordstat-3.md new file mode 100644 index 000000000..cb9e26050 --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T0-current-wordstat-3.md @@ -0,0 +1,15 @@ +## Review Focus + +Input classes and failure modes the spec implies that no task's tests currently exercise, most likely to bite first: + +- **Runs of whitespace, tabs, and newlines as word separators** — `count_words(" a\tb\nc ")` is 3, not a count of spaces plus one; the spec says "whitespace-separated tokens", and the plan only ever feeds single-space input. → Task 1, add `test_count_words_collapses_mixed_whitespace`. +- **Blank lines and a lone newline** — `count_lines("a\n\nb")` is 3, `count_lines("\n")` is 1, and `count_lines("a\n\n")` is 2: the trailing-newline rule drops exactly one empty final line, not every empty line. → Task 1, add `test_count_lines_keeps_interior_and_leading_blank_lines`. +- **A trailing newline counted as a character** — `count_chars("abc\n")` is 4, since chars include whitespace; the line rule must not leak into the char rule. → Task 1, add `test_count_chars_includes_newlines`. +- **The stderr message on a missing file** — spec requires a message on stderr *and* exit 1; a silent `return 1`, or a traceback printed alongside it, satisfies the plan's test but not the spec. → Task 3, extend `test_main_missing_file_returns_1` to capture stderr and assert it is non-empty and names the path. +- **A path that exists but is not a readable file** — a directory or a permission-denied file raises `IsADirectoryError`/`PermissionError`, not `FileNotFoundError`; a reasonable person expects the same stderr-message-and-exit-1 path, not a traceback. → Task 3, add `test_main_directory_path_reports_error_and_returns_1`. +- **An empty input file** — end to end this prints `words: 0\nlines: 0\nchars: 0` and returns 0; nothing in the spec licenses a special-cased "empty file" message or a nonzero exit. → Task 3, add `test_main_empty_file_reports_zeros_and_returns_0`. +- **Invoked with no path, or with two paths** — argparse's `SystemExit(2)` plus a usage line on stderr is the expected behavior; `main` must not swallow it into 0, and callers must not see a bare `IndexError`. → Task 3, add `test_main_without_path_exits_2_with_usage`. +- **Non-ASCII text** — `count_chars("héllo")` is 5 characters, not bytes, and `cli` must open the file with an explicit UTF-8 encoding rather than inheriting a locale that mis-decodes it on another machine. → Task 1, add `test_count_chars_counts_unicode_characters`; Task 3, add `test_main_reads_utf8_regardless_of_locale`. +- **CRLF line endings** — `"a\r\nb\r\n"` is 2 lines, and `\r` still counts toward chars; a file authored on Windows should not report double the lines. → Task 1, add `test_count_lines_handles_crlf`. +- **The report's exact shape** — `format_report` returns three lines with no trailing newline, and `cli` prints it so stdout ends with exactly one `\n`; the plan pins the string once but never pins that the printed output isn't double-spaced or missing its final newline. → Task 2, add `test_format_report_has_no_trailing_newline`; Task 3, assert captured stdout equals `report + "\n"`. +- **Zero-valued and non-int stats reaching the formatter** — `{"words": 0, ...}` formats as `words: 0` with no special-casing, and a stats dict missing a key is a programming error that should surface as a plain `KeyError`, not a partial report. → Task 2, add `test_format_report_zero_values` and `test_format_report_missing_key_raises`. diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T0-current-wordstat-4.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T0-current-wordstat-4.md new file mode 100644 index 000000000..35a56e471 --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T0-current-wordstat-4.md @@ -0,0 +1,18 @@ +```markdown +## Review Focus + +The spec fixes only a few example inputs; these are the classes it implies but no task's tests touch. Each line names the input, the behavior a reasonable person expects, and the test that pins it. + +- **Runs of whitespace, tabs, and newlines between words** — `count_words` splits on any whitespace run, so `"a b\tc\nd"` is 4 words, not 6 empty-string-inflated tokens (the naive `text.split(" ")` fails here). Test: Task 1, `test_counter.py` — `count_words("a b\tc\nd") == 4`. +- **Text with no trailing newline at all** — `count_lines("a")` is 1; the spec's "trailing newline does not add a line" rule must not be implemented as `text.count("\n")`, which reports 0. Test: Task 1, `test_counter.py` — `count_lines("a") == 1`. +- **Blank lines, including a lone newline** — interior empty lines are real lines: `count_lines("a\n\nb")` is 3 and `count_lines("\n")` is 1. Test: Task 1, `test_counter.py` — `count_lines("a\n\nb") == 3` and `count_lines("\n") == 1`. +- **Whitespace-only text** — `count_words(" \n\t")` is 0 words, matching the empty-string case, while `count_chars` still counts all 5 characters. Test: Task 1, `test_counter.py` — `count_words(" \n\t") == 0` and `count_chars(" \n\t") == 5`. +- **Newlines inside `count_chars`** — "including whitespace" includes line breaks, so `count_chars("a\nb\n")` is 4; the character count must not be computed from stripped or per-line text. Test: Task 1, `test_counter.py` — `count_chars("a\nb\n") == 4`. +- **A stats dict whose keys arrive in a different order** — `format_report` always emits words, then lines, then chars, regardless of insertion order, so it must index the three keys rather than iterate `stats.items()`. Test: Task 2, `test_formatter.py` — `format_report({"chars": 57, "lines": 3, "words": 12}) == "words: 12\nlines: 3\nchars: 57"`. +- **An empty file** — a zero-byte file is valid input, not an error: the report is `"words: 0\nlines: 0\nchars: 0"` and the exit code is 0. Test: Task 3, `test_cli.py` — `main([empty_path])` returns 0 and prints exactly that report. +- **A file that is not valid UTF-8 (binary or another encoding)** — reading it must produce the stderr message and exit 1, not a `UnicodeDecodeError` traceback out of `main`. Test: Task 3, `test_cli.py` — write `b"\xff\xfe\x00"` to a temp file; `main([path]) == 1` and stderr is non-empty. +- **A path that exists but cannot be read as a file (a directory, or a mode-000 file)** — same contract as a missing file: message to stderr, return 1, since the spec's intent is "unreadable input exits 1," not "only `FileNotFoundError` exits 1." Test: Task 3, `test_cli.py` — `main([tmpdir]) == 1` with stderr non-empty. +- **Wrong argument count — no path, or two paths** — argparse prints usage to stderr and the process exits non-zero; the test must state whether `main` returns that code or lets `SystemExit` propagate, so callers know which. Test: Task 3, `test_cli.py` — `assertRaises(SystemExit)` around `main([])` and `main(["a", "b"])`, asserting `code == 2`. +- **Stream discipline on the failure path** — the error message goes to stderr and stdout stays empty, so `wordstat f > out.txt` never writes a half-report. Test: Task 3, `test_cli.py` — `main(["/no/such/file"])` with both streams captured: stdout is `""`, stderr mentions the path. +- **CRLF (Windows) input** — `"a\r\nb\r\n"` is 2 lines and 2 words, with the `\r` counted in chars; line splitting must not leave stray `\r` in a token. Test: Task 1, `test_counter.py` — `count_lines("a\r\nb\r\n") == 2`, `count_words("a\r\nb\r\n") == 2`, `count_chars("a\r\nb\r\n") == 6`. +``` diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T0-current-wordstat-5.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T0-current-wordstat-5.md new file mode 100644 index 000000000..c91d6aa99 --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T0-current-wordstat-5.md @@ -0,0 +1,13 @@ +## Review Focus + +- Runs of whitespace, leading/trailing whitespace, and whitespace-only text: `count_words(" the quick ")` is 2 and `count_words(" ")` is 0 — a `split(" ")` implementation returns empty-string tokens and inflates both. → Task 1, `test_count_words_ignores_repeated_and_edge_whitespace`. +- A `path` that exists but is not a readable file (a directory, or a mode-000 file): the spec's "missing file" branch is really "cannot read it" — stderr message and exit 1, not an `IsADirectoryError`/`PermissionError` traceback. → Task 3, `test_main_returns_1_for_directory_and_unreadable_path`. +- A stats dict whose insertion order is not words/lines/chars: `format_report` must emit the three fixed lines in spec order, so iterating `stats.items()` breaks as soon as `cli` builds the dict differently. → Task 2, `test_format_report_uses_fixed_line_order_regardless_of_dict_order`. +- Newlines and non-ASCII text in `count_chars`: "including whitespace" means `count_chars("a\nb")` is 3, and characters are code points, so `count_chars("héllo")` is 5 even though it is 6 UTF-8 bytes. → Task 1, `test_count_chars_counts_newlines_and_non_ascii_as_characters`. +- An empty input file: every stat is 0 and the run still succeeds — report `"words: 0\nlines: 0\nchars: 0"` and exit 0, not empty output or an error path. → Task 3, `test_main_on_empty_file_reports_zeros_and_returns_0`. +- The missing-file branch's observable output, not just its return code: a non-empty message naming the path on stderr, nothing on stdout, and no traceback — the planned test asserts only the return value. → Task 3, `test_main_missing_file_writes_message_to_stderr_and_nothing_to_stdout`. +- Blank interior lines and a lone newline: `count_lines("a\n\nb")` is 3 and `count_lines("\n")` is 1 — the trailing-newline rule suppresses an empty *final* line, not empty lines generally. → Task 1, `test_count_lines_keeps_blank_interior_lines`. +- CRLF text reaching `counter` directly as a string (not laundered by universal newlines): `count_lines("a\r\nb")` is 2, and the `\r` still counts in `count_chars`. → Task 1, `test_count_lines_handles_crlf_endings`. +- A file that is not valid UTF-8: reading it must fail as a reported error with exit 1, or be read with an explicit fallback — either way a `UnicodeDecodeError` traceback is not an acceptable answer for a file the user handed us. → Task 3, `test_main_returns_1_for_undecodable_file`. +- `main([])` and `main(["a", "b"])`: argparse's usage message on stderr and a non-zero exit, with `SystemExit` either propagating or converted deliberately — the caller should not see `IndexError` or a silent 0. → Task 3, `test_main_rejects_missing_and_extra_arguments`. +- A path that begins with `-` (including bare `-`): argparse treats it as an unknown option rather than a filename, so the tool exits on usage instead of reporting an unreadable file — pick one behavior and pin it. → Task 3, `test_main_handles_dash_prefixed_path`. diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T0-current-wordstat-6.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T0-current-wordstat-6.md new file mode 100644 index 000000000..fa2b4c2c5 --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T0-current-wordstat-6.md @@ -0,0 +1,15 @@ +## Review Focus + +Input classes and failure modes the spec implies but no task's tests exercise, most likely to bite first: + +1. **Words separated by anything other than a single space** — tabs, newlines, runs of spaces, and leading/trailing whitespace are all whitespace, so `count_words(" the\tquick \n brown fox \n")` must be 4; an implementation using `text.split(" ")` passes every test in Task 1 as written and silently over-counts on every real file. → Task 1, add to step 1: `count_words(" the\tquick \n brown fox \n")` == 4. +2. **An empty file, end to end** — `counter` is tested against `""` but `cli` never is; a 0-byte file must print `words: 0\nlines: 0\nchars: 0` and return 0, not crash or print a blank report. → Task 3, add to step 1: `main([empty_path])` prints `"words: 0\nlines: 0\nchars: 0\n"` and returns 0. +3. **`main([])` with no path argument** — the spec makes `main(argv) → int` the contract, but argparse's default on a missing positional is `SystemExit(2)`, which escapes rather than returning; a caller expects an int (or, if exit is intended, that intent pinned by a test). → Task 3, add to step 1: `main([])` raises `SystemExit` with code 2 and writes usage to stderr — assert the chosen behavior explicitly. +4. **Where the missing-file message goes** — the spec says stderr; Task 3 only asserts the return code, so a `print(...)` to stdout passes while corrupting the report stream for anyone piping output. → Task 3, add to step 1: `main(["/no/such/file"])` returns 1, stderr is non-empty and names the path, stdout is empty. +5. **Trailing newline on printed output** — `format_report` returns a 3-line string with no trailing newline; stdout must be exactly that plus one newline, since `print(report)` and `sys.stdout.write(report)` differ and both look correct by eye. → Task 3, add to step 1: captured stdout for the known-content file equals `expected_report + "\n"` exactly. +6. **Line counting outside the trailing-newline rule** — the spec pins only `"a\nb"`/`"a\nb\n"`; a single line with no newline (`count_lines("abc")` == 1), a lone newline (`count_lines("\n")` == 1), and interior blank lines (`count_lines("a\n\nb")` == 3) are all common and all distinguish plausible implementations. → Task 1, add to step 1: those three assertions. +7. **A path that exists but is not a readable file** — a directory or a permission-denied file raises `IsADirectoryError`/`PermissionError`, not `FileNotFoundError`; a reasonable person expects the same stderr-message-and-1 treatment as a missing file, not a traceback. → Task 3, add to step 1: `main([tmpdir])` returns 1 with a message on stderr. +8. **A file that is not valid UTF-8** — reading in text mode raises `UnicodeDecodeError` mid-`main`, so an unreadable-encoding file escapes the exit-code contract entirely; expect a message on stderr and 1. → Task 3, add to step 1: `main([path_with_bytes(b"\xff\xfe\x00")])` returns 1 and writes to stderr. +9. **Non-ASCII text** — `count_chars` counts characters, not bytes, so `count_chars("héllo")` == 5 and `count_words("héllo wörld")` == 2; a byte-oriented implementation diverges only on this input. → Task 1, add to step 1: those two assertions. +10. **CRLF line endings** — `"a\r\nb\r\n"` should report 2 lines, and the char count must match whatever the `cli` read produces, so the newline mode used to open the file is part of the contract rather than an accident. → Task 1, add to step 1: `count_lines("a\r\nb\r\n")` == 2; Task 3, add to step 1: a CRLF-bytes file reports the same line count as its LF twin. +11. **`stats` dicts that are not the canonical shape** — output order must be words/lines/chars regardless of dict insertion order, and extra keys must be ignored; both are silent formatting bugs otherwise. → Task 2, add to step 1: `format_report({"chars": 57, "lines": 3, "words": 12, "bytes": 99})` == `"words: 12\nlines: 3\nchars: 57"`. diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T1-ruling-cost-ledgerlite-1.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T1-ruling-cost-ledgerlite-1.md new file mode 100644 index 000000000..227ae5fcb --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T1-ruling-cost-ledgerlite-1.md @@ -0,0 +1,33 @@ +```markdown +## Review Focus + +Rulings on input classes the spec implies but no task's tests exercise. Each line: condition — expectation — cost — where it is settled. + +**Rulings that earn a test** + +- A malformed row reaching the user through the CLI (spec's exit 2 path) — stderr gets exactly `ledgerlite: :: `, stdout stays empty, return code is 2 — the spec's only documented failure mode for bad data is entirely untested; also resolves the interface conflict between Task 2 (`parse_csv(text: str)`) and Task 6 (`parse_csv(path: str)`): the CLI reads the file, maps `OSError` to exit 1, and passes text plus path so `ParseError.line` can be rendered with the path — Test: Task 6, `test_malformed_row_returns_2_with_path_and_line`. +- An amount with more than two fractional digits (`1.005`) — rejected as malformed, exactly as the spec spells out; `Decimal("1.005")` constructs happily, so nothing rejects it unless the code checks the exponent — a cent silently vanishes from a category total and from the closing balance, with no error — Test: Task 2, `test_three_fractional_digits_is_malformed`. +- An amount that is not a decimal number: `abc`, empty, `1,500.00`, `NaN`, `Infinity`, `1e5` — all rejected as malformed — `decimal.InvalidOperation` is an `ArithmeticError`, not a `ValueError`, so an `except ValueError` around parsing lets it escape as a traceback; worse, `Decimal("NaN")` succeeds and poisons every sum, printing `NaN` where money belongs — Test: Task 2, `test_non_decimal_amounts_are_malformed` (include `NaN` and `1e5`). +- A row with the wrong column count, both short (`2026-03-04,-7.50`) and long (an unquoted comma in the description) — rejected as malformed with the row's line number — an unpacking or `IndexError` traceback instead of the spec's message; the spec names wrong column count first among malformed rows — Test: Task 2, `test_wrong_column_count_is_malformed`. +- A file with no header row: a zero-byte file, and a file whose first line is already data — malformed, exit 2 — Task 2's step 3 says "validate the header" but no test pins it; a header-less export silently loses its first transaction from both the totals and the balance, and a zero-byte file crashes on an empty-list index — Test: Task 2, `test_missing_or_wrong_header_is_malformed`. +- A file exported from Windows or Excel: CRLF line endings and a UTF-8 BOM before `date` — parses normally — with a BOM the first header cell is `\ufeffdate`, so header validation rejects a perfectly good bank export as malformed and the user has no way to see why — Test: Task 2, `test_bom_and_crlf_parse`. +- A quoted description containing a comma (`2026-03-04,-7.50,"COFFEE, SHOP"`) — one description field, not a column-count error — `csv.reader` handles this, but only if rows are fed to it rather than `str.split(",")`; getting it wrong rejects the whole file over an ordinary merchant name — Test: Task 2, `test_quoted_description_with_comma`. +- A rules line that is not `=`: no `=` at all, `a=b=c`, `=food`, `coffee=` — a line with no `=` is not a rule; `a=b=c` splits on the first `=` only — a one-character typo in the rules file produces a traceback instead of a report, and `=food` silently matches every transaction — Test: Task 3, `test_malformed_rules_lines`. +- A rule whose substring is written in uppercase or mixed case (`COFFEE=food`) — matches `coffee shop`; the spec says matching is case-insensitive on the description, which means both sides are folded — a naive `sub in description.lower()` never matches, and the money lands in `uncategorized` with no sign anything went wrong — Test: Task 3, `test_rule_substring_case_is_folded`. +- A rules file that maps a substring to the literal category `uncategorized`, alongside rows that match no rule — one merged `uncategorized` line, still printed last — a dict keyed on the category name either double-counts or loses one of the two groups, so the printed totals no longer sum to the closing balance — Test: Task 5, `test_explicit_uncategorized_merges_and_sorts_last`. +- Zero transactions (header-only CSV) — `format_report({}, opening)` produces exactly `closing balance: 0.00` with no leading blank line and no phantom `uncategorized: 0.00`; the spec promises the opening amount as the closing balance here but says nothing about the category block — a report that opens with a stray blank line, or invents a category that has no transactions — Test: Task 5, `test_empty_totals_report`. +- `--opening -100` — accepted; overdrafts are the ordinary case for this tool — argparse reads `-100` as an option flag and errors with "expected one argument", so a negative opening balance is simply unusable — Test: Task 6, `test_negative_opening`. +- `--opening abc` and `--opening 1.005` — a bad value is a usage error, and the opening amount is held to the same two-fractional-digit rule as row amounts — `type=Decimal` lets `InvalidOperation` escape argparse (it catches only `ValueError`/`TypeError`) as a traceback, and `1.005` silently rounds the closing balance by a cent — Test: Task 6, `test_bad_opening_is_a_usage_error`. +- `--rules` pointing at a file that cannot be read — the same treatment the spec gives TRANSACTIONS: `ledgerlite: cannot read : `, exit 1 — the spec's silence about the rules file is not permission to emit a traceback for a mistyped path — Test: Task 6, `test_unreadable_rules_returns_1`. +- TRANSACTIONS naming a directory, or a file without read permission — exit 1 with the "cannot read" message, same as a missing file — an implementation that pre-checks `os.path.exists` passes Task 6's missing-file test and then crashes here; catching `OSError` around `open` covers all three at once — Test: Task 6, `test_directory_path_returns_1`. +- `python3 -m ledgerlite report txns.csv` — runs; this is the invocation the spec's Behavior section documents — the package layout lists no `__main__.py` and Task 6 only ever calls `main(argv)` in-process, so the shipped tool has no way to be started by the user it was written for — Test: Task 6, `test_module_entry_point` (add `ledgerlite/__main__.py`). + +**Rulings the final reviewer checks deliberately** + +- A transactions file that is not valid UTF-8 (Latin-1 bank exports are common) — some deliberate outcome, not a bare `UnicodeDecodeError`; `UnicodeDecodeError` is a `ValueError`, so it lands in neither the `OSError` exit-1 path nor the `ParseError` exit-2 path — Reviewer checks that one of the two paths claims it and that the reason text names the encoding problem. +- Leading or trailing whitespace inside fields (` 2026-03-04, -7.50 , COFFEE`) — one consistent rule, applied to all three columns; the spec does not say, and either stripping or rejecting is defensible, but "date rejects, description keeps" is not — Reviewer checks the choice is uniform across columns and stated in the module docstring. +- Category names differing in case (`Food`, `apple`) — "alphabetically" resolved one way; plain `sorted` puts every capitalized name before every lowercase one — Reviewer checks the sort key and that `uncategorized` stays last regardless. +- `main([])`, `main(["nonsense"])`, `main(["report"])` — argparse's own errors exit rather than returning from `main`, and argparse's usage code is 2, colliding with the spec's malformed-file code — Reviewer checks the collision is acknowledged and that `main` documents where its `-> int` contract stops. +- Amounts near `Decimal`'s default 28-digit precision, and a `-0.00` amount in the input — `format_amount` must not raise from `quantize` or print `-0.00` where the spec shows `0.00` — Reviewer checks the quantize call and the sign handling. +- Nothing in the printed report depends on transaction order (the closing balance sums order-independently), so Task 6's end-to-end test cannot detect a broken or unwired `ordered()` — Reviewer checks `ordered` is actually called in the CLI pipeline and that Task 4's stability test is the sole guard, and that every amount printed anywhere goes through `format_amount` rather than `str(Decimal)`. +``` diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T1-ruling-cost-ledgerlite-2.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T1-ruling-cost-ledgerlite-2.md new file mode 100644 index 000000000..20ecb6c5a --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T1-ruling-cost-ledgerlite-2.md @@ -0,0 +1,27 @@ +```markdown +## Review Focus + +Input classes and failure modes `design.md` implies but no task's tests exercise. Each line is a ruling: the condition, what a reasonable person using this tool expects, the cost of getting it wrong, and who catches it. Costs a user would report as a bug earn a test in the task that owns the code, written in that task's own step style; the rest are lines the final reviewer checks deliberately. + +- **A malformed row reaching the CLI (any of the four classes in `design.md`)** — `ledgerlite: :: ` on stderr, exit code **2**, and nothing at all on stdout — the spec's third exit code is never exercised: nothing pins `ParseError` → 2 rather than 1, and nothing pins the empty stdout, so a half-written report can be flushed before the error is noticed and a shell script that branches on `$?` silently mis-files a rejected statement — *Test: Task 6, `test_malformed_row_returns_2_and_prints_nothing` (assert `rc == 2`, `out.getvalue() == ""`, and stderr containing `:3:`)* +- **An amount with more than two fractional digits (`1.005`), with `1.5` and `1.50` still accepted** — rejected as malformed per `design.md` line 31, while the two legal spellings pass — Task 2's Interfaces block names only "wrong column count, unparseable date, or an amount that is not a decimal number" and so drops this rule from the plan entirely; without it `1.005` is accepted and `format_amount`'s quantize silently rounds it, so a report's category totals disagree with the source file by cents that never appear anywhere — *Test: Task 2, `test_over_precise_amount_is_malformed` (`1.005` raises `ParseError`; `1.5` and `1.50` parse)* +- **A wrong column count (2 or 4 fields) and a non-numeric amount (`abc`, `1,200.00`, `$7.50`, empty)** — both rejected as malformed with the offending 1-based line number — these are two of the three classes Task 2's own Interfaces block promises, and only the bad-date one has a test; an uncaught `decimal.InvalidOperation` or `IndexError` escapes as a traceback instead of the spec'd message, and a short row silently drops a transaction — *Test: Task 2, `test_wrong_column_count_and_bad_amount_raise` (both raise `ParseError` with the right `.line`)* +- **`nan`, `NaN`, `Infinity`, `-inf` in the amount column** — rejected as malformed, because they are not "a decimal number with up to two fractional digits" — `Decimal("nan")` constructs without raising, so the naive `Decimal(field)` check admits it; one such row poisons every sum it touches and the tool prints `uncategorized: NaN` and `closing balance: NaN` with exit 0, a wrong answer presented as a successful one — *Test: Task 2, `test_nan_and_infinity_amounts_are_malformed` (each raises `ParseError`)* +- **An empty (0-byte) file, or a file whose header is not exactly `date,amount,description` (missing header, reordered, extra column, `date, amount, description` with spaces)** — rejected as malformed at line 1, exit 2 — Task 2 Step 3 says "validate the header" but no test holds it to that; if validation is skipped or lenient, a truncated download or a differently-shaped export produces `closing balance: 0.00`, which reads as a legitimately empty account rather than a file the tool could not understand — *Test: Task 2, `test_empty_file_and_bad_header_raise_at_line_1`* +- **A trailing blank line, or a file ending `\n\n`** — parsed as if it were not there; a blank final line is not a malformed row — `csv.reader` over `splitlines()` yields `[]` for it, which a strict column-count check rejects, so the whole statement is refused over trailing whitespace that every editor and export tool adds — *Test: Task 2, `test_trailing_blank_line_is_ignored`* +- **A leading UTF-8 BOM (`\ufeff`), as spreadsheet-exported CSVs carry** — parsed normally; the BOM is not part of the header — otherwise the header check sees `\ufeffdate` and rejects an entirely ordinary bank export with a message about the header that names a column the user can see is correct, and there is no obvious fix from their side — *Test: Task 2, `test_utf8_bom_before_header_is_accepted`* +- **A quoted description containing a comma (`2026-03-04,-7.50,"COFFEE, SHOP"`)** — one transaction with the comma preserved in the description, since `design.md` calls description free text — merchant names contain commas constantly; if the row is split naively the file is rejected as a 4-column row, and a statement that is valid CSV cannot be reported at all — *Test: Task 2, `test_quoted_description_with_comma`* +- **`--rules` pointing at a path that cannot be read** — an error on stderr and a non-zero exit, never a report — `design.md` spells out the cannot-read path only for TRANSACTIONS, so the natural implementation lets the rules read fail open or throw raw; failing open is the dangerous half — every transaction lands in `uncategorized`, the closing balance is right, and the report looks plausible enough to file with a typo'd `--rules` path — *Test: Task 6, `test_unreadable_rules_file_is_an_error` (non-zero rc, empty stdout, `ledgerlite: cannot read` on stderr)* +- **TRANSACTIONS that opens but does not decode as UTF-8 (a latin-1 export), or is a directory** — the spec'd `ledgerlite: cannot read : ` and exit 1 — `UnicodeDecodeError` is not an `OSError`, so an `except OSError` around the read misses it and the user gets a traceback where the spec promises one line; the directory case only works if the same handler is broad enough — *Test: Task 6, `test_undecodable_file_reports_cannot_read` (write latin-1 bytes; assert rc 1 and the `cannot read` prefix)* +- **`--opening` that is not a decimal number (`abc`, `1,200`, empty)** — a one-line error and a non-zero exit — `Decimal("abc")` raises `InvalidOperation`, and `design.md` never mentions this path, so the default outcome is a bare traceback on a plain typo; note the exit code chosen here collides with the malformed-row 2 unless deliberately picked — *Test: Task 6, `test_bad_opening_is_an_error_not_a_traceback` (non-zero rc, empty stdout, no exception escaping `main`)* +- **`--opening -50` (a negative opening balance)** — accepted, giving a closing balance 50 lower — the spec allows negative amounts and says nothing to exclude a negative opening, and an overdrawn starting balance is an ordinary case; argparse's handling of a value that looks like an option is subtle enough to break silently, and the failure blocks a legitimate invocation outright — *Test: Task 6, `test_negative_opening` (assert the closing-balance line)* +- **A rules line with no `=` (`just some text`), or with more than one (`a=b=c`)** — the no-`=` line does not crash the tool, and `a=b=c` means substring `a` → category `b=c` (split on the first `=` only) — `parse_rules` is spec'd as "one `substring=category` per non-blank line" and is silent on both; `line.split("=")` with tuple unpacking raises `ValueError` on each, so one typo in a hand-edited rules file takes down a run with a traceback rather than an error message — *Test: Task 3, `test_malformed_rule_lines` (no-`=` line handled without raising; `a=b=c` → `("a", "b=c")`)* +- **`format_report` with no categories at all (header-only CSV, or every transaction filtered away)** — `closing balance: ` as the whole report, with no leading blank line dangling above it — `design.md`'s "Then a blank line, then `closing balance`" reads as unconditional, and the natural join emits a report starting with `\n`; the empty-account case is exactly when a user is checking the tool against a known-zero statement, and leading blank output is the kind of thing they report — *Test: Task 5, `test_format_report_with_no_categories` (equals `"closing balance: 0.00"`)* +- **`format_amount(Decimal("-0.00"))`, reachable from a literal `-0.00` row or `--opening -0`** — `0.00`; the leading `-` is for negatives and negative zero is not one — `str(Decimal("-0.00").quantize(...))` keeps the sign, so a report line reads `food: -0.00`, which a user reads as either a bug or a rounding artifact hiding a real amount — *Test: Task 5, `test_format_amount_negative_zero` (`format_amount(Decimal("-0.00")) == "0.00"`)* +- **Category names differing only in case (`Food` from one rule, `food` from another)** — Reviewer checks: they are distinct keys, and plain `sorted()` puts every capitalized category above every lowercase one, which is not what "alphabetically" means to a reader. Cost is a surprising but visibly-explicable ordering, not a wrong number — worth a deliberate look at the sort key rather than a test. +- **A rules file that assigns the literal category `uncategorized`** — Reviewer checks: those transactions merge into the same bucket as the unmatched ones, and the merged line still sorts last. Defensible as-is; confirm it doesn't instead produce two `uncategorized` lines or drop one. +- **A rule with an empty substring (`=food`)** — Reviewer checks: an empty substring is `in` every description, so such a rule captures everything after it in file order. Confirm the behavior is at least consistent (first-match-wins still holds) and does not crash. +- **A quoted CSV field containing a newline** — Reviewer checks: `csv.reader` over `text.splitlines()` (Task 2, Step 3) breaks the record in two and reports a spurious malformed row. Legal CSV, but vanishingly rare in bank exports — worth knowing the limitation exists rather than engineering around it. +- **Tie-stability on equal dates** — Reviewer checks: `design.md` insists on it, but with only per-category totals and a closing balance in the output, no ordering is observable end-to-end — Task 4's unit test is the only thing holding the guarantee. Confirm `ordered()` is still actually called in the CLI pipeline, since nothing downstream would notice its absence. +- **`main([])`, `main(["repot", ...])`, or an unknown flag** — Reviewer checks: argparse raises `SystemExit`, so `main` does not return an `int` as its signature promises, and the code it exits with is 2 — the same code the spec reserves for malformed rows. Acceptable for a `__main__` entry point; confirm it is a deliberate choice and that no traceback reaches the user. +``` diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T1-ruling-cost-ledgerlite-3.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T1-ruling-cost-ledgerlite-3.md new file mode 100644 index 000000000..5e95b9acb --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T1-ruling-cost-ledgerlite-3.md @@ -0,0 +1,32 @@ +```markdown +## Review Focus + +Input classes and failure modes `design.md` implies but no task's tests exercise. Each line is a ruling: the executor treats it the way it treats a plan conflict — the spec's silence is not permission to break. Lines ending in `Test: Task N` add that test to that task, in that task's step style (a `test_…` method in that task's test file, written in Step 1 and passing by Step 4). Lines ending in `Reviewer checks` are for the final reviewer to confirm deliberately. + +- Amount with more than two fractional digits (`1.005`, `1.500`) — the spec names this malformed by example, so the file is rejected with the line number, not silently rounded — a rounded amount changes a total the user will reconcile against their bank; reported as a bug — `Test: Task 2 — add test_amount_with_three_fraction_digits_is_malformed to test_parse.py, asserting ParseError for "1.005" and "1.500" and no raise for "1.5"/"1.50"`. +- A row with the wrong column count (missing field, extra field, trailing comma) — rejected as malformed with the physical line number, not an `IndexError` traceback or a row built from whatever fields arrived — a traceback instead of the specified `path:line: reason`; reported as a bug — `Test: Task 2 — add test_wrong_column_count_raises_with_line to test_parse.py for a 2-field and a 4-field row`. +- Amount that is not a decimal number (`abc`, empty field, `1,234.56`) — rejected as malformed, with `1,234.56` in particular never read as `1.23` or `1234.56` — an unparsed thousands separator silently misstates every total; reported as a bug — `Test: Task 2 — add test_non_numeric_amount_raises to test_parse.py`. +- Amount `NaN`, `nan`, `Infinity`, `-inf` — rejected as malformed, because `decimal.Decimal` accepts all of them and the spec's "decimal number" does not include them — a single `NaN` row poisons every sum and prints `closing balance: NaN`, and no error is shown; reported as a bug — `Test: Task 2 — add test_nan_and_infinity_amounts_are_malformed to test_parse.py`. +- A malformed row reaching the CLI — exit code 2, `ledgerlite: :: ` on stderr, and stdout completely empty — exit code 2 and the whole-file rejection are specified behavior that no task currently tests at all; a wrapper script that trusts exit 0, or a half-printed report, is reported as a bug — `Test: Task 6 — add test_malformed_row_returns_2_and_prints_nothing to test_cli.py, asserting rc == 2, out == "", and err starting with "ledgerlite: :3:"`. +- A CSV that opens with a UTF-8 BOM (the ordinary shape of a spreadsheet-exported bank file) — the header validates and the file parses — the user's real export is rejected as malformed and the tool looks broken on its primary input; reported as a bug — `Test: Task 2 — add test_bom_before_header_is_accepted to test_parse.py`. +- A blank line in the CSV, including a trailing one after the last row — skipped, with subsequent line numbers still matching the physical file — files ending in a blank line are extremely common, and rejecting them as "wrong column count" makes the tool unusable on hand-edited input; reported as a bug — `Test: Task 2 — add test_blank_lines_are_skipped_and_line_numbers_stay_physical to test_parse.py`. +- A description field quoted because it contains a comma (`"COFFEE SHOP, LONDON"`) — parsed as one description, not split into extra columns — the spec calls description free text, and rejecting a legal CSV quoting as malformed is reported as a bug — `Test: Task 2 — add test_quoted_description_with_comma to test_parse.py`. +- TRANSACTIONS unreadable for a reason other than "missing" — a directory, a permission-denied file, or bytes that are not valid UTF-8 — all give exit 1 and `ledgerlite: cannot read : ` — catching only `FileNotFoundError` passes the existing test and tracebacks on the others; a traceback in place of the specified message is reported as a bug — `Test: Task 6 — add test_directory_path_reports_cannot_read to test_cli.py, asserting rc == 1 and the "cannot read" prefix for a directory path`. +- `--rules` naming a file that cannot be read — the same `cannot read` message and exit 1 as an unreadable TRANSACTIONS, not a traceback and not a silently rules-free report — a mistyped rules path that prints a plausible all-uncategorized report is worse than an error; reported as a bug — `Test: Task 6 — add test_unreadable_rules_file_returns_1 to test_cli.py`. +- `report` with no `--rules` at all — every transaction is uncategorized, so the report is a single `uncategorized:` line plus the closing balance — the spec states this default explicitly and no test runs the CLI without `--rules`; a crash or a missing report is reported as a bug — `Test: Task 6 — add test_no_rules_flag_reports_all_uncategorized to test_cli.py`. +- `report` with no `--opening` — opening is `0` and the closing balance is the plain sum of amounts — the specified default is untested, and an opening of `None` or `"0"` (str) either tracebacks or concatenates; reported as a bug — `Test: Task 6 — add test_opening_defaults_to_zero to test_cli.py`. +- `--opening` given a non-numeric value (`--opening abc`, `--opening ""`) — a clean usage error, not a traceback: `argparse`'s `type=` converts only `ValueError`/`TypeError` into a message, and `Decimal` raises `decimal.InvalidOperation`, which is an `ArithmeticError` and escapes — a typo dumping a stack trace at the user; reported as a bug — `Test: Task 6 — add test_bad_opening_value_is_a_clean_error to test_cli.py, asserting no traceback escapes main and stdout is empty`. +- Every transaction matching a rule — no `uncategorized` line is printed at all, and nothing raises `KeyError` looking for the bucket — the spec lists a category line per category present; a phantom `uncategorized: 0.00` line or a crash is reported as a bug — `Test: Task 5 — add test_no_uncategorized_line_when_all_categorized to test_report.py`. +- Zero transactions (header-only CSV) — the report is the specified layout with no category lines: a blank line then `closing balance: `, and exit 0 — the spec fixes the closing balance for the empty case but not the totals block; a traceback or a fabricated category line on an empty month is reported as a bug — `Test: Task 5 — add test_empty_totals_prints_only_closing_balance to test_report.py`, plus an end-to-end assertion in the Task 6 report test file. +- A total or closing balance of negative zero (`Decimal("-0.00")`, or amounts that cancel via a negative-signed zero) — prints `0.00`, since the spec's own example gives `0.00` and reserves the leading `-` for negatives — `-0.00` in a financial report reads as a formatting defect; reported as a bug — `Test: Task 5 — add test_negative_zero_formats_as_zero to test_report.py`. +- A rules line with no `=`, or with only `=`, or with several (`a=b=c`) — a line with no separator does not crash the run, and a line with several splits once so the category is `b=c` — `str.split("=")` unpacking raises `ValueError` on both, so one stray line in a rules file kills an otherwise valid report; reported as a bug — `Test: Task 3 — add test_malformed_rule_lines to test_rules.py`. +- Rules-file line hygiene: CRLF endings, and whitespace around the separator (`coffee = food`) — the category is `food`, not `food\r` or ` food`, and the substring is `coffee` — a `\r` or leading space inside a category name corrupts every report line derived from it and sorts it wrongly; reported as a bug — `Test: Task 3 — add test_rule_lines_are_stripped to test_rules.py`. +- A rule whose substring is uppercase or mixed case (`COFFEE=food`) against a lowercase description — matches, because the spec makes matching case-insensitive on the description without constraining the rule's case — folding only one side silently drops categories the user wrote rules for; reported as a bug — `Test: Task 3 — add test_matching_folds_both_sides to test_rules.py`. +- Date ordering is invisible in the output: per-category totals and the closing balance are both order-independent, so a CLI that never calls `ordered()` passes every test in Tasks 4 and 6 — the spec still mandates the ordering, and the next feature (a per-transaction listing) will be built on it — cost is dead code and a latent wrong ordering, not a wrong report today — Reviewer checks that `cli.py` actually passes `ordered(...)` output downstream. +- Interface conflict between tasks: Task 2 produces `parse_csv(text: str)` while Task 6 lists it as `parse_csv(path: str)` — the CLI owns file I/O (so it owns the exit-1 `cannot read` path) and passes text in, keeping the argv path only for the `:` message — cost is a duplicated read or an exit code assigned in the wrong module — Reviewer checks which side was resolved and that both exit codes still originate in `cli.py`. +- `main([])`, `main(["report"])`, `main(["--help"])` — argparse raises `SystemExit` rather than returning, so `main(argv) -> int` does not hold for usage errors — cost is a caller that treats `main` as total getting an exception, and a usage exit code that collides with the spec's exit 2 for malformed files — Reviewer checks the chosen behavior is deliberate and documented. +- A rules file with an empty substring (`=food`), or one whose category is literally `uncategorized` — the empty substring matches every description and swallows the report; an explicit `uncategorized` category merges into the uncategorized bucket and stays last — cost is a surprising report from a rules file the user considers valid — Reviewer checks the behavior is coherent, not crashing. +- Category names differing only in case (`Food` and `food` from two rules) — both appear, ordered by whatever "alphabetically" was implemented (`sorted()` puts `Food` first) — cost is ordering the user may find odd, not a wrong number — Reviewer checks the ordering choice is consistent with the spec's word "alphabetically". +- Amounts large enough to exceed the default `decimal` context precision (28 digits) in `quantize` — `InvalidOperation` rather than a formatted number — cost is a traceback on absurd input well outside bank-statement range — Reviewer checks the failure is at worst a clean error. +- Output piped to a closing consumer (`ledgerlite report x.csv | head -1`) — `BrokenPipeError` noise on stderr after a successful run — cost is cosmetic stderr chatter in a shell pipeline — Reviewer checks whether the CLI's stdout write is guarded. +``` diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T1-ruling-cost-ledgerlite-4.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T1-ruling-cost-ledgerlite-4.md new file mode 100644 index 000000000..3711532ea --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T1-ruling-cost-ledgerlite-4.md @@ -0,0 +1,23 @@ +## Review Focus + +Input classes the spec implies but no task's tests exercise. Each line is a ruling: the condition, what a reasonable user expects, the cost of getting it wrong, and where that cost is paid for. + +- **An amount with more than two fractional digits (`1.005`)** — design.md names this explicitly as malformed; the row is rejected and the whole file exits 2 — silent acceptance quantizes `1.005` to `1.00` or `1.01`, so a report's money is wrong with no signal, and Task 2's stated interface omits this rule entirely — *Test: Task 2, add `test_three_decimal_amount_is_malformed` to `test_parse.py` (`1.005` → `ParseError` at line 2; `1.5` and `1.50` parse)* +- **A row with the wrong column count, and an amount that is not a number at all (`abc`, empty field)** — both are malformed per the spec, rejected with the 1-based line of the first bad row — Task 2 tests only the bad-date branch, so the other two named branches can be missing or report the wrong line and the suite stays green — *Test: Task 2, add `test_wrong_column_count_and_non_numeric_amount_raise_with_line` to `test_parse.py`* +- **`NaN`, `nan`, `Infinity`, `-Inf` in the amount column** — rejected as not a decimal number — `Decimal("nan")` constructs without raising, so these pass validation, poison every total they touch, and then crash `format_amount`'s `quantize` with an `InvalidOperation` traceback instead of the spec'd exit 2 — *Test: Task 2, add `test_nan_and_infinity_amounts_are_malformed` to `test_parse.py`* +- **A zero-byte file, and a file whose first row is data rather than the `date,amount,description` header** — a missing or wrong header is reported as a malformed file, not silently absorbed — Task 2 Step 3 says "validate the header" but nothing tests it: an empty file can raise `IndexError` out of `parse_csv`, and a headerless file silently discards its first transaction, understating the closing balance — *Test: Task 2, add `test_empty_file_and_bad_header_are_malformed` to `test_parse.py`* +- **A quoted description containing a comma (`"COFFEE, DOWNTOWN"`) and CRLF line endings** — both are ordinary bank-export CSV and parse as three columns — a `str.split(",")` or `split("\n")` implementation rejects a valid file with exit 2, or leaves `\r` glued to the description; Task 2's fixture has neither, so `csv.reader` over `splitlines()` is untested where it matters — *Test: Task 2, add `test_quoted_comma_and_crlf` to `test_parse.py`* +- **A rules line with no `=` (a stray word, a comment)** — reported or skipped, never a traceback — an unguarded `line.split("=")` unpack raises `ValueError` straight out of `main`, so one typo in a rules file replaces the report with a stack trace — *Test: Task 3, add `test_rule_line_without_equals` to `test_rules.py`, and rule the behavior (skip or error) in the interface* +- **A rule whose value contains `=` (`amazon=shopping=online`)** — split on the first `=` only; substring `amazon`, category `shopping=online` — splitting on every `=` raises or truncates the category, quietly renaming a bucket in the report — *Test: Task 3, same test as above (`split("=", 1)`)* +- **A rule substring written in uppercase or mixed case (`COFFEE=food`)** — matching is case-insensitive on both sides, per "Matching is case-insensitive on the description" — Task 3's test lower-cases only the rule side, so `substring in description.lower()` passes it while every capitalized rule in a real rules file silently matches nothing and its spend lands in `uncategorized` — *Test: Task 3, add `test_uppercase_rule_substring_matches` to `test_rules.py`* +- **Two or more transactions in the same category** — their amounts sum — no existing test puts two transactions in one bucket, so a `totals[c] = t.amount` assignment instead of `+=` passes the whole suite while reporting only the last transaction per category: the headline numbers are wrong on every real input — *Test: Task 5, extend `test_totals_map_none_to_uncategorized` (or add `test_totals_sum_within_category`) in `test_report.py` with two `food` rows and two uncategorized rows* +- **A transactions file with no data rows (header only, or a fresh month)** — a report with no category lines, then the blank line, then `closing balance: ` — `format_report({}, ...)` can emit a leading blank line, an extra newline, or raise on an empty join; Task 2 covers header-only parsing but nothing covers formatting the empty result the CLI then hands to stdout — *Test: Task 5, add `test_format_report_with_no_categories` to `test_report.py`* +- **Any malformed row reaching the CLI** — stderr gets `ledgerlite: :: `, stdout stays empty, exit code is 2 — this is a whole spec'd exit path with no test anywhere: Task 6 covers only exit 0 and exit 1, so today's plan is satisfied by a `ParseError` traceback escaping `main`, and by a partial report already flushed to stdout before the bad row was reached — *Test: Task 6, add `test_malformed_row_returns_2` to `test_cli.py` (asserts rc 2, empty stdout, stderr matching `::`)* +- **`--rules` pointing at a file that cannot be read** — the same `ledgerlite: cannot read : ` treatment and exit 1 the spec gives TRANSACTIONS; the spec's silence here is not permission to crash — an unguarded second `open()` gives a `FileNotFoundError` traceback for a mistyped flag — *Test: Task 6, add `test_unreadable_rules_returns_1` to `test_cli.py`* +- **A transactions file that is not valid UTF-8 (Latin-1 bank export), and a directory passed as TRANSACTIONS** — both are "cannot read": exit 1 with the spec'd message — `UnicodeDecodeError` is a `ValueError`, not an `OSError`, so an `except OSError` handler lets it through as a traceback; the directory case is only safe if the handler wraps the read as well as the open — *Test: Task 6, add `test_undecodable_file_and_directory_return_1` to `test_cli.py`* +- **`--opening` given a value that is not a decimal number (`--opening 1,00`, `--opening abc`)** — a one-line error, not a traceback — bare `Decimal(args.opening)` raises `InvalidOperation` and dumps a stack trace for a typo; the spec fixes the default (`0`) but not this path, and any sane exit (1, or argparse's usage error) beats a crash — *Test: Task 6, add `test_bad_opening_value_is_reported` to `test_cli.py`, asserting no traceback and a `ledgerlite:` message on stderr* +- **`main([])`, `main(["report"])`, an unknown subcommand** — usage on stderr and a non-zero exit — argparse raises `SystemExit`, so `main` violates its own `-> int` contract and its exit code (2) collides with the malformed-file code; harmless at a shell prompt, wrong for any programmatic caller — *Reviewer checks* +- **A trailing blank line at EOF, and fields padded with spaces (`2026-03-04, -7.50, COFFEE`)** — a file ending in a stray newline is not an error; padded fields are a judgment call the implementation should make consistently (strip, or reject as malformed) rather than by accident of `date.fromisoformat` raising — cost is one confusing exit 2 on a file the user considers fine — *Reviewer checks* +- **`Decimal("-0.00")` in the file, or a category whose amounts cancel** — printed as `0.00`; the spec's examples show no negative zero — `quantize` preserves the sign, so a report can read `food: -0.00`; cosmetic, not a money error — *Reviewer checks* +- **A rule that names the category `uncategorized`, and categories differing only in case (`Food` vs `food`)** — explicit `uncategorized` merges into the unmatched bucket and still sorts last; case-distinct names are distinct buckets ordered by Python's default sort (uppercase first), which is "alphabetical" only loosely — both are defensible readings, so the cost is a mildly surprising line order, not a wrong number — *Reviewer checks* +- **Scientific notation in the amount column (`1e2`, `2.5E1`)** — `Decimal` accepts it and the fractional-digit check must not mistake the exponent for scale; the value formats to `100.00` correctly either way — accepting it is harmless, rejecting it is defensible; only silent misquantization would matter — *Reviewer checks* diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T1-ruling-cost-ledgerlite-5.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T1-ruling-cost-ledgerlite-5.md new file mode 100644 index 000000000..f6b8a85af --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T1-ruling-cost-ledgerlite-5.md @@ -0,0 +1,35 @@ +```markdown +## Review Focus + +Rulings on inputs the spec implies but no task's tests exercise. Each line: the input, what a reasonable person expects, the cost of getting it wrong, and where it is settled. Lines that end in a test name are additions to that task's Step 1 test (same style as the other cases in that test); the rest are for the final reviewer to check deliberately. + +### Rulings that earn a test + +- Malformed row reaches the CLI (any of: bad date, bad amount, wrong column count) — `ledgerlite: :: ` on stderr, exit 2, stdout completely empty — the spec's third exit code is unimplemented and untested; a scripted caller can't tell "bad data" from "missing file", and half a report on stdout gets treated as real numbers — **Test: Task 6, `test_malformed_row_returns_2_with_path_and_line`** (asserts `rc == 2`, `out.getvalue() == ""`, and stderr equals `ledgerlite: :3: ...`-shaped text; the CLI, not `parse_csv`, owns the path prefix). +- `2026-03-04,1.005,x` — rejected as malformed, while `1.5` and `1.50` are accepted — the spec names this case with these exact examples; silently accepting sub-cent amounts makes every downstream total wrong by amounts nobody can see in two-digit output — **Test: Task 2, `test_over_two_fractional_digits_is_malformed`** (also asserts `1.5` and `1.50` parse). +- `2026-03-04,1.00` and `2026-03-04,1.00,x,extra` — rejected as wrong column count with that row's line number — untested, and a short row is the shape most likely to raise `IndexError` instead of `ParseError`, so a data problem surfaces as a traceback — **Test: Task 2, `test_wrong_column_count_raises_with_line`**. +- `2026-03-04,abc,x` and `2026-03-04,,x` — rejected as an unparseable amount, `ParseError.line` pointing at the row — only the bad-date branch is tested; an uncaught `decimal.InvalidOperation` escapes as a traceback and exit 1's message shape is never produced — **Test: Task 2, `test_bad_amount_raises_with_line`**. +- `2026-03-04,NaN,x` / `Infinity` / `-sNaN` — rejected as malformed — `Decimal("NaN")` is a *successful* parse, so the naive try/except accepts it, and every total and the closing balance downstream print as `NaN`: a report that looks authoritative and is arithmetically void — **Test: Task 2, `test_non_finite_amount_is_malformed`**. +- Empty file, and a file whose first line is not `date,amount,description` — rejected as malformed (line 1), never an `IndexError` or a silent run that treats the header as data — the plan validates the header in Step 3 but no test pins it; an empty file is the natural result of a failed export and must produce the exit-2 message, not a crash — **Test: Task 2, `test_empty_file_and_bad_header_are_malformed`**. +- CSV whose header starts with a UTF-8 BOM (`\ufeffdate,amount,description`) — accepted as a normal file — this is what Excel and most bank exports produce; strict header comparison rejects a perfectly valid file and the user has no way to see why from `` — **Test: Task 2, `test_bom_before_header_is_accepted`** (the CLI must read with `encoding="utf-8-sig"` for this to hold end to end). +- Blank line inside the CSV, and a file ending `...\n\n` — skipped, not counted as a malformed zero-column row — `csv.reader` over `splitlines()` yields `[]` for these; a trailing blank line is invisible in an editor and would reject the whole file — **Test: Task 2, `test_blank_lines_are_skipped`** (asserts the line numbers of later rows still count the header as line 1). +- Two transactions sharing a category — one line whose total is their sum — every category in the Task 5 test has exactly one transaction, so a `totals_by_category` that overwrites instead of accumulating passes the suite while under-reporting every real ledger — **Test: Task 5, `test_totals_sum_within_a_category`** (including two uncategorized rows summing under `uncategorized`). +- No transactions at all (header-only CSV) — `closing balance: ` with no category lines and no stray leading blank line — `"\n".join([])` plus the blank separator emits leading newlines or an `IndexError`, so the one case the spec calls out explicitly ("the opening amount if there are none") is the one that renders wrong — **Test: Task 5, `test_empty_totals_renders_closing_only`**. +- Rules line with no `=` (`coffee`, or a `# comment`) — skipped, or reported, but never a crash — `line.split("=")` unpacking raises `ValueError` and the user gets a traceback from a one-character typo in a file they hand-edit constantly — **Test: Task 3, `test_rule_line_without_equals_is_skipped`**. +- `report FILE` with neither `--rules` nor `--opening` — every transaction under `uncategorized` and the balance running from `0` — both defaults are spec'd and neither is exercised on a successful run (the CLI test always passes both; the default path is only reached by the missing-file test, which exits before using them) — **Test: Task 6, `test_defaults_no_rules_zero_opening`**. +- `--rules /no/such/file` — `ledgerlite: cannot read /no/such/file: ` and exit 1, same as an unreadable TRANSACTIONS — the spec is silent on the rules file, but its silence is not permission to traceback; the two file arguments are equally likely to be mistyped — **Test: Task 6, `test_unreadable_rules_file_returns_1`**. +- `--opening abc` and `--opening 1.005` — an error message and a non-zero exit, not `decimal.InvalidOperation` — the opening balance is typed by hand on every invocation, and an unconverted string also silently corrupts the closing balance if concatenation happens to succeed — **Test: Task 6, `test_bad_opening_is_rejected`**. +- TRANSACTIONS containing non-UTF-8 bytes (e.g. a latin-1 `£`) — `ledgerlite: cannot read : `, exit 1 — `UnicodeDecodeError` is not an `OSError`, so the obvious `except OSError` misses it and a legacy export from a real bank produces a traceback instead of the spec'd message — **Test: Task 6, `test_undecodable_file_returns_1`**. + +### Rulings the final reviewer checks + +- A category summing to exactly zero out of negative amounts, or a literal `-0.00` in the CSV — prints `0.00`, not `-0.00`; `-0.00` is not a negative number and reads as a formatting defect. **Reviewer checks** `format_amount` normalizes negative zero. +- TRANSACTIONS is a directory, or is permission-denied — the same `cannot read` message and exit 1 as a missing file. **Reviewer checks** that the CLI catches `OSError` broadly rather than only `FileNotFoundError`. +- A rules file that assigns the literal category `uncategorized` — merges into the one `uncategorized` bucket and stays last. **Reviewer checks** that the bucket is keyed by name, so no duplicate line and no alphabetical placement can appear. +- Categories differing only in case (`Food`, `food`) — a sort order a person would call alphabetical; raw ASCII sort puts every capitalized category above every lowercase one. **Reviewer checks** the sort key and that `uncategorized`-last survives it. +- `=food` (empty substring, matches everything) and `coffee = food` (spaces around the separator, so the substring is `coffee ` and matches nothing). **Reviewer checks** the chosen stripping behavior is deliberate and consistent between substring and category. +- CRLF line endings, and a quoted description containing an embedded newline or comma — commas inside quotes must not read as a column-count error, and `ParseError.line` must still name the row a user would count in their editor. **Reviewer checks** how the reader is fed (`splitlines()` splits inside quoted fields) and whether line numbers stay truthful. +- Exponent-notation and signed amounts (`1E+2`, `+7.50`, `1.5E-3`) — the two-fractional-digit rule must be decided on the value's exponent, not on the text after the `.`, or `1.5E-3` slips through as valid. **Reviewer checks** the fractional-digit test uses `Decimal.as_tuple().exponent`. +- `--opening -100` (an overdrawn opening balance) — accepted as a value, not parsed as an unknown option by argparse. **Reviewer checks** it end to end, since argparse's negative-number handling depends on the parser's other option strings. +- Dates `date.fromisoformat` accepts but the spec does not (`20260304`, `2026-03-04T00:00:00`). **Reviewer checks** that the leniency is accepted deliberately rather than tightened by accident into a rejection of well-formed dates. +``` diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T1-ruling-cost-ledgerlite-6.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T1-ruling-cost-ledgerlite-6.md new file mode 100644 index 000000000..310724313 --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T1-ruling-cost-ledgerlite-6.md @@ -0,0 +1,24 @@ +## Review Focus + +Input classes and failure modes `design.md` implies but no task's tests currently exercise. Each line is a ruling: condition, what a reasonable user expects, cost of getting it wrong, and where it is settled. + +- **Any malformed row, seen from the CLI** — `ledgerlite: :: ` on stderr, exit code 2, nothing at all on stdout — no task tests exit 2; `ParseError` escapes `main` as a traceback and the shell sees a nonzero code that means "missing file", so a script cannot tell a corrupt ledger from a typo'd path, and a partial report may already have been printed — **Test: Task 6, `test_malformed_row_returns_2_and_prints_nothing`** +- **An amount with three or more fractional digits (`1.005`)** — malformed, whole file rejected, exit 2; `1.5` and `1.50` accepted (Task 2's stated interface omits this rule, though the spec names it explicitly with these examples) — the row is silently accepted and quantized away, so every category total and the closing balance drift by sub-cent amounts that never reconcile against the bank, with nothing on stderr to explain why — **Test: Task 2, `test_over_precise_amount_is_malformed`** +- **An amount that `Decimal` accepts but a bank statement never means: `nan`, `inf`, `1E3`, `1_000`** — treated as not a decimal number, so malformed, exit 2 — `Decimal("nan")` parses, poisons every sum it touches, and then `quantize` raises `InvalidOperation` inside `format_amount`, turning bad input into a traceback from the reporting layer instead of a line-numbered parse error — **Test: Task 2, `test_nan_and_exponent_amounts_are_malformed`** +- **A row with the wrong column count (extra comma, missing description, empty line in the middle of the file)** — malformed, exit 2, correct 1-based line number — indexing a short row raises `IndexError` and the user gets a traceback with no line number, on a file where one bad row out of a thousand is the whole problem — **Test: Task 2, `test_wrong_column_count_reports_line`** +- **A description quoted because it contains a comma (`"TFR, RENT MARCH"`)** — one field, file accepted, description keeps the comma — a split-on-comma implementation rejects an entire ordinary bank export with a spurious "wrong column count", and `csv.reader` is only a step-3 suggestion with no test holding it in place — **Test: Task 2, `test_quoted_description_with_comma`** +- **A first line that is not `date,amount,description` (header absent because the export was already trimmed, or columns renamed/reordered)** — malformed at line 1, exit 2 — Task 2 says "validate the header" but pins no behavior; a headerless file silently discards its first transaction from every total and from the closing balance, and the report looks entirely plausible — **Test: Task 2, `test_missing_or_wrong_header_is_malformed`** +- **A trailing blank line, or CRLF line endings, in either input file** — accepted exactly as the LF form; no phantom row, no `\r` clinging to a category name — a `\r` on the last field of a rules line yields a category printed as `food\r` that garbles the report line and sorts as its own category; a phantom empty row is reported as malformed at a line the user cannot see in their editor — **Test: Task 2, `test_crlf_and_trailing_blank_line_accepted`** (rules side: **Test: Task 3, `test_crlf_rules_file`**) +- **Two or more transactions in the same category** — one line per category, amounts summed — Task 5's fixture has exactly one transaction per category, so plain dict assignment instead of accumulation passes the suite while showing only the last amount; money vanishes from the report and the category totals stop summing to the closing balance, which is the one invariant the report exists to show — **Test: Task 5, `test_totals_sum_within_a_category`** +- **Zero transactions (header-only CSV: a month with no activity)** — no category lines, then the blank line, then `closing balance: `, exit 0 — Task 4 covers the empty closing balance but no task formats an empty report; the likely outputs are a leading stray blank line or an `IndexError`/`max()`-on-empty crash, on the most benign input the tool can receive — **Test: Task 5, `test_format_report_with_no_categories`** +- **`--rules` omitted** — every transaction uncategorized, report is one `uncategorized:` line, blank line, closing balance, exit 0 — the spec offers this as a first-class mode and only the with-rules path is tested end to end; a `None` rules value reaching `categorize` crashes the simplest possible invocation of the tool — **Test: Task 6, `test_report_without_rules`** +- **`--rules` pointing at a missing or unreadable file** — the same `ledgerlite: cannot read : ` plus exit 1 as TRANSACTIONS gets; the spec's silence here is not permission to ignore it — swallowing the error prints a well-formed report in which everything is `uncategorized` and the user, seeing a real closing balance, never learns their rules path was wrong; the arithmetic is right and the answer is useless — **Test: Task 6, `test_unreadable_rules_file_returns_1`** +- **TRANSACTIONS is a directory, is permission-denied, or is not valid UTF-8 (cp1252 bank export)** — `cannot read : `, exit 1 — only `FileNotFoundError` is tested; `IsADirectoryError`, `PermissionError`, and `UnicodeDecodeError` are all "cannot be read" to the person at the prompt but escape as tracebacks if the handler catches only the one tested case — **Test: Task 6, `test_directory_and_undecodable_file_return_1`** +- **`--opening` omitted, and fractional openings (`--opening 0.10`, `--opening -12.50`)** — default `0`; parsed as `Decimal`, never `float` — `float(args.opening)` passes Task 6's `"100"` test unnoticed and then reintroduces binary rounding into the closing balance of every subsequent run, violating the spec's flat prohibition on float in the one number the user checks first — **Test: Task 6, `test_default_and_fractional_opening_are_decimal`** +- **A rule whose substring is written in mixed or upper case (`Coffee=food`, `RENT=housing`)** — matches case-insensitively, same as a lowercase rule — Task 3's test lowercases the rule side by accident of the fixture, so folding only the description passes; a rules file written in Title Case then matches nothing, and every transaction lands in `uncategorized` with no error anywhere — **Test: Task 3, `test_rule_substring_case_is_folded_too`** +- **A rules line with no `=` (a `#` comment, a stray word, a half-typed rule)** — the run continues rather than dying; skipping the line is an acceptable ruling, unpacking it is not — a one-character typo in a config file produces a `ValueError: not enough values to unpack` traceback instead of a report — **Test: Task 3, `test_rules_line_without_equals_does_not_crash`** +- **`--opening` that is not a number (`abc`, empty string, `1,200`)** — a one-line diagnostic and a nonzero exit, not an `InvalidOperation` traceback; the spec assigns no code, so argparse's own exit 2 or the tool's is equally defensible — **Reviewer checks** +- **`parse_csv` takes text (Task 2) but Task 6's Interfaces line consumes `parse_csv(path)`** — one of the two must give, and the choice decides where exit 1 and exit 2 are produced; per the plan's own architecture the CLI owns all I/O, so it should read the file (owning `cannot read`) and hand text to a pure parser (owning the line-numbered `ParseError`), with the CLI prefixing the path — **Reviewer checks** +- **A rules file whose category is literally `uncategorized`, or a rule with an empty substring (`=food`)** — the named bucket merges into the no-category bucket and is still listed last, rather than printing two `uncategorized` lines; an empty substring matches every description and categorizes the whole file — **Reviewer checks** +- **Amount fields with surrounding whitespace, a leading `+`, or a currency symbol (` -7.50`, `+2500.00`, `$12.00`)** — `Decimal` tolerates the first two and rejects the third as malformed, which is the right split; the two-fractional-digit check must be applied to the parsed value, not to a substring scan of the raw text, or ` 1.50 ` and `+1.50` misclassify — **Reviewer checks** +- **Same-date ties** — order is preserved, but note that ties have no observable effect on this report: the closing balance is order-independent and no per-transaction lines are printed, so ordering needs no coverage beyond Task 4's stability test and should not be elaborated further — **Reviewer checks** diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T1-ruling-cost-wordstat-1.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T1-ruling-cost-wordstat-1.md new file mode 100644 index 000000000..cec2fed7a --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T1-ruling-cost-wordstat-1.md @@ -0,0 +1,23 @@ +```markdown +## Review Focus + +Input classes the spec implies but no task above tests. Each line is a ruling: the +condition, what a reasonable user expects, the cost of getting it wrong, and where +that cost is paid — a test in the owning task, or a deliberate reviewer check. + +- Whitespace-only or padded text (`" \n\t "`, `" a b "`) into `count_words` — no tokens in the first, exactly 2 in the second; runs of whitespace collapse and leading/trailing whitespace is not a token — `text.split(" ")` returns empty strings as words, so every indented or double-spaced file reports an inflated word count, silently wrong for the most common real input — Test: Task 1, added to step 1 alongside `count_words("")`. +- Tabs and newlines as separators (`count_words("a\tb\nc")` == 3) — "whitespace-separated" means all whitespace, not just the space character — a tab-indented or multi-line file undercounts words, and the whole tool's headline number is wrong — Test: Task 1, step 1. +- Single line with no trailing newline (`count_lines("a")` == 1) — one line of text is one line — implementing lines as `text.count("\n")` passes both existing cases (`"a\nb"`, `"a\nb\n"`) and is off by one for every file lacking a final newline — Test: Task 1, step 1. +- Blank lines inside and at the end (`count_lines("a\n\nb")` == 3, `count_lines("a\n\n")` == 2) — interior blank lines count; exactly one trailing newline is absorbed, per the spec's rule — miscounted paragraph-separated text; also pins the boundary between the spec's stated rule and a second trailing newline — Test: Task 1, step 1. +- `format_report` given the three keys in a different insertion order — output is always `words`, then `lines`, then `chars` — rendering by iterating the dict makes report line order depend on how `cli` happened to build it, so the same file can print differently in different call paths — Test: Task 2, step 1 (build the dict as `{"chars": 57, "lines": 3, "words": 12}`, assert the spec's exact string). +- `format_report` on all-zero stats — `"words: 0\nlines: 0\nchars: 0"`, three lines, no trailing newline — a trailing `"\n"` here plus `print` in `cli` yields a blank line in every report; the empty-file case is where a stray newline is least likely to be noticed by eye — Test: Task 2, step 1. +- Empty file through `cli.main` — the zero report on stdout and exit 0; empty is a valid file, not an error — treating "no content" as failure makes the tool unusable in pipelines that legitimately produce empty output — Test: Task 3, step 1. +- Path that exists but is not a readable file (a directory) — the same stderr message and exit 1 as a missing file — catching only `FileNotFoundError` lets `IsADirectoryError` escape as a traceback, so `wordstat .` crashes instead of explaining itself — Test: Task 3, step 1, next to the `/no/such/file` case. +- Non-UTF-8 / binary input (e.g. a file of `b"\xff\xfe\x00"`) — a message to stderr and exit 1, not a `UnicodeDecodeError` traceback — pointing the tool at a PDF or image is an ordinary user mistake; a traceback reads as a crash in the tool rather than a wrong argument — Test: Task 3, step 1. +- Failure path stream discipline — on exit 1, stdout is empty and the message is on stderr — `wordstat missing.txt > out.txt` must not write the error into `out.txt`; a diagnostic on stdout corrupts every downstream consumer of the report — Test: Task 3, step 1, asserted in the missing-file case. +- Wrong argument count (`main([])`, `main(["a", "b"])`) — argparse usage on stderr and a nonzero exit, no bare traceback — Reviewer checks: argparse raises `SystemExit(2)` rather than returning an int, which is acceptable CLI behavior but means `main`'s "→ int" contract holds only for the two documented outcomes; confirm nothing in the module claims otherwise. +- `PermissionError` on an unreadable file — same message-and-exit-1 treatment as the directory case — Reviewer checks: the directory test drives the handler, so confirm the `except` clause is broad enough (`OSError`) to cover this too rather than naming errors one at a time. +- CRLF input (`"a\r\nb\r\n"`) — 2 lines, 2 words; `chars` counts the `\r` because the spec counts characters including whitespace — Reviewer checks: `splitlines()` gives the right line count for free; just confirm no `\r` stripping was added that would make `chars` disagree with the file size on disk. +- Non-ASCII text (`"héllo"` → 5 chars) — characters, not bytes — Reviewer checks: holds automatically if `cli` reads in text mode with an explicit `encoding="utf-8"`; confirm the read is not `"rb"` with a decode bolted on. +- `format_report` given a dict missing a key or carrying extra keys — Reviewer checks: `cli` is the only caller and always supplies exactly the three, so a `KeyError` here is a programming error, not user input; confirm no defensive `.get(..., 0)` was added that would quietly print `words: 0` for a real bug. +``` diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T1-ruling-cost-wordstat-2.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T1-ruling-cost-wordstat-2.md new file mode 100644 index 000000000..dd9d42772 --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T1-ruling-cost-wordstat-2.md @@ -0,0 +1,23 @@ +```markdown +## Review Focus + +Input classes the spec implies but the task tests above do not reach. Each line is a +ruling: read it as binding on the task named at the end of it. + +- **Runs of whitespace, tabs, and newlines between words** (`"a b\tc\nd e"`) — every whitespace run is one separator, so this is 5 words; `count_words` splits on whitespace generally, not on the single space character — an implementation using `text.split(" ")` reports 6+ words on any file with aligned columns or multiple sentences per line, i.e. a silently wrong headline number — **Test: Task 1, add to step 1: `count_words("a b\tc\nd e") == 5`.** +- **Whitespace-only text** (`" \n\t"`) — no tokens, so 0 words; `""` == 0 already in the plan does not distinguish this, because `"".split(" ")` also yields no usable token but `" ".split(" ")` yields four empty ones — a blank-but-not-empty file reports 4 words — **Test: Task 1, add to step 1: `count_words(" \n\t") == 0`.** +- **Text that is exactly one newline** (`"\n"`) — one empty line, so 1; this is the case that forces `count_lines` to be written as "count newlines, plus one if the text is non-empty and unterminated" rather than `len(text.rstrip("\n").split("\n"))`, which returns 1 for `""` too — off-by-one on every line count, or a `0` where the plan's `""` test says `0` only by luck — **Test: Task 1, add to step 1: `count_lines("\n") == 1`.** +- **Blank lines inside the text** (`"a\n\nb"`) — blank lines are lines, so 3; an implementation that filters empty segments to make the trailing-newline rule work will drop them — under-reports line count on any prose file with paragraph breaks — **Test: Task 1, add to step 1: `count_lines("a\n\nb") == 3`.** +- **Newlines counted as characters** (`"a\nb\n"`) — `chars` includes whitespace per the spec, and newlines are whitespace, so 4 — the `chars` number silently disagrees with `wc -c` on every multi-line file, and the trailing-newline rule for `count_lines` is the obvious place for a stray `rstrip()` to leak into `count_chars` — **Test: Task 1, add to step 1: `count_chars("a\nb\n") == 4`.** +- **Non-ASCII text** (`"héllo — ok"`) — `chars` counts characters, not UTF-8 bytes, so 10 — reporting bytes makes the number wrong for every non-English file with no error to hint at it — **Test: Task 1, add to step 1: `count_chars("héllo") == 5`.** +- **A stats dict whose keys were inserted in some other order** (`{"chars": 57, "words": 12, "lines": 3}`) — the report is always `words`, then `lines`, then `chars`; `format_report` reads the three keys by name rather than iterating the dict — the report's labels and numbers stay correct but the line order shifts with an unrelated change inside `cli`, which reads as a formatter bug — **Test: Task 2, add to step 1: same expected string as the plan's case, from a dict built in `chars, words, lines` order.** +- **A UTF-8 file containing non-ASCII bytes** — reads and reports successfully regardless of the machine's locale; `cli` opens with an explicit `encoding="utf-8"` instead of the platform default — on a non-UTF-8 locale the tool dies with a `UnicodeDecodeError` traceback on a perfectly ordinary file — **Test: Task 3, add to step 1: write a temp file containing `"héllo\n"`, assert `main([path])` returns 0 and prints `chars: 6`.** +- **A path that exists but cannot be read as text** (a directory, or a binary file with invalid UTF-8) — the same one-line stderr message and exit 1 as a missing file, not a traceback; the `except` in `cli` covers `OSError` and `UnicodeDecodeError`, not just `FileNotFoundError` — a raw traceback on `wordstat .` is the single most likely first thing a user sees, and it looks like a crash rather than a usage mistake — **Test: Task 3, add to step 1: `main([tmpdir])` returns 1 and writes nothing to stdout.** +- **No path argument at all** (`main([])`) — argparse's own usage message on stderr and a non-zero exit, no traceback and no attempt to read a file — the bare-invocation case is how the tool is first tried, and an `IndexError` there reads as broken software — **Test: Task 3, add to step 1: `main([])` raises `SystemExit` with a non-zero code (argparse's exit 2 is acceptable; `main` must not swallow it into 0).** +- **An empty file** — a valid input, not an error: prints `words: 0\nlines: 0\nchars: 0` and returns 0 — treating empty as failure, or printing a blank report, misreports a legitimate file as broken — **Test: Task 3, add to step 1: `main([empty_path])` returns 0 and prints the three zero lines.** +- **CRLF line endings** (a file written `"a\r\nb\r\n"`) — 2 lines; Python's universal-newline text mode translates `\r\n` to `\n` on read, so this should already hold, and `chars` legitimately counts the translated 4 rather than the on-disk 6 — **Reviewer checks** that `cli` opens in text mode (no `"rb"`, no `newline=""`), which is what makes this true for free. +- **`main` must pass its `argv` through** to `parse_args(argv)` — a bare `parse_args()` reads `sys.argv[1:]` and quietly ignores the caller's list — **Reviewer checks** the call site; the plan's Task 3 tests can pass by accident when `sys.argv` happens to hold something path-shaped. +- **Extra positional arguments** (`main([a, b])`) — argparse rejects them rather than silently ignoring `b`, since the spec defines exactly one positional — **Reviewer checks** that `path` is declared as a single positional with no `nargs`. +- **Missing or non-integer keys in the stats dict** (`format_report({"words": 12})`) — `cli` is the only caller and always supplies all three, so a `KeyError` here is acceptable; no defensive defaults, and no silent `0` that would hide a `counter` failure as a plausible-looking report — **Reviewer checks** that `format_report` neither defaults nor validates. +- **Report trailing newline** — exactly one, from a single `print` of the 3-line string; `format_report` itself returns no trailing newline, per the spec's example — **Reviewer checks** for a doubled blank line at the end of the output. +``` diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T1-ruling-cost-wordstat-3.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T1-ruling-cost-wordstat-3.md new file mode 100644 index 000000000..e9bd8ce2f --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T1-ruling-cost-wordstat-3.md @@ -0,0 +1,20 @@ +```markdown +## Review Focus + +Input classes and failure modes the spec implies but the tasks above do not +exercise. Each line is a ruling: the cost decides whether it earns a test now or +a deliberate look from the final reviewer. + +- Text with no trailing newline and no interior newline (`count_lines("a")`) — one line of text is one line — `text.count("\n")` alone returns 0 here, and every single-line file (the most common input a CLI like this gets) reports `lines: 0`; `count("\n") + 1` is the mirror bug that survives the planned `""` case only by luck — Test: Task 1, add `count_lines("a") == 1` and `count_lines("a\n") == 1` to step 1's list. +- Whitespace that is not a single space character: tabs, newlines, and runs of spaces (`count_words("a\nb\tc")`, `count_words("a b")`, `count_words(" \n ")`) — "whitespace-separated" means all whitespace, and an all-whitespace file has zero words — `str.split(" ")` passes the planned `"the quick brown fox"` case and then miscounts every real file: indented text, double-spaced sentences, and multi-line input all inflate the word count — Test: Task 1, add these three cases to step 1's `count_words` list. +- Text containing newlines passed to `count_chars` (`count_chars("a\nb") == 3`) — "including whitespace" includes the line breaks — the planned cases are both single-line, so a `strip()`/`splitlines()`-based implementation passes them and then undercounts every multi-line file by exactly its line count, with no test that ever disagrees — Test: Task 1, add `count_chars("a\nb") == 3` to step 1's list. +- Blank lines inside the text (`count_lines("a\n\nb") == 3`) — blank lines are lines — an implementation that filters empty strings out of `splitlines()` to satisfy the trailing-newline rule silently drops interior blank lines, so paragraph-separated prose reports too few lines — Test: Task 1, add `count_lines("a\n\nb") == 3` to step 1's list. +- An empty file, end to end (`main([empty_path])`) — a report of `words: 0`, `lines: 0`, `chars: 0` and exit 0; an empty file is valid input, not an error — a crash or a spurious exit 1 on a legitimately empty file is a bug the user hits the first time they pipe an empty log in; Task 1 tests `""` at the function level but nothing checks that the CLI path survives it — Test: Task 3, add a case to step 1: `main([path_of_empty_file])` prints `"words: 0\nlines: 0\nchars: 0"` and returns 0. +- `path` names a directory, or a file the user cannot read (`IsADirectoryError`, `PermissionError`) — the same treatment as a missing file: one clear line on stderr, return 1 — the spec names only "missing file", so `except FileNotFoundError` is the natural implementation and every other unreadable path dumps a traceback instead of a message; a user who tab-completes a directory name gets a stack trace from a five-line stats tool — Test: Task 3, add a case to step 1: `main([tmpdir])` returns 1 and writes to stderr, not a traceback. +- A file that is not decodable text (binary, or bytes invalid in the read encoding) — a message on stderr and exit 1, not a `UnicodeDecodeError` traceback — pointing this tool at a PDF or an image is an ordinary mistake, and the failure mode a user reports is "it crashed", not "it told me no"; the planned tests only ever read ASCII temp files — Test: Task 3, add a case to step 1: `main([path_of_bytes b"\xff\xfe\x00"])` returns 1 with a stderr message. +- The file read must name its encoding explicitly (`encoding="utf-8"`) — the same file yields the same `chars` count on any machine — `open(path)` uses the platform's locale encoding, so a UTF-8 file with accented text or emoji counts differently (or raises) under a non-UTF-8 locale, and CI passing proves nothing about the user's shell — Reviewer checks the `open()` call in `wordstat/cli.py` passes an explicit encoding. +- Non-ASCII text reaching `count_chars` — characters, not bytes: `count_chars("héllo") == 5` — `len()` on a `str` is already correct, so this is only at risk if the implementation reads or re-encodes bytes; low cost given the encoding ruling above covers the real failure — Reviewer checks `count_chars` is `len(text)` over a `str` and never touches `encode()`. +- `format_report` given a dict missing a key, or with extra keys — extra keys ignored; a missing key is a programming error, so a bare `KeyError` is acceptable — `cli` is the only caller and it always builds all three keys, so a defensive branch here would be untested code guarding an impossible state — Reviewer checks the formatter reads exactly the three documented keys and adds no validation. +- Report punctuation at the boundary: `format_report` returns no trailing newline, and `cli` prints it with exactly one — output that ends in a single newline, as every other CLI does — `print(report)` on a report that already ends in `"\n"` yields a blank trailing line; Task 2 pins the string exactly and Task 3 asserts on the printed report, so this is covered by construction as long as neither side adds a newline the other also adds — Reviewer checks the formatter's return value and the `print` in `cli` do not both supply the final newline. +- `main([])`, or `main([a, b])` — a usage message and a non-zero exit, which is argparse's own behavior — argparse raises `SystemExit(2)` rather than returning an int, which contradicts the spec's `main(argv) → int` signature in a way a caller embedding `main` would notice; for a CLI whose only caller is the shell, the default is the right behavior and worth no code — Reviewer checks that argparse's default arity handling is left in place and that no `try/except SystemExit` obscures it. +``` diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T1-ruling-cost-wordstat-4.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T1-ruling-cost-wordstat-4.md new file mode 100644 index 000000000..d8ca92609 --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T1-ruling-cost-wordstat-4.md @@ -0,0 +1,23 @@ +```markdown +## Review Focus + +Input classes and failure modes the spec implies but no task's tests exercise. Each line is a ruling: the cost decides whether it earns a test or a deliberate look by the final reviewer. + +- **Runs of whitespace and tabs between words** (`"the quick\tbrown\nfox"`) — 4 words; "whitespace-separated" means any whitespace in any quantity, not one space — a `split(" ")` implementation reports 6 for a file that plainly has 4 words, wrong for most real prose — Test: Task 1, add `count_words("the quick\tbrown\nfox") == 4` beside the existing `count_words` cases. +- **Whitespace-only text** (`" \n\t "`) — 0 words; there are no tokens — same `split(" ")` bug reports 4 words in a blank file, and it is the case a reader is most likely to notice — Test: Task 1, add `count_words(" \n\t ") == 0`. +- **Text that is only a newline** (`"\n"`) — 1 line; the file has one (empty) line, and the trailing-newline rule removes the phantom line *after* it, not the line itself — the boundary between `count_lines("") == 0` and this is exactly where a `split("\n")`-minus-one implementation goes negative or off by one — Test: Task 1, add `count_lines("\n") == 1`. +- **Blank lines in the interior** (`"a\n\nb"`) — 3 lines; only a *trailing* newline is discounted, blank lines inside the text are lines — silently undercounting paragraph-separated text is a wrong answer on the most ordinary input this tool will ever see — Test: Task 1, add `count_lines("a\n\nb") == 3`. +- **Trailing newline in the char count** (`"a\n"`) — 2 chars; `count_chars` includes whitespace, and the newline is whitespace, so it counts here even though `count_lines` discounts it — the two rules pull opposite ways on the same byte and an implementer harmonizing them will strip it from one of the three stats — Test: Task 1, add `count_chars("a\n") == 2` next to the existing `count_chars` cases. +- **Non-ASCII text** (`"héllo"`, emoji) — 5 chars, characters not bytes; the spec counts characters, and a UTF-8 encoded `é` is one — reporting 6 for `"héllo"` is a wrong stat in any non-English file, and it means the module is counting the encoding rather than the text — Test: Task 1, add `count_chars("héllo") == 5`. +- **A stat that is zero** (`{"words": 0, "lines": 0, "chars": 0}`) — `"words: 0\nlines: 0\nchars: 0"`; zero formats like any other number, no "empty file" special case — an implementation that treats falsy values specially prints a blank or a placeholder where a legible `0` belongs — Test: Task 2, add the all-zeros dict beside the existing `format_report` case. +- **The report's own line endings** — exactly three lines, no trailing newline on the returned string; the spec's example ends at `"chars: 57"` — a trailing newline in the returned string plus `print` in the CLI puts a stray blank line at the end of every report, which is the kind of thing that gets reported — Test: Task 2, assert the return value does not end in `"\n"` and splits into exactly 3 lines. +- **An empty file through the CLI** — `"words: 0\nlines: 0\nchars: 0"` on stdout, exit 0; an empty file is a valid file with valid stats, not an error — a crash or a nonzero exit on an empty file breaks scripted use and contradicts the exit-code contract — Test: Task 3, add a temp-file case with `""` as content asserting exact stdout and return 0. +- **Path is a directory** — the same treatment as a missing file: message to stderr, return 1 — `open()` raises `IsADirectoryError`, not `FileNotFoundError`, so catching only the latter turns a plausible typo (`wordstat ./notes`) into a traceback, which a user reports as a crash — Test: Task 3, add `main([tmpdir])` returns 1 with no traceback. +- **A file that is not decodable text** (binary, or a legacy 8-bit encoding) — an error message and return 1, not a `UnicodeDecodeError` traceback; the tool reads text and should say so when it cannot — the spec is silent on binary input, and that silence is not permission to dump a stack trace at the user — Test: Task 3, write bytes `b"\xff\xfe\x00"` to a temp file, assert `main([path])` returns 1 and stderr is non-empty. +- **The failure path actually says something** — the missing-file case emits a message naming the path to stderr and writes nothing to stdout — the current Task 3 test asserts only `return 1`, so a silent failure or a report accidentally printed to stderr passes; a CLI that exits 1 with no explanation is a bug report — Test: Task 3, extend the `/no/such/file` case to assert stdout is empty and stderr mentions the path. +- **Wrong argument count** (no args, two paths) — argparse's usage message on stderr and a nonzero process exit — argparse raises `SystemExit(2)` rather than returning an int, so `main` violates its `→ int` contract here; acceptable for the `__main__` path but a trap for any in-process caller — Reviewer checks that the `__main__` guard passes `main`'s return value to `sys.exit` and that nothing swallows `SystemExit` into a 0. +- **CRLF input** (`"a\r\nb\r\n"`) — 2 lines; a file authored on Windows counts the same as one authored on Unix — Python's text-mode universal newlines already translates this, so the ruling is really "read in text mode, don't read bytes and decode by hand" — Reviewer checks that `cli` opens the file in text mode with an explicit encoding rather than reading binary. +- **`format_report` given a dict missing a key or carrying extras** — `KeyError` is acceptable; the spec fixes the dict's shape and the only caller is `cli` — a wrong-shape dict is a programming error inside this package, not user input, so a clear exception beats a defensive default that hides the real bug — Reviewer checks that the formatter does not paper over missing keys with `.get(..., 0)`. +- **A very large file** — the spec says "tiny CLI" and a whole-file read is in scope; no streaming required — a multi-gigabyte file would exhaust memory, but nothing in the spec or the stat definitions suggests that workload, so the cost is hypothetical — Reviewer checks the read is a single deliberate `read()` and not an accidental repeated read per stat. +- **Grapheme clusters vs code points** (combining accents, ZWJ emoji sequences) — `count_chars` counts Python code points; `"e\u0301"` is 2 — defensible and consistent with `len`, and any other choice needs a library the constraints forbid — Reviewer checks the docstring says code points so the number is not mistaken for a visual character count. +``` diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T1-ruling-cost-wordstat-5.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T1-ruling-cost-wordstat-5.md new file mode 100644 index 000000000..07dc84399 --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T1-ruling-cost-wordstat-5.md @@ -0,0 +1,23 @@ +```markdown +## Review Focus + +Rulings on inputs the spec implies but the task tests above do not reach. Each line is binding: where it says `Test:`, that task owns the case and adds it in its own step-1 test file before implementing; where it says `Reviewer checks`, the final reviewer confirms the behavior deliberately rather than rediscovering it. + +- Runs of mixed whitespace between words (`"a b\tc\nd"`) — 4 words, because "whitespace-separated tokens" means any whitespace in any quantity, not single spaces — a `split(" ")` implementation reports 7 and every word count on real prose is wrong — Test: Task 1, add to `test_counter.py`: `count_words("a b\tc\nd") == 4`. +- Whitespace-only text (`" \n"`) — 0 words, not 1 empty token — a padded or blank file reports a phantom word — Test: Task 1, add to `test_counter.py`: `count_words(" \n") == 0`. +- Blank lines inside the text (`"a\n\nb"`) — 3 lines; the spec's trailing-newline rule drops only the final one, it does not collapse interior blanks — stripping the whole string, or counting only non-empty lines, undercounts every file with paragraph breaks — Test: Task 1, add to `test_counter.py`: `count_lines("a\n\nb") == 3` and `count_lines("a\nb\n\n") == 3`. +- Text that is exactly `"\n"` — 1 line: one line exists and it is empty, whereas `""` is 0 lines per the spec — an off-by-one that flips sign between the two nearest neighbors of the empty case — Test: Task 1, add to `test_counter.py`: `count_lines("\n") == 1`. +- Non-ASCII text (`"héllo"`, emoji) — `count_chars` returns 5, i.e. characters as Python sees them, not UTF-8 bytes — a length that silently disagrees with what the user counted by eye on any accented text — Test: Task 1, add to `test_counter.py`: `count_chars("héllo") == 5`. +- CRLF text reaching `counter` directly (`"a\r\nb"`) — 2 lines, and `count_chars` counts the `\r` as a character since the spec says "including whitespace" — the CLI's text-mode read normalizes this, so the risk is only a direct caller; worth confirming the counter doesn't split on `\r` alone — Reviewer checks. +- Stats dict whose keys arrive in another order (`{"chars": 57, "words": 12, "lines": 3}`) — report is always `words`, then `lines`, then `chars` — a formatter that iterates the dict emits a differently-ordered report depending on how the caller built it, which no `cli`-level test would catch — Test: Task 2, add to `test_formatter.py`: assert the same 3-line string for a reordered dict. +- Zero-valued stats (`{"words": 0, "lines": 0, "chars": 0}`) — three lines reading `words: 0` etc., not blanks or omitted lines — an empty file's report loses its numbers — Test: Task 2, add to `test_formatter.py`: exact string for the all-zero dict. +- The report string's ending — `format_report` returns no trailing newline (per the spec's example) and `cli` prints exactly one — a double blank line or a missing final newline breaks `diff`/golden comparisons downstream — Test: Task 2, add to `test_formatter.py`: `not report.endswith("\n")`; Task 3's CLI test asserts captured stdout ends with exactly one `"\n"`. +- Stats dict missing a key — a `KeyError` is acceptable, since `cli` is the only caller and always supplies all three; what is not acceptable is a report with a blank or `None` value — a silently malformed report is worse than a crash — Reviewer checks. +- Empty file passed to `cli` — report of three zeros on stdout, exit 0; an empty file is valid input, not an error — a legitimate file treated as failure breaks scripts that count possibly-empty output — Test: Task 3, add to `test_cli.py`: temp file with `""` → stdout is the all-zero report, return 0. +- Missing file — the message goes to **stderr** and stdout stays empty, alongside the spec's return of 1 — a pipeline like `wordstat f | wc` otherwise ingests the error text as data — Test: Task 3, extend the existing `main(["/no/such/file"])` case to assert stderr is non-empty and stdout is empty. +- `path` names a directory, or a file the user cannot read — same shape as the missing-file case: one-line message to stderr, return 1 — an uncaught `IsADirectoryError`/`PermissionError` traceback is a user-facing crash for an ordinary typo like passing `.` — Test: Task 3, add to `test_cli.py`: `main([tmpdir])` returns 1 with empty stdout and non-empty stderr. +- Binary or non-UTF-8 file — message to stderr, return 1 — an uncaught `UnicodeDecodeError` traceback when someone points the tool at a PDF or a latin-1 log — Test: Task 3, add to `test_cli.py`: temp file of bytes `b"\xff\xfe\x00"` → returns 1, stdout empty. +- No arguments, or two or more paths — argparse's own usage message and a non-zero exit; the spec's "single positional `path`" is the whole contract — a bare `wordstat` that tracebacks instead of printing usage looks broken on first use; note that argparse raises `SystemExit` here rather than letting `main` return, so the reviewer confirms the `__main__` wiring still yields a non-zero process exit — Reviewer checks. +- A path beginning with `-` (e.g. `-notes.txt`), and the literal `-` — argparse will read these as options; the spec asks for no stdin support, so `-` legitimately fails, but it should fail with a message and non-zero exit rather than an obscure parse error — an unlikely filename shape whose blast radius is one confusing message — Reviewer checks. +- Very large file, and stdout closed early (`wordstat big.txt | head -1`) — reading the file whole is fine for a "tiny text-stats CLI"; a `BrokenPipeError` traceback on a truncated pipe is noise, not data loss — Reviewer checks. +``` diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T1-ruling-cost-wordstat-6.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T1-ruling-cost-wordstat-6.md new file mode 100644 index 000000000..08ee66dca --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T1-ruling-cost-wordstat-6.md @@ -0,0 +1,27 @@ +```markdown +## Review Focus + +Input classes the spec implies but no task's tests reach. Each line is a ruling: the +condition, what a reasonable person running `wordstat` expects, the cost of getting it +wrong, and where that cost lands — a test in the task that owns the code, or a +deliberate check by the final reviewer. + +- **Tabs and newlines as word separators (`count_words("a\tb\nc")`)** — "whitespace-separated" means all whitespace, so 3 — a `split(" ")` implementation miscounts every multi-line file, i.e. the only kind of file this tool is pointed at; the headline number is silently wrong — *Test: Task 1, add to `test_counter.py` step 1: `count_words("a\tb\nc") == 3`.* +- **Leading, trailing, and repeated spaces (`count_words(" a b ")`)** — 2, not 5 empty-string tokens — same silently-wrong word count as above, from the same naive split; cheap to pin, expensive to notice later — *Test: Task 1, add to `test_counter.py` step 1: `count_words(" a b ") == 2` and `count_words(" ") == 0`.* +- **Only the final newline is absorbed (`count_lines("a\n\n")`)** — 2: `"a"` plus one empty line; the rule in the spec drops one trailing newline, not all of them — an `rstrip("\n")` implementation under-counts any file ending in a blank line, and undercounting grows with the number of trailing blanks — *Test: Task 1, add to `test_counter.py` step 1: `count_lines("a\n\n") == 2` and `count_lines("\n") == 1`.* +- **CRLF line endings (`count_lines("a\r\nb\r\n")`)** — 2; a text file authored on Windows is still a 2-line file — an implementation splitting only on `\n` is right here but one splitting on `\r\n` or counting `\r` as a line is off by N; a user diffing against `wc -l` reports it — *Test: Task 1, add to `test_counter.py` step 1: `count_lines("a\r\nb\r\n") == 2`.* +- **Newlines counted as characters (`count_chars("a\nb\n")`)** — 4: "including whitespace" includes line terminators — omitting them makes `chars` disagree with the file's own size for every real file; the report looks plausible and is wrong — *Test: Task 1, add to `test_counter.py` step 1: `count_chars("a\nb\n") == 4`.* +- **Non-ASCII text (`count_chars("héllo")`)** — 5 characters, not 6 bytes — counting bytes makes the tool wrong for every accented or non-Latin file while staying right for ASCII, so it ships looking correct — *Test: Task 1, add to `test_counter.py` step 1: `count_chars("héllo") == 5` and `count_words("naïve café") == 2`.* +- **A file whose bytes are not valid UTF-8** — a one-line error on stderr and a nonzero exit, not a `UnicodeDecodeError` traceback — a traceback on a binary file mistaken for text is the classic "your tool crashed" bug report, and it is indistinguishable to the user from the missing-file case the spec does handle — *Test: Task 3, add to `test_cli.py` step 1: `main([path])` for a temp file containing `b"\xff\xfe\x00"` returns nonzero and writes nothing to stdout.* +- **No argument at all (`main([])`)** — argparse's usage message and a nonzero status; the spec types `main(argv) -> int`, so a caller may not be prepared for `SystemExit` — argparse's default `sys.exit(2)` escapes `main`, so any embedding or test that calls `main` directly dies instead of receiving a code; the spec's own signature is the thing that breaks — *Test: Task 3, add to `test_cli.py` step 1: `main([])` either returns nonzero or raises `SystemExit` with a nonzero code, and stderr is non-empty.* +- **Path names a directory** — the same stderr message and exit 1 as a missing file; "I can't read that" is one story to the user — an unhandled `IsADirectoryError` traceback from `wordstat .` is a crash on a plausible typo, and catching only `FileNotFoundError` is the natural way to get it wrong — *Test: Task 3, add to `test_cli.py` step 1: `main([tmpdir])` returns 1 and writes to stderr, not stdout.* +- **Unreadable file (mode `000`)** — message to stderr, exit 1 — same crash class as the directory case and fixed by the same `except OSError`; a traceback here reads as tool breakage rather than a permissions problem the user can fix — *Test: Task 3, add to `test_cli.py` step 1: `main([path])` on a `chmod 000` temp file returns 1 with a non-empty stderr (skip if running as root).* +- **Empty file** — `words: 0\nlines: 0\nchars: 0` and exit 0; an empty file is a valid input with a valid answer, not an error — reporting `lines: 1`, crashing on the empty string, or exiting nonzero all break scripts that stat many files in a loop — *Test: Task 3, add to `test_cli.py` step 1: `main([empty_path])` prints the all-zero report and returns 0.* +- **Report has no trailing newline (`format_report` output)** — exactly the spec's `"words: …\nlines: …\nchars: …"`, with `print` in `cli` supplying the final newline — a newline in both places emits a blank line after every report, which shows up in every piped or golden-file comparison — *Test: Task 2, add to `test_formatter.py` step 1: assert the returned string equals the spec's literal and does not end in `"\n"`; Task 3, assert captured stdout ends with exactly one `"\n"`.* +- **Key order in the report is fixed** — always words, then lines, then chars, whatever order the caller's dict was built in — iterating the input dict makes the report order an accident of `cli`'s construction; a reordered report silently breaks anything reading the output positionally — *Test: Task 2, add to `test_formatter.py` step 1: pass a dict built in the order chars, lines, words and assert the spec's line order.* +- **Error message names the offending path and goes to stderr, never stdout** — stdout carries only the report, so `wordstat f | …` stays parseable even when it fails — an error printed to stdout feeds garbage into the next command in a pipe and makes exit-code-ignoring scripts act on it — *Reviewer checks: confirm Task 3's failure paths write via `sys.stderr` and that the missing-file assertion also pins stdout empty.* +- **`format_report` given a dict missing a key, or with non-int values** — a `KeyError`/`TypeError` here is acceptable; `cli` is the only caller and always supplies three ints — silently printing `words: None` or defaulting to 0 would hide a real `counter` bug behind a plausible report, which is worse than a loud failure — *Reviewer checks: Task 2 must not add `.get(key, 0)`-style defaults that mask absent keys.* +- **`main(argv)` with `argv=None` or `argv` omitted** — falls back to `sys.argv[1:]`, or is simply not supported; it must not read process arguments while a caller-supplied list is being passed — passing `argv` explicitly to `parse_args` is one line and its absence makes every test's `main([path])` a lie about what the CLI parses — *Reviewer checks: Task 3 calls `parser.parse_args(argv)` with the argument, not bare `parse_args()`.* +- **Extremely large input file** — the whole file is read into memory; that is a fine trade for a "tiny text-stats CLI" — a streaming rewrite is unwarranted scope, but the choice should be deliberate rather than accidental — *Reviewer checks: single `read()` is intentional and no task claims streaming behavior it does not have.* +- **`-` as the path** — treated as a literal filename that does not exist (message, exit 1); the spec never promises stdin, and argparse will not invent it — the only failure mode is a half-built stdin path that reads nothing and reports zeros as if it succeeded — *Reviewer checks: nothing in Task 3 special-cases `-` unless the spec is amended.* +``` diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T2-ruling-free-ledgerlite-1.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T2-ruling-free-ledgerlite-1.md new file mode 100644 index 000000000..041731195 --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T2-ruling-free-ledgerlite-1.md @@ -0,0 +1,26 @@ +## Review Focus + +These are input classes and failure modes `design.md` implies but no task's tests exercise. Each line is a ruling: the executor implements it as written, and where a line names a test, that test belongs in the named task's Step 1 in that task's own style. Lines marked *Reviewer checks* carry no test; the final reviewer confirms them deliberately. + +- Amount with more than two fractional digits (`1.005`, `0.001`) — malformed row, exit 2, whole file rejected; `1.5`, `1.50`, `5` all accepted (Task 2's Interfaces block omits this rule that `design.md` states explicitly, so a bare `Decimal(field)` accepts it) — a silently rounded cent lands in a category total and the closing balance, so the report disagrees with the bank by an amount nobody can trace — *Test: Task 2, `test_over_two_fractional_digits_is_malformed`.* +- Amount spelled `nan`, `NaN`, `sNaN`, `inf`, `-Infinity` — malformed row, exit 2 — `Decimal("nan")` constructs without raising, so any "is it a decimal number" check built on `try: Decimal(...)` passes it; a NaN then poisons every sum it touches (all comparisons false, totals print `NaN`) and `Infinity.quantize(...)` raises `InvalidOperation` as an uncaught traceback inside `format_amount` — *Test: Task 2, `test_nan_and_inf_amounts_are_malformed`.* +- Amount in exponent notation (`1e3`, `2.5E2`, `1E-3`) — `Decimal` accepts these, so rule one decides them: `1E-3` is `0.001` and therefore malformed, `1e3` is a clean integer amount and is accepted, printing as `1000.00` — an unhandled exponent either rejects a legitimate amount or smuggles a sub-cent value past the two-digit rule — *Test: Task 2, `test_exponent_notation_amounts`.* +- Wrong column count in both directions — a row with two fields and a row with four are both malformed, exit 2 with that row's line number; but a quoted description containing a comma (`2026-03-04,-7.50,"COFFEE, SHOP"`) is three columns and parses, description intact — free-text descriptions contain commas in real exports, and rejecting them makes the tool refuse ordinary bank CSVs while a naive `len(row) != 3` check on pre-split text corrupts the description — *Test: Task 2, `test_column_count_and_quoted_comma`.* +- Header missing, misspelled, reordered (`amount,date,description`), or the file is zero bytes — malformed, exit 2, reason naming line 1; never an `IndexError` or a silent read of the header row as a transaction — an empty or headerless file that parses as zero transactions prints a clean report of nothing and the user believes their account is empty — *Test: Task 2, `test_bad_or_missing_header_rejected_at_line_1`.* +- UTF-8 BOM at the start of the file and `\r\n` line endings (what a spreadsheet export produces) — parses normally — the BOM makes the first header field `"\ufeffdate"`, so header validation rejects the entire file and the tool appears broken on the most common way users will produce input — *Test: Task 2, `test_bom_and_crlf_accepted`.* +- Whitespace around fields (`2026-03-04, -7.50, COFFEE SHOP`), a blank line between rows, and a trailing blank line at EOF — fields are stripped before parsing; a trailing newline is not a row; an interior blank line is malformed with its own line number — `Decimal` tolerates surrounding whitespace but `date.fromisoformat` does not, so the untrimmed case fails on a row that is plainly fine, and `csv.reader` hands a blank line back as `[]`, which a `len(row) != 3` check reports with a confusing reason — *Test: Task 2, `test_field_whitespace_and_blank_lines`.* +- Rules line with no `=`, with several (`a=b=c`), or with an empty substring (`=food`) — a line without `=` is malformed input the tool must not crash on; `a=b=c` splits on the first `=` only, giving category `b=c`; an empty substring is rejected rather than applied — `line.split("=")` unpacked into two names raises `ValueError` on both odd lines, and an empty substring is contained in every string, so one stray line silently files every transaction under one category — *Test: Task 3, `test_malformed_rule_lines`.* +- Whitespace-only rules line, and padding around either side (` coffee = food `) — treated as blank and as `("coffee", "food")` respectively — `if not line` does not catch a line of spaces, and an untrimmed rule never matches while its category prints with leading spaces in the report — *Test: Task 3, `test_rule_lines_are_stripped`.* +- Two or more transactions in one category, and two rules pointing at the same category (`coffee=food`, `cafe=food`) — one line per category with the amounts summed — every category in Task 5's existing test holds exactly one transaction, so the summing that is the entire point of `totals_by_category` is never exercised; a dict-assign-instead-of-accumulate bug shows only the last amount, and duplicate `food:` lines make the per-category totals not add up to the closing balance — *Test: Task 5, `test_totals_sum_within_category`.* +- A rule whose category is literally `uncategorized` (`atm=uncategorized`) — merges with the no-rule-matched bucket into a single `uncategorized` line, still printed last — two lines with the same name, or a rule-set category sorted alphabetically while the `None` bucket goes last, gives a report with a repeated label and two different numbers under it — *Test: Task 5, `test_explicit_uncategorized_category_merges_and_stays_last`.* +- Category names differing in case (`Food`, `bank`, `Rent`) — ordered case-insensitively, with a deterministic tiebreak for names differing only in case — plain `sorted()` sorts all capitalized names ahead of all lowercase ones, so "alphabetically" in the spec silently means ASCII order and a long report becomes hard to read — *Test: Task 5, `test_category_order_is_case_insensitive`.* +- A category total or closing balance that is negative zero — prints `0.00`, never `-0.00` — a single `-0.00` row, or a `Decimal` sum that keeps the negative sign, produces `-0.00` from `quantize` plus a sign check, and the spec's own formatting examples give `0.00` — *Test: Task 5, `test_negative_zero_prints_as_zero`.* +- Header-only CSV, or a CSV whose rows all match no rule — with no transactions the report is exactly `closing balance: `, with no leading blank line and no `uncategorized: 0.00` line invented for a bucket with nothing in it; with no rules, exactly one `uncategorized` line — the spec fixes the closing balance for the empty case but not the layout, and a blank first line or a phantom zero row makes the empty report look like a bug or like a lost transaction — *Test: Task 5, `test_empty_totals_report`, and Task 6, `test_header_only_and_no_rules_end_to_end` (which also pins `--opening` defaulting to `0`).* +- A malformed row reaching the CLI — `ledgerlite: :: ` on stderr, exit 2, and stdout byte-for-byte empty — no task tests the exit-2 path at all, and a CLI that formats and prints the report before or while validating leaves a partial report on stdout that a caller redirecting to a file will treat as real — *Test: Task 6, `test_malformed_row_returns_2_with_empty_stdout`.* +- `--opening` that is not a decimal (`abc`), that carries more than two fractional digits (`100.005`), or that is negative (`--opening -50` as well as `--opening=-50`) — non-numeric and over-precise values are usage errors with a clear message and a non-zero status, negative openings are accepted in both spellings — `Decimal("abc")` is an `InvalidOperation` traceback at the user, an over-precise opening silently shifts every closing balance by a fraction of a cent, and overdrawn accounts are exactly the case where a user reaches for `--opening` with a minus sign — *Test: Task 6, `test_opening_argument_validation`.* +- `main([])`, `main(["--help"])`, `main(["repot", "x.csv"])` — argparse's own failure path must not be confused with the spec's exit codes: `main` either returns an int or the collision is handled deliberately, because argparse exits with status 2, which this spec has already assigned to "malformed row" — a script branching on status 2 to report a bad CSV will report a bad CSV when the real problem is a typo'd subcommand, and `main` raising `SystemExit` breaks the documented `main(argv) -> int` contract for every in-process caller, including these tests — *Test: Task 6, `test_usage_errors_do_not_return_2`.* +- `--rules` pointing at a missing file, a directory, or a file without read permission — the same shaped diagnostic and exit 1 as an unreadable TRANSACTIONS — the spec names only TRANSACTIONS in the exit-1 rule, and its silence about the rules file is not permission for a mistyped `--rules` path to raise `FileNotFoundError` at the user (nor to be ignored, which would print a plausible all-`uncategorized` report) — *Test: Task 6, `test_unreadable_rules_file_returns_1`.* +- TRANSACTIONS that exists but cannot be decoded (latin-1 accents), is a directory, or is unreadable by permission — all take the `cannot read : ` exit-1 path — Task 6 tests only the missing-file case, so `UnicodeDecodeError` and `IsADirectoryError` escape as tracebacks; a valid UTF-8 description with non-ASCII characters must round-trip into the report unchanged — *Test: Task 6, `test_undecodable_and_directory_paths_return_1` plus a non-ASCII description in the end-to-end report.* +- A quoted description containing a newline (`"COFFEE\nSHOP"`) — either accepted as one row or rejected as malformed with a correct line number; not silently split into two bogus rows — feeding `csv.reader` from `text.splitlines()` breaks multi-line quoted fields and desynchronizes every line number after the break, so subsequent error messages point at the wrong row — *Reviewer checks.* +- Amounts large enough to exceed the default `decimal` context precision of 28 significant digits when summed — totals stay exact, or the limit is raised deliberately — the spec's guarantee is that money is never a float, and a silently rounded 28-digit sum breaks that guarantee by another route; ordinary bank data will not reach it, which is why nothing will catch it if it is wrong — *Reviewer checks.* +- Every module's amount handling, read end to end for `float` — no `float()`, no `round()`, no `%`/f-string numeric formatting of a `Decimal` through a float path, no `sum()` seeded with `0` where the exactness matters — the spec states "never float" as a global constraint, and a single float round-trip anywhere in parse → balance → report reintroduces representation error that no individual unit test on small values will reveal — *Reviewer checks.* diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T2-ruling-free-ledgerlite-2.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T2-ruling-free-ledgerlite-2.md new file mode 100644 index 000000000..47cffb3ee --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T2-ruling-free-ledgerlite-2.md @@ -0,0 +1,30 @@ +## Review Focus + +These are input classes and failure modes `design.md` implies but no task's tests exercise. Each line is a ruling: the spec's silence is not permission for the input to break the program. Lines marked `Test:` name the task that owns the code and the test to add there in that task's own step style; lines marked `Reviewer checks` are deliberate final-review items, not rediscoveries. + +- Amount with more than two fractional digits (`1.005`), alongside `1.5` and `1.50` — the spec names this exact case as malformed, so the file is rejected with the row's line number, while one- and two-digit forms are accepted — a spec'd error path is missing entirely (Task 2's interface lists only column count, date, and "not a decimal number"), so fractional cents silently round into every total — *Test: Task 2, `test_more_than_two_fractional_digits_is_malformed`* +- Amount strings `Decimal()` accepts that are not decimal numbers: `NaN`, `Infinity`, `-inf`, `1e3`, `1_000` — rejected as malformed rows — `Decimal("NaN")` constructs without error, then poisons every category total and the closing balance, which print as `NaN` with exit code 0 and no warning — *Test: Task 2, `test_nan_inf_and_exponent_amounts_are_malformed`* +- Row with two or four fields, and non-numeric or empty amount (`abc`, ``) — `ParseError` with the correct 1-based line (header is line 1) and a reason naming what is wrong — Task 2 tests only a bad date, so the other two spec'd reasons ship untested; the likely failure is an `IndexError` traceback instead of exit 2 — *Test: Task 2, `test_wrong_column_count_and_bad_amount_raise_with_line`* +- Missing, misspelled, or mis-cased header (`Date,Amount,Desc`), first line already a data row, and a zero-byte file — rejected as malformed at line 1 with a reason that mentions the header; a real transaction is never consumed as a header and a header is never counted as a transaction — silent loss of the first transaction, or `IndexError` on the empty file; Task 2 step 3 says "validate the header" but nothing pins the behavior — *Test: Task 2, `test_bad_or_missing_header_is_rejected_at_line_1`* +- Blank line between data rows, and trailing blank lines at end of file — the trailing tail is tolerated; an interior blank line is reported with its own line number, never treated as a zero-amount transaction — a phantom row shifts the balance, or a crash on an unpacking of `[]` — *Test: Task 2, `test_blank_lines`* +- Description quoted because it contains a comma (`"COFFEE, SHOP"`), and a quoted description containing a newline — the comma case is one field of a three-column row; the embedded-newline case either parses as one row or is reported with a line number that matches the file as a human counts it — ordinary bank exports rejected as "wrong column count", and `text.splitlines()` (Task 2 step 3) makes every reported line number wrong after any multi-line field, sending the user to an innocent row — *Test: Task 2, `test_quoted_description_with_comma_and_newline`* +- Leading/trailing whitespace in the date and amount fields (`" 2026-03-04 "`, `" -7.50"`) — one consistent ruling: whitespace around date and amount is stripped before parsing; the description is preserved verbatim, spaces included — either a whole file is rejected over cosmetic spacing, or descriptions get silently trimmed and substring rules stop matching what the user sees in the file — *Test: Task 2, `test_surrounding_whitespace_in_fields`* +- Rules line with no `=`, including a `#` comment line — reported as a bad rules file, not silently dropped and not a traceback — `substring, category = line.split("=")` raises `ValueError: not enough values to unpack` and the user sees a stack trace instead of a message — *Test: Task 3, `test_rules_line_without_equals`* +- Rules line with extra `=` (`gym=health=fitness`) and with an empty substring (`=food`) — split on the first `=` only, so the category may contain `=`; an empty substring is rejected rather than matched against everything — an empty-substring rule matches every description, collapsing the entire report into one category with no error — *Test: Task 3, `test_rules_split_on_first_equals_and_reject_empty_substring`* +- Rules text with padding and CRLF (`coffee = food`, `"coffee=food\r\n"`) — substring and category are stripped, so a category never carries a stray `\r` or space — the rule silently never matches, or `food\r` is printed, mangling terminal output and sorting under an invisible character — *Test: Task 3, `test_rules_strip_whitespace_and_carriage_returns`* +- Uppercase rule substring against a lowercase description (`COFFEE=food` on `daily coffee`) — case-insensitive in both directions: both sides folded, not just the description — Task 3's test only lowercases the rule, so any rules file written in caps (the common style, matching bank statements) matches nothing and the whole report is `uncategorized` — *Test: Task 3, `test_match_is_case_insensitive_on_both_sides`* +- Zero transactions (header-only CSV) — no category lines at all, exactly one blank line, then `closing balance: ` — a leading blank line, a bare `uncategorized: 0.00`, or an `IndexError` on the last transaction for the legitimate empty-month case the spec calls out ("the opening amount if there are none") — *Test: Task 5, `test_format_report_with_no_categories`* +- Every transaction matched by a rule — no `uncategorized` line is emitted — a `uncategorized: 0.00` line appears for a bucket with no members; Task 5's tests always include an uncategorized transaction, so the absent case is unexercised — *Test: Task 5, `test_no_uncategorized_line_when_all_categorized`* +- A rule whose category is literally `uncategorized` (`atm=uncategorized`) — its amounts merge with the no-rule bucket into a single line, still printed last — two `uncategorized:` lines, or one bucket silently overwriting the other in the totals dict and money vanishing from the report while the closing balance disagrees — *Test: Task 5, `test_rule_named_uncategorized_merges`* +- Categories whose names differ in case or start with a capital (`Travel` beside `food`, `Food` beside `food`) — "alphabetically" means case-insensitive, so `food` precedes `Travel`; equal-folding names remain distinct keys in a deterministic order — plain `sorted()` puts every capitalized category above every lowercase one, which reads as an ordering bug to anyone scanning the report — *Test: Task 5, `test_category_order_is_case_insensitive`* +- Category whose amounts sum to exactly zero (`+7.50` and `-7.50`) — the line is still printed, as `0.00`, never `-0.00` — a negative-zero artifact in a financial report destroys trust in every other number on the page — *Test: Task 5, `test_zero_total_formats_as_plain_zero`* +- Malformed transactions file through the CLI — exit code 2, `ledgerlite: :: ` on stderr, and stdout completely empty ("the whole file is rejected") — the spec's entire exit-2 contract is untested: Task 6 covers only exit 0 and exit 1, so a `ParseError` could escape as a traceback, exit 1, or a partial report already flushed to stdout before the bad row — *Test: Task 6, `test_malformed_row_returns_2_and_prints_nothing`* +- `--rules` pointing at a missing or unreadable file — the same `ledgerlite: cannot read : ` treatment and exit 1 as the transactions file; the spec's error list names only TRANSACTIONS but a user cannot tell the difference in a shell — `FileNotFoundError` traceback for a one-character typo in the flag argument — *Test: Task 6, `test_unreadable_rules_file_returns_1`* +- `--opening` given `abc`, `1.005`, `""`, and `-100` — invalid values produce a `ledgerlite:`-prefixed message on stderr and a nonzero exit; a negative opening is accepted as an ordinary starting balance — `decimal.InvalidOperation` traceback from `argparse(type=Decimal)`, and an overdrawn account unusable if `-100` is misread as a flag — *Test: Task 6, `test_opening_rejects_bad_values_and_accepts_negative`* +- TRANSACTIONS that is a directory, permission-denied, or non-UTF-8 bytes — all take the `cannot read : ` path with exit 1 — `IsADirectoryError`/`PermissionError`/`UnicodeDecodeError` tracebacks; only `FileNotFoundError` is exercised — *Test: Task 6, `test_unreadable_kinds_all_return_1`* +- Transactions CSV with a UTF-8 BOM and CRLF line endings, as real bank exports have — parsed normally — the BOM makes the first header field `\ufeffdate` and a valid file is rejected as a bad header, with an error message that looks identical to a correct one — *Test: Task 6, `test_bom_and_crlf_csv_parses`* +- No arguments, and an unknown subcommand — argparse usage on stderr and a nonzero exit with no traceback — the user gets a stack trace for a typo; note also that argparse's own exit code 2 overlaps the spec's malformed-row 2, which is acceptable, but `main` must never leak `SystemExit` out of a successful `report` run — *Test: Task 6, `test_no_args_and_unknown_command`* +- `float` anywhere in the pipeline — the spec says amounts are "parsed as `decimal.Decimal`, never float"; no `float()`, no `round()`, no `%f`/`:.2f` on a converted value, and `--opening` reaches `Decimal` from the string, not via float — a single float round-trip introduces cents-level drift that no existing test would catch because every test amount happens to be float-representable — *Reviewer checks* +- Date strings Python 3.11's `date.fromisoformat` accepts beyond the spec's `YYYY-MM-DD` (`20260304`, `2026-03-04T00:00:00`, `2026-W10-1`) — one deliberate ruling, applied consistently, rather than an accident of the stdlib version — a file half-accepted and half-rejected on formats the spec never listed; the sort key and the error path must agree about what a date is — *Reviewer checks* +- Tie ordering end-to-end, not just in `balance.ordered` — two rows sharing a date appear in input order in the final report path, after `categorize` has rebuilt every `Transaction` — Task 4 tests stability on objects it constructs directly; the CLI path passes through a rebuild and a totals dict, so a set/dict round-trip or a re-sort could reorder same-date rows and change nothing testable except the running balance the spec defines by that order — *Reviewer checks* +- `parse_csv`'s single argument at the CLI seam — one module reads bytes from disk and decodes them, and the parser takes what that module produces; the layer that opens the file is the layer that reports `cannot read`, and the layer that parses is the layer that reports `:` — split responsibility here yields either a double-open, a decode error surfacing as exit 2, or a parse error surfacing as exit 1 — *Reviewer checks* diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T2-ruling-free-ledgerlite-3.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T2-ruling-free-ledgerlite-3.md new file mode 100644 index 000000000..caa86a64e --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T2-ruling-free-ledgerlite-3.md @@ -0,0 +1,23 @@ +## Review Focus + +Rulings on input classes the spec implies but no task's tests reach. Each is ` — — — `. + +- **A malformed row reaching the CLI (not just `parse_csv`)** — `ledgerlite: :: ` on stderr, exit 2, and stdout completely empty — Task 6's interface names only exit 0 and exit 1, so the likely implementation lets `ParseError` escape as a traceback or reports it as exit 1; a script that keys off exit codes treats a rejected file as a read failure, or the user sees a Python stack trace instead of the bad line number — **Test: Task 6 (`test_malformed_row_returns_2_and_prints_nothing`)** +- **An amount with more than two fractional digits (`1.005`), against `1.5` and `1.50` which are valid** — the row is malformed and the file is rejected — this is the one malformedness rule the spec spells out with examples and no test asserts it; sub-cent precision silently enters the ledger and totals stop matching the bank statement — **Test: Task 2 (`test_extra_fractional_digits_is_malformed`)** +- **A row with two columns or four columns (trailing comma, extra field)** — `ParseError` naming that physical line — listed in Task 2's interface but untested; a short row either raises a bare `IndexError` or, worse, a long row is silently truncated and a description fragment is dropped — **Test: Task 2 (`test_wrong_column_count_raises_with_line`)** +- **Header missing, misspelled, column-reordered, or the file empty (zero bytes)** — rejection at line 1 with a reason that says the header is wrong — Task 2 Step 3 says "validate the header" and nothing pins the behavior; an empty file crashes on `rows[0]`, and a reordered header (`date,description,amount`) parses every description as an amount and rejects a valid file with a baffling message — **Test: Task 2 (`test_bad_or_missing_header_raises_at_line_1`)** +- **`NaN`, `Infinity`, `-inf`, `1e999` in the amount column** — malformed, same as any other non-number — `Decimal("NaN")` and `Decimal("Infinity")` construct successfully, so a naive `try: Decimal(field)` accepts them; a single `NaN` poisons every total and the closing balance to `NaN`, and the report prints a plausible-looking file of nonsense with exit 0 — **Test: Task 2 (`test_non_finite_amount_is_malformed`)** +- **A quoted description containing a comma or an embedded newline (`"COFFEE, SHOP"`, `"ACME\nCORP"`)** — CSV quoting is honored; these are ordinary descriptions, not malformed rows — Task 2 Step 3 prescribes `csv.reader` over `text.splitlines()`, which splits inside quoted fields and turns one valid row into two broken ones; real bank exports contain both, and the whole file is rejected with a line number pointing at a line the user cannot see — **Test: Task 2 (`test_quoted_description_with_comma_and_embedded_newline`)** +- **Blank and whitespace-only lines, including a trailing `\n\n` and CRLF endings** — skipped, not treated as rows, and reported line numbers still count physical lines — a file that ends with a stray newline is rejected as "wrong column count" on its last line, which reads as data corruption to the user — **Test: Task 2 (`test_blank_lines_skipped_and_line_numbers_stay_physical`)** +- **A UTF-8 BOM on the header, or bytes that are not valid UTF-8** — the BOM is tolerated (read as `utf-8-sig`); undecodable bytes are a read failure: `ledgerlite: cannot read : `, exit 1 — Excel-exported CSVs carry BOMs, and a BOM makes the first column name `\ufeffdate`, rejecting a perfectly good file; a Latin-1 export raises an unhandled `UnicodeDecodeError` traceback — **Test: Task 6 (`test_bom_is_tolerated_and_undecodable_file_returns_1`)** +- **TRANSACTIONS is a directory, or is unreadable for permissions** — the exit-1 "cannot read" path, same as missing — Task 6 tests only a nonexistent path; `IsADirectoryError`/`PermissionError` escape as tracebacks from a tool whose contract is a clean message and exit 1 — **Reviewer checks** +- **`--rules` pointing at a missing or unreadable file** — an error, not silent success: the spec makes rules optional but does not make a *named* rules file optional — the traceback case is bad; the worse case is swallowing the error and printing every transaction as `uncategorized`, a report that looks valid and is entirely wrong — **Test: Task 6 (`test_unreadable_rules_file_returns_1`)** +- **`--opening` and `--rules` both omitted** — opening is `0` and every transaction is uncategorized — the spec states both defaults and every CLI test passes both flags; a default of `None` reaches `closing_balance` and raises a `TypeError`, i.e. the simplest possible invocation is the untested one — **Test: Task 6 (`test_defaults_zero_opening_and_no_rules`)** +- **`--opening abc`, `--opening 1.005`, `--opening ""`** — rejected with a message and a nonzero exit, never a traceback — `Decimal("abc")` raises `decimal.InvalidOperation`, and applying the two-digit rule to amounts but not to the opening balance lets sub-cent precision in through the back door — **Test: Task 6 (`test_bad_opening_is_rejected`)** +- **`--opening -50` (a negative value in bare-dash form)** — accepted as negative fifty; overdrafts and credit-card balances are the normal case, not an edge case — argparse reads the leading `-` as an option prefix and dies with "expected one argument", so the user is told their valid input is a syntax error and has to guess `--opening=-50` — **Test: Task 6 (`test_negative_opening_accepted`)** +- **A rules line with no `=`, surrounding whitespace (`coffee = food`), a second `=` (`a=b=c`), or an empty substring (`=food`)** — whitespace is trimmed, `=` splits on the first occurrence, and a line that cannot be a rule is either skipped or reported — never silently turned into a rule with an empty substring that matches every description — one hand-edited rules file collapses the entire report into a single bogus category, with no indication anything went wrong — **Test: Task 3 (`test_malformed_and_whitespace_rule_lines`)** +- **A rule whose category is literally `uncategorized`, and categories differing only in case (`food` vs `Food`)** — the explicit `uncategorized` merges with the no-match bucket and still sorts last; case variants sort case-insensitively rather than by ASCII, where every capitalized name lands ahead of every lowercase one — a dict keyed on the raw string yields two `uncategorized:` lines in one report, and `Zoo` printed above `apple` reads as a broken alphabetical sort — **Test: Task 5 (`test_explicit_uncategorized_merges_and_sort_is_case_insensitive`)** +- **A category total of negative zero (a `-0.00` row, or amounts cancelling to `Decimal("-0.00")`)** — prints `0.00`; the spec reserves the leading `-` for negatives and `-0.00` is not negative — `quantize` preserves the sign, so the report shows `-0.00` and the user reasonably reads it as a rounding artifact hiding a real amount — **Test: Task 5 (`test_negative_zero_formats_as_zero`)** +- **A report with no transactions at all (header-only CSV)** — a well-formed report: closing balance equal to the opening amount, and a defined shape for the zero-category case rather than a stray leading blank line or an `IndexError` in the join — Task 4 tests the empty closing balance, but no test formats an empty totals dict; the trivially valid input produces the ugliest output the tool can emit — **Test: Task 5 (`test_format_report_with_no_categories`)** +- **`main([])`, `main(["report"])`, an unknown subcommand** — a usage message and a nonzero exit with no traceback — `main(argv) -> int` is typed as returning an int, but argparse raises `SystemExit`; anything calling `main` as a library function gets an exception instead of a return code — **Test: Task 6 (`test_missing_arguments_exits_with_usage`)** +- **A ledger whose sums exceed Decimal's default 28-significant-digit context** — totals and the closing balance are exact; the spec's insistence on `Decimal` over float is an exactness requirement, not a type preference — context rounding reintroduces exactly the silent inexactness `Decimal` was chosen to prevent, in the one number the user checks against their bank — **Reviewer checks** diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T2-ruling-free-ledgerlite-4.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T2-ruling-free-ledgerlite-4.md new file mode 100644 index 000000000..c58a40e00 --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T2-ruling-free-ledgerlite-4.md @@ -0,0 +1,26 @@ +## Review Focus + +Rulings on inputs the spec implies but no task's tests reach. Each is binding on the executor: ` — — — `. + +- **Amount that `Decimal()` accepts but a bank never writes — `nan`, `NaN`, `-Infinity`, `1e5`, ` 1.5 `** — "is not a decimal number" means *not a plain finite decimal literal*, so these are malformed rows: exit 2, whole file rejected — `Decimal("nan")` parses without complaint and then poisons every sum it touches; the report prints `NaN` where money goes and the closing balance is silently meaningless instead of loudly refused — *Test: Task 2, `test_rejects_nan_infinity_and_exponent_forms` in `test_parse.py`.* +- **Amount with more than two fractional digits (`1.005`)** — malformed, exit 2. Task 2's **Interfaces** enumerates only wrong column count, bad date, and non-decimal amount; the spec's fourth clause is missing from the plan and must be implemented and tested — sub-cent amounts flow into totals and the printed report disagrees with the statement it is supposed to reconcile — *Test: Task 2, `test_rejects_more_than_two_fractional_digits` in `test_parse.py`.* +- **Trailing zeros past two places (`1.500`, `2.50000`)** — ruling: **accepted**. The check is about precision, not keystrokes, and the spec's own examples (`1.5` and `1.50` both fine) treat two-decimal *value* as the criterion — reject, and an ordinary export with padded columns is refused wholesale; accept by rounding instead of by exactness, and the `1.005` rule above is lost — *Test: Task 2, `test_accepts_exact_trailing_zeros` in `test_parse.py`.* +- **A malformed row reaching the CLI** — no task tests exit code 2 end to end. Expect `ledgerlite: :: ` on stderr, **stdout completely empty**, rc 2, and `` spelled exactly as typed on the command line (not resolved or absolutized) — the spec's whole-file-rejection promise is unverified; a partially printed report followed by an error is the worst possible outcome for someone reconciling accounts — *Test: Task 6, `test_malformed_row_returns_2_with_empty_stdout` in `test_cli.py`.* +- **`ParseError.line` vs. the physical file line** — the number in the message must be the line a text editor would jump to, header counted as line 1, unaffected by quoting or blank lines. Note the plan conflict: Task 6's **Interfaces** says `parse_csv(path: str)` while Task 2 produces `parse_csv(text: str)`; parse stays text-only and the CLI supplies the path for the message — an error pointing at the wrong line sends the user editing a valid row — *Test: Task 2, `test_line_number_is_physical_file_line` in `test_parse.py`.* +- **Quoted fields: a description containing a comma or quotes, and a quoted field containing an embedded newline** — real CSV quoting is honored; a description is never split into extra columns. Task 2 Step 3 specifies `csv.reader` over `text.splitlines()`, which shreds a multi-line quoted field into bogus short rows and desynchronizes every line number after it — a valid bank export is rejected as malformed, citing a row that is fine — *Test: Task 2, `test_quoted_fields_with_commas_and_embedded_newlines` in `test_parse.py`.* +- **CRLF line endings (Windows-exported CSV)** — `\r` never survives into the amount or the description; `-7.50\r` must not be reported as a non-decimal amount — every row of an ordinary Excel/bank export is malformed and the tool is unusable on the most common export path — *Test: Task 2, `test_crlf_line_endings` in `test_parse.py`.* +- **Header that isn't exactly `date,amount,description` — missing entirely, reordered, extra column, `Date, Amount, Description` — and a zero-byte file** — malformed at line 1, exit 2, with a readable reason; never an `IndexError` traceback on the empty file. Task 2 Step 3 says "validate the header" but nothing pins it — a data row consumed as a header loses a transaction; reordered columns feed a date into the amount parser — *Test: Task 2, `test_rejects_bad_or_missing_header` and `test_rejects_empty_file` in `test_parse.py`.* +- **A blank line inside the CSV (interior or duplicated trailing newline)** — ruling: a zero-column row is a malformed row at its own line number, not silently skipped, consistent with "wrong column count rejects the whole file" — silently skipping means a truncated export reports a clean balance for a file that lost rows — *Test: Task 2, `test_blank_line_is_malformed` in `test_parse.py`.* +- **Header-only file / a month with no transactions** — rc 0 and a real report: no category lines, then the blank line, then `closing balance: `. Task 2 covers `parse_csv` returning `[]`, but no task formats an empty totals dict — an empty month is a normal input, and a crash or a stray leading blank line there breaks the tool for exactly the case with nothing to debug — *Test: Task 5, `test_format_report_with_no_categories` in `test_report.py`.* +- **`--rules` omitted** — every transaction is uncategorized and the report is a single `uncategorized: ` line plus the closing balance. The spec states this outright; Task 6 only tests the with-rules path — the documented default path is entirely unexercised, and `--rules`-optional plumbing (a `None` path) is where a `TypeError` hides — *Test: Task 6, `test_no_rules_all_uncategorized` in `test_cli.py`.* +- **`--rules` naming a missing or unreadable file** — same treatment as TRANSACTIONS: `ledgerlite: cannot read : `, rc 1. The spec rules only on TRANSACTIONS, and its silence is not permission to ignore the rules path — a typo'd `--rules` produces a clean all-uncategorized report, a wrong answer that looks exactly like a right one — *Test: Task 6, `test_missing_rules_file_returns_1` in `test_cli.py`.* +- **TRANSACTIONS is a directory, is permission-denied, or contains non-UTF-8 bytes** — all three are `cannot read : ` with rc 1. Only `FileNotFoundError` is tested, and `UnicodeDecodeError` is **not** an `OSError`, so the natural `except OSError` misses it — a latin-1 export produces a raw traceback instead of the one-line error the spec promises — *Test: Task 6, `test_unreadable_inputs_return_1` in `test_cli.py` (directory and undecodable bytes).* +- **Rules lines that aren't rules: no `=`, empty substring (`=food`), empty category (`coffee=`), extra `=` (`a=b=c`), surrounding whitespace** — a line that cannot be a rule is skipped, never turned into an empty-substring rule; `=` splits on the first occurrence only, so `a=b=c` is `a` → `b=c`; substring and category are stripped. Task 3 tests only blank-line skipping — an empty substring matches every description, so one junk line files the entire ledger under one category and the report is confidently wrong — *Test: Task 3, `test_parse_rules_rejects_malformed_lines` in `test_rules.py`.* +- **Uppercase rule substring against a lowercase description (`COFFEE=food` vs `blue bottle coffee`)** — case-insensitive means *both* sides are folded. Task 3's test uses a lowercase rule against a mixed-case description, which still passes if only the description is lowered — half of all hand-written rules files match nothing, and the failure is invisible: everything just lands in `uncategorized` — *Test: Task 3, `test_match_is_case_insensitive_on_both_sides` in `test_rules.py`.* +- **Two rules mapping to the same category, and a rules file whose category is literally `uncategorized`** — totals accumulate rather than overwrite, and a real `uncategorized` category merges with the no-match bucket and still prints last. Task 5's test gives each category exactly one transaction, so `totals[c] = amount` passes it — an overwrite drops transactions from the category lines while the closing balance still counts them, so the two halves of the report contradict each other and neither is flagged — *Test: Task 5, `test_totals_accumulate_and_merge_uncategorized` in `test_report.py`.* +- **`--opening` that is not a plain two-decimal number (`abc`, `1.005`, `nan`, `1e9`), and `--opening` parsed as `float`** — rejected with a usage-style error and a non-zero exit; when valid, held as `Decimal` from argparse onward, never via `float` — a float opening reinjects binary rounding into every printed total, which is the one thing the spec forbids outright, and `Decimal("nan")` as an opening balance makes the entire report `NaN` — *Test: Task 6, `test_bad_opening_rejected` and `test_opening_is_decimal_not_float` in `test_cli.py`.* +- **`--opening -100`** — accepted as a negative opening balance; argparse must not read the value as an unknown flag — an overdrawn account, the most likely reason to check a balance, is unrepresentable and errors at the usage line — *Test: Task 6, `test_negative_opening` in `test_cli.py`.* +- **Negative zero: `--opening -0`, or a category whose amounts cancel** — prints `0.00`, never `-0.00`; the spec's leading `-` is for negatives and zero is not negative — `-0.00` in a financial report reads as a rounding artifact and undermines trust in every other figure — *Test: Task 5, extend `test_format_amount` in `test_report.py` with `Decimal("-0")` and `Decimal("-0.00")`.* +- **Category names differing only in case, or non-ASCII (`Food`/`food`, `Épicerie`)** — ruling: "alphabetically" is a plain codepoint sort (`sorted()`), so `Food` precedes `food` and no case-folding or locale collation is applied; `uncategorized` is last regardless — anything cleverer makes ordering vary by platform or locale and the report stops being diffable between runs — *Reviewer checks.* +- **No arguments, or an unknown subcommand** — argparse's usage message on stderr and a non-zero exit, no traceback. Ruling: `main` may raise `SystemExit` here rather than returning an int, and argparse's exit code 2 is allowed to collide with the malformed-row code 2; the collision is accepted, not worked around — anyone wrapping `main` must tolerate `SystemExit`, so this is documented rather than papered over — *Reviewer checks.* +- **Exact decimal arithmetic throughout** — no `float` anywhere in the package, no rounding mode reliance beyond `quantize(Decimal("0.01"))` on values already exact at two places, and the default 28-digit context is accepted as sufficient for realistic ledgers — a single `float()` or `round()` on the path from CSV to stdout reintroduces the error the whole design exists to avoid — *Reviewer checks (grep the tree for `float`, `round(`, `%f`, `:.2f`).* diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T2-ruling-free-ledgerlite-5.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T2-ruling-free-ledgerlite-5.md new file mode 100644 index 000000000..5788a4910 --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T2-ruling-free-ledgerlite-5.md @@ -0,0 +1,28 @@ +```markdown +## Review Focus + +Rulings on inputs the spec implies but no task's tests currently exercise. Each is binding: the spec's silence on an input is not permission for the program to break on it. Lines marked `Test:` name the task that owns the code and the test to add there in that task's step style; lines marked `Reviewer checks` are verified deliberately at the end rather than rediscovered. + +- Amount with more than two fractional digits (`1.005`, `-0.001`) — rejected as a malformed row, since the spec names this case explicitly while Task 2's interface lists only three causes — a silently rounded cent per row, so the printed totals and closing balance disagree with the bank — Test: Task 2, `test_parse.py::test_amount_over_two_decimals_is_malformed`. +- Amount that `Decimal` accepts but a ledger cannot use — `NaN`, `nan`, `Infinity`, `-inf`, `1E+400` — treated as malformed like any other non-number — `Decimal("NaN")` raises nothing, then poisons every sum it touches; a report of `uncategorized: NaN` and `closing balance: NaN` with exit 0 — Test: Task 2, `test_parse.py::test_nan_and_infinity_amounts_are_malformed`. +- Row with the wrong column count, or with an empty `date` or `amount` field (`2026-03-04,,x`, `,,`, a four-column row) — malformed row, exit 2, with a reason naming what was wrong — an `IndexError`/`InvalidOperation` traceback instead of the specified stderr line and exit code — Test: Task 2, `test_parse.py::test_short_row_and_empty_fields_are_malformed`. +- File with no header, a reordered or renamed header, or zero bytes — malformed, exit 2 with a reason about the header; a zero-byte file is not an empty ledger — a headerless file silently loses its first transaction, or an empty file prints a confident `closing balance: 0.00` — Test: Task 2, `test_parse.py::test_missing_or_wrong_header_is_malformed`. +- Header preceded by a UTF-8 BOM (`\ufeffdate,amount,description`), as exported by spreadsheet and bank tools — accepted; the BOM is stripped, not compared — every real bank export is rejected as having a bad header — Test: Task 2, `test_parse.py::test_bom_before_header_is_accepted`. +- Trailing newline at end of file, and a blank line between rows — a trailing newline (or a final `\n\n`) is not a row; an interior blank line is a malformed row — the ordinary case of a file ending in a newline is rejected, or a stray blank line is counted as a zero-amount transaction — Test: Task 2, `test_parse.py::test_trailing_blank_line_is_ignored`. +- Description containing a comma, a double quote, or an embedded newline inside a quoted CSV field (`2026-03-04,-7.50,"COFFEE, LTD"`) — parsed as one field by `csv` quoting rules, and reported line numbers stay correct — splitting on `text.splitlines()` cuts quoted fields apart, giving a bogus "wrong column count" and, after an embedded newline, line numbers that point at the wrong row for every later error — Test: Task 2, `test_parse.py::test_quoted_description_with_comma_and_newline`. +- A readable file whose first malformed row is anywhere in it — exit 2, `ledgerlite: :: ` on stderr, stdout completely empty, whole file rejected — the report is streamed before the bad row is reached, so the user gets a half-report on stdout plus an error, or gets exit 1 (unreadable) for a file that read fine; note `parse_csv` takes text and does not know ``, so the CLI owns putting the path in the message — Test: Task 6, `test_cli.py::test_malformed_row_returns_2_and_prints_nothing`. +- `--rules` pointing at a missing, unreadable, or directory path — a diagnostic on stderr and a non-zero exit, never a traceback and never a silently uncategorized report — a `FileNotFoundError` traceback, or every transaction lands in `uncategorized` and the user believes their rules matched nothing — Test: Task 6, `test_cli.py::test_unreadable_rules_file_errors`. +- Rules line with no `=` at all — a `#` comment, a stray word, a whitespace-only line — skipped or reported, never unpacked blindly — `ValueError: not enough values to unpack` traceback on a rules file a user hand-edited — Test: Task 3, `test_rules.py::test_lines_without_equals_are_skipped`. +- Rules line with more than one `=` (`coffee=food=drink`), spaces around the separator (`coffee = food`), or CRLF endings (`coffee=food\r`) — split on the first `=` only, with both sides stripped — a category printed as ` food` that sorts before every real name, or a substring `food\r` that can never match any description — Test: Task 3, `test_rules.py::test_split_on_first_equals_and_strip`. +- Rules line with an empty substring (`=food`) — does not silently swallow the ledger; an empty substring is not a rule — `"" in description` is always true, so the first such line categorizes every transaction and all other rules become dead — Test: Task 3, `test_rules.py::test_empty_substring_is_not_a_rule`. +- Several transactions in the same category, and several with no category — totals accumulate per category; two uncategorized rows sum into one `uncategorized` line — no test currently sums two rows into one bucket; last-write-wins over an assignment would drop transactions from the totals while the closing balance still counts them, so the report does not add up — Test: Task 5, `test_report.py::test_totals_accumulate_within_category`. +- A rules file whose category is literally `uncategorized` — merges with the no-category bucket into one line, printed last, exactly once — two `uncategorized:` lines in the output, or an alphabetically-placed one plus a trailing one — Test: Task 5, `test_report.py::test_explicit_uncategorized_category_merges`. +- A category total of negative zero, and an amount of `-0.00` in the CSV — prints `0.00`; the spec's `0.00` has no sign — `Decimal("-0.00").quantize(...)` formats as `-0.00`, an amount format the spec never permits — Test: Task 5, `test_report.py::test_negative_zero_prints_unsigned`. +- Header-only CSV, or any run with zero transactions — closing balance equals the opening amount, exit 0, and the report does not open with a stray blank line before `closing balance:` — a leading blank line or a missing separator makes the zero-transaction report a special case downstream consumers must handle — Test: Task 6, `test_cli.py::test_header_only_csv_reports_opening_balance`. +- `--opening` that is not a decimal number (`abc`, ``, `NaN`, `1.005`) — rejected with a diagnostic and a non-zero exit — an `InvalidOperation` traceback, or a `NaN` opening that silently makes the entire report `NaN` — Test: Task 6, `test_cli.py::test_bad_opening_amount_errors`. +- `--opening -100` (an overdrawn account) — accepted as a negative opening balance — argparse's negative-number handling treats the value as an option and the tool errors out on a completely ordinary starting balance — Test: Task 6, `test_cli.py::test_negative_opening_is_accepted`. +- TRANSACTIONS that exists but cannot be read as text — a directory, a permission-denied file, undecodable bytes — `ledgerlite: cannot read : ` and exit 1, the same as a missing file — `IsADirectoryError`/`UnicodeDecodeError` traceback; only `FileNotFoundError` was ever tested — Test: Task 6, `test_cli.py::test_directory_and_undecodable_file_return_1`. +- `main([])`, `main(["report"])`, an unknown subcommand, `--help` — `main(argv) -> int` returns an int for user error rather than letting argparse's `SystemExit` escape, or this is a deliberate documented exception — an embedder or test calling `main` gets a raised `SystemExit` instead of the return code the interface promises — Reviewer checks. +- Amounts large enough to exceed the default decimal context's 28 significant digits when summed — the sum is exact or the program refuses, but never silently rounded — `Decimal` addition rounds to context precision without raising, so a large ledger's closing balance is quietly wrong while every individual row is right — Reviewer checks. +- An `--opening` or a total that needs rounding to two digits at print time — `format_amount` never changes a value's magnitude; two-digit inputs quantize exactly and anything else was already rejected upstream — a `quantize` that both rounds and could raise `InvalidOperation` on large values, turning a formatting step into a source of wrong numbers and crashes — Reviewer checks. +``` diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T2-ruling-free-ledgerlite-6.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T2-ruling-free-ledgerlite-6.md new file mode 100644 index 000000000..558d04b4a --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T2-ruling-free-ledgerlite-6.md @@ -0,0 +1,23 @@ +## Review Focus + +These are input classes and failure modes the spec implies but no task's tests exercise. Each line is a ruling: the executor treats it as binding as the task steps themselves. + +- **An amount with more than two fractional digits (`1.005`, `0.001`)** — the spec names this as malformed by example, so the file is rejected with exit 2; Task 2's Interfaces list omits it from the reasons for `ParseError` — the list is incomplete, not permissive — *Cost: over-precise amounts are silently rounded into totals, so a report that looks exact is off by cents and the user cannot tell which row did it* — *Test: Task 2, `test_over_precise_amount_is_malformed` (assert `1.005` raises `ParseError`, `1.5` and `1.50` do not).* +- **A malformed row reaching the CLI** — stderr gets exactly `ledgerlite: :: `, stdout stays empty, exit code is 2 — Task 6 tests only exit 0 and exit 1, so the entire exit-2 path is unexercised end to end — *Cost: the highest-traffic error in a hand-edited CSV either crashes with a traceback or prints a partial report a user pastes into a spreadsheet as if it were complete* — *Test: Task 6, `test_malformed_row_returns_2` (assert rc, `path:2:` prefix on stderr, empty stdout).* +- **A row with the wrong column count (two fields, four fields, an empty line mid-file)** — malformed, `ParseError` with that row's line number — named in Task 2's Interfaces but never asserted — *Cost: a short row raises `IndexError`/`ValueError` from inside the parser, so the user sees a traceback instead of the line to fix* — *Test: Task 2, `test_wrong_column_count_raises_with_line` (both a 2-field and a 4-field row).* +- **`nan`, `NaN`, `Infinity`, `-Infinity` in the amount column** — not decimal numbers, so malformed, exit 2; `Decimal()` accepts all four without complaint — *Cost: NaN poisons every total it touches (comparisons and sums go silently wrong) and `Infinity.quantize()` raises `InvalidOperation` during formatting, crashing after the file was accepted* — *Test: Task 2, `test_nan_and_infinity_amounts_are_malformed`.* +- **An empty file, or a file whose first row is not `date,amount,description`** (wrong order, extra column, data with no header) — malformed, exit 2, with a reason that says the header is wrong — Task 2's Step 3 says "validate the header" but no test pins the behavior, and header-only vs. empty are different inputs — *Cost: an empty file reports `closing balance: 0.00` with no complaint, and a headerless export silently loses its first transaction* — *Test: Task 2, `test_missing_or_wrong_header_is_malformed` (assert `parse_csv("")` and a wrong-header file both raise, and `line == 1`).* +- **A trailing newline producing a final blank line, or a blank line between rows** — skipped, not treated as a zero-column row; line numbers in any later `ParseError` still count physical lines from 1 so the number matches what an editor shows — *Cost: every file that ends with a blank line — most of them — is rejected wholesale with a confusing column-count error, and if blanks are skipped naively the reported line numbers drift and point at the wrong row* — *Test: Task 2, `test_blank_lines_are_skipped_and_line_numbers_stay_physical`.* +- **A quoted description containing a comma or an embedded newline (`"COFFEE, SHOP"`)** — the spec calls description free text, so a quoted comma yields exactly three fields and the description keeps its comma; an embedded newline either parses or is rejected as malformed, never mis-reported as a column-count error on the wrong line — Task 2's Step 3 parses `text.splitlines()`, which splits inside quoted fields — *Cost: any bank export with a comma in the payee name is rejected or split into a bogus row, and the error message blames a line the user cannot find a problem on* — *Test: Task 2, `test_quoted_description_with_comma`.* +- **A CSV saved by Excel, with a UTF-8 BOM before the header** — read as a normal file; the BOM is not part of the `date` column name — *Cost: the most common way a non-technical user produces this CSV fails with "wrong header" on a file that visibly has the right header* — *Test: Task 6, `test_bom_header_is_accepted` (write `"\ufeff" + CSV`, assert rc 0).* +- **TRANSACTIONS is a directory, or holds bytes that are not valid UTF-8** — both are "cannot read": `ledgerlite: cannot read : ` on stderr, exit 1 — `UnicodeDecodeError` is not an `OSError`, so a handler that catches only `OSError` misses it — *Cost: a latin-1 export from an older bank crashes with a traceback instead of the one-line error the spec promises* — *Test: Task 6, `test_undecodable_file_returns_1` (write `b"\xff\xfe..."`, assert rc 1 and the message shape).* +- **`--rules` naming a file that cannot be read** — the spec's exit-1 rule is written for TRANSACTIONS, but a user who typos the rules path expects the same one-line error and a nonzero exit, not a traceback and not a silently uncategorized report — *Cost: a typo'd `--rules` path produces a plausible-looking report in which every transaction is `uncategorized`, and the user believes their rules matched nothing* — *Test: Task 6, `test_missing_rules_file_returns_1`.* +- **`--opening` given a non-number, an empty string, or more than two fractional digits (`abc`, `--opening 1.005`)** — rejected with a one-line message and a nonzero exit; the opening amount is held to the same format as amounts in the CSV — *Cost: `Decimal("abc")` raises `InvalidOperation` before any output, so the user gets a traceback for a plain typo* — *Test: Task 6, `test_bad_opening_returns_nonzero_without_traceback`.* +- **No subcommand, an unknown subcommand, or `report` with no path** — usage on stderr and a nonzero result, and `main(argv)` honors its `-> int` contract rather than letting argparse's `SystemExit` escape into the caller — *Cost: the documented `main(argv) -> int` entry point terminates the interpreter instead of returning, which breaks any test or wrapper that calls it* — *Test: Task 6, `test_no_subcommand_reports_usage` (call `main([])`; assert it returns nonzero or that `SystemExit` is the deliberate, documented behavior).* +- **A rules line with no `=`, an empty substring (`=food`), an empty category (`coffee=`), a second `=` (`atm=cash=out`), or padding (`coffee = food`)** — split on the first `=` only, so `atm` maps to `cash=out`; surrounding whitespace on both halves is stripped so `coffee = food` gives the category `food`; a line with no `=` and a line with an empty substring are not silently turned into a rule — an empty substring matches every description — Task 3 tests only blank-line skipping — *Cost: one stray line in the rules file relabels the entire ledger under a single category, or prints a category with a leading space that sorts before everything else* — *Test: Task 3, `test_malformed_and_padded_rule_lines`.* +- **A rule substring written in uppercase against a lowercase description (`COFFEE=food` vs `blue bottle coffee`)** — matches; the spec's "case-insensitive on the description" means neither side's case matters — Task 3's test only covers a lowercase substring against a mixed-case description, which passes even if the implementation folds only the description — *Cost: rules copied from a bank statement in all caps match nothing, and the failure is invisible: everything just lands in `uncategorized`* — *Test: Task 3, `test_matching_folds_both_sides`.* +- **A rules file that maps some substring to the literal category `uncategorized`** — those transactions sum into the same `uncategorized` line as the unmatched ones, and that line still prints last — *Cost: one bucket silently overwrites the other, dropping real money from the report while the closing balance still reconciles, which is the hardest kind of error to notice* — *Test: Task 5, `test_explicit_uncategorized_merges_and_stays_last`.* +- **Zero transactions (header-only file)** — no category lines at all, and no leading blank line before `closing balance: `; the spec's "then a blank line" describes a separator, not a line that survives when there is nothing above it — *Cost: output begins with a stray blank line, breaking any downstream `head -1`/diff and looking like a truncated report* — *Test: Task 5, `test_empty_totals_report` (assert `format_report({}, Decimal("0")) == "closing balance: 0.00"`).* +- **Category names differing in case (`food`, `Housing`, `travel`)** — "alphabetically" should read as a user reads it; a raw `sorted()` puts every capitalized category ahead of every lowercase one — *Cost: report order looks arbitrary and shifts when a user capitalizes a category in the rules file* — *Reviewer checks: confirm the sort key is deliberate and that `uncategorized` is last under whatever key is chosen, even against a category like `Zebra`.* +- **A negative amount that quantizes to zero, or an opening of `-0`** — prints `0.00`, not `-0.00`; the spec's `0.00` example carries no sign — *Cost: a cosmetically wrong `-0.00` in a financial report reads as a bug and undermines trust in the rest of the numbers* — *Reviewer checks: exercise `format_amount(Decimal("-0.001"))` and `format_amount(Decimal("-0"))` against the implementation.* +- **The parse boundary itself** — Task 2 produces `parse_csv(text: str)` but Task 6's Interfaces block lists it as `parse_csv(path: str)`; one of them is wrong, and reading the file must live on whichever side owns the exit-1 "cannot read" message — *Cost: if the CLI hands a path to a text-taking parser, every run treats the path string as CSV content and reports "wrong header" with exit 2 instead of reading the file at all* — *Reviewer checks: confirm one signature, that the CLI reads with `encoding="utf-8-sig"`, and that `ParseError.line` is reported against the file path the user typed.* diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T2-ruling-free-wordstat-1.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T2-ruling-free-wordstat-1.md new file mode 100644 index 000000000..ab6b3193a --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T2-ruling-free-wordstat-1.md @@ -0,0 +1,23 @@ +```markdown +## Review Focus + +Rulings on input classes the spec implies but no task's tests reach. Each line is binding: ` — — — `. + +- **Words separated by tabs, newlines, or runs of spaces** (`"a\tb\nc"`, `"a b"`) — the spec says "whitespace-separated tokens," so all whitespace splits and repeats collapse: both cases are 2–3 tokens, never empty tokens — a `split(" ")` implementation silently inflates every count on real prose, and the tool's one job is counting — *Test: Task 1, `test_count_words_all_whitespace_kinds`* +- **Leading and trailing whitespace** (`" a b "`, `"a b\n"`) — counted as 2 words, not 3 or 4; boundary whitespace produces no token — off-by-one on nearly every file read from disk, since files end in a newline — *Test: Task 1, `test_count_words_strips_boundary_whitespace`* +- **Whitespace-only text** (`" \n\t"`) — 0 words, and the whitespace still counts toward chars and lines — a crash or a phantom word for a file of blank lines — *Test: Task 1, `test_count_words_whitespace_only_is_zero`* +- **Interior blank lines and bare newlines** (`"a\n\nb"` → 3, `"\n"` → 1, `"a\n\n"` → 2) — the spec's rule ("a trailing newline does not add an empty final line") suppresses exactly one final empty line and no others; interior blanks are real lines — line counts drift on any paragraph-separated document, and the "" → 0 case tempts an implementation that special-cases emptiness instead of using consistent line splitting — *Test: Task 1, `test_count_lines_blank_lines_and_bare_newline`* +- **CRLF line endings** (`"a\r\nb"`) — 2 lines; `\r` is whitespace for word purposes and one character for chars — a file authored on Windows reports one line and a `\r`-suffixed final token — *Test: Task 1, `test_count_lines_crlf`* +- **Newlines inside `count_chars`** (`"a\nb\n"` → 4) — "including whitespace" means newlines and tabs count too; nothing is stripped — chars silently becomes "chars excluding line breaks," disagreeing with `wc -c` for no stated reason — *Test: Task 1, `test_count_chars_includes_newlines`* +- **Non-ASCII text** (`"café"` → 4 chars, `"héllo wörld"` → 2 words) — characters means Unicode code points, not bytes; accented letters are ordinary word characters — a UTF-8 file reports inflated chars and possibly split words, wrong for most of the world's text — *Test: Task 1, `test_count_chars_counts_unicode_characters`* +- **`format_report` given the keys in a different insertion order** (`{"chars": 57, "lines": 3, "words": 12}`) — output order is always words, lines, chars, because the spec fixes the report's shape, not the dict's — the report layout becomes a function of how the caller happened to build the dict, and Task 3's test passes only by luck — *Test: Task 2, `test_format_report_order_independent_of_dict_order`* +- **`format_report` output has no trailing newline** — the spec's example string ends at `chars: 57`; the CLI's `print` supplies the single terminating newline — either a blank line at the end of every report or a report that doesn't end in a newline at all, and downstream `diff`/pipe users see the difference — *Test: Task 2, `test_format_report_has_no_trailing_newline`; CLI's single-trailing-newline stdout asserted in Task 3, `test_main_stdout_ends_with_one_newline`* +- **`format_report` given a dict missing a key or carrying extra keys** — a missing key is a programming error and may raise `KeyError`, but extra keys are ignored rather than printed; the report is exactly 3 lines — a stats dict that grows later starts leaking unformatted rows into the report — *Reviewer checks* +- **Empty file** — success, not failure: `words: 0 / lines: 0 / chars: 0` and exit 0 — an empty file is a legitimate input, and treating it as an error or dividing by zero turns a valid answer into a failure — *Test: Task 3, `test_main_empty_file_reports_zeros_exit_zero`* +- **`path` is a directory** — same treatment as a missing file: message naming the path to stderr, return 1 — the spec names only "missing file," but a bare `IsADirectoryError` traceback is never the right answer to `wordstat .` — *Test: Task 3, `test_main_directory_path_returns_one`* +- **File that isn't valid UTF-8** (binary, or Latin-1 bytes) — a diagnostic to stderr and return 1; the tool declines to count rather than crashing — an unhandled `UnicodeDecodeError` traceback on any `wordstat` of a JPEG or a legacy-encoded file — *Test: Task 3, `test_main_undecodable_file_returns_one`* +- **Unreadable file (exists, permission denied)** — same shape as the other read failures: stderr message, return 1, no traceback — the error path must be keyed on "read failed," not on `os.path.exists`, or a whole class of real failures escapes as a stack trace — *Reviewer checks* +- **No path argument, or more than one** — argparse's usage message on stderr and a non-zero exit; `main` must not return 0 and must not print a report — a silent success or an internal traceback where the user expects usage help — *Test: Task 3, `test_main_missing_argument_exits_nonzero`* +- **Errors go to stderr and the report goes to stdout, exclusively** — stdout carries only the 3-line report so `wordstat f | ...` is safe to pipe; no partial report is printed before an error return — error text mixed into piped output corrupts every downstream consumer — *Test: Task 3, `test_main_error_writes_nothing_to_stdout`* +- **Exit code is the return value of `main`, and `main` returns an `int` on every path** — the spec types `main(argv) → int`; no path returns `None` (which would surface as exit 0) — a failure that reports success to the shell, defeating `wordstat f && ...` — *Reviewer checks* +``` diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T2-ruling-free-wordstat-2.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T2-ruling-free-wordstat-2.md new file mode 100644 index 000000000..895c8767e --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T2-ruling-free-wordstat-2.md @@ -0,0 +1,32 @@ +## Review Focus + +Input classes and failure modes `design.md` implies but no task's tests exercise. Each line is a ruling: the spec's silence is not permission for the input to break the program. + +**Task 1 — `counter`** + +- Runs of whitespace and mixed whitespace kinds (`"a b\tc\nd"`, `" a b "`) — 4 words and 2 words; tokens are whitespace-separated, so repeated separators and leading/trailing padding produce no empty tokens — a naive `split(" ")` inflates every real-world count (indented code, double-spaced prose), silently corrupting the headline number — Test: Task 1, add to `test_counter.py` step 1: `count_words("a b\tc\nd") == 4`, `count_words(" a b ") == 2`, `count_words(" ") == 0`, `count_words("\n\n") == 0`. +- `count_lines("\n")` and `count_lines("a")` — 1 and 1; a trailing newline is a terminator, not a line, but text without any newline is still one line, and only `""` is 0 lines — off-by-one at the smallest inputs, and the `""`/`"\n"` boundary is exactly where a `count("\n")` or `splitlines()` implementation diverges from the spec — Test: Task 1, add to `test_counter.py` step 1: `count_lines("\n") == 1`, `count_lines("a") == 1`, `count_lines("") == 0`. +- Blank interior and multiple trailing newlines (`"a\n\nb"`, `"a\n\n"`) — 3 and 2; only the single final terminator is absorbed, blank lines count as lines — `rstrip("\n")` collapses every trailing blank line at once, so files that end with a blank line undercount by an unbounded amount — Test: Task 1, add to `test_counter.py` step 1: `count_lines("a\n\nb") == 3`, `count_lines("a\n\n") == 2`. +- CRLF text (`"a\r\nb\r\n"`) — 2 lines, and `\r` is whitespace for word-splitting, so `count_words` is 2 — a Windows-authored file otherwise reports one line, or every word gains a phantom `\r`; `splitlines()` additionally splits on `\x0b`, `\x0c`, `\u2028`, which over-counts lines relative to "number of lines" — Test: Task 1, add to `test_counter.py` step 1: `count_lines("a\r\nb\r\n") == 2` and `count_lines("a\x0cb") == 1`. +- Non-ASCII text (`"héllo wörld"`, emoji) — `count_chars` counts characters, not bytes or UTF-16 units: `count_chars("héllo") == 5` — a byte-oriented count reports inflated character totals for any non-English text, which is the one stat a user cannot sanity-check by eye — Test: Task 1, add to `test_counter.py` step 1: `count_chars("héllo") == 5`, `count_chars("a\nb") == 3`. +- `count_chars` on whitespace and empty input (`""`, `"\n"`, `" \t"`) — 0, 1, 2; "including whitespace" means newlines and tabs are characters and nothing is stripped — a `strip()` inside the counter makes `chars` disagree with the file's actual size for the common case of a newline-terminated file — Test: Task 1, add to `test_counter.py` step 1: `count_chars("") == 0`, `count_chars("\n") == 1`, `count_chars(" \t") == 2`. +- Non-`str` input (`None`, `bytes`, an int) — these are programming errors, not user input; the functions may raise, but must not return a wrong number — silently coercing (e.g. `str(None)` → 4 words) turns a caller bug into a plausible-looking report that nobody investigates — Reviewer checks. +- Large input (a multi-megabyte file's text) — the counters stay single-pass over the string and hold no per-token list larger than needed — the spec's "tiny CLI" framing invites `len(text.split())`-style code that is fine, but building repeated intermediate copies per stat turns a large file into seconds of wall time and multiplied memory — Reviewer checks. + +**Task 2 — `formatter`** + +- Stats dict whose insertion order differs from report order (`{"chars": 57, "words": 12, "lines": 3}`) — output is always `words`, then `lines`, then `chars` — an implementation that iterates the dict produces a report whose line order depends on how the caller built the dict, so output is unstable across call sites and undiffable — Test: Task 2, add to `test_formatter.py` step 1: a dict built in `chars, words, lines` order renders `"words: 12\nlines: 3\nchars: 57"`. +- All-zero stats (`{"words": 0, "lines": 0, "chars": 0}`) — renders all three lines with `0`, no omission, no "empty file" special case — the empty-file path is the most likely first real invocation; a formatter that falsy-checks values emits a truncated or blank report and the CLI looks broken — Test: Task 2, add to `test_formatter.py` step 1: all-zero dict renders `"words: 0\nlines: 0\nchars: 0"`. +- Report string boundaries — exactly three lines joined by `\n`, with no trailing newline and no leading blank line, so the CLI's `print` supplies the single final newline — a trailing newline here plus `print` yields a blank line in every report and breaks byte-exact comparison in Task 3's test — Test: Task 2, add to `test_formatter.py` step 1: assert the returned value does not end with `"\n"` and `splitlines()` has length 3. +- Stats dict missing a key, or carrying extra keys — a missing required key raises rather than printing a partial or `None`-valued report; extra keys are ignored, not appended — a report that prints `words: None` or leaks an unrelated key reads as real data to the user — Reviewer checks. + +**Task 3 — `cli`** + +- Empty file — prints `words: 0\nlines: 0\nchars: 0` and returns 0; an empty file is valid input, not an error — exiting non-zero or crashing here fails the simplest smoke test a user will run and breaks any script that pipes the report — Test: Task 3, add to `test_cli.py` step 1: `main([empty_path])` returns 0 and stdout is exactly `"words: 0\nlines: 0\nchars: 0\n"`. +- `path` is a directory, or an existing file the user cannot read — same treatment as missing: one message to stderr naming the path, return 1 — the spec names only "missing file", so an uncaught `IsADirectoryError`/`PermissionError` traceback is the default; a traceback both scares the user and gives exit code 1 only by accident — Test: Task 3, add to `test_cli.py` step 1: `main([tmpdir])` returns 1, stderr is non-empty, stdout is empty, and no exception escapes. +- Failure output discipline — on any failure stdout stays empty and the message goes to stderr — a report or half-report on stdout alongside exit 1 poisons `wordstat f > out.txt` pipelines, which trust stdout only when the status is 0 — Test: Task 3, add to `test_cli.py` step 1: extend the `/no/such/file` case to assert stdout is `""` and stderr mentions the path. +- No arguments, or two or more positional paths (`main([])`, `main([a, b])`) — argparse's own usage error is acceptable behavior, but the CLI must not print a traceback and must not treat a missing path as the empty string — `main` is specified to *return* an exit code, so a bare `SystemExit(2)` escaping into a caller (or a test) is a behavior the spec never sanctions and should be a deliberate decision, documented in `cli.py` — Reviewer checks. +- File containing bytes that are not valid UTF-8 — the CLI reports a readable error and returns 1 rather than raising `UnicodeDecodeError` — pointing the tool at a binary file is an ordinary user mistake, and a decode traceback is indistinguishable from a bug in the tool — Test: Task 3, add to `test_cli.py` step 1: write `b"\xff\xfe\x00rubbish"` to a temp file, assert `main([path])` returns 1 and stderr is non-empty. +- Encoding is chosen explicitly, not inherited from the locale — the file is opened with `encoding="utf-8"` so a UTF-8 file yields the same `chars` count on every machine — a bare `open(path)` follows `locale.getpreferredencoding()`, so the same file reports different stats (or fails to decode) on a differently configured host — Reviewer checks. +- Non-ASCII file content end to end — the printed `chars` matches `counter.count_chars` on the decoded text, and printing does not fail — a stdout encoding mismatch raises on write *after* the work succeeded, the most confusing possible failure point — Reviewer checks. +- `argv` handling convention — `main(argv)` parses the list it is given, excluding the program name, and consults `sys.argv` only when no `argv` is supplied — mixing the two conventions makes every test pass a path that argparse silently discards as `prog`, so tests appear to exercise a path the real CLI never takes — Reviewer checks. diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T2-ruling-free-wordstat-3.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T2-ruling-free-wordstat-3.md new file mode 100644 index 000000000..a556d64df --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T2-ruling-free-wordstat-3.md @@ -0,0 +1,23 @@ +```markdown +## Review Focus + +Input classes and failure modes the spec implies but the task tests above do not +exercise. Each line is a ruling: the spec's silence is not permission for the +input to break the program. + +- **Runs of whitespace, tabs, and leading/trailing spaces in `count_words`** (`" a\t\tb "`) — a word count of 2; whitespace-separated means splitting on runs, not on single spaces — a report that inflates word counts on any real indented or tab-aligned file, which is most of them — *Test: Task 1, `test_count_words_ignores_repeated_and_surrounding_whitespace`* +- **Whitespace-only text in `count_words`** (`" \n\t"`) — 0, not 1 — an off-by-one on blank-ish files that also silently validates a naive `text.split(" ")` implementation — *Test: Task 1, `test_count_words_whitespace_only_is_zero`* +- **Newlines as word separators** (`count_words("a\nb")`) — 2; the spec says whitespace-separated, and a newline is whitespace — every multi-line file reports one word per line joined into one token — *Test: Task 1, `test_count_words_splits_on_newlines`* +- **A blank line before the trailing newline** (`count_lines("a\n\n")`) — 2, not 1: the spec's rule strips only the final newline, and the blank line before it is a real line — undercounts every file that ends in a blank line, a near-universal editor default — *Test: Task 1, `test_count_lines_counts_trailing_blank_line`* +- **A text that is exactly one newline** (`count_lines("\n")`) — 1, since the newline terminates a (empty) line rather than being absent content — the boundary between the `""`→0 rule and the trailing-newline rule is where a hand-rolled `count("\n")` implementation goes wrong — *Test: Task 1, `test_count_lines_single_newline_is_one`* +- **CRLF line endings** (`"a\r\nb\r\n"`) — 2 lines; a file written on Windows is not a different file — line counts double or the `\r` leaks into the word tokens, on input the user cannot tell apart from a working case — *Reviewer checks* (verify the implementation delegates to `str.splitlines()` rather than splitting on `"\n"`) +- **Newlines and empty text in `count_chars`** (`count_chars("a\nb")` == 3, `count_chars("")` == 0) — the spec says including whitespace, so a newline is a character — a char count that disagrees with `wc -c` on every multi-line file — *Test: Task 1, `test_count_chars_includes_newlines_and_empty`* +- **Non-ASCII text end to end** (a file containing `"héllo wörld\n"`) — 2 words, 12 chars: characters, not UTF-8 bytes — silently wrong counts for any non-English text, and a latent bug if the file is opened in binary or without an explicit encoding — *Test: Task 3, `test_main_counts_non_ascii_characters_not_bytes`* +- **A file that is not decodable as UTF-8** (latin-1 or binary bytes) — a message on stderr and exit 1, the same shape as a missing file, never a `UnicodeDecodeError` traceback — a stack trace instead of an error message is the difference between a tool and a crash, and this is the most likely real-world bad input after a wrong path — *Test: Task 3, `test_main_undecodable_file_returns_1`* +- **A path that exists but is not a readable file** (a directory; a file with permissions cleared) — stderr message and exit 1; `IsADirectoryError` and `PermissionError` are not `FileNotFoundError`, but the user's mistake is the same class of mistake — an unhandled traceback on a plain typo like passing the enclosing directory — *Test: Task 3, `test_main_directory_path_returns_1`* +- **An empty file** — the report `"words: 0\nlines: 0\nchars: 0"` and exit 0; an empty file is valid input with valid answers — an empty or crashing report on a legitimate file, and a divide-by-zero-shaped hole if averages are ever added — *Test: Task 3, `test_main_empty_file_reports_zeros_and_exits_0`* +- **Zero or multiple positional arguments** (`main([])`, `main(["a", "b"])`) — argparse's own usage message and a nonzero result; the spec types `main(argv) → int`, so a caller that treats `main` as a function must not be killed by an escaping `SystemExit` — a library-style caller or test harness dies instead of seeing an exit code — *Reviewer checks* (confirm the contract chosen — catch `SystemExit` and return its code, or document that `main` exits — is consistent between `cli.py` and `test_cli.py`) +- **Stream discipline on the error path** — nothing on stdout when the file cannot be read, and nothing on stderr on success — a shell pipeline (`wordstat f | ...`) consumes an error message as data, or a `2>/dev/null` invocation swallows the report — *Reviewer checks* (the Task 3 tests capture both streams; assert the unused one is empty in each case) +- **Field order and exact shape of the report** — always `words`, then `lines`, then `chars`, one per line, no trailing newline in the returned string, regardless of the insertion order of the incoming dict — an implementation that iterates the dict passes Task 2's single literal test and then reorders the report whenever `cli` builds the dict differently — *Reviewer checks* (verify `format_report` names the three keys explicitly rather than looping over `stats`) +- **A stats dict missing a key** (`format_report({"words": 1})`) — a clear failure, not a partial report with a blank or `None` field — a report that looks complete while stating a number nobody computed — *Reviewer checks* (an uncaught `KeyError` is acceptable here; a silent `stats.get(key, 0)` default is not) +``` diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T2-ruling-free-wordstat-4.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T2-ruling-free-wordstat-4.md new file mode 100644 index 000000000..bfd5201bc --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T2-ruling-free-wordstat-4.md @@ -0,0 +1,21 @@ +## Review Focus + +Input classes and failure modes `design.md` implies but no task's tests currently exercise. Each line is a ruling: the spec's silence is not permission for the input to break the program. + +- **Runs of whitespace, tabs, and newlines between words** (`" a\tb\nc "`, `" "`) — a "whitespace-separated token" count of 3 and 0, not one count per gap or per space character — a `text.split(" ")` implementation inflates the word count on every indented or blank-line-containing file, so the headline number in the report is silently wrong for ordinary prose — *Test: Task 1 step 1 — `count_words(" a\tb\nc ") == 3`, `count_words(" ") == 0`, `count_words("\n") == 0`.* +- **A file whose text is just a newline, or has blank interior lines** (`"\n"`, `"a\n\nb"`, `"\n\n"`) — 1, 3, and 2 lines: the spec's "trailing newline does not add an empty final line" removes the *final* empty line only, it does not delete real blank lines — off-by-one on nearly every real text file (almost all end in `\n`), and a `text.count("\n")` implementation additionally loses the unterminated last line — *Test: Task 1 step 1 — `count_lines("\n") == 1`, `count_lines("a\n\nb") == 3`, `count_lines("\n\n") == 2`.* +- **CRLF line endings** (`"a\r\nb\r\n"`) — a file authored on Windows reports the same 2 lines as its Unix twin, and the `\r` still counts toward `chars` because `chars` includes all whitespace — line counts differ by ~2× between platforms for the same document, and users comparing against their editor stop trusting the tool — *Test: Task 1 step 1 — `count_lines("a\r\nb\r\n") == 2`, `count_chars("a\r\nb") == 4`.* +- **`str.splitlines()`'s exotic break characters** (`\x0b`, `\x0c`, `\u2028`, `\x85`) — one documented notion of "a line" that `count_words` and `count_chars` do not contradict — a form feed in a printed document splits a line for `count_lines` while `count_words` sees one token, producing a report whose three numbers describe three different texts — *Reviewer checks: the choice (universal newlines via `splitlines` vs. `\n`-only) is deliberate and consistent across the three functions.* +- **Newlines and non-ASCII characters counted by `count_chars`** (`"a\nb"`, `"café"`, `"日本"`) — 3, 4, and 2: characters including all whitespace, counted as Python characters and not as UTF-8 bytes — a `len(text.encode())` or newline-stripping implementation reports a number that matches neither the editor's character count nor anything else, and the error grows with how non-English the text is — *Test: Task 1 step 1 — `count_chars("a\nb") == 3`, `count_chars("café") == 4`.* +- **A non-`str` passed to a counter** (`None`, `bytes`, an already-split list) — a loud `TypeError`/`AttributeError` at the call boundary — returning a plausible-looking `0` for `None` turns a caller's bug into a believable report that nobody investigates — *Reviewer checks: no `try/except` or truthiness guard in `counter.py` converts a bad type into a number.* +- **A stats dict whose keys were inserted in another order** (`{"chars": 57, "lines": 3, "words": 12}`) — the report is always `words`, then `lines`, then `chars`, because the spec fixes the report's shape, not the dict's — a formatter built by iterating `stats.items()` emits label/value pairs in dict order, so report layout depends on the caller's construction order and any golden-output comparison becomes flaky — *Test: Task 2 step 1 — `format_report` with reversed insertion order equals `"words: 12\nlines: 3\nchars: 57"`.* +- **A stats dict missing a key, or carrying extra keys** (`{"words": 1}`, `{..., "bytes": 9}`) — a missing key raises naming the key it wanted; unknown keys are ignored rather than appended — a `.get(key, 0)` fallback prints `chars: 0` for a 57-character file, which is a wrong answer presented as a right one; an extra key leaking into the output breaks the fixed 3-line contract — *Test: Task 2 step 1 — missing `"chars"` raises `KeyError`; extra `"bytes"` key does not change the output.* +- **The report's trailing newline at the formatter/CLI seam** — `format_report` returns a 3-line string with no trailing `\n` (as the spec's example shows), and `cli` adds exactly one when printing — a formatter that ends in `\n` plus `print` yields a blank line before the shell prompt; a `sys.stdout.write` without one glues the prompt to `chars: 57` — *Test: Task 3 step 1 — captured stdout is exactly `report + "\n"`.* +- **No path argument, or two paths** (`main([])`, `main(["a.txt", "b.txt"])`) — argparse's usage message on stderr and a non-zero exit, never a traceback or `IndexError` — note this means `main` *raises* `SystemExit(2)` instead of returning an int, which the spec's "`main(argv) → int`" does not mention; tests and any programmatic caller must expect it — *Test: Task 3 step 1 — `main([])` raises `SystemExit` with a non-zero code and writes nothing to stdout.* +- **Path names a directory** (`main(["/tmp"])`) — the same treatment as a missing file: one message on stderr, return 1 — `IsADirectoryError` is not a `FileNotFoundError`, so the obvious `except FileNotFoundError` lets it escape as a traceback, which is the exact failure mode the spec's missing-file rule exists to prevent — *Test: Task 3 step 1 — `main([tmpdir])` returns 1.* +- **Path exists but is unreadable** (mode `000`, or inside an unsearchable directory) — message on stderr, return 1 — a `PermissionError` traceback on a permission problem reads as a crash rather than as the operator's own filesystem telling them no — *Reviewer checks: the read is guarded by `OSError` (or an equivalent covering `PermissionError`), not by `FileNotFoundError` alone; skipped as a test because it is not portable and vacuous under root.* +- **File containing bytes that are not valid UTF-8** (`b"\xff\xfe\x00"`, a JPEG handed over by mistake) — a clear stderr message and return 1 — an unguarded `UnicodeDecodeError` traceback is the most likely way a real user first sees this tool fail, and it leaks a stack trace instead of naming the file — *Test: Task 3 step 1 — a temp file of `b"\xff\xfe\x00"` gives `main([path]) == 1` with empty stdout.* +- **An empty (0-byte) file** — success: `"words: 0\nlines: 0\nchars: 0"` and exit 0, because emptiness is a valid answer, not an error — treating it as failure or printing nothing at all makes the tool unusable in scripts that stat many files, and it is the one input where all three counters hit their base case at once — *Test: Task 3 step 1 — empty temp file prints the all-zero report and returns 0.* +- **Stdout on the failure paths** — nothing is written to stdout when `main` returns 1; diagnostics go only to stderr — a partial or all-zero report on stdout for an unreadable file feeds a bogus record into whatever pipeline is consuming the output, with the real error invisible in a redirected log — *Test: Task 3 step 1 — assert captured stdout is empty for the missing-file case.* +- **Invocation from a shell** (`python3 -m wordstat.cli missing.txt`, or any `__main__` wiring) — the return value of `main` reaches the process exit status, e.g. `sys.exit(main(sys.argv[1:]))` — a module that calls `main(...)` and discards the result exits 0 on failure, so `&&` chains and `set -e` scripts treat a failed run as a success — *Reviewer checks: any entry-point block propagates the exit code; the spec's return-1 contract is worthless if nothing consumes it.* +- **A very large file** (multi-gigabyte log) — reading the whole file into memory is the accepted design, since the spec defines the counters over a single `text` string — worth stating once so nobody "fixes" it by streaming and quietly changes the `count_lines` semantics at chunk boundaries — *Reviewer checks: the whole-file read is intentional and documented; no test.* diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T2-ruling-free-wordstat-5.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T2-ruling-free-wordstat-5.md new file mode 100644 index 000000000..b7c617466 --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T2-ruling-free-wordstat-5.md @@ -0,0 +1,19 @@ +```markdown +## Review Focus + +Input classes and failure modes the spec implies but no task's tests exercise. Each line is a ruling: the executor treats it as binding, the same as a plan conflict. + +- Runs of whitespace, tabs, and leading/trailing whitespace in `count_words` (`" the fox \t\n"`, `" "`) — "whitespace-separated tokens" means splitting on any whitespace run, so this is 2 and 0; no empty-string tokens are ever counted — silent overcount on every real file (indented code, aligned tables) makes the whole report untrustworthy while looking plausible — Test: Task 1, `test_counter.py::test_count_words_collapses_whitespace_runs`. +- Blank lines and multiple trailing newlines in `count_lines` (`"a\n\nb"` → 3, `"a\n\n"` → 2, `"\n"` → 1) — only *one* trailing newline is absorbed; an interior blank line is a real line and `"a\n\n"` ends with a genuine empty line before the final newline — off-by-one on any file that ends with a blank line, the most common real-world shape, and the bug is invisible on the plan's two-line fixtures — Test: Task 1, `test_counter.py::test_count_lines_blank_and_repeated_trailing_newlines`. +- CRLF text (`"a\r\nb\r\n"`) — a file written on Windows reports 2 lines, and `\r` is a character like any other in `count_chars` — a Windows-authored file reports one line or a doubled line count, and the user has no way to tell the tool is wrong rather than their file — Test: Task 1, `test_counter.py::test_count_lines_crlf`. +- Non-ASCII text (`"héllo 世界"`) — `count_chars` counts characters (Unicode code points), not bytes, so this is 8 — a byte count silently mislabelled "chars" is wrong for most of the world's text; picking `len(bytes)` here is easy and never caught by ASCII fixtures — Test: Task 1, `test_counter.py::test_count_chars_counts_codepoints_not_bytes`. +- A file whose bytes are not valid UTF-8 (binary, or Latin-1 accents) — the tool fails like a tool: a message on stderr and exit 1, never a `UnicodeDecodeError` traceback — an uncaught traceback on `wordstat photo.jpg` looks like a crash, is unusable in a shell pipeline, and exits with the wrong code — Test: Task 3, `test_cli.py::test_main_undecodable_file_returns_1`. +- `path` names a directory, or an unreadable file — same contract as a missing file: stderr message, exit 1 — `IsADirectoryError`/`PermissionError` escaping `main` breaks the "returns an int" promise the spec makes for every invocation, and only `FileNotFoundError` is easy to remember to catch — Test: Task 3, `test_cli.py::test_main_directory_path_returns_1`. +- `main([])` and `main([a, b])` — wrong arity gets argparse's usage message and a nonzero exit, and the behavior is *decided* rather than accidental: let `SystemExit(2)` propagate (argparse's contract) and say so, or catch it and return 2 — a `SystemExit` leaking out of a function documented as returning `int` breaks any caller that embeds `main`, including the test suite itself — Test: Task 3, `test_cli.py::test_main_wrong_arity`. +- An empty file (0 bytes) — a valid input, not an error: `words: 0 / lines: 0 / chars: 0` and exit 0 — treating "no content" as failure makes the tool wrong on a legitimate file and inconsistent with `count_*("")` from Task 1 — Test: Task 3, `test_cli.py::test_main_empty_file_reports_zeros_and_exits_0`. +- Exact stdout bytes of a successful run — the report is three lines terminated by exactly one newline: `format_report` returns no trailing newline and `main` prints it once — a trailing blank line breaks `diff`, `wc -l`, and golden-file comparison for every downstream user; the plan's "prints the expected report" assertion can pass while the framing is wrong — Test: Task 3, `test_cli.py::test_main_stdout_is_exactly_three_lines_one_newline`. +- `format_report` given a dict missing a key, or carrying extra keys — the three named keys are required and extras are ignored; a missing key fails loudly at the formatter rather than printing `words: None` — a report with a plausible-looking wrong field is worse than a crash, and this is the seam where a `counter` rename would otherwise fail silently — Test: Task 2, `test_formatter.py::test_format_report_missing_key_raises`. +- Large stat values and field alignment (`{"words": 1234567, ...}`) — values are rendered in full, unpadded and unseparated; the `label: value` shape does not change with magnitude — a formatter that pads to a fixed width to look tidy silently changes output format at scale, breaking anything parsing the report — Reviewer checks. +- A file large enough that whole-file reading matters (hundreds of MB) — spec says read the file and compute three stats; the design's single-string API accepts the whole-file read, so this is an accepted limit, not a defect — filing it as a bug later, or "fixing" it with a streaming rewrite that changes the `counter` signatures, churns the design for a case the spec never asked for — Reviewer checks. +- Import surface: `cli` calls the real `counter`/`formatter` names, not names it wishes existed — the three modules compose only through the signatures in `design.md`; no duplicated counting logic inside `cli` — a private reimplementation in `cli` means Task 1's tests pass while the shipped binary counts differently, the one failure this suite structurally cannot see — Reviewer checks. +``` diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T2-ruling-free-wordstat-6.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T2-ruling-free-wordstat-6.md new file mode 100644 index 000000000..1f3133d0d --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T2-ruling-free-wordstat-6.md @@ -0,0 +1,21 @@ +## Review Focus + +Input classes the spec implies but no task's tests pin down. Each line is a ruling: the executor follows it when the spec is silent, and the named test is where it becomes permanent. + +- **Whitespace-only text (`" \n\t "`) into `count_words`** — zero words, not one, and no crash — a file of blank lines reports a phantom word, which is the exact case a stats tool is used to check — *Test: Task 1, `test_count_words_whitespace_only`* +- **Runs of spaces, leading/trailing whitespace, and tabs (`" a\t\tb "`) into `count_words`** — 2 words; separators collapse regardless of kind or count — `split(" ")` instead of `split()` inflates every real-world file's word count — *Test: Task 1, `test_count_words_collapses_separators`* +- **`count_lines("\n")` and `count_lines("a\n\nb")`** — 1 and 3: a trailing newline is a terminator, but an interior blank line is a line — off-by-one on the single most-eyeballed number in the report, and blank-line-separated prose undercounts — *Test: Task 1, `test_count_lines_trailing_vs_interior_newline`* +- **CRLF text (`"a\r\nb\r\n"`) into `count_lines` and `count_chars`** — 2 lines; `\r` is part of the line terminator, not content, so it is not counted as a character either — a file authored on Windows reports doubled lines or inflated chars with no hint why — *Test: Task 1, `test_crlf_line_endings`* +- **Text containing `\f`, `\v`, or U+2028 into `count_lines`** — lines are delimited by `\n`/`\r\n` only; an exotic control character inside a line does not start a new one — `str.splitlines()` silently splits on all of these, so counts drift on any file with page breaks or pasted Unicode — *Test: Task 1, `test_count_lines_ignores_exotic_separators`* +- **Non-ASCII text (`"héllo 日本語"`) into `count_chars`** — counts characters, not bytes: `count_chars("日本語")` == 3 — the spec says "characters"; a byte count is a different, wrong number for most of the world's text — *Test: Task 1, `test_count_chars_is_characters_not_bytes`* +- **Text whose final character is a newline into `count_chars`** — the newline counts (`count_chars("a\n")` == 2); "including whitespace" admits no exception for the last one — chars and lines disagree with `wc -c` and each other, and neither is defensibly right — *Test: Task 1, `test_count_chars_includes_trailing_newline`* +- **A stats dict whose keys were inserted out of order (`{"chars": 57, "words": 12, "lines": 3}`)** — the report is always words, lines, chars in that order — a report whose line order depends on how the caller built the dict cannot be diffed or scripted against — *Test: Task 2, `test_format_report_fixed_line_order`* +- **A stats dict missing a key, e.g. `{"words": 12}`** — `format_report` raises `KeyError`/`ValueError` rather than printing `None` or a partial report — a report that silently omits a stat looks like a valid answer; loud failure is recoverable, a wrong number is not — *Test: Task 2, `test_format_report_missing_key_raises`* +- **Report string's own edges: `format_report` returns exactly 3 lines with no trailing newline, and `cli` prints it followed by exactly one newline** — no blank line at the end of output, no missing final newline — the spec's example string is the contract; either extra or absent trailing whitespace breaks piping into `diff`, `tail`, or a test — *Test: Task 3, `test_main_stdout_exact_bytes`* +- **`path` names a directory** — message to stderr, return 1 — the same shape as a missing file — `open()` on a directory raises `IsADirectoryError`, which is not `FileNotFoundError`; catching only the latter dumps a traceback for a plain user typo — *Test: Task 3, `test_main_directory_path_returns_1`* +- **`path` exists but cannot be read (mode `000`, or a broken symlink)** — message to stderr, return 1 — spec's "missing file → 1" is about *unreadable input*, not one errno — a permissions problem in a script surfaces as a crash and a nonstandard exit code instead of a diagnosable error — *Test: Task 3, `test_main_unreadable_file_returns_1`* +- **File containing bytes that are not valid UTF-8 (a binary file)** — message to stderr, return 1; never a `UnicodeDecodeError` traceback — pointing the tool at a PDF or `.png` is an ordinary mistake and must produce an error message, not a stack trace — *Test: Task 3, `test_main_undecodable_file_returns_1`* +- **An empty file** — `words: 0\nlines: 0\nchars: 0` and exit 0 — an empty file is valid input with a valid answer, not an error — *Test: Task 3, `test_main_empty_file_returns_0`* +- **`main([])` and `main(["a", "b"])`** — argparse's usage message and its own exit status; `main` does not swallow `SystemExit` and return 0 — a wrapper that reports success for a malformed invocation hides the bug from whatever called it — *Reviewer checks: confirm `main` neither catches `SystemExit` nor re-maps it to 0/1* +- **`main(["-"])`** — treated as a literal filename, so it fails like any missing file (return 1); stdin is not a feature this spec grants — *Reviewer checks: confirm no special-casing of `-` was invented* +- **`main()` called with no argument** — either `argv` is required, or it defaults to `sys.argv[1:]`; it must never fall through to argparse reading `sys.argv` including the program name — a default that shifts by one argument makes the function unusable outside the console script and untestable in-process — *Reviewer checks: confirm the signature's default (if any) strips `argv[0]`* diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T3-per-input-ledgerlite-1.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T3-per-input-ledgerlite-1.md new file mode 100644 index 000000000..83027a875 --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T3-per-input-ledgerlite-1.md @@ -0,0 +1,47 @@ +```markdown +## Review Focus + +One line per input a person hands this program, naming what the spec did not foresee, what they will reasonably expect, and the test that pins it. Each test belongs in the named task's Step 1, in that task's own step style (`- [ ] **Step 1: Write the failing test**`), and must fail before that task's implementation step. + +**The `TRANSACTIONS` path argument** + +- **A path that exists but is not a readable file** — a directory (`ledgerlite report ~/statements`) or a file with no read permission; the spec only names "cannot be read", and the plan's only test is a missing path. Expect the same `ledgerlite: cannot read : ` on stderr and exit 1, never a traceback. → **Task 6**, `test_directory_path_returns_1`: `main(["report", tempfile.mkdtemp()])` returns `1`, stdout is `""`, stderr starts with `ledgerlite: cannot read `. +- **A file the bank exported in a non-UTF-8 encoding** — one `£` or `é` in latin-1 raises `UnicodeDecodeError`, which is not an `OSError`, so an `except OSError:` read guard lets it escape. Expect exit 1 with the `cannot read` message; a person cannot tell an undecodable file from an unopenable one and does not want a stack trace either way. → **Task 6**, `test_undecodable_file_returns_1`: write `b"date,amount,description\n2026-03-04,-7.50,CAF\xc9\n"` in binary, assert `main(["report", p]) == 1` and stderr starts with `ledgerlite: cannot read `. +- **An empty file** — a zero-byte export, or `--rules` and `TRANSACTIONS` swapped on the command line. The spec assumes a header row exists and says nothing about its absence; silently printing `closing balance: 0.00` for a file that is not a transactions file is the wrong answer. Expect exit 2 with a line-1 message. → **Task 2**, `test_empty_text_is_a_parse_error`: `parse_csv("")` raises `ParseError` with `line == 1` and a reason mentioning the header. + +**The transactions CSV contents** + +- **A file with no header row, data first** (`2026-03-04,-7.50,COFFEE SHOP` on line 1) — the plan validates the header, so this is caught, but a review must confirm it is caught *as an error* rather than by consuming the first transaction as the header and silently dropping it. Expect exit 2, not a report that is one row short. → **Task 2**, `test_missing_header_is_rejected_not_consumed`: `parse_csv("2026-03-04,-7.50,x\n2026-03-05,1.00,y\n")` raises `ParseError` with `line == 1`. +- **A header saved by Excel, with a UTF-8 BOM and/or spaces** (`\ufeffdate, amount, description`) — `csv.reader` hands back `"\ufeffdate"` and `" amount"`, so a literal `== ["date","amount","description"]` check rejects the most common real-world file. Expect the file to be accepted; a BOM and padding are formatting, not corruption. → **Task 2**, `test_header_tolerates_bom_and_spaces`: `parse_csv("\ufeffdate, amount, description\n2026-03-04,-7.50,x\n")` returns one `Transaction` with `amount == Decimal("-7.50")`. +- **A header in a different column order** (`date,description,amount`, common across banks) — the spec fixes one order, and a positional parser will try `date.fromisoformat("COFFEE SHOP")`. Expect a message naming the header problem at line 1, not a per-row date error that sends the reader hunting through their data. → **Task 2**, `test_reordered_header_reports_header_error`: `parse_csv("date,description,amount\n2026-03-04,x,-7.50\n")` raises `ParseError` with `line == 1` and `"header" in cm.exception.reason`. +- **A trailing blank line, or a blank line between months** — many exports end `...\n\n`; `csv.reader` yields `[]`, which a strict column-count check calls malformed and rejects the whole file. Expect blank lines to be skipped, since a person sees nothing wrong on that line. → **Task 2**, `test_blank_lines_are_skipped`: `parse_csv("date,amount,description\n\n2026-03-04,-7.50,x\n\n")` returns exactly one transaction. +- **An amount `Decimal` accepts but a bank statement never means** — `NaN`, `Infinity`, `sNaN`, and `1_000` all construct successfully, so a bare `Decimal(field)` admits them; `NaN` then poisons every total and prints `NaN` where money belongs. Expect exit 2 naming the row. → **Task 2**, `test_nan_infinity_and_underscores_are_malformed`: for each of `"NaN"`, `"Infinity"`, `"1_000"`, `parse_csv(f"date,amount,description\n2026-03-04,{v},x\n")` raises `ParseError` with `line == 2`. +- **An amount written the way people write money** — `$50.00`, `1,200.00` (quoted), `(50.00)` for money out, `+50.00`, ` 50.00 ` with padding, or an empty amount cell. The spec's "not a decimal number" covers rejecting these, but `Decimal` itself tolerates surrounding whitespace and a leading `+`, so behavior splits by accident. Expect a per-row exit-2 message naming the field, and consistent treatment of the padded and `+` forms. → **Task 2**, `test_money_notation_variants`: table-drive `"$50.00"`, `'"1,200.00"'`, `"(50.00)"`, `""` → `ParseError` at `line == 2`; assert the chosen behavior for `" 50.00 "` and `"+50.00"` explicitly rather than leaving it to `Decimal`. +- **An amount in scientific notation or with absurd magnitude** — `2.5E+3` has no fractional digits so a naive "≤2 decimals" check passes it, and a value near or beyond 28 significant digits makes `Decimal.quantize(Decimal("0.01"))` raise `InvalidOperation` inside `format_amount`, crashing at print time after parsing succeeded. Expect rejection at parse time or exact printing — not a traceback from the formatter. → **Task 2**, `test_exponent_and_oversized_amounts_are_malformed`: `"2.5E+3"` and `"1E30"` each raise `ParseError` at `line == 2`; and **Task 5**, `test_format_amount_never_raises_on_large_values`: `format_amount(Decimal("1E30"))` returns `"1000000000000000000000000000000.00"` or the module's documented handling, with no `InvalidOperation`. +- **An amount with three decimals, e.g. `1.005`** — the spec explicitly requires this be malformed with exit 2, and Task 2's interface list omits it entirely. Expect exit 2; a reader whose file has sub-cent rows must not get a report whose totals were silently rounded. → **Task 2**, `test_three_fractional_digits_is_malformed`: `parse_csv("date,amount,description\n2026-03-04,1.005,x\n")` raises `ParseError` with `line == 2`, while `1.5` and `1.50` both parse. +- **A date in a non-ISO or surprising-ISO form** — `2026/03/04` and `2026-3-4` are what people actually type, and 3.11's `date.fromisoformat` *accepts* `20260304` and week dates like `2026-W10-3`, so the parser is looser than the spec on one side and rejects common input on the other. Expect a clear line-numbered exit-2 message for slash and unpadded dates, and a deliberate decision (tested either way) on the compact and week forms. → **Task 2**, `test_date_forms`: `"2026/03/04"` and `"2026-3-4"` raise `ParseError` at `line == 2`; assert explicitly whether `"20260304"` parses to `date(2026, 3, 4)` or raises. +- **A description containing a comma, a quote, or a quoted embedded newline** — `"SHOP, LTD"` works, but a quoted field spanning two physical lines is destroyed by parsing over `text.splitlines()`, and every later line number reported to the user is off by one. Expect the description preserved whole and line numbers that match what the person sees in their editor. → **Task 2**, `test_quoted_commas_and_embedded_newline`: `parse_csv('date,amount,description\n2026-03-04,-7.50,"SHOP, LTD"\n')` yields description `"SHOP, LTD"`; and a row with `"multi\nline"` either parses as one transaction or raises `ParseError` whose `line` points at the row's first physical line. +- **A row with a bad value below a good one, run against a real file** — the spec says the whole file is rejected and nothing reaches stdout; Task 6 never wires `ParseError` to exit 2, so as planned a malformed file will traceback out of `main`. Expect `ledgerlite: :: ` on stderr, empty stdout, exit 2. → **Task 6**, `test_malformed_row_returns_2`: `main(["report", write("date,amount,description\n2026-03-04,-7.50,x\n2026-13-40,1.00,y\n")])` returns `2`, stdout is `""`, stderr matches `^ledgerlite: .*:3: `. + +**The `--rules` path and the rules file contents** + +- **A rules path that is missing or misspelled** — the spec defines a read failure only for `TRANSACTIONS`, and its silence is not permission to traceback. Expect the same `ledgerlite: cannot read : ` and exit 1; a typo'd `--rules` must not be reported as a bug in the statement. → **Task 6**, `test_missing_rules_file_returns_1`: `main(["report", csv_path, "--rules", "/no/such/rules.txt"])` returns `1`, stdout is `""`, stderr starts with `ledgerlite: cannot read /no/such/rules.txt`. +- **A rules line with no `=`** — a comment (`# food`), a heading, or a half-typed rule; `line.split("=")` unpacking raises `ValueError` and the person gets a traceback for a file they consider obviously fine. Expect such lines skipped (comments and stray text ignored) or a clear message naming the rules file and line — decided and tested, not left to an exception. → **Task 3**, `test_parse_rules_ignores_lines_without_equals`: `parse_rules("# food rules\ncoffee=food\njustsomething\n")` returns `[("coffee", "food")]`. +- **A rules line with more than one `=`** (`e=mc2=physics`, or a URL substring) — split-all crashes or drops data. Expect a split on the *first* `=`: substring `e`, category `mc2=physics`. → **Task 3**, `test_parse_rules_splits_on_first_equals`: `parse_rules("e=mc2=physics\n")` returns `[("e", "mc2=physics")]`. +- **A rules line padded with spaces** (`coffee = food`) — the category becomes `" food"` and the report prints a line with a leading space that also sorts wrongly. Expect both sides stripped so the report reads `food: -7.50`. → **Task 3**, `test_parse_rules_strips_whitespace`: `parse_rules(" coffee = food \n")` returns `[("coffee", "food")]`. +- **A rule with an empty substring** (`=food`, easy to produce by deleting a word) — `"" in description` is always true, so every transaction lands in `food` and the report is confidently wrong. Expect the rule ignored, or an error naming it. → **Task 3**, `test_empty_substring_rule_is_ignored`: `categorize([Transaction(date(2026,3,4), Decimal("1"), "MYSTERY")], parse_rules("=food\n"))[0].category is None`. +- **A rule whose category is literally `uncategorized`** — it collides with the bucket the spec reserves for unmatched rows, and two same-named lines could be printed, or the explicit one sorted alphabetically instead of last. Expect one merged `uncategorized` line, still last. → **Task 5**, `test_explicit_uncategorized_merges_and_sorts_last`: `totals_by_category` over one `category="uncategorized"` row and one `category=None` row yields a single `"uncategorized"` key with the summed amount, and `format_report` places it after `zebra`. +- **Non-ASCII descriptions matched case-insensitively** (`CAFÉ` vs `café`, `ISTANBUL` vs `istanbul`) — `str.lower()` is not reliable case folding; a person whose statement is not pure ASCII expects their rule to match. Expect `casefold()` on both sides. → **Task 3**, `test_case_insensitive_match_is_casefolded`: `categorize([Transaction(date(2026,3,4), Decimal("-3"), "CAFÉ NERO")], [("café", "food")])[0].category == "food"`. + +**The `--opening` value** + +- **A value that is not a number** (`abc`, `""`, `1,200.00`, `$100`) — `Decimal(...)` raises `InvalidOperation`, a bare traceback for a plain typo. Expect a one-line message on stderr, empty stdout, and a nonzero exit. → **Task 6**, `test_bad_opening_reports_and_exits_nonzero`: `main(["report", csv_path, "--opening", "abc"])` returns nonzero, stdout is `""`, stderr starts with `ledgerlite: ` and no `Traceback` appears. +- **`NaN` or `Infinity` as the opening** — `Decimal` accepts both, and the closing balance silently becomes `NaN`, which `format_amount` will print where money is expected. Expect the same rejection as any other non-amount. → **Task 6**, `test_nan_opening_is_rejected`: `main(["report", csv_path, "--opening", "NaN"])` returns nonzero and stdout is `""`. +- **A negative opening for an overdrawn account** (`--opening -50`) — the leading dash makes this argparse-fragile the moment a short flag is added, and it is a completely ordinary thing to pass. Expect it to work, and `--opening=-50` to work identically. → **Task 6**, `test_negative_opening_accepted_both_spellings`: `main(["report", csv_path, "--opening", "-50"])` and `main(["report", csv_path, "--opening=-50"])` both return `0` and print the same closing balance. +- **An opening with three or more decimals** (`100.005`, pasted from another tool) — the spec forbids this precision in *rows* and is silent for `--opening`, and `quantize` will round it half-even at print time so the printed closing balance does not equal opening plus the amounts. Expect it rejected like a malformed amount, so the report never disagrees with its own arithmetic. → **Task 6**, `test_over_precise_opening_is_rejected`: `main(["report", csv_path, "--opening", "100.005"])` returns nonzero, stdout is `""`. + +**The command line as a whole, and the environment it runs in** + +- **No arguments, an unknown subcommand, or `--help`** — argparse raises `SystemExit`, so `main(argv) -> int` breaks its contract and any caller (including the test suite) sees an exception instead of a code. Expect usage on stderr and a nonzero return, with `--help` printing help and returning `0`. → **Task 6**, `test_argv_shapes_return_codes`: `main([])` and `main(["summarize", "f.csv"])` return nonzero without raising `SystemExit`; `main(["report", "--help"])` returns `0`. +- **Output piped to a pager or `head`, or a stdout whose encoding cannot hold a non-ASCII category** (`PYTHONIOENCODING=ascii`, `LC_ALL=C`, `ledgerlite report x.csv | head -1`) — the first gives a `BrokenPipeError` traceback plus an "Exception ignored" note at interpreter shutdown, the second a `UnicodeEncodeError` partway through the report. Expect a quiet nonzero exit for the broken pipe and a single stderr message for the unencodable one, never a half-written report followed by a stack trace. → **Task 6**, `test_output_stream_failures_are_handled`: with stdout redirected to `io.TextIOWrapper(io.BytesIO(), encoding="ascii")` and a rules file categorizing a row as `café`, `main([...])` returns nonzero with a `ledgerlite: ` message and no traceback; with a stdout stub whose `write` raises `BrokenPipeError`, `main([...])` returns nonzero and writes nothing further. +``` diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T3-per-input-ledgerlite-2.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T3-per-input-ledgerlite-2.md new file mode 100644 index 000000000..ab8db1d31 --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T3-per-input-ledgerlite-2.md @@ -0,0 +1,39 @@ +## Review Focus + +Each line is one thing a person actually hands this program, the surprise the spec never mentions, and what they will reasonably expect to happen. Each carries the test that pins it, named against the task that owns the code. + +- **`TRANSACTIONS` — the path itself.** They will point it at a directory (`ledgerlite report .`) or at a file they can't read; the spec only says "cannot be read," and `FileNotFoundError` is the only failure the plan's test covers. Expect the same `ledgerlite: cannot read : ` line and exit 1 for `IsADirectoryError` and `PermissionError`, never a traceback. + - [ ] **Task 6, Step 1a: add `test_unreadable_path_returns_1`** — parametrize over a temp directory and a `chmod(0o000)` file; assert `rc == 1`, `out == ""`, and `err.startswith("ledgerlite: cannot read ")` for both. + +- **`TRANSACTIONS` — a row with too many fractional digits.** A bank export carrying `1.005` or an interest line of `0.333` is malformed per the spec, but Task 2's `ParseError` list stops at "not a decimal number," so `1.005` would parse and silently skew every total. Expect rejection of the whole file with the line number. + - [ ] **Task 2, Step 1a: add `test_amount_with_three_decimals_is_malformed`** — `parse_csv("date,amount,description\n2026-03-04,1.005,x\n")` raises `ParseError` with `line == 2`; `1.5`, `1.50`, `1`, and `-0.05` all parse. + +- **`TRANSACTIONS` — a malformed row reaching the CLI.** Task 6 wires `parse_csv` but names no exit 2 path at all, so today a bad row escapes as an unhandled `ParseError`. Expect `ledgerlite: :: ` on stderr, exit 2, and an empty stdout — no partial report. + - [ ] **Task 6, Step 1b: add `test_malformed_row_returns_2`** — write a CSV whose third line is `2026-03-04,abc,x`; assert `rc == 2`, `out.getvalue() == ""`, and `err.getvalue() == f"ledgerlite: {csv_path}:3: ...\n"` matched by prefix `f"ledgerlite: {csv_path}:3: "`. + +- **`TRANSACTIONS` — a file that came out of a spreadsheet.** They will hand it a UTF-8 BOM, CRLF line endings, a trailing blank line, spaces after the commas, and a quoted `"SHOP, LTD"` description. None of that is malformed data to a person; the spec's grammar just never mentions it. Expect all five to parse to the obvious three transactions. + - [ ] **Task 2, Step 1b: add `test_spreadsheet_export_quirks`** — `parse_csv("\ufeffdate, amount, description\r\n2026-03-04, -7.50, \"SHOP, LTD\"\r\n\r\n")` returns one transaction with `amount == Decimal("-7.50")` and `description == "SHOP, LTD"`. + +- **`TRANSACTIONS` — a file with no header, or no bytes.** They will export rows without the header line, or hand over a zero-byte file from a failed download. The spec says the header exists; it does not say what happens when it doesn't. Expect a line-1 malformed error and exit 2, not an `IndexError` and not a report that silently drops the first transaction as a header. + - [ ] **Task 2, Step 1c: add `test_missing_or_empty_header_is_line_1_error`** — both `parse_csv("2026-03-04,-7.50,COFFEE\n")` and `parse_csv("")` raise `ParseError` with `line == 1` and a reason naming the header. + +- **`TRANSACTIONS` — a header-only month with no activity.** A quiet account produces zero transactions; `totals_by_category` is then empty and the spec's layout ("lines, blank line, closing balance") degenerates to a report that opens with a blank line. Expect just `closing balance: 0.00`. + - [ ] **Task 5, Step 1a: add `test_empty_totals_prints_only_closing_balance`** — `format_report({}, Decimal("0"))` equals `"closing balance: 0.00"` with no leading newline. + +- **`TRANSACTIONS` — bytes that aren't UTF-8.** A UK bank export in cp1252 (`£`) or a Latin-1 `Café` decodes to a `UnicodeDecodeError` at open time. Expect the file-level failure the person already understands: `ledgerlite: cannot read : ` and exit 1. (This also settles the split between Task 2's `parse_csv(text)` and Task 6's `parse_csv(path)`: the CLI owns decoding, with `encoding="utf-8"` stated explicitly.) + - [ ] **Task 6, Step 1c: add `test_undecodable_bytes_returns_1`** — write `b"date,amount,description\n2026-03-04,-7.50,Caf\xe9\n"`; assert `rc == 1` and `err` starts with `f"ledgerlite: cannot read {csv_path}: "`. + +- **`--rules RULES` — a path that doesn't exist.** They will typo it or move the file; the spec promises a message only for `TRANSACTIONS`, and "`--rules` is optional" must not be read as "an unreadable rules file means no rules." Expect the same message shape and exit 1, never a silently all-`uncategorized` report that looks correct. + - [ ] **Task 6, Step 1d: add `test_unreadable_rules_file_returns_1`** — `main(["report", csv_path, "--rules", "/no/such/rules.txt"])` returns 1, stdout empty, `err` starts with `"ledgerlite: cannot read /no/such/rules.txt: "`. + +- **`--rules RULES` — a rules file written by a human.** They will add `# groceries` comments, a bare `coffee` with no `=`, trailing spaces (`rent = housing `), an `amazon=shopping=online`, and indented blank lines. The spec gives one grammar and no error path. Expect lines without `=` to be ignored rather than crash or invent a category, the split to be on the first `=`, and both sides stripped. + - [ ] **Task 3, Step 1a: add `test_rule_lines_are_forgiving`** — `parse_rules("# comment\ncoffee\n \nrent = housing \namazon=shopping=online\n")` equals `[("rent", "housing"), ("amazon", "shopping=online")]`. + +- **`--opening AMOUNT` — a number typed the way people type money.** They will pass `1,200.00`, `$100`, `100.005`, `abc`, or an empty string; the spec says only that it defaults to `0`. Expect `ledgerlite: --opening: ` on stderr and exit 2, not a `decimal.InvalidOperation` traceback — and `--opening -50` must be read as a negative opening balance, not an unknown flag. + - [ ] **Task 6, Step 1e: add `test_bad_opening_returns_2` and `test_negative_opening_is_accepted`** — `main(["report", csv_path, "--opening", "1,200.00"])` returns 2 with `err.startswith("ledgerlite: --opening: ")` and empty stdout; `main(["report", csv_path, "--opening", "-50"])` returns 0 and ends `"closing balance: 1542.50\n"`. + +- **`argv` — the command line with nothing useful on it.** They will run `ledgerlite` bare, misspell the subcommand (`ledgerlite repot ...`), or ask for `--help`. `argparse` answers all three with `SystemExit`, which breaks the `main(argv) -> int` contract the CLI test relies on. Expect a usage message and a returned nonzero code (0 for `--help`), with no exception escaping `main`. + - [ ] **Task 6, Step 1f: add `test_argv_errors_return_codes_not_exceptions`** — `main([])` and `main(["repot", "x.csv"])` each return a nonzero int with `"usage:"` in stderr; `main(["--help"])` returns 0; none raises `SystemExit`. + +- **The environment — stdout, and the locale it inherits.** They will pipe the report into `head` or close the pipe early, and they will run this on a machine whose default encoding isn't UTF-8. Neither is in the spec. Expect a broken pipe to end the program quietly instead of printing a `BrokenPipeError` traceback, and file reads to be explicitly UTF-8 so `LC_ALL=C` doesn't change what parses. + - [ ] **Task 6, Step 1g: add `test_broken_stdout_exits_quietly` and `test_locale_independent_read`** — redirect stdout to a stream whose `write` raises `BrokenPipeError` and assert `main([...])` returns nonzero without propagating; and run the end-to-end case with a `Café` description via `subprocess.run([sys.executable, "-m", "ledgerlite", ...], env={**os.environ, "LC_ALL": "C"})` asserting `returncode == 0`. diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T3-per-input-ledgerlite-3.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T3-per-input-ledgerlite-3.md new file mode 100644 index 000000000..95513c5df --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T3-per-input-ledgerlite-3.md @@ -0,0 +1,45 @@ +## Review Focus + +Each line is an input a person actually hands this program, the thing they are most likely to hand it that `design.md` does not mention, and what they will expect to happen. The spec's silence on any of these is not permission to traceback, mis-parse, or print a wrong number. Each line names the task that owns the code and the test to add there, in that task's step style (a test method in that task's Step 1 block, watched failing in Step 2). + +**TRANSACTIONS (positional path)** + +- **The path is a directory** (tab-completed `data/` instead of `data/march.csv`) — expect `ledgerlite: cannot read : ` and exit 1, the same as a missing file; `open()` on a directory raises `IsADirectoryError`, which is an `OSError`, so the handler must catch `OSError`, not `FileNotFoundError`. → **Task 6**, `test_directory_path_returns_1`: `main(["report", self.tmpdir])` → `1`, stderr starts `"ledgerlite: cannot read "`, stdout `""`. +- **The file is not UTF-8** (a bank export in cp1252 with `CAFÉ` in a description) — expect the exit-1 "cannot read" path with the decode failure as the reason, not a `UnicodeDecodeError` traceback. → **Task 6**, `test_undecodable_file_returns_1`: write `b"date,amount,description\n2026-03-04,-1.00,CAF\xc9\n"`, assert rc `1` and stderr starts `"ledgerlite: cannot read "`. +- **The file is empty** (0 bytes — an export that failed, or `touch`ed by hand) — expect exit 2 with a line-1 "missing header" message, not an `IndexError` off `rows[0]` and not a silent empty report. → **Task 2**, `test_empty_text_is_parse_error`: `parse_csv("")` raises `ParseError` with `line == 1` and a reason naming the header. +- **The header carries a UTF-8 BOM** (`\ufeffdate,amount,description` — what Excel writes on "Save as CSV UTF-8") — expect the file to parse normally; a bare `== "date"` comparison rejects a perfectly good export. → **Task 2**, `test_bom_header_accepted`: `parse_csv("\ufeff" + CSV)` equals `parse_csv(CSV)`. +- **The header is capitalized, reordered, or has an extra column** (`Date,Amount,Description`; `date,description,amount`; a trailing `balance`) — expect case and surrounding spaces tolerated, and any other shape rejected at line 1 rather than silently reading the description column as the amount. → **Task 2**, `test_header_case_tolerated_shape_enforced`: `parse_csv("Date, Amount, Description\n...")` parses; `parse_csv("date,description,amount\n...")` raises `ParseError` with `line == 1`. +- **An amount written the way a bank prints it** (`$1,200.00`, `1,200.00`, `(7.50)` for negative) — expect exit 2 with a message naming the bad amount; note `Decimal("1,200.00")` raises `decimal.InvalidOperation`, an `ArithmeticError` and *not* a `ValueError`, so a `try/except ValueError` around the conversion lets it escape as a traceback. → **Task 2**, `test_currency_formatted_amount_is_parse_error`: each of `"$1,200.00"`, `"1,200.00"`, `"(7.50)"` raises `ParseError` with `line == 2`. +- **An amount of `nan`, `NaN`, `inf`, or `Infinity`** — `Decimal` accepts all of these, after which every total and the closing balance print as `NaN` (or `format_amount`'s `quantize` raises `InvalidOperation`); expect them rejected as malformed, exit 2. → **Task 2**, `test_nonfinite_amount_is_parse_error`: `"nan"`, `"-Infinity"` each raise `ParseError`; guard with `Decimal.is_finite()`. +- **An amount with more than two fractional digits** (`1.005`, `2.5000`, split-bill arithmetic) — `design.md` line 31 requires exit 2 for this and **Task 2's Interfaces block omits the rule entirely**; expect it rejected, not silently rounded by `format_amount`. → **Task 2**, `test_more_than_two_fraction_digits_is_parse_error`: `"1.005"` raises `ParseError`, `"1.5"` and `"1.50"` parse. +- **An amount in exponent notation** (`1e2` from a spreadsheet, `1.5E-4`) — expect the fractional-digit rule judged from `Decimal.as_tuple().exponent` (`1e2` is fine, `1.5E-4` is 0.00015 and is not), because splitting the string on `"."` wrongly accepts `1.5E-4` and wrongly counts `1e2`. → **Task 2**, `test_exponent_notation_fraction_digits`: `parse_csv` accepts `"1e2"` as `Decimal("100")`; `"1.5E-4"` raises `ParseError`. +- **A blank line inside or at the end of the file** (trailing newlines, a blank row left by Excel) — expect blank lines skipped, not reported as "wrong column count". → **Task 2**, `test_blank_lines_skipped`: `parse_csv("date,amount,description\n\n2026-03-04,-7.50,x\n\n")` returns one transaction. +- **A quoted description containing a comma or an embedded newline** (`"COFFEE, LARGE"`, a multi-line memo field) — expect one field and correct line numbers; Task 2 Step 3's `csv.reader(text.splitlines())` splits inside the quoted field, corrupting both the row and every later line number. → **Task 2**, `test_quoted_description_with_comma_and_newline`: `'2026-03-04,-7.50,"COFFEE, LARGE\nTABLE 4"'` yields one transaction whose description contains the comma and the newline, and a following bad row still reports its true line. +- **Fields padded with spaces** (` 2026-03-04, -7.50 , COFFEE `) — expect the row to parse with the description stripped, not a date-parse failure. → **Task 2**, `test_surrounding_whitespace_tolerated`: that row parses to `date(2026, 3, 4)`, `Decimal("-7.50")`, `"COFFEE"`. +- **`-` as the path**, meaning "read stdin" — the spec defines no stdin mode; expect either stdin honored or a clean `ledgerlite: cannot read -: ...` and exit 1, never a traceback. → **Task 6**, `test_dash_path_reports_cleanly`: `main(["report", "-"])` returns `1` with stderr starting `"ledgerlite: cannot read -"`. + +**`--rules RULES` (path)** + +- **The rules path is missing or unreadable** — the spec specifies the read error only for TRANSACTIONS; expect the same `ledgerlite: cannot read : ` and exit 1, with nothing on stdout. → **Task 6**, `test_missing_rules_file_returns_1`: `main(["report", csv_path, "--rules", "/no/such/rules.txt"])` → `1`, stdout `""`. +- **A rules line with no `=`**, or a `#` comment, or a stray trailing line — expect comments and blank lines skipped and a `=`-less line reported clearly; never an unpack `ValueError` escaping to the terminal. → **Task 3**, `test_rule_line_without_equals`: `parse_rules("# my rules\ncoffee=food\n")` returns `[("coffee", "food")]`; `parse_rules("coffee food\n")` raises a clear error rather than crashing on tuple unpacking. +- **A rules line with more than one `=`** (`pay=day=income`) — expect a single split on the first `=`, giving substring `pay` and category `day=income`. → **Task 3**, `test_rule_splits_on_first_equals`: `parse_rules("pay=day=income\n") == [("pay", "day=income")]`. +- **Spaces around the `=`** (`coffee = food`, written for readability) — expect substring `coffee` and category `food`; unstripped, the substring `" coffee "` matches nothing and the report prints a leading space in the category name. → **Task 3**, `test_rule_whitespace_stripped`: `parse_rules("coffee = food\n") == [("coffee", "food")]`. +- **A rule with an empty substring** (`=food`, a half-edited line) — `"" in description` is always true, so this silently categorizes every transaction as `food`; expect it skipped or rejected. → **Task 3**, `test_empty_substring_rule_ignored`: `categorize([txn], parse_rules("=food\ncoffee=drink\n"))[0].category` is not `"food"`. +- **A rule whose category is literally `uncategorized`** (`atm=uncategorized`) — expect matched and unmatched transactions not to be silently merged into one bucket, and `uncategorized` listed exactly once, last. → **Task 5**, `test_explicit_uncategorized_category`: totals over one `category="uncategorized"` transaction and one `category=None` transaction keep the full sum, and `format_report` emits a single `uncategorized:` line, last. +- **An empty rules file** (created but not yet filled in) — expect exit 0 with everything under `uncategorized`, identical to omitting `--rules`. → **Task 6**, `test_empty_rules_file_is_all_uncategorized`: `main([...,"--rules", write("")])` → `0`, stdout has one `uncategorized:` line. + +**`--opening AMOUNT`** + +- **A negative opening balance** (`--opening -100`, an overdrawn account) — argparse reads `-100` as an option token and fails with "expected one argument"; expect `--opening -100` to work. → **Task 6**, `test_negative_opening_accepted`: `main(["report", csv_path, "--opening", "-100"])` → `0` and the closing balance reflects `-100`. +- **A non-numeric or bank-formatted opening** (`abc`, `$100`, `1,000`, an empty string) — expect an argparse-style `ledgerlite: ...` error on stderr and exit 2, not a `decimal.InvalidOperation` traceback. → **Task 6**, `test_non_numeric_opening_is_usage_error`: `main(["report", csv_path, "--opening", "$100"])` → `2`, stdout `""`, stderr non-empty. +- **An over-precise or non-finite opening** (`--opening 1.005`, `--opening nan`) — expect rejection on the same terms as row amounts, rather than `format_amount` silently half-even rounding the closing balance or printing `NaN`. → **Task 6**, `test_opening_precision_and_nonfinite_rejected`: both return `2` with stdout `""`. + +**argv and invocation** + +- **No arguments, or a mistyped subcommand or flag** (`ledgerlite`, `ledgerlite repot`, `--rule`) — argparse subparsers are *not* required by default in 3.11, so a bare invocation leaves the subcommand `None` and crashes in the dispatch; expect a usage message on stderr and exit 2. → **Task 6**, `test_no_args_prints_usage_exit_2`: `main([])` → `2`, stdout `""`, stderr contains `"usage"`; same for `main(["repot", csv_path])`. +- **How they start it at all** — the spec's invocation is `ledgerlite report ...`, but Task 6 produces only `main(argv)` with no `__main__.py` and no console entry point, so the person has nothing to run; expect `python3 -m ledgerlite report TRANSACTIONS` to work. → **Task 6**, `test_module_entry_point`: `subprocess.run([sys.executable, "-m", "ledgerlite", "report", csv_path])` exits `0` and prints the report. + +**Environment and output stream** + +- **stdout is a closed pipe** (`ledgerlite report march.csv | head -1`) — expect a quiet exit, not a `BrokenPipeError` traceback and "Exception ignored" noise on stderr. → **Task 6**, `test_broken_stdout_pipe_exits_quietly`: redirect stdout to a stream whose `write` raises `BrokenPipeError`; `main([...])` returns non-zero without raising. +- **stdout cannot encode a category name** (a `café=food` rule under `PYTHONIOENCODING=ascii` or a C-locale cron job — the only environment value that reaches this program) — expect the report written or a clean error, not a `UnicodeEncodeError` traceback mid-report. → **Task 6**, `test_non_ascii_category_on_ascii_stdout`: redirect stdout to `io.TextIOWrapper(io.BytesIO(), encoding="ascii")`; `main([...])` does not raise and returns a documented code. diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T3-per-input-ledgerlite-4.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T3-per-input-ledgerlite-4.md new file mode 100644 index 000000000..5d0836a90 --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T3-per-input-ledgerlite-4.md @@ -0,0 +1,40 @@ +## Review Focus + +Every line below is an input the person hands `ledgerlite` that `design.md` does not mention. The spec is a vision document; its silence is not permission for the input to crash, so each line states what a reasonable person expects and the test that pins it, in the owning task's step style (a `- [ ]` step added to that task, with the assertion written into that task's Step 1 test class). + +**`TRANSACTIONS` (positional argument, and the file it names)** + +- Path names a directory, or an existing file with no read permission — spec defines exit 1 only for "cannot be read"; `IsADirectoryError`/`PermissionError` must land there too, not a traceback. → *Task 6, Step 1:* `CliTests.test_directory_path_returns_1` — `main(["report", tempfile.mkdtemp()])` returns `1`, stdout empty, stderr starts `ledgerlite: cannot read `. +- File is not UTF-8 (a bank export in latin-1, `DÉPÔT` in a description) — decoding is part of reading, so expect exit 1 with the reason, not a `UnicodeDecodeError` traceback. → *Task 6, Step 1:* `CliTests.test_undecodable_file_returns_1` — write `b"date,amount,description\n2026-03-04,-1.00,CAF\xc9\n"`, assert rc `1` and stderr starts `ledgerlite: cannot read `. +- File is zero bytes (a `touch`ed file, or a failed export) — there is no header, so expect the whole-file rejection with a message, not an empty report and not `IndexError`. → *Task 2, Step 1:* `ParseTests.test_empty_file_is_parse_error` — `parse_csv("")` raises `ParseError` with `line == 1` and a reason naming the missing header. +- Header saved by Excel: UTF-8 BOM (`\ufeffdate,...`), capitalized (`Date,Amount,Description`), or padded (`date, amount, description`) — the person sees the right three columns and expects them accepted; anything genuinely different (renamed, reordered, four columns) is a line-1 rejection. → *Task 2, Step 1:* `ParseTests.test_header_bom_case_and_spacing_accepted` — all three variants parse one row; `parse_csv("amount,date,description\n")` raises `ParseError(line=1)`. +- CRLF line endings from a Windows export — with `text.splitlines()` the trailing `\r` rides along on `description`, and on a two-column-tail file poisons `amount`; expect identical results to LF input. → *Task 2, Step 1:* `ParseTests.test_crlf_rows_parse_like_lf` — `parse_csv("date,amount,description\r\n2026-03-04,-7.50,COFFEE\r\n")[0]` has `amount == Decimal("-7.50")` and `description == "COFFEE"`. +- Blank line in the middle or a trailing blank line from hand-editing — expect it skipped, with later rows still reporting their true 1-based line numbers. → *Task 2, Step 1:* `ParseTests.test_blank_lines_skipped_line_numbers_preserved` — a file with a blank line 2 and a bad date on line 3 yields one transaction only in the good case and `ParseError.line == 3` in the bad one. +- Description containing a comma or an embedded newline inside quotes (`"ACME, INC."`, a two-line memo field) — `csv` handles quoting only if the reader sees the file, not `splitlines()` output; expect the field intact and line numbers still correct. → *Task 2, Step 1:* `ParseTests.test_quoted_description_with_comma_and_newline` — `parse_csv('date,amount,description\n2026-03-04,-7.50,"ACME, INC.\nMEMO"\n')` gives one transaction whose description contains both `,` and `\n`. +- Amount strings `decimal.Decimal` happily accepts but the spec never meant: `nan`, `Infinity`, `1.5e3` — today these parse and silently make the closing balance `NaN` or a thousandfold error at exit 0; expect malformed-row rejection. → *Task 2, Step 1:* `ParseTests.test_non_finite_and_exponent_amounts_rejected` — each of `nan`, `Infinity`, `1.5e3` on line 2 raises `ParseError(line=2)`. +- Amount written for humans: `$1,200.00`, `(7.50)` for a debit, `+1.50`, ` -7.50 ` — expect surrounding whitespace and a leading `+` tolerated, and the currency/comma/parenthesis forms rejected with the line number and a reason that says what is wrong, never `InvalidOperation` escaping. → *Task 2, Step 1:* `ParseTests.test_human_written_amounts` — `parse_csv(...)` accepts `" +1.50 "` as `Decimal("1.50")`; `$1,200.00` and `(7.50)` each raise `ParseError(line=2)`. +- Date forms `datetime.date.fromisoformat` accepts on 3.11 but the spec calls ISO 8601 day precision: `20260304`, `2026-03-04T09:00`, `2026-W10-1` — and forms it rejects that people type, `03/04/2026`, `2026-3-4`. Expect only `YYYY-MM-DD`, everything else a line-numbered rejection. → *Task 2, Step 1:* `ParseTests.test_only_yyyy_mm_dd_dates_accepted` — `2026-03-04` parses; each of the five other forms raises `ParseError(line=2)`. +- Path given as `-`, expecting to pipe from another command — the spec defines no stdin mode, so expect the honest exit-1 read error for a file named `-`, not a hang. → *Task 6, Step 1:* `CliTests.test_dash_path_is_a_read_error` — `main(["report", "-"])` returns `1` with stderr starting `ledgerlite: cannot read -`. + +**`--rules RULES` (and the file it names)** + +- Rules path missing or unreadable — the spec gives an exit code only for `TRANSACTIONS`; the person expects the same message shape and exit 1, since a typo'd `--rules` otherwise silently reports everything as `uncategorized`. → *Task 6, Step 1:* `CliTests.test_missing_rules_file_returns_1` — `main(["report", csv_path, "--rules", "/no/such/rules.txt"])` returns `1`, stdout empty, stderr starts `ledgerlite: cannot read /no/such/rules.txt`. +- Rule line with no `=` (`coffee food`), an empty substring (`=food`), or an empty category (`coffee=`) — expect a clear rejection naming the rules path and line, because silently dropping a mistyped rule looks like "the tool ignored my categories". → *Task 3, Step 1:* `RulesTests.test_malformed_rule_lines_rejected` — each of `"coffee food\n"`, `"=food\n"`, `"coffee=\n"` makes `parse_rules` raise `ParseError` with `line == 1`. +- Rules file with `#` comments, indented lines, CRLF endings, or a second `=` in the value (`coffee=food=drink`) — expect comments skipped, whitespace and `\r` stripped so the category is `food` and not `food\r`, and the split on the first `=` only. → *Task 3, Step 1:* `RulesTests.test_comments_whitespace_crlf_and_second_equals` — `parse_rules("# note\r\n coffee = food \r\nrent=food=drink\r\n") == [("coffee", "food"), ("rent", "food=drink")]`. +- Two rules pointing at the same category, or a rule whose category is literally `uncategorized` — expect one merged line per category name, with the `uncategorized` bucket merged too and still printed last. → *Task 5, Step 1:* `ReportTests.test_duplicate_and_literal_uncategorized_categories_merge` — transactions categorized `"food"`, `"food"`, `"uncategorized"`, `None` give two total keys, and `format_report` puts `uncategorized` last. +- Categories differing only by case (`Food` from the rules file, `food` from another rule) — "alphabetically" to a person is not ASCII order, where every capital sorts before every lowercase. Expect case-insensitive ordering with the names printed as written. → *Task 5, Step 1:* `ReportTests.test_category_order_is_case_insensitive` — totals `{"Zoo", "apple", "Bank"}` format in the order `apple`, `Bank`, `Zoo`. + +**`--opening AMOUNT`** + +- Non-numeric value (`abc`, `$100`, `1,000`, empty string) — `decimal.InvalidOperation` is an `ArithmeticError`, so `argparse`'s `type=` handling does not catch it and the person gets a traceback; expect a usage-style message on stderr and a non-zero return. → *Task 6, Step 1:* `CliTests.test_bad_opening_is_a_clean_error` — `main(["report", csv_path, "--opening", "$100"])` returns non-zero, stdout empty, stderr mentions `--opening`, and no exception escapes. +- Negative or high-precision opening (`--opening -50`, `--opening 100.005`) — expect the negative accepted as a value rather than read as a flag, and the extra digit carried exactly through the arithmetic while the printed closing balance is quantized to two places. → *Task 6, Step 1:* `CliTests.test_negative_and_high_precision_opening` — `--opening -50` on the end-to-end CSV yields `closing balance: 1542.50`; `--opening 0.005` on a single `0.00` row prints `0.01` (half-up, deterministically). + +**`argv` as a whole** + +- Invoked with no arguments, an unknown subcommand, or `--help` — `argparse` raises `SystemExit`, so `main` returns nothing at all and the documented `main(argv) -> int` contract breaks for every caller. Expect usage on stderr and a returned non-zero code (0 for `--help`). → *Task 6, Step 1:* `CliTests.test_no_args_and_bad_subcommand_return_codes` — `main([])` and `main(["summary", "x.csv"])` each return `2` with usage text on stderr and no `SystemExit` propagating. + +**Environment** + +- stdout closed early because the report is piped to `head` or `less` and the person quits — expect a silent exit, not a `BrokenPipeError` traceback and not "Exception ignored" noise on stderr. → *Task 6, Step 1:* `CliTests.test_broken_stdout_exits_quietly` — with `sys.stdout` replaced by an object whose `write` raises `BrokenPipeError`, `main(["report", csv_path])` returns non-zero and stderr contains no traceback. +- An ASCII stdout encoding (`LC_ALL=C` with output piped) against a non-ASCII category name from the rules file — expect the report still written, with the encoding failure reported as a clean error rather than a `UnicodeEncodeError` mid-report leaving half a report on stdout. → *Task 6, Step 1:* `CliTests.test_non_ascii_category_on_ascii_stdout` — rules `café=café`, stdout wrapped in `io.TextIOWrapper(..., encoding="ascii", errors="strict")`; assert rc is `0` with the name written, or non-zero with a `ledgerlite: ` message and nothing partial on stdout. +- No environment variable configures behavior — `--rules` and `--opening` are the only knobs, so a `LEDGERLITE_RULES` or `LEDGERLITE_OPENING` in the environment must not change the output. → *Task 6, Step 1:* `CliTests.test_environment_does_not_configure_defaults` — with both variables set to nonsense via `unittest.mock.patch.dict(os.environ, ...)`, `main(["report", csv_path])` prints the same report as with a clean environment. diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T3-per-input-ledgerlite-5.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T3-per-input-ledgerlite-5.md new file mode 100644 index 000000000..d6fc99667 --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T3-per-input-ledgerlite-5.md @@ -0,0 +1,36 @@ +## Review Focus + +Each line is an input a real user hands this program, the thing they are most likely to hand it that `design.md` does not mention, what a reasonable person expects to happen, and the test that pins it — named here with its owning task instead of being edited into that task's Step 1. + +**TRANSACTIONS (the positional path)** + +- **Path is a directory** (`ledgerlite report .`) — spec only says "cannot be read"; opening a directory raises `IsADirectoryError`, not `FileNotFoundError`, so a handler narrowed to the latter crashes. Expect `ledgerlite: cannot read .: ` on stderr and rc 1. → Task 6, add to Step 1: `test_directory_path_returns_1` — `main(["report", tempfile.mkdtemp()])` returns 1, stdout empty, stderr starts `ledgerlite: cannot read `. +- **Path exists but is not readable** (`chmod 000`) — same class of failure, different `errno`; expect the same one-line message and rc 1, never a traceback. → Task 6, add to Step 1: `test_unreadable_file_returns_1` — write a file, `os.chmod(p, 0)`, assert rc 1 and the `cannot read` prefix (skip when running as root). +- **File is not UTF-8** (a bank export in cp1252, `RESTAURANT CAFÉ`) — spec says `description` is "free text" and never names an encoding, so a bare `open(path).read()` dies with `UnicodeDecodeError` in the middle of the pipeline. Expect the decode failure reported as an unreadable file: `ledgerlite: cannot read : ` and rc 1. → Task 6, add to Step 1: `test_undecodable_file_returns_1` — write `b"date,amount,description\n2026-03-04,-1.00,CAF\xe9\n"`, assert rc 1 and stdout empty. +- **File saved by Excel: UTF-8 BOM and CRLF line endings** — the BOM makes the first header cell `\ufeffdate`, so header validation rejects a file the person considers perfectly normal, and `\r` rides along on the last field. Expect the BOM stripped and CRLF accepted, giving the ordinary report. → Task 2, add to Step 1: `test_bom_and_crlf_header_and_rows` — `parse_csv("\ufeffdate,amount,description\r\n2026-03-04,-7.50,COFFEE SHOP\r\n")` returns one transaction with `description == "COFFEE SHOP"`. +- **Completely empty file, or a file whose first line is data rather than the header** — spec fixes the header row but is silent on its absence; an empty file is a plausible "I exported nothing yet" and a headerless file is a plausible hand-rolled export. Expect the empty file to be a malformed-file error naming line 1 (rc 2), not an `IndexError` and not a silent `closing balance: 0.00`. → Task 2, add to Step 1: `test_missing_header_raises_at_line_1` — `parse_csv("")` and `parse_csv("2026-03-04,-7.50,x\n")` each raise `ParseError` with `line == 1`. +- **Blank lines and trailing-newline padding between rows** — spec says nothing about them, and every editor leaves one at end of file; a blank line read as a row is "wrong column count" at a line number the person can't act on. Expect the trailing newline to be harmless (already covered by `test_header_only_is_empty`) and an interior blank line to be rejected with its own line number, whichever the implementation chooses — pinned so it can't be accidental. → Task 2, add to Step 1: `test_interior_blank_line_raises_with_line` — `parse_csv("date,amount,description\n\n2026-03-04,-1.00,x\n")` raises `ParseError` with `line == 2`. +- **`amount` written the way banks write it: `$1,200.00`, `1 200,00`, `(7.50)` for negative, `+2500.00`, `1.005`, `1e3`, `nan`, `Infinity`** — spec forbids only ">2 fractional digits" and "not a decimal number", but `Decimal("nan")`, `Decimal("Infinity")` and `Decimal("1e3")` all *construct successfully*, so a naive `try: Decimal(s)` admits values that poison every total, and Task 2's interface list currently drops the two-fractional-digit rule the spec requires. Expect all of these rejected as malformed with the offending line, and `1.5`/`1.50`/`-0.00` accepted. → Task 2, add to Step 1: `test_amount_rejects_nonfinite_exponent_and_extra_digits` — table-drive `"$1,200.00"`, `"(7.50)"`, `"1.005"`, `"1e3"`, `"nan"`, `"Infinity"`, `""` each raising `ParseError` at `line == 2`, and `"1.5"`, `"1.50"`, `"+2500.00"`, `"-0.00"` each parsing. +- **`description` containing a comma or embedded quotes** (`RENT, MARCH`, `JOE"S DINER`) — spec calls description free text and the file is a CSV, so quoted fields are the normal encoding of this; a naive `line.split(",")` reports a bogus "wrong column count" on a valid row. Expect `csv.reader` quoting honored and the description preserved verbatim for matching. → Task 2, add to Step 1: `test_quoted_description_with_comma` — `parse_csv('date,amount,description\n2026-03-04,-900.00,"RENT, MARCH"\n')` yields `description == "RENT, MARCH"`. +- **A malformed row reaching the CLI** — spec's rc 2 path (`ledgerlite: :: `, nothing on stdout) is specified but Task 6's interface only names rc 0 and rc 1, so `ParseError` would escape as a traceback. Expect the formatted message on stderr, empty stdout, rc 2. → Task 6, add to Step 1: `test_malformed_row_returns_2` — CSV with `2026-13-40,1.00,x` as line 2; assert rc 2, stdout empty, stderr matches `^ledgerlite: .*:2: `. + +**`--rules RULES` (the second file)** + +- **The rules path is missing or unreadable** — spec defines the failure message only for TRANSACTIONS and is silent here, yet a typo'd `--rules` path is at least as likely as a typo'd CSV path. Expect the same shape: `ledgerlite: cannot read : `, rc 1, nothing on stdout — not a traceback and not a silent fall-back to "everything uncategorized". → Task 6, add to Step 1: `test_missing_rules_file_returns_1` — `main(["report", csv_path, "--rules", "/no/such/rules.txt"])` returns 1 and stderr starts `ledgerlite: cannot read /no/such/rules.txt`. +- **Rules lines the format doesn't cover: a line with no `=`, a `# comment`, `coffee = food` with spaces, `coffee=` with an empty category, `=food` with an empty substring, and `netflix=sub=scription` with two `=`** — spec gives one grammar and no error behavior, so `line.split("=")` either raises `ValueError`, unpacks wrong, or silently creates a `" food"` category that sorts and prints oddly. Expect: split on the *first* `=`, surrounding whitespace stripped from both sides, blank and `#` lines skipped, and a line with no `=` or an empty side rejected or skipped by one stated rule rather than by accident. → Task 3, add to Step 1: `test_parse_rules_edge_lines` — assert `parse_rules("# note\ncoffee = food\nnetflix=sub=scription\n")` equals `[("coffee", "food"), ("netflix", "sub=scription")]`, and pin the chosen behavior for `"nonsense\n"` and `"coffee=\n"`. +- **A rule whose category is literally `uncategorized`** (`atm=uncategorized`) — spec reserves that name for unmatched rows but never says the name is reserved, and nothing stops a user typing it. Expect one `uncategorized:` line, still last, whose total merges the matched and unmatched amounts — never two lines with the same name and never a dropped total. → Task 5, add to Step 1: `test_explicit_uncategorized_merges_and_stays_last` — totals for a `"uncategorized"`-categorized txn plus a `None`-categorized txn sum into a single key, and `format_report` places it after `zoo`. +- **A rules file with CRLF endings or a BOM** — same Excel/Notepad provenance as the CSV; a trailing `\r` makes the category `"food\r"`, which prints a carriage return mid-report. Expect `\r` and BOM stripped. → Task 3, add to Step 1: `test_parse_rules_strips_bom_and_crlf` — `parse_rules("\ufeffcoffee=food\r\n")` equals `[("coffee", "food")]`. +- **An empty rules file** — a plausible "I haven't written any rules yet" that the spec doesn't mention; expect the same output as omitting `--rules`, not a crash or an empty report. → Task 6, add to Step 1: `test_empty_rules_file_all_uncategorized` — rules file `""`, assert stdout is `uncategorized: 1592.50\n\nclosing balance: 1692.50\n` for the standard CSV with `--opening 100`. + +**`--opening AMOUNT` (the argument)** + +- **A negative opening balance written the natural way, `--opening -50.00`** — spec's example uses a positive number only, but overdrafts are the whole reason this flag exists, and `argparse` reads the leading `-` as an option and fails with "expected one argument". Expect both `--opening -50.00` and `--opening=-50.00` to work. → Task 6, add to Step 1: `test_negative_opening_both_spellings` — `main(["report", csv_path, "--opening", "-50"])` and `main(["report", csv_path, "--opening=-50"])` both return 0 and print `closing balance: 1542.50`. +- **A non-numeric or non-finite opening: `abc`, `1,000`, `$100`, `100.005`, `nan`** — spec says only that it defaults to `0`, so `Decimal(value)` raises `InvalidOperation` (a traceback) for the first three and silently accepts the last two. Expect a one-line `ledgerlite: ` diagnostic on stderr with a non-zero exit and nothing on stdout. → Task 6, add to Step 1: `test_bad_opening_is_reported_not_traced` — for each of `"abc"`, `"1,000"`, `"nan"`, `"100.005"`, assert the return code is non-zero, stdout is empty, and stderr starts `ledgerlite: `. + +**argv as a whole** + +- **No arguments, an unknown subcommand, or `--help`** — spec gives one invocation and never says what a bare `ledgerlite` does; `argparse` raises `SystemExit`, which breaks the `main(argv) -> int` contract the plan promises to callers and tests. Expect `main` to return an int (2 for a usage error, 0 for `--help`) or the tests to state that `SystemExit` is the contract — pinned either way. → Task 6, add to Step 1: `test_no_args_and_unknown_command_return_usage_code` — `main([])` and `main(["summarize", "x"])` each yield exit status 2 (wrap in `assertRaises(SystemExit)` and read `.code`, or assert the returned int), with stdout empty. + +**Environment** + +- **Output destination the person chose: `ledgerlite report big.csv | head -1`, or a terminal whose encoding can't render a category like `café`** — spec assumes a plain stdout; the first raises `BrokenPipeError` at interpreter shutdown (noisy traceback after correct output), the second raises `UnicodeEncodeError` when `PYTHONIOENCODING=ascii` or the locale is `C`. Expect a quiet exit on a closed pipe and a `ledgerlite: ` diagnostic rather than a traceback on an unencodable character. → Task 6, add to Step 1: `test_output_errors_do_not_traceback` — call `main` with a stdout stub whose `write` raises `BrokenPipeError`, then one raising `UnicodeEncodeError`, asserting a non-zero int is returned and no exception escapes. diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T3-per-input-ledgerlite-6.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T3-per-input-ledgerlite-6.md new file mode 100644 index 000000000..7dd45cb7e --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T3-per-input-ledgerlite-6.md @@ -0,0 +1,43 @@ +## Review Focus + +Each line is an input this program takes from a person, the thing they are most likely to hand it that `design.md` does not mention, what they would reasonably expect, and the task that owns the code plus the test that pins it. The spec's silence on these is not permission to traceback. + +- **argv, no subcommand** — `ledgerlite` or `main([])` with nothing after it, or a typo'd `repot`. `argparse` subparsers are not required by default in 3.11, so `main([])` reaches `args.transactions` and dies with `AttributeError`; expected is a usage message on stderr and a nonzero exit. → *Task 6*, `test_no_subcommand_prints_usage`: `main([])` returns 2 (or raises `SystemExit(2)` caught and returned), stdout is empty, stderr contains `usage:`. + +- **TRANSACTIONS is a directory** — `ledgerlite report .` or a path to a folder of exports. `open()` raises `IsADirectoryError`, which is an `OSError` but not a `FileNotFoundError`; expected is the same `cannot read` path as a missing file. → *Task 6*, `test_directory_transactions_returns_1`: `main(["report", tempfile.mkdtemp()])` returns 1 and stderr starts with `ledgerlite: cannot read `. + +- **TRANSACTIONS is not UTF-8** — a bank export saved as cp1252 with `£` or `Café` in a description. `UnicodeDecodeError` is a `ValueError`, not an `OSError`, so an `except OSError` handler lets it escape; expected is `cannot read : ` and exit 1, not a traceback. → *Task 6*, `test_undecodable_transactions_returns_1`: write `b"date,amount,description\n2026-03-04,-7.50,Caf\xe9\n"`, `main(["report", path])` returns 1 and stderr starts with `ledgerlite: cannot read `. + +- **TRANSACTIONS carries an Excel BOM** — Excel's "CSV UTF-8" prefixes the file with `\ufeff`, making the first header cell `\ufeffdate`. Header validation then rejects a file the person sees as correct; expected is that it parses normally. → *Task 2*, `test_header_with_utf8_bom_is_accepted`: `parse_csv("\ufeffdate,amount,description\n2026-03-04,-7.50,x\n")` returns one `Transaction` with `amount == Decimal("-7.50")`. + +- **TRANSACTIONS has a wrong or absent header** — `Date,Amount,Description` from a different export, a reordered `amount,date,description`, an extra `,balance` column, or a file that starts straight at the data with no header at all, plus the zero-byte file. Silently treating line 1 as a header drops a real transaction or transposes every field; expected is a line-1 malformed error and exit 2. Task 2 Step 3 says "validate the header" without saying what validation rejects — decide, and cover the empty file so it is not an `IndexError`. → *Task 2*, `test_bad_header_raises_at_line_1`: `parse_csv("amount,date,description\n...")` and `parse_csv("")` each raise `ParseError` with `.line == 1`. + +- **An amount written the way people write money** — `$1,200.00`, `1,200.00`, `(50.00)` for a debit, `+50`, `" -7.50 "` with padding, or an empty cell. `Decimal("1,200.00")` raises `InvalidOperation`, which is an `ArithmeticError` and **not** a `ValueError`, so a `try/except ValueError` around the amount lets it escape uncaught; expected is `ledgerlite: :: ` and exit 2. → *Task 2*, `test_comma_and_currency_amounts_raise_parse_error`: each of `"$100"`, `"1,200.00"`, `"(50.00)"`, `""` as the amount raises `ParseError` with the offending `.line`. + +- **An amount that is a valid `Decimal` but not a number of pounds** — `NaN`, `nan`, `Infinity`, `-Infinity`, `1E3`. `Decimal("NaN")` parses without complaint, then poisons every total and the closing balance into `NaN`, and `format_amount` blows up on `quantize`. Expected is rejection as malformed. → *Task 2*, `test_nan_and_infinity_amounts_raise_parse_error`: `parse_csv("date,amount,description\n2026-03-04,NaN,x\n")` and the `Infinity` variant each raise `ParseError` with `.line == 2`. + +- **An amount with more than two fractional digits** — `1.005` from an interest or FX line. The spec names this as malformed, but Task 2's interface lists only wrong column count, unparseable date, and non-decimal amount, so the plan currently drops a spec requirement; expected is exit 2, with `1.5` and `1.50` still accepted. → *Task 2*, `test_over_precise_amount_is_malformed`: `parse_csv(...,"1.005",...)` raises `ParseError` at its line, while `"1.5"` and `"1.50"` both parse to `Decimal("1.50")` by value. + +- **A date in the person's local format** — `03/04/2026`, `4 Mar 2026`, or unpadded `2026-3-4`. `date.fromisoformat` in 3.11 also silently accepts `20260304` and datetime forms like `2026-03-04T00:00:00`; expected is a clear per-line error for the non-ISO forms rather than a crash or a mystery date. → *Task 2*, `test_non_iso_dates_raise_parse_error`: `"03/04/2026"`, `"2026-3-4"`, and `"4 Mar 2026"` each raise `ParseError` with `.line == 2`. + +- **A trailing or interior blank line in the CSV** — nearly every editor and export leaves one, and `csv.reader` yields it as a zero-column row, so "wrong column count" fires on a file the person considers clean. Expected is that wholly blank lines are skipped, while a genuinely short row (`2026-03-04,-7.50`) still errors. → *Task 2*, `test_blank_lines_are_skipped`: `parse_csv("date,amount,description\n\n2026-03-04,-7.50,x\n\n")` returns exactly one transaction; `parse_csv("date,amount,description\n2026-03-04,-7.50\n")` raises `ParseError` with `.line == 2`. + +- **`--rules` pointing at a path that does not exist** — a typo, or a rules file that has moved. The spec specifies read failure only for TRANSACTIONS, and Task 6's interface inherits that silence, so the rules read is an unguarded `open()`; expected is the same `cannot read ` message and exit 1, naming the rules path. → *Task 6*, `test_missing_rules_file_returns_1`: `main(["report", csv_path, "--rules", "/no/such/rules.txt"])` returns 1, stdout is empty, stderr starts with `ledgerlite: cannot read /no/such/rules.txt`. + +- **A rules line with no `=`** — a header or comment such as `# March rules`, or a half-typed `coffee`. `substring, category = line.split("=")` raises `ValueError: not enough values to unpack`; expected is that the line is ignored or reported, not a traceback. → *Task 3*, `test_rules_lines_without_equals_are_skipped`: `parse_rules("# March rules\ncoffee=food\nrent\n")` equals `[("coffee", "food")]`. + +- **A rules line with spaces or extra `=`** — `coffee = food` is how most people write it, and `e=commerce=shopping` is one word away. Unstripped, the substring `"coffee "` matches nothing and the category prints as `" food"` with a leading space; an unbounded `split("=")` raises on the second `=`. Expected is a split on the first `=` with both sides stripped. → *Task 3*, `test_rules_strip_and_split_on_first_equals`: `parse_rules("coffee = food\ne=commerce=shopping\n")` equals `[("coffee", "food"), ("e", "commerce=shopping")]`. + +- **A rules line with an empty substring** — `=misc` or a stray `=`. An empty substring is `in` every description, so the first such rule swallows the whole ledger into one category; expected is that it is ignored. → *Task 3*, `test_empty_substring_rule_is_ignored`: `parse_rules("=misc\ncoffee=food\n")` equals `[("coffee", "food")]`, and `categorize([Transaction(date(2026,3,4), Decimal("1"), "MYSTERY")], parse_rules("=misc\n"))[0].category` is `None`. + +- **A rules file whose category is literally `uncategorized`** — `venmo=uncategorized` is a natural thing to write. Those amounts must merge with the no-rule bucket into one line, still printed last, rather than producing two `uncategorized:` lines or sorting it alphabetically. → *Task 5*, `test_explicit_uncategorized_merges_and_stays_last`: `totals_by_category([Transaction(..., Decimal("5"), "a", "uncategorized"), Transaction(..., Decimal("2"), "b"), Transaction(..., Decimal("-1"), "c", "zoo")])` equals `{"uncategorized": Decimal("7"), "zoo": Decimal("-1")}` and `format_report` puts `uncategorized: 7.00` after `zoo: -1.00`. + +- **`--opening` that is not a bare decimal** — `$100`, `1,200.00`, `100 GBP`, `abc`, or an empty string. `Decimal("$100")` raises `InvalidOperation`, escaping as a traceback; expected is an argparse-style usage error and exit 2, consistent with the malformed-input code. → *Task 6*, `test_bad_opening_is_a_usage_error`: `main(["report", csv_path, "--opening", "$100"])` returns 2, stdout is empty, stderr mentions `--opening`. + +- **`--opening NaN` or `--opening Infinity`** — accepted by `Decimal` and quietly turns the closing balance into `NaN`; expected is rejection alongside the other bad `--opening` values. → *Task 6*, `test_nonfinite_opening_is_a_usage_error`: `main(["report", csv_path, "--opening", "NaN"])` returns 2 and stdout is empty. + +- **A negative `--opening`** — `--opening -50` for an overdraft is the common case for this tool, and it sits in argparse's negative-number special case, so it works only as long as no option string looks like a negative number. Expected is that it is read as a value, not as an unknown option. → *Task 6*, `test_negative_opening_is_accepted`: with a header-only CSV, `main(["report", csv_path, "--opening", "-50"])` returns 0 and stdout is `"closing balance: -50.00\n"`; `--opening=-50` gives the same output. + +- **`--opening 100.005`** — the spec forbids three fractional digits in a row amount but says nothing about the opening figure, and `format_amount`'s `quantize` will silently round it (banker's rounding, so `100.005` → `100.00`) into a closing balance that does not reconcile. Decide: reject it like a row amount, or accept and document the rounding — either way pin it. → *Task 6*, `test_over_precise_opening`: `main(["report", header_only_csv, "--opening", "100.005"])` returns 2 with stderr mentioning `--opening` (or, if accepted, returns 0 with stdout `"closing balance: 100.00\n"` — one assertion, chosen deliberately). + +- **Environment: no variables are read, but the locale is an input anyway** — `open(path)` without `encoding=` uses the locale default, so the same UTF-8 CSV that works on macOS fails under `LC_ALL=C` or on Windows cp1252, and stdout encoding can mangle a non-ASCII category name from the rules file. Expected is that behavior does not depend on the shell's locale. → *Task 6*, `test_reads_utf8_regardless_of_locale`: patch `locale.getpreferredencoding` to return `"ascii"`, then `main(["report", csv_with_utf8_description, "--rules", rules_with_utf8_category])` returns 0 and stdout contains the non-ASCII category — pinning explicit `encoding="utf-8"` at both read sites. diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T3-per-input-wordstat-1.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T3-per-input-wordstat-1.md new file mode 100644 index 000000000..fbdc19276 --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T3-per-input-wordstat-1.md @@ -0,0 +1,26 @@ +## Review Focus + +Each line below is one thing the person running `wordstat` hands it, the value they are most likely to hand it that `design.md` does not mention, what a reasonable person expects to happen, and the test that pins it — named against the task that owns the code. + +**The `path` argument (as it arrives in `argv`)** + +- **No argument at all** — someone types `wordstat` to see what it does. The spec says "parse a single positional `path`" and stops there. Expect argparse's usage line on stderr and a nonzero exit, never a traceback or an `IndexError`. → *Task 3, `test_cli.py`*: `main([])` raises `SystemExit` with code 2 and writes a usage message to stderr. +- **Two or more paths** — `wordstat a.txt b.txt`, expecting stats for both. Expect a usage error, not a silent report on only the first file. → *Task 3, `test_cli.py`*: `main([p1, p2])` raises `SystemExit` with code 2. +- **A path that begins with `-`** (`-notes.txt`, or a bare `-` meaning stdin) — argparse reads it as an option and reports "unrecognized arguments". Expect that error to be a usage message, and that `--` before the path makes the real file work. → *Task 3, `test_cli.py`*: `main(["-x"])` raises `SystemExit(2)`; `main(["--", path])` prints the report and returns 0. +- **A directory instead of a file** — `wordstat notes/` by tab-completion. The spec foresees only "missing file", so `IsADirectoryError` escapes as a traceback. Expect the same one-line stderr message and return 1 as a missing file. → *Task 3, `test_cli.py`*: `main([tmpdir])` returns 1 and writes a non-empty message to stderr. +- **A file that exists but cannot be read** (mode `0o000`, or inside an unreadable directory) — `PermissionError`, again outside the spec's "missing file" case. Expect stderr message and return 1. → *Task 3, `test_cli.py`*: with a temp file `chmod`ed to `0o000`, `main([path])` returns 1 and stderr is non-empty (skip the test when `os.geteuid() == 0`). + +**The contents of that file** + +- **An empty file** — `wordstat empty.txt` on a freshly touched file. Expect `words: 0 / lines: 0 / chars: 0` and exit 0, not a crash and not empty output. → *Task 3, `test_cli.py`*: `main([empty])` prints `"words: 0\nlines: 0\nchars: 0"` and returns 0. +- **Bytes that are not UTF-8** — a Latin-1 or cp1252 export, or a UTF-16 file from Notepad; the read raises `UnicodeDecodeError` after the file has already opened successfully. Expect the same treatment as any other unreadable file: one message on stderr, return 1 (or, if you instead choose `errors="replace"`, exit 0 and count the replacement characters — pick one and pin it). → *Task 3, `test_cli.py`*: a file written as `b"caf\xe9\n"` makes `main([p])` return 1 with a message on stderr, no traceback. +- **Windows CRLF line endings** — a file authored on Windows. Expect `"a\r\nb\r\n"` to be 2 lines and the `\r` not to become part of a word or an extra token. → *Task 1, `test_counter.py`*: `count_lines("a\r\nb\r\n")` == 2 and `count_words("a\r\nb")` == 2. +- **Runs of tabs and multiple/leading/trailing spaces** — an indented or column-aligned file. Expect whitespace runs to collapse, not to produce empty tokens. → *Task 1, `test_counter.py`*: `count_words(" a\t\tb \n")` == 2. +- **Blank lines, and a file that is just a newline** — prose with paragraph breaks. Expect interior blank lines to count, and `"\n"` to be one line (the spec only fixes the trailing-newline case). → *Task 1, `test_counter.py`*: `count_lines("a\n\nb\n")` == 3 and `count_lines("\n")` == 1. +- **Non-ASCII text** — accents, CJK, emoji. Expect `chars` to count characters, not bytes, so the number matches what the person sees in their editor. → *Task 1, `test_counter.py`*: `count_chars("héllo")` == 5 and `count_chars("日本語")` == 3. +- **A large file** — a log or a book, tens of megabytes. Expect it to finish and report correct numbers rather than being slowed by holding several copies of the text at once. → *Task 3, `test_cli.py`*: a generated ~10 MB temp file of known content makes `main([p])` return 0 with the expected counts. + +**Everything else the person's shell supplies** + +- **A stdout pipe that closes early** — `wordstat big.txt | head -1`. Expect a quiet exit, not a `BrokenPipeError` traceback on stderr. → *Task 3, `test_cli.py`*: run the module under `subprocess` piped into `head -1` and assert stderr contains no `Traceback`. +- **Locale/encoding environment (`LC_ALL=C`, `PYTHONIOENCODING`, `PYTHONUTF8`)** — the spec never mentions an encoding, so `open(path)` inherits whatever the shell says and the same file yields different counts (or a decode error) on different machines. Expect the counts to be independent of the environment, which means naming `encoding="utf-8"` explicitly at the read. → *Task 3, `test_cli.py`*: `subprocess` run of the CLI over a UTF-8 file with `env={"LC_ALL": "C"}` produces byte-identical output to the default-environment run. diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T3-per-input-wordstat-2.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T3-per-input-wordstat-2.md new file mode 100644 index 000000000..824d4e2c7 --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T3-per-input-wordstat-2.md @@ -0,0 +1,51 @@ +## Review Focus + +One line per thing a person hands this program. The spec names one happy path — a positional path to a readable UTF-8 text file — and says nothing about the rest; that silence is not permission for these to traceback, miscount, or exit 0 on failure. + +**The command line (`argv`)** + +- **No path at all** — someone runs `wordstat` bare to see what it does. `argparse` prints usage and raises `SystemExit(2)`; `main` never returns, so any caller doing `sys.exit(main(argv))` sees an exception path instead of an int. A reasonable person expects a usage message on stderr and a nonzero exit, no traceback. + - Test: add to `test_cli.py` (Task 3, step 1): `test_no_path_argument_exits_nonzero_with_usage` — `assertRaises(SystemExit)` around `main([])`, assert code is nonzero and stderr (captured) mentions usage. +- **Two or more paths** — habit from `wc a.txt b.txt`. Spec defines a *single* positional, so the second file is silently rejected or silently ignored depending on the `nargs` chosen. A reasonable person expects a clear usage error and no report printed, rather than stats for only the first file with no warning. + - Test: add to `test_cli.py` (Task 3, step 1): `test_extra_path_arguments_exit_nonzero_and_print_no_report` — `main(["a.txt", "b.txt"])` raises `SystemExit` with nonzero code and stdout stays empty. +- **`-` as the path** — the universal "read stdin" convention from `wc`/`cat`. The spec doesn't mention stdin, so `open("-")` is attempted and fails. A reasonable person expects either stdin to be read or the ordinary "no such file" message and exit 1 — not a bare `FileNotFoundError` traceback. + - Test: add to `test_cli.py` (Task 3, step 1): `test_dash_path_reports_error_and_returns_1` — `main(["-"])` returns 1 and writes a message naming `-` to stderr. +- **`--help`** — the first thing anyone types. Exits 0 via `SystemExit`, which is correct, but only if `main` is not wrapped in a bare `except Exception` that swallows it into exit 1. + - Test: add to `test_cli.py` (Task 3, step 1): `test_help_exits_zero` — `assertRaises(SystemExit)` around `main(["--help"])`, assert code is 0. + +**The file at that path** + +- **A directory** (`wordstat .`, or a path completed by the shell to a folder). `open()` raises `IsADirectoryError`, which is *not* `FileNotFoundError`, so a `try/except FileNotFoundError` written straight from the spec lets it escape as a traceback. A reasonable person expects the same one-line stderr message and exit 1 as a missing file. + - Test: add to `test_cli.py` (Task 3, step 1): `test_directory_path_reports_error_and_returns_1` — `main([tempfile.mkdtemp()])` returns 1 and writes a message to stderr. +- **A file they can't read** — root-owned or mode `000`. `PermissionError`, again not `FileNotFoundError`. A reasonable person expects a message and exit 1. + - Test: add to `test_cli.py` (Task 3, step 1): `test_unreadable_file_reports_error_and_returns_1` — `chmod(path, 0o000)` on a temp file, `main([path])` returns 1, stderr non-empty (skip when running as root). +- **A file that isn't UTF-8** — a Latin-1 or UTF-16 export, or any binary the person pointed at by mistake. `open().read()` raises `UnicodeDecodeError` mid-read. A reasonable person expects a message and exit 1, not a traceback and not a half-printed report. + - Test: add to `test_cli.py` (Task 3, step 1): `test_undecodable_file_reports_error_and_returns_1` — write `b"caf\xe9\n"` to a temp file, `main([path])` returns 1, stdout empty, stderr non-empty. +- **An empty file** — the spec's counts are defined on `""`, but nothing says what the *report* looks like. A reasonable person expects the same 3-line report reading `words: 0 / lines: 0 / chars: 0` and exit 0, not blank output. + - Test: add to `test_cli.py` (Task 3, step 1): `test_empty_file_prints_zero_report_and_returns_0` — assert stdout equals `"words: 0\nlines: 0\nchars: 0\n"`. +- **A file written on Windows (CRLF)** — extremely common in handed-over text. `"a\r\nb\r\n"` must be 2 lines, and `\r` must not ride along inside the last word of each line or inflate the word count. The spec's `"a\nb"` examples never say. + - Test: add to `test_counter.py` (Task 1, step 1): `test_crlf_text` — `count_lines("a\r\nb\r\n") == 2` and `count_words("a\r\nb\r\n") == 2`. +- **A file with blank lines and trailing blank lines** — paragraph-separated prose. Spec says a trailing newline adds no line; it is silent on `"a\n\n"`. A reasonable person expects the blank line to count (2), and interior blank lines to count too. + - Test: add to `test_counter.py` (Task 1, step 1): `test_blank_lines_count` — `count_lines("a\n\n") == 2`, `count_lines("a\n\nb\n") == 3`. +- **A file padded with tabs, runs of spaces, or leading/trailing whitespace** — real prose, not the spec's single-spaced example. Naive `text.split(" ")` yields empty tokens. A reasonable person expects `" a\t\tb "` to be 2 words. + - Test: add to `test_counter.py` (Task 1, step 1): `test_words_ignore_repeated_and_tab_whitespace` — `count_words(" a\t\tb ") == 2`, `count_words(" ") == 0`. +- **A file with non-ASCII text** — accents, CJK, emoji. "Characters including whitespace" must mean characters, not bytes: `count_chars("café") == 4`, not 5. Nothing in the spec forces the distinction. + - Test: add to `test_counter.py` (Task 1, step 1): `test_chars_counts_characters_not_bytes` — `count_chars("café") == 4`, `count_chars("日本 語") == 4`. +- **A very large file** — a log or a dumped corpus. Reading whole-file into a string is what the spec implies and is acceptable, but a reasonable person expects it to finish rather than be quadratic; this is a note to keep counting single-pass, not a behavior test. + - Test: none added; instead Task 1, step 2 must implement each count as one pass over the text with no repeated `split()` of the whole document per statistic. + +**Whatever `cli` hands `formatter`** + +- **A stats dict with a missing or misspelled key** — the realistic failure once a fourth statistic is added later. `"words: {stats['words']}"` raises `KeyError` deep inside formatting. A reasonable person expects a loud, named failure rather than a report with a blank or `None` where a number belongs. + - Test: add to `test_formatter.py` (Task 2, step 1): `test_missing_key_raises_keyerror` — `assertRaises(KeyError)` on `format_report({"words": 1, "lines": 2})`. +- **Counts that are large** — a corpus with millions of words. The report must not gain thousands separators or scientific notation on some platforms; formatting must be plain `int`. + - Test: add to `test_formatter.py` (Task 2, step 1): `test_large_counts_render_as_plain_integers` — `format_report({"words": 1234567, "lines": 1, "chars": 9999999})` contains `"words: 1234567"`. + +**The environment** + +- **The ambient locale / `LANG`, `LC_ALL`, `PYTHONIOENCODING`** — the person's shell, not something they think of as input. A bare `open(path)` decodes using `locale.getpreferredencoding()`, so the *same* file gives different `chars` counts (or an error) on a `C`-locale machine versus a UTF-8 one. A reasonable person expects the same file to produce the same numbers everywhere. + - Test: add to `test_cli.py` (Task 3, step 1): `test_file_read_as_utf8_regardless_of_locale` — write UTF-8 `"café\n"`, run `main([path])` with `locale.getpreferredencoding` patched to return `"ascii"`, assert stdout reports `chars: 5` and return is 0. Task 3, step 2 must pass `encoding="utf-8"` explicitly. +- **A closed or broken stdout** — `wordstat big.txt | head -1`, or output redirected to a full disk. `print` raises `BrokenPipeError`/`OSError` after the pipe closes. A reasonable person expects a silent, nonzero-or-zero exit without a `BrokenPipeError` traceback on their terminal. + - Test: add to `test_cli.py` (Task 3, step 1): `test_broken_stdout_does_not_traceback` — redirect stdout to an object whose `write` raises `BrokenPipeError`, assert `main([path])` returns an int rather than propagating. +- **The current working directory** — every path the person types is relative to it. A relative path must be resolved against the process CWD, not against the package directory, and the error message must echo the path as typed so they can see what was actually looked up. + - Test: add to `test_cli.py` (Task 3, step 1): `test_relative_path_resolves_against_cwd_and_error_echoes_input` — `chdir` into a temp dir holding `notes.txt`, assert `main(["notes.txt"])` returns 0; then assert `main(["nope.txt"])` returns 1 and stderr contains `"nope.txt"`. diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T3-per-input-wordstat-3.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T3-per-input-wordstat-3.md new file mode 100644 index 000000000..3260867cc --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T3-per-input-wordstat-3.md @@ -0,0 +1,20 @@ +```markdown +## Review Focus + +One line per input this program takes from the person running it, the thing they are most likely to hand it that `design.md` does not mention, and the test that pins the answer to the task owning that code. The spec describes the happy path — one existing, readable, UTF-8 text file — so every line below is a case its silence must not turn into a traceback. + +- **`path` argument, omitted** — they type `wordstat` with no file, expecting a usage message on stderr and a non-zero exit, not a bare `IndexError` or a `SystemExit` escaping through `main`. → Test in Task 3: `main([])` raises `SystemExit` with code 2 and writes usage to stderr (argparse's own behavior, asserted so it stays deliberate). +- **`path` argument, more than one** — they type `wordstat a.txt b.txt` expecting either both files reported or a clear error, never silent stats for `a.txt` only. → Test in Task 3: `main(["a.txt", "b.txt"])` raises `SystemExit` (code 2) rather than returning 0 with one file's report. +- **`path` argument naming a directory** — they tab-complete `wordstat notes/` and expect the same treatment the spec gives a missing file: message to stderr, return 1, no `IsADirectoryError` traceback. → Test in Task 3: `main([tmpdir])` returns 1 and stderr is non-empty. +- **`path` argument naming an unreadable file** — a root-owned or `chmod 000` file, where they expect the missing-file path, not `PermissionError`. → Test in Task 3: `main([path])` on a `chmod(0o000)` temp file returns 1 with a message on stderr (skip the test when running as root, where the chmod does not bite). +- **`path` argument given as `-`** — the near-universal CLI convention for stdin, which they will try when piping; the spec never mentions stdin, so the honest outcome is a deliberate one — either read stdin or report `-` as an unopenable file with return 1 — but not a traceback. → Test in Task 3: `main(["-"])` returns 1 with a message on stderr, pinning the chosen behavior (change the assertion if Task 3 elects to read stdin instead). +- **File contents that are not valid UTF-8** — a `latin-1` CV or a `.docx` they mistook for text; they expect a "cannot read this file" message and return 1, not a `UnicodeDecodeError` half-way through. → Test in Task 3: `main([path])` on a file whose bytes are `b"caf\xe9\n"` returns 1 and writes to stderr (or, if Task 3 decodes with `errors="replace"`, asserts a report is printed and 0 returned — one or the other, chosen explicitly). +- **File contents that are empty** — a freshly `touch`ed file; they expect `words: 0 / lines: 0 / chars: 0`, three lines and exit 0, not a crash or empty output. → Test in Task 3: `main([empty_path])` prints `"words: 0\nlines: 0\nchars: 0"` and returns 0. +- **File contents with CRLF line endings** — anything authored on Windows; they expect `lines` to match what their editor shows and the stray `\r` not to be counted as text. → Test in Task 1: `count_lines("a\r\nb\r\n") == 2` and `count_chars("a\r\nb\r\n")` matches the documented character rule for the `\r` explicitly (and Task 3 opens the file in text mode so universal newlines apply). +- **File contents that are whitespace only** — spaces, tabs, and blank lines from a stripped-out draft; they expect `words: 0`, not `1`. → Test in Task 1: `count_words(" \t\n\n ") == 0` and `count_lines("\n\n\n") == 3`. +- **File contents with non-ASCII characters** — accents or CJK or emoji; they expect `chars` to count characters as they see them, not UTF-8 bytes. → Test in Task 1: `count_chars("héllo") == 5` and `count_words("naïve café") == 2`. +- **File contents with no trailing newline** — the common case for hand-edited files; the spec pins `"a\nb"` == 2, so the last unterminated line must still count. → Test in Task 1: `count_lines("a\nb\nc") == 3` (already implied by the spec; asserted so the "count `\n`" shortcut cannot pass). +- **The stats dict handed to `format_report`** — all zeros and large values reach it from real files; they expect exactly three lines, no trailing newline, so `wordstat f | wc -l` and downstream `cut`/`grep` behave. → Test in Task 2: `format_report({"words": 0, "lines": 0, "chars": 0}) == "words: 0\nlines: 0\nchars: 0"` and the result does not end in `"\n"`. +- **Relative paths resolved against the current working directory** — they run `wordstat notes.txt` from wherever they are; the report must be for the file in *their* cwd, with no hidden `os.chdir` or path rewriting. → Test in Task 3: `main(["notes.txt"])` run with cwd set to a temp directory containing `notes.txt` prints that file's report and returns 0. +- **stdout closed early** — they pipe into `head -1` or `less` and quit, expecting silence, not a `BrokenPipeError` traceback and the "Exception ignored" noise on stderr. → Test in Task 3: `main([path])` with `sys.stdout` replaced by a stream whose `write` raises `BrokenPipeError` returns non-zero (or exits quietly) without the exception propagating. +``` diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T3-per-input-wordstat-4.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T3-per-input-wordstat-4.md new file mode 100644 index 000000000..688ef586e --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T3-per-input-wordstat-4.md @@ -0,0 +1,44 @@ +```markdown +## Review Focus + +Every input below is something the person running `wordstat` can hand it today. The spec names only two: a `path` that exists and a `path` that doesn't. Its silence on the rest is not permission for them to crash, so each line names the input, the likely surprise, what a reasonable person expects, and the test that owns it. + +- **`argv` with no path at all** — someone types `wordstat` to see what it does. Expect a usage message on stderr and a non-zero exit, not a `SystemExit` traceback leaking out of `main`; decide and pin whether the code is argparse's 2 or the spec's 1. + - Task 3, step 1 (`test_cli.py`): `main([])` raises no traceback, writes usage to stderr, and returns a non-zero code (assert the exact code chosen). + +- **`argv` with more than one path** — `wordstat *.txt` in a directory of several files. Expect a usage message naming the extra argument and a non-zero exit, not silently reporting on only the first file. + - Task 3, step 1 (`test_cli.py`): `main([path_a, path_b])` returns the same non-zero code as the no-argument case and prints no report to stdout. + +- **A `path` value that begins with `-`** — a real filename like `-notes.txt`, or a mistyped `--path file.txt`. argparse reads it as an unknown option. Expect a clear "unrecognized arguments" message and non-zero exit, and `wordstat -- -notes.txt` to work on the real file. + - Task 3, step 1 (`test_cli.py`): `main(["-notes.txt"])` returns non-zero with stderr text; `main(["--", path])` on a temp file named `-notes.txt` prints the report and returns 0. + +- **An empty-string `path`** — `wordstat "$FILE"` where the shell variable is unset. Expect the same treatment as a missing file: message to stderr, return 1, no `FileNotFoundError` traceback. + - Task 3, step 1 (`test_cli.py`): `main([""])` returns 1 and writes a non-empty message to stderr. + +- **A `path` that is a directory** — `wordstat notes/` by tab-completion. Python raises `IsADirectoryError`, which is not `FileNotFoundError`. Expect the missing-file path: message to stderr, return 1. + - Task 3, step 1 (`test_cli.py`): `main([tmpdir])` returns 1 and writes a message mentioning the path to stderr. + +- **A `path` that exists but is unreadable** — a root-owned or `chmod 000` file. `PermissionError` is again not `FileNotFoundError`. Expect an error naming the file and return 1. + - Task 3, step 1 (`test_cli.py`): with a temp file `chmod`ed to `0o000` (skip the test when running as root), `main([path])` returns 1 and writes to stderr. + +- **File contents that are not valid UTF-8** — a PDF, a JPEG, a `latin-1` text file dragged onto the terminal. `open(path).read()` raises `UnicodeDecodeError`. Expect an error saying the file could not be read as text and return 1, not a traceback and not silently mangled counts. + - Task 3, step 1 (`test_cli.py`): a temp file of `b"\xff\xfe\x00\x81"` makes `main([path])` return 1 and write a message to stderr. + +- **An empty file** — `wordstat empty.log` on a freshly rotated log. Expect a full report of zeros and exit 0, not a blank line or a crash on `stats` construction. + - Task 3, step 1 (`test_cli.py`): a zero-byte temp file makes `main([path])` print exactly `"words: 0\nlines: 0\nchars: 0\n"` and return 0. + +- **File contents that are whitespace only** — a file of blank lines, or one holding just `"\n"`. Expect words 0 while lines still counts the blank lines; `count_words` must not report 1 for a single space. + - Task 1, step 1 (`test_counter.py`): `count_words(" ")` == 0, `count_words("\n\t \n")` == 0, `count_lines("\n\n")` == 2, `count_chars("\n\n")` == 2. + +- **File contents with CRLF line endings** — a file written on Windows or pulled from a zip. Expect lines counted per line and `\r` never left glued to a word: `"a\r\nb\r\n"` is 2 lines, 2 words, 6 chars. + - Task 1, step 1 (`test_counter.py`): `count_lines("a\r\nb\r\n")` == 2, `count_words("a\r\nb\r\n")` == 2, `count_chars("a\r\nb\r\n")` == 6. + +- **File contents with non-ASCII text** — accented prose, CJK, an emoji. Expect `count_chars` to count characters, not UTF-8 bytes, and non-breaking or ideographic spaces to be handled by whatever splitting rule is chosen, consistently. + - Task 1, step 1 (`test_counter.py`): `count_chars("héllo")` == 5, `count_words("日本 語")` == 2, `count_chars("🙂")` == 1. + +- **Very large file contents** — `wordstat access.log` on a multi-gigabyte log. The spec's `read()`-everything reading is a memory ceiling the spec never states. A reasonable person expects it to finish rather than exhaust memory; if the plan keeps whole-file reads, say so out loud as a documented limit rather than leaving it implicit. + - Task 3, step 1 (`test_cli.py`): `main([path])` on a temp file of ~5 MB of repeated lines returns 0 and reports counts matching the generated content (a guard that the reading strategy stays correct on non-trivial sizes). + +- **Ambient stdout — the only environment value this program touches; it reads no environment variables** — `wordstat big.txt | head -1` closes the pipe early. Expect a quiet exit, not a `BrokenPipeError` traceback and "Exception ignored" noise at interpreter shutdown. + - Task 3, step 1 (`test_cli.py`): with `sys.stdout` patched to an object whose `write` raises `BrokenPipeError`, `main([path])` returns a non-zero code and lets no exception escape. +``` diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T3-per-input-wordstat-5.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T3-per-input-wordstat-5.md new file mode 100644 index 000000000..e7416ce9d --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T3-per-input-wordstat-5.md @@ -0,0 +1,32 @@ +```markdown +## Review Focus + +The spec names one argument (`path`) and one file (whatever `path` points at), and foresees exactly one thing going wrong: the file is missing. Everything below is something a person will plausibly hand this program that the spec did not mention. None of it may raise an uncaught exception or print a traceback: the contract for every bad input is a one-line message on stderr and exit 1, and the contract for every readable file is a 3-line report and exit 0. + +Inputs from the command line (all owned by **Task 3**, `wordstat/cli.py`): + +- **No `path` at all** — `wordstat` typed bare. Expect the argparse usage message on stderr and a nonzero exit, not a traceback or a report of zeros. → Task 3 test: `- \`main([])\` raises \`SystemExit\` with code 2 and writes "usage:" to stderr.` +- **Two or more paths** — `wordstat a.txt b.txt`, expecting stats for both. Expect a usage error rather than silently ignoring `b.txt`. → Task 3 test: `- \`main(["a.txt", "b.txt"])\` raises \`SystemExit\` with code 2; stdout is empty.` +- **An unknown flag** — `wordstat --words notes.txt`. Expect a usage error naming the bad option. → Task 3 test: `- \`main(["--words", path])\` raises \`SystemExit\` with code 2 and mentions \`--words\` on stderr.` +- **`path` is a directory** — `wordstat .` or a tab-completed folder name. `open()` raises `IsADirectoryError`, which is not `FileNotFoundError`, so the spec's one error branch misses it. Expect the same friendly message and exit 1. → Task 3 test: `- \`main([str(tmpdir)])\` returns 1, writes a message naming the path to stderr, prints nothing to stdout.` +- **`path` exists but is not readable** — a root-owned or `chmod 000` file. `PermissionError`, again not `FileNotFoundError`. Expect message and exit 1. → Task 3 test: `- for a temp file \`chmod\`ed to \`0o000\`, \`main([path])\` returns 1 and writes to stderr (skip the test when running as root).` +- **`path` is `-`** — the near-universal shell idiom for "read stdin". The spec is silent, so pick one behavior and pin it: treat `-` as a literal filename, which lands in the not-found branch. Expect a clear message, exit 1, and no hang waiting on a terminal. → Task 3 test: `- \`main(["-"])\` returns 1 and writes a message to stderr without reading stdin.` +- **`path` is the empty string** — `wordstat ""` from an unset shell variable. Expect message and exit 1, not an `IsADirectoryError` or a confusing empty-quoted message. → Task 3 test: `- \`main([""])\` returns 1 and writes a non-empty message to stderr.` +- **`path` contains an unexpanded `~`** — `wordstat "~/notes.txt"`, quoted so the shell left it alone. Expect the not-found message to echo the path the person typed so they can see why it failed; expansion is not required. → Task 3 test: `- \`main(["~/nope.txt"])\` returns 1 and the stderr message contains \`~/nope.txt\`.` + +Inputs from the file's bytes (counting rules owned by **Task 1**, `wordstat/counter.py`; decoding and reading owned by **Task 3**): + +- **A file that is not valid UTF-8** — a latin-1 export, or any binary a person points at by mistake. `read()` raises `UnicodeDecodeError` mid-report. Expect a message and exit 1, not a traceback. → Task 3 test: `- for a temp file containing \`b"caf\xe9"\`, \`main([path])\` returns 1 and writes a message to stderr.` +- **A file with CRLF line endings** — anything authored on Windows. The `\r` must not become a word or an extra line; `chars` still counts it, since the spec counts all whitespace. → Task 1 test: `- \`count_lines("a\r\nb\r\n")\` == 2; \`count_words("a\r\nb\r\n")\` == 2; \`count_chars("a\r\n")\` == 3.` +- **A file whose last line has no trailing newline** — the common case for hand-edited files; the spec only shows the trailing-newline pair. → Task 1 test: `- \`count_lines("a")\` == 1; \`count_lines("a\nb\nc")\` == 3.` +- **A file with blank lines and tabs** — a paragraph-separated document. Blank lines still count as lines; tabs separate words and are not words themselves. → Task 1 test: `- \`count_lines("a\n\nb\n")\` == 3; \`count_words("a\t\tb\n \n")\` == 2.` +- **A completely empty file** — `touch notes.txt`, or `/dev/null`. Expect a real report of zeros and exit 0, not an error and not blank output. → Task 3 test: `- for an empty temp file, \`main([path])\` prints \`"words: 0\nlines: 0\nchars: 0"\` and returns 0.` +- **Zero counts reaching the formatter** — the empty file above must render as digits, not as blanks, `None`, or a dropped line; the report is always exactly 3 lines. → Task 2 test: `- \`format_report({"words": 0, "lines": 0, "chars": 0})\` == \`"words: 0\nlines: 0\nchars: 0"\` and has exactly 3 lines.` +- **A file of non-ASCII text** — accents, CJK, emoji. `chars` must count characters, not bytes, or the number will silently disagree with what the person sees on screen. → Task 1 test: `- \`count_chars("héllo")\` == 5; \`count_chars("日本")\` == 2; \`count_words("héllo wörld")\` == 2.` + +Inputs from the environment (owned by **Task 3**): + +- **A non-UTF-8 stdout encoding** — `LC_ALL=C` or `PYTHONIOENCODING=ascii` in a cron job or Docker image, with a non-ASCII filename in the error message or non-ASCII text in the file. Printing raises `UnicodeEncodeError` after the work succeeded. Expect the report to still be emitted, or at worst a clean exit 1 — never a traceback over a correct answer. → Task 3 test: `- with \`sys.stdout\` replaced by a \`TextIOWrapper\` over \`io.BytesIO\` using \`encoding="ascii", errors="replace"\`, \`main([path])\` for a file of non-ASCII text returns 0 and writes 3 lines.` +- **stdout closed early** — `wordstat big.txt | head -1`, which raises `BrokenPipeError` on flush. Expect a silent, nonzero-but-not-crashing exit rather than the `Exception ignored in: <_io.TextIOWrapper>` noise Python prints at shutdown. → Task 3 test: `- with \`sys.stdout\` replaced by an object whose \`write\` raises \`BrokenPipeError\`, \`main([path])\` returns nonzero and raises nothing.` +- **The current working directory** — every relative `path` is resolved against it, and the spec never says so. Expect a relative path to resolve against the process CWD, and the error message for a missing relative path to be unambiguous about what was tried. → Task 3 test: `- with \`os.chdir(tmpdir)\`, \`main(["notes.txt"])\` reads \`tmpdir/notes.txt\` and returns 0.` +``` diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T3-per-input-wordstat-6.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T3-per-input-wordstat-6.md new file mode 100644 index 000000000..a61a09c89 --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T3-per-input-wordstat-6.md @@ -0,0 +1,27 @@ +```markdown +## Review Focus + +Each line below is an input a person actually hands `wordstat` — the file it reads, the arguments on the command line, the environment the process runs in — paired with what the design does not say about it. `design.md` describes the happy path (a text file, one path argument, a three-line report); its silence elsewhere is not a licence to traceback. Each line names the task that owns the code and the test that pins the behavior, written in that task's step style. + +- **Bytes of the file at `path` — not valid UTF-8.** People run this on whatever is lying around: a `.pdf`, a `.png`, a Latin-1 `README`. `open(path).read()` raises `UnicodeDecodeError` and the user sees a traceback for an ordinary mistake. Expectation: a one-line message on stderr naming the file and return 1, exactly like a missing file — never a stack trace. → **Task 3**, `test_cli.py`: writing `b"caf\xe9 \x00\xff"` to a temp file, `main([path])` returns 1 and the traceback text `UnicodeDecodeError` does not appear in stderr. + +- **Bytes of the file at `path` — a UTF-8 BOM at the front.** Files authored on Windows or exported from Excel start with `\ufeff`; nothing crashes, the first word count is just quietly wrong and `chars` is one too high. Expectation: the BOM is not a character and not part of the first word — `"\ufeffthe quick"` is 2 words, 9 chars. → **Task 1**, `test_counter.py`: `count_words("\ufeffthe quick brown fox")` == 4; `count_chars("\ufeffabc")` == 3. + +- **Contents of the file at `path` — completely empty (0 bytes).** `touch`ed files, truncated logs, `/dev/null`. The design fixes `count_lines("")` == 0 but never says what the CLI prints, and it is easy to write a `main` that reports 1 line for no text. Expectation: a normal report of `words: 0` / `lines: 0` / `chars: 0` and exit 0 — empty input is valid input, not an error. → **Task 3**, `test_cli.py`: `main([path])` for an empty temp file prints `"words: 0\nlines: 0\nchars: 0"` and returns 0. + +- **Contents of the file at `path` — CRLF line endings.** Any file that has been near Windows or a Git checkout with `core.autocrlf`. A naive `text.split("\n")` counts lines correctly but leaves a `\r` glued onto every word, and `chars` silently includes the carriage returns. Expectation: `"a\r\nb\r\n"` is 2 lines and 2 words, and the report never contains a stray `\r`. → **Task 1**, `test_counter.py`: `count_lines("a\r\nb\r\n")` == 2; `count_words("a\r\nb\r\n")` == 2. + +- **Contents of the file at `path` — blank lines and trailing blank lines.** Prose files end with a blank line or two; the design only pins `"a\nb\n"` == 2, leaving `"a\n\nb\n\n"` ambiguous. Expectation: blank lines are lines (they are lines a person can see), only the single final newline is absorbed — `"a\n\nb\n\n"` is 4. → **Task 1**, `test_counter.py`: `count_lines("a\n\nb\n\n")` == 4; `count_lines("\n")` == 1. + +- **`path` naming a directory.** `wordstat .` and `wordstat src/` are typed constantly, often by tab-completion. The design's only error case is "missing file", so an `except FileNotFoundError` lets `IsADirectoryError` through as a traceback. Expectation: the same clean stderr message and return 1. → **Task 3**, `test_cli.py`: `main([tempfile.mkdtemp()])` returns 1 and writes a non-empty message to stderr. + +- **`path` naming a file the user may not read.** Reading someone else's file, or a root-owned log. `PermissionError` is not `FileNotFoundError` and escapes the same way. Expectation: stderr message, return 1. → **Task 3**, `test_cli.py`: for a temp file `chmod`ed to `0o000`, `main([path])` returns 1 (skip the test when running as root, where the `chmod` does not bite). + +- **`argv` with no positional argument at all.** Someone types `wordstat` to see what it does. `argparse` prints usage and raises `SystemExit(2)`; the design says `main(argv)` *returns* an int, so a caller that trusts the signature gets an exception instead. Expectation: usage on stderr and a non-zero exit — and whichever of the two `main` does, it is pinned by a test rather than left to accident. → **Task 3**, `test_cli.py`: `with self.assertRaises(SystemExit) as e: main([])`, and `e.exception.code` == 2. + +- **`argv` with more than one path.** The natural guess for a stats tool is `wordstat *.txt`, which the shell expands to many paths. The design specifies exactly one positional, so extras must fail loudly rather than silently reporting on only the first file. Expectation: usage/error on stderr, non-zero exit, and no report printed for `a.txt` alone. → **Task 3**, `test_cli.py`: `assertRaises(SystemExit)` for `main([path_a, path_b])`, and stdout is empty. + +- **Environment — the locale / `PYTHONIOENCODING` that decides the default decode.** `open()` with no `encoding=` uses `locale.getpreferredencoding()`, so the same file gives different `chars` (or a `UnicodeDecodeError`) under `LANG=C` versus `LANG=en_US.UTF-8`. A person expects the same file to produce the same numbers on their laptop and in CI. Expectation: the read specifies its encoding explicitly, so counts do not move with the environment. → **Task 3**, `test_cli.py`: with `mock.patch("locale.getpreferredencoding", return_value="ascii")`, `main([path])` for a file containing `"café"` still returns 0 and prints `chars: 4`. + +- **Environment — stdout closed early.** `wordstat big.txt | head -1` closes the pipe, and the interpreter prints `BrokenPipeError ... Exception ignored` noise on shutdown. Expectation: quiet exit; a pipeline consumer stopping early is not the program's error. → **Task 3**, `test_cli.py`: with `sys.stdout` replaced by an object whose `write` raises `BrokenPipeError`, `main([path])` returns non-zero without propagating the exception. +``` diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T4-cap-ledgerlite-1.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T4-cap-ledgerlite-1.md new file mode 100644 index 000000000..7eecc0c5b --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T4-cap-ledgerlite-1.md @@ -0,0 +1,87 @@ +```markdown +## Review Focus + +1. **A malformed row reaching the CLI** — the spec's exit code 2 path is never exercised end to end; a user with one bad row in a 900-row export should see `ledgerlite: :: ` on stderr, an empty stdout, and status 2, not a `ParseError` traceback or a half-printed report. + - [ ] **Task 6, Step 1 (add to `test_cli.py`)** — pin the whole-file rejection: + ```python + def test_malformed_row_returns_2(self): + csv_path = write("date,amount,description\n2026-03-04,-7.50,ok\n2026-03-05,1.005,bad\n") + self.addCleanup(os.unlink, csv_path) + out, err = io.StringIO(), io.StringIO() + with redirect_stdout(out), redirect_stderr(err): + rc = main(["report", csv_path]) + self.assertEqual(rc, 2) + self.assertEqual(out.getvalue(), "") + self.assertTrue(err.getvalue().startswith(f"ledgerlite: {csv_path}:3: ")) + self.assertTrue(err.getvalue().endswith("\n")) + ``` + +2. **An amount with more than two fractional digits** — `1.005` (interest, FX-converted, or 3-decimal exports) is malformed per the spec, while `1.5` and `1.50` are fine; Task 2's interface omits this rule entirely, so today it would silently be accepted and quietly round in the totals. + - [ ] **Task 2, Step 1 (add to `test_parse.py`)** — pin the fractional-digit rule (and add it to the `ParseError` reasons in Task 2's Interfaces): + ```python + def test_more_than_two_fractional_digits_is_malformed(self): + with self.assertRaises(ParseError) as cm: + parse_csv("date,amount,description\n2026-03-04,1.005,x\n") + self.assertEqual(cm.exception.line, 2) + + def test_one_and_two_fractional_digits_are_fine(self): + txns = parse_csv("date,amount,description\n2026-03-04,1.5,x\n2026-03-05,1.50,y\n") + self.assertEqual([t.amount for t in txns], [Decimal("1.5"), Decimal("1.50")]) + ``` + +3. **`report FILE` with no `--rules` and no `--opening`** — the simplest and most common invocation is untested; it should print every transaction under `uncategorized` with an opening of `0`, not crash on a `None` rules path or default the opening to a float. + - [ ] **Task 6, Step 1 (add to `test_cli.py`)** — pin the defaults: + ```python + def test_defaults_no_rules_zero_opening(self): + csv_path = write(CSV) + self.addCleanup(os.unlink, csv_path) + out, err = io.StringIO(), io.StringIO() + with redirect_stdout(out), redirect_stderr(err): + rc = main(["report", csv_path]) + self.assertEqual(rc, 0) + self.assertEqual(out.getvalue(), "uncategorized: 1592.50\n\nclosing balance: 1592.50\n") + self.assertEqual(err.getvalue(), "") + ``` + +4. **A negative or fractional `--opening`** — an overdrawn account starts below zero, and `--opening=-12.50` must reach the balance as an exact `Decimal` (argparse also needs the `=` form for a leading-dash value); `1200.005` or `abc` should be rejected as a usage error rather than becoming a float or a `decimal.InvalidOperation` traceback. + - [ ] **Task 6, Step 1 (add to `test_cli.py`)** — pin the opening parse: + ```python + def test_negative_decimal_opening(self): + csv_path = write(CSV) + self.addCleanup(os.unlink, csv_path) + out = io.StringIO() + with redirect_stdout(out): + rc = main(["report", csv_path, "--opening=-12.50"]) + self.assertEqual(rc, 0) + self.assertTrue(out.getvalue().endswith("closing balance: 1580.00\n")) + + def test_non_numeric_opening_is_a_usage_error(self): + with redirect_stderr(io.StringIO()): + with self.assertRaises(SystemExit): + main(["report", "/no/such/file.csv", "--opening", "abc"]) + ``` + +5. **A `--rules` path that cannot be read** — a typo'd rules filename is as likely as a typo'd CSV, and the spec's silence on it is not permission to raise `FileNotFoundError`; it should report `ledgerlite: cannot read : ` and return 1, printing nothing to stdout. + - [ ] **Task 6, Step 1 (add to `test_cli.py`)** — pin the rules-file read error: + ```python + def test_unreadable_rules_file_returns_1(self): + csv_path = write(CSV) + self.addCleanup(os.unlink, csv_path) + out, err = io.StringIO(), io.StringIO() + with redirect_stdout(out), redirect_stderr(err): + rc = main(["report", csv_path, "--rules", "/no/such/rules.txt"]) + self.assertEqual(rc, 1) + self.assertEqual(out.getvalue(), "") + self.assertTrue(err.getvalue().startswith("ledgerlite: cannot read /no/such/rules.txt")) + ``` + +6. **A header-only CSV (no transactions)** — a date-filtered export with nothing in range should print the closing balance equal to the opening amount with no category lines and no stray leading blank line; `format_report` is only ever tested with a populated totals dict. + - [ ] **Task 5, Step 1 (add to `test_report.py`)** — pin the empty report: + ```python + def test_format_report_with_no_transactions(self): + self.assertEqual(format_report({}, Decimal("100")), "closing balance: 100.00") + self.assertEqual(totals_by_category([]), {}) + ``` +``` + +Note: the instruction asked for five lines; I listed six because the negative/fractional `--opening` case (line 4) and the missing-`--rules` case (line 5) are distinct failures that both sit squarely in the spec's `--opening`/`--rules` sentence, and dropping either would leave an untested spec clause. If you want exactly five, line 6 (header-only CSV) is the one I'd cut — it is the least likely to be hit and the least damaging when it is. diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T4-cap-ledgerlite-2.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T4-cap-ledgerlite-2.md new file mode 100644 index 000000000..1b95e1f54 --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T4-cap-ledgerlite-2.md @@ -0,0 +1,20 @@ +```markdown +## Review Focus + +The spec implies these inputs and failure modes; no task above exercises them. Most likely to bite first. + +1. **A malformed row reaching the CLI** — a real CSV with one bad date or a short row: the user expects `ledgerlite: :: ` on stderr, exit 2, and *nothing* on stdout (no partial report). Task 2 tests `ParseError.line` but no test ever runs a bad file through `main`, so the exit code, the `::` prefix, and the empty-stdout guarantee are all unpinned. + - [ ] **Task 6, Step 1: add `test_malformed_row_returns_2` to `test_cli.py`** — write a CSV whose third line is `2026-13-40,1.00,x`, call `main(["report", path])`, assert `rc == 2`, `out.getvalue() == ""`, and `err.getvalue() == f"ledgerlite: {path}:3: ...\n"` (exact reason text as implemented, path and line pinned). + +2. **`ledgerlite report FILE` with no flags** — the most common invocation. `--opening` defaults to `0` and every transaction is uncategorized. Task 6's only success test always passes `--rules` and `--opening 100`, so an argparse default of `"0"` (str) or a `float` conversion crashes or misprints on the bare command and no test notices. + - [ ] **Task 6, Step 1: add `test_bare_report_uses_zero_opening_and_no_rules` to `test_cli.py`** — call `main(["report", csv_path])` with the existing three-row CSV, assert `rc == 0` and `out.getvalue() == "uncategorized: 1592.50\n\nclosing balance: 1592.50\n"`. + +3. **Two or more transactions in the same category** — totals are sums. Every existing test has exactly one transaction per category, so an implementation that assigns (`totals[cat] = t.amount`) instead of accumulating passes the whole suite while reporting the wrong money. + - [ ] **Task 5, Step 1: add `test_totals_sum_within_a_category` to `test_report.py`** — three transactions, two `"food"` (`-7.50`, `-2.25`) and one uncategorized (`5.00`), assert `totals_by_category(txns) == {"food": Decimal("-9.75"), "uncategorized": Decimal("5.00")}`. + +4. **An amount with more than two fractional digits** — `1.005` is malformed (reject the whole file); `1.5` and `1.50` are fine. This is an explicit spec rule with no test anywhere; the obvious `Decimal(field)` accepts `1.005` silently and the extra precision then leaks into the printed totals via `quantize`. + - [ ] **Task 2, Step 1: add `test_amount_precision` to `test_parse.py`** — assert `parse_csv("date,amount,description\n2026-03-04,1.005,x\n")` raises `ParseError` with `line == 2`, and that `1.5` and `1.50` parse to `Decimal("1.5")` and `Decimal("1.50")` without raising. + +5. **A rules file written in the bank's own casing (`COFFEE=food`)** — matching is case-insensitive on the description, which means insensitive in both directions. The existing test only has a lowercase substring against a mixed-case description, so `substring in description.lower()` passes while every uppercase rule silently matches nothing. + - [ ] **Task 3, Step 1: extend `test_first_match_wins_case_insensitive` in `test_rules.py`** — add a case with rules `[("COFFEE", "food")]` against description `"coffee shop"`, asserting `out[0].category == "food"`. +``` diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T4-cap-ledgerlite-3.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T4-cap-ledgerlite-3.md new file mode 100644 index 000000000..55ffbb4d0 --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T4-cap-ledgerlite-3.md @@ -0,0 +1,79 @@ +```markdown +## Review Focus + +The spec's edges that no task's tests currently touch — most likely to bite first: + +1. **A CSV with one malformed row, via the CLI** (`ledgerlite report bad.csv`): the spec's exit code 2, the `ledgerlite: :: ` message on stderr, and an *empty* stdout — the whole file rejected. Task 2 tests `ParseError` but nothing in Task 6 tests the CLI mapping it to exit 2, so the likely outcome today is a raw traceback and exit code 1. + - [ ] **Task 6, Step 1, add `CliTests.test_malformed_row_returns_2`:** + ```python + def test_malformed_row_returns_2(self): + csv_path = write("date,amount,description\n2026-03-04,-7.50,COFFEE\nnot-a-date,1.00,x\n") + self.addCleanup(os.unlink, csv_path) + out, err = io.StringIO(), io.StringIO() + with redirect_stdout(out), redirect_stderr(err): + rc = main(["report", csv_path]) + self.assertEqual(rc, 2) + self.assertEqual(out.getvalue(), "") + self.assertTrue(err.getvalue().startswith(f"ledgerlite: {csv_path}:3: ")) + self.assertTrue(err.getvalue().endswith("\n")) + ``` + +2. **`ledgerlite report txns.csv` with neither `--rules` nor `--opening`** — the simplest possible invocation, which the spec makes explicit (`--rules` optional, opening defaults to `0`): every transaction lands under `uncategorized` and the closing balance starts from zero. Every CLI test passes both flags, so an implementation that opens `args.rules` unconditionally crashes on the common case. + - [ ] **Task 6, Step 1, add `CliTests.test_defaults_no_rules_zero_opening`:** + ```python + def test_defaults_no_rules_zero_opening(self): + csv_path = write(CSV) + self.addCleanup(os.unlink, csv_path) + out, err = io.StringIO(), io.StringIO() + with redirect_stdout(out), redirect_stderr(err): + rc = main(["report", csv_path]) + self.assertEqual(rc, 0) + self.assertEqual(out.getvalue(), "uncategorized: 1592.50\n\nclosing balance: 1592.50\n") + self.assertEqual(err.getvalue(), "") + ``` + +3. **Two or more transactions in the same category** (two coffees, or two uncategorized rows): the category's line is the *sum* of its amounts. Every totals test uses exactly one transaction per category, so a `totals[cat] = amount` assignment bug — silently dropping all but the last row of each category, and disagreeing with the closing balance — passes the suite. + - [ ] **Task 5, Step 1, add `ReportTests.test_totals_sum_multiple_rows_per_category`:** + ```python + def test_totals_sum_multiple_rows_per_category(self): + txns = [ + Transaction(date(2026, 3, 1), Decimal("-7.50"), "a", "food"), + Transaction(date(2026, 3, 2), Decimal("-2.25"), "b", "food"), + Transaction(date(2026, 3, 3), Decimal("10.00"), "c"), + Transaction(date(2026, 3, 4), Decimal("5.00"), "d"), + ] + self.assertEqual(totals_by_category(txns), {"food": Decimal("-9.75"), "uncategorized": Decimal("15.00")}) + ``` + +4. **A real bank export whose shape is off — a row with the wrong column count, or a first line that isn't `date,amount,description`** (extra column, `Date,Amount,Description`, or no header at all): both are malformed per the spec and must be reported and rejected, never silently truncated to three fields or have a data row eaten as a header. Task 2's Step 3 says "validate the header" but no test holds it to that. + - [ ] **Task 2, Step 1, add `ParseTests.test_bad_shape_is_rejected`:** + ```python + def test_bad_shape_is_rejected(self): + cases = { + "date,amount,description\n2026-03-04,-7.50,COFFEE,EXTRA\n": 2, + "date,amount,description\n2026-03-04,-7.50\n": 2, + "date,amount\n2026-03-04,-7.50\n": 1, + "2026-03-04,-7.50,COFFEE\n": 1, + } + for text, line in cases.items(): + with self.subTest(text=text): + with self.assertRaises(ParseError) as cm: + parse_csv(text) + self.assertEqual(cm.exception.line, line) + ``` + +5. **Amount strings `Decimal` happily accepts but the spec forbids — `1.005`, `NaN`, `Infinity`, an empty field** — each is a malformed row, while `1.5` and `1.50` must both parse. `Decimal("NaN")` and `Decimal("1.005")` raise nothing, so the natural `Decimal(field)` implementation admits them and the report later prints `NaN` totals or a silently rounded amount. + - [ ] **Task 2, Step 1, add `ParseTests.test_amount_precision_and_non_numbers`:** + ```python + def test_amount_precision_and_non_numbers(self): + for bad in ("1.005", "abc", "NaN", "Infinity", "", "1,200.00"): + with self.subTest(bad=bad): + with self.assertRaises(ParseError) as cm: + parse_csv(f"date,amount,description\n2026-03-04,{bad},x\n") + self.assertEqual(cm.exception.line, 2) + + def test_one_and_two_decimal_amounts_are_fine(self): + txns = parse_csv("date,amount,description\n2026-03-04,1.5,a\n2026-03-05,1.50,b\n2026-03-06,-2,c\n") + self.assertEqual([t.amount for t in txns], [Decimal("1.5"), Decimal("1.50"), Decimal("-2")]) + ``` +``` diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T4-cap-ledgerlite-4.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T4-cap-ledgerlite-4.md new file mode 100644 index 000000000..620f1e967 --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T4-cap-ledgerlite-4.md @@ -0,0 +1,91 @@ +```markdown +## Review Focus + +These are the input classes the spec requires the program to handle correctly, but that no task's tests currently exercise. Listed most likely to bite first. + +1. **A malformed row reaching the CLI** — a real bank export with one bad line must exit 2, print `ledgerlite: :: ` to stderr, and print *nothing* to stdout; today exit code 2 appears nowhere in any test, and Task 6 only covers exit 0 and exit 1. +2. **An amount with more than two fractional digits (`1.005`)** — the spec names this as malformed while `1.5` and `1.50` are fine; Task 2's interface omits the rule entirely, so nothing stops a third digit from silently becoming a total that prints rounded. +3. **`report FILE` with neither `--rules` nor `--opening`** — the documented bare invocation must report every transaction under `uncategorized` against an opening of `0`, printed as `0.00`; every CLI test passes both flags, so the default wiring (a `None` rules path, an opening that must be `Decimal`, not `int`/`float`) is never executed. +4. **A rules line with no `=` on it** — a comment, a stray word, or a hand-edited half-line must be ignored rather than crash the tool with an unpacking traceback, and a rule whose category contains `=` must split on the first separator only. +5. **A transactions file with a header and no data rows** — the spec explicitly contemplates zero transactions ("the opening amount if there are none"), so the report must be a blank line then `closing balance: ` with no category lines, not a crash or a stray leading blank line. + +### The tests that pin them + +**1. Malformed row through the CLI** — Task 6, `test_cli.py::CliTests`: + +- [ ] **Step 1 (add to the failing test): `test_malformed_row_returns_2`** + +```python + def test_malformed_row_returns_2(self): + csv_path = write("date,amount,description\n2026-03-01,1.00,OK\n2026-13-40,1.00,BAD\n") + self.addCleanup(os.unlink, csv_path) + out, err = io.StringIO(), io.StringIO() + with redirect_stdout(out), redirect_stderr(err): + rc = main(["report", csv_path]) + self.assertEqual(rc, 2) + self.assertEqual(out.getvalue(), "") + self.assertTrue(err.getvalue().startswith(f"ledgerlite: {csv_path}:3: ")) +``` + +**2. Amount with more than two fractional digits** — Task 2, `test_parse.py::ParseTests` (and extend the Task 2 interface to name this rule and the wrong-column-count and non-numeric-amount rules it already claims): + +- [ ] **Step 1 (add to the failing test): `test_amount_precision_and_shape`** + +```python + def test_three_fractional_digits_is_malformed(self): + with self.assertRaises(ParseError) as cm: + parse_csv("date,amount,description\n2026-03-04,1.005,x\n") + self.assertEqual(cm.exception.line, 2) + + def test_one_and_two_fractional_digits_are_fine(self): + txns = parse_csv("date,amount,description\n2026-03-04,1.5,a\n2026-03-04,1.50,b\n") + self.assertEqual([t.amount for t in txns], [Decimal("1.5"), Decimal("1.50")]) + + def test_wrong_column_count_and_bad_amount_raise_with_line(self): + for bad in ("2026-03-04,1.00\n", "2026-03-04,1.00,x,y\n", "2026-03-04,twelve,x\n"): + with self.subTest(bad=bad), self.assertRaises(ParseError) as cm: + parse_csv("date,amount,description\n" + bad) + self.assertEqual(cm.exception.line, 2) +``` + +**3. Bare invocation with no flags** — Task 6, `test_cli.py::CliTests`: + +- [ ] **Step 1 (add to the failing test): `test_defaults_uncategorized_and_zero_opening`** + +```python + def test_defaults_uncategorized_and_zero_opening(self): + csv_path = write(CSV) + self.addCleanup(os.unlink, csv_path) + out, err = io.StringIO(), io.StringIO() + with redirect_stdout(out), redirect_stderr(err): + rc = main(["report", csv_path]) + self.assertEqual(rc, 0) + self.assertEqual(out.getvalue(), "uncategorized: 1592.50\n\nclosing balance: 1592.50\n") + self.assertEqual(err.getvalue(), "") +``` + +**4. Rules line with no separator** — Task 3, `test_rules.py::RulesTests`: + +- [ ] **Step 1 (add to the failing test): `test_parse_rules_tolerates_odd_lines`** + +```python + def test_lines_without_a_separator_are_skipped(self): + self.assertEqual(parse_rules("coffee=food\nnot a rule\n \nrent=housing\n"), + [("coffee", "food"), ("rent", "housing")]) + + def test_splits_on_first_separator_only(self): + self.assertEqual(parse_rules("a=b=c\n"), [("a", "b=c")]) +``` + +**5. No transactions at all** — Task 5, `test_report.py::ReportTests` (`closing_balance([], opening)` is already covered by Task 4): + +- [ ] **Step 1 (add to the failing test): `test_no_categories`** + +```python + def test_totals_of_no_transactions_is_empty(self): + self.assertEqual(totals_by_category([]), {}) + + def test_format_report_with_no_categories(self): + self.assertEqual(format_report({}, Decimal("100")), "\nclosing balance: 100.00") +``` +``` diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T4-cap-ledgerlite-5.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T4-cap-ledgerlite-5.md new file mode 100644 index 000000000..a321abc3f --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T4-cap-ledgerlite-5.md @@ -0,0 +1,68 @@ +```markdown +## Review Focus + +These are the input classes the spec calls for that no task above exercises. Each line names the input, the behavior a reasonable person expects, and the test that pins it — most likely to bite first. + +- **A transactions file with a malformed row, run through the CLI.** The spec's exit code 2 path — `ledgerlite: :: ` on stderr, nothing on stdout, the whole file rejected — is never exercised end to end; Task 6 only wires the happy path and the unreadable-file path, so `ParseError` will escape `main` as a traceback and a nonzero-by-accident exit. + - [ ] **Task 6, Step 1 (additional case)** — add to `test_cli.py`: + ```python + def test_malformed_row_returns_2_and_prints_nothing(self): + path = write("date,amount,description\n2026-03-04,-7.50,COFFEE SHOP\n2026-03-05,1.005,BAD\n") + self.addCleanup(os.unlink, path) + out, err = io.StringIO(), io.StringIO() + with redirect_stdout(out), redirect_stderr(err): + rc = main(["report", path]) + self.assertEqual(rc, 2) + self.assertEqual(out.getvalue(), "") + self.assertTrue(err.getvalue().startswith(f"ledgerlite: {path}:3: ")) + ``` + +- **Malformed rows that are not a bad date: wrong column count, a non-numeric amount, and an amount with more than two fractional digits.** `1.005` must be rejected while `1.5` and `1.50` are accepted; Task 2 tests only the bad date and its Interfaces block omits the fractional-digit rule entirely, so `1.005` will silently parse and skew every total. + - [ ] **Task 2, Step 1 (additional cases)** — add to `test_parse.py`: + ```python + def test_one_and_two_fractional_digits_are_fine(self): + txns = parse_csv("date,amount,description\n2026-03-04,1.5,a\n2026-03-05,1.50,b\n") + self.assertEqual([t.amount for t in txns], [Decimal("1.5"), Decimal("1.50")]) + + def test_malformed_amounts_and_column_counts(self): + for row in ("2026-03-04,1.005,x", "2026-03-04,abc,x", "2026-03-04,,x", "2026-03-04,1.00", "2026-03-04,1.00,x,y"): + with self.assertRaises(ParseError) as cm: + parse_csv(f"date,amount,description\n{row}\n") + self.assertEqual(cm.exception.line, 2) + self.assertTrue(cm.exception.reason) + ``` + +- **`report FILE` with neither `--rules` nor `--opening`, and a fractional `--opening`.** Every transaction should land under `uncategorized` against an opening of `0`, and `--opening 0.10` must be read as `Decimal`, not `float`; no task test runs the CLI without both flags, so a missing-rules crash or a float opening would ship. + - [ ] **Task 6, Step 1 (additional case)** — add to `test_cli.py`: + ```python + def test_defaults_and_decimal_opening(self): + path = write(CSV) + self.addCleanup(os.unlink, path) + out, err = io.StringIO(), io.StringIO() + with redirect_stdout(out), redirect_stderr(err): + self.assertEqual(main(["report", path]), 0) + self.assertEqual(out.getvalue(), "uncategorized: 1592.50\n\nclosing balance: 1592.50\n") + out2 = io.StringIO() + with redirect_stdout(out2), redirect_stderr(err): + self.assertEqual(main(["report", path, "--opening", "0.10"]), 0) + self.assertEqual(out2.getvalue(), "uncategorized: 1592.50\n\nclosing balance: 1592.60\n") + self.assertEqual(err.getvalue(), "") + ``` + +- **A transactions file with only a header row.** The spec says the closing balance is the opening amount when there are no transactions; with no categories there are no category lines, so the report must not open with a dangling blank separator line. Task 5 only formats a three-category dict, and Task 6 never reports on an empty file. + - [ ] **Task 5, Step 1 (additional case)** — add to `test_report.py`: + ```python + def test_no_transactions_prints_only_closing_balance(self): + self.assertEqual(totals_by_category([]), {}) + self.assertEqual(format_report({}, Decimal("100")), "closing balance: 100.00") + ``` + +- **A rules file whose substrings are capitalized (`Coffee=food`, `RENT=housing`).** Matching is case-insensitive on the description, which must mean either side may be cased however the user wrote it; Task 3's test only ever passes lowercase substrings, so the natural `substring in description.lower()` implementation matches nothing for a capitalized rules file and quietly reports everything as `uncategorized`. + - [ ] **Task 3, Step 1 (additional case)** — add to `test_rules.py`: + ```python + def test_uppercase_rule_substring_still_matches(self): + txns = [Transaction(date(2026, 3, 4), Decimal("-7.50"), "coffee shop")] + self.assertEqual(categorize(txns, [("COFFEE", "food")])[0].category, "food") + self.assertEqual(parse_rules("Coffee=food\n"), [("Coffee", "food")]) + ``` +``` diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T4-cap-ledgerlite-6.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T4-cap-ledgerlite-6.md new file mode 100644 index 000000000..13cd5cedc --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T4-cap-ledgerlite-6.md @@ -0,0 +1,20 @@ +```markdown +## Review Focus + +The spec's edges that no task's tests currently touch, most likely to bite first: + +1. **A malformed row reaching the CLI** — the whole exit-2 path is untested end to end: given a CSV whose line 3 has an unparseable date, the user expects `ledgerlite: :3: ` on stderr, exit code 2, and *nothing* on stdout (the whole file is rejected). Task 2 tests `ParseError` in isolation and Task 6 tests only exit 0 and exit 1, so nothing checks that `ParseError` is caught, formatted with the path, or that stdout stays empty. + → **Task 6, Step 1**, add to `test_cli.py`: `test_malformed_row_returns_2` — write `"date,amount,description\n2026-03-01,1.00,a\n2026-13-40,1.00,b\n"`, run `main(["report", path])`, assert `rc == 2`, `out.getvalue() == ""`, and `err.getvalue().startswith(f"ledgerlite: {path}:3: ")`. + +2. **An amount with more than two fractional digits (`1.005`)** — the spec names this as malformed while `1.5` and `1.50` are fine, but Task 2's interface omits the rule and no test covers it, so a rate-rounded row will be silently accepted and quietly re-rounded in the report instead of rejected. + → **Task 2, Step 1**, add to `test_parse.py`: `test_amount_precision` — `parse_csv("date,amount,description\n2026-03-04,1.005,x\n")` raises `ParseError` with `line == 2`, while `1.5` and `1.50` on line 2 both parse to `Decimal("1.5")` and `Decimal("1.50")` respectively. (Also widen Task 2's **Produces** text to list the fractional-digit rule.) + +3. **`--opening` omitted, or given a negative/fractional amount** — every existing test passes `--opening 100`, so the documented default of `0` is never run and nothing forces the flag to be parsed as `Decimal` rather than `float`; a user reporting on a fresh file or an overdrawn account gets a wrong closing balance. + → **Task 6, Step 1**, add to `test_cli.py`: `test_opening_default_and_decimal` — `main(["report", csv_path])` (no flag) ends with `closing balance: 1592.50`; `main(["report", csv_path, "--opening", "-25.75"])` ends with `closing balance: 1566.75`. + +4. **No `--rules` at all** — the spec's documented "every transaction is uncategorized" mode is never exercised through the CLI, so the most likely first invocation a user tries can crash on a `None` rules path or print a `None:` line. + → **Task 6, Step 1**, add to `test_cli.py`: `test_no_rules_all_uncategorized` — `main(["report", csv_path, "--opening", "100"])` returns 0 and prints exactly `"uncategorized: 1592.50\n\nclosing balance: 1692.50\n"` with empty stderr. + +5. **A rule whose substring is capitalized (`Coffee=food`)** — Task 3 only tests lowercase substrings against an uppercase description, so an implementation that lowercases just the description passes while the spec's case-insensitive matching fails on the first rules file a person hand-writes. + → **Task 3, Step 1**, add to `test_rules.py`: `test_rule_substring_case_insensitive` — `categorize([Transaction(date(2026, 3, 4), Decimal("-7.50"), "coffee shop")], [("COFFEE", "food")])[0].category == "food"`. +``` diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T4-cap-wordstat-1.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T4-cap-wordstat-1.md new file mode 100644 index 000000000..b587bf2f7 --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T4-cap-wordstat-1.md @@ -0,0 +1,11 @@ +```markdown +## Review Focus + +The spec's behavior list is a vision of what `wordstat` must do, not an inventory of what a real file or command line will hand it. These five inputs the spec implies but the tasks above never exercise, most likely to bite first: + +1. **Real multi-line text with irregular whitespace** — the spec says `count_words` counts "whitespace-separated tokens", but every test word is separated by exactly one space, so a naive `text.split(" ")` passes while miscounting tabs, newlines, runs of spaces, and leading/trailing space in any actual file; expected: tabs and newlines separate words and empty runs count for nothing. *Test — Task 1, step 1, `test_counter.py`: `count_words("the\tquick\nbrown fox ")` == 4; `count_words(" ")` == 0.* +2. **The missing-file message itself** — the spec requires a message to stderr, but Task 3 asserts only the return code, so a `cli` that returns 1 silently (or writes the error to stdout, polluting the report a caller is piping) passes; expected: a nonempty stderr message naming the path, and nothing on stdout. *Test — Task 3, step 1, `test_cli.py`: `main(["/no/such/file"])` under `redirect_stdout`/`redirect_stderr` returns 1, stderr contains `/no/such/file`, stdout is `""`.* +3. **A path that exists but cannot be read as a file** — `wordstat .` or a root-owned file raises `IsADirectoryError`/`PermissionError`, not the `FileNotFoundError` the missing-file test pins, so an `except FileNotFoundError` handler lets a traceback escape; expected: the same stderr-message-and-return-1 path as a missing file. *Test — Task 3, step 1, `test_cli.py`: `main([tmpdir])` returns 1 and writes a message to stderr, no exception raised.* +4. **No path argument, or more than one** — the spec types `main(argv)` as returning an int, but a bare positional makes `main([])` raise `SystemExit(2)` out of the caller, and a hand-rolled `argv[0]` raises `IndexError`; expected: a usage message on stderr and a nonzero exit, with no traceback. *Test — Task 3, step 1, `test_cli.py`: `main([])` and `main([p1, p2])` each either return nonzero or raise `SystemExit` with a nonzero code, and write usage to stderr.* +5. **A file that is not valid UTF-8** — pointing `wordstat` at a PDF, a Latin-1 `.txt`, or any binary makes the plain `open(path).read()` raise `UnicodeDecodeError` mid-read; expected: an error message on stderr and return 1, not a traceback. *Test — Task 3, step 1, `test_cli.py`: write `b"\xff\xfe\x00bad"` to a temp file; `main([path])` returns 1 and writes a message to stderr.* +``` diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T4-cap-wordstat-2.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T4-cap-wordstat-2.md new file mode 100644 index 000000000..5f5b16294 --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T4-cap-wordstat-2.md @@ -0,0 +1,56 @@ +```markdown +## Review Focus + +The spec's behavior list is a vision of the happy path; these are the inputs a +real user hands the tool that no task above exercises, most likely first. + +1. **No path argument at all** — `cli.main([])`, i.e. the user runs `wordstat` + bare. `argparse` raises `SystemExit(2)` from inside `parse_args`, so `main` + never returns an `int` and callers importing it see an exception instead of an + exit code. A reasonable person expects the usage message on stderr and a + non-zero return, not a raised `SystemExit` escaping a function documented as + returning `int`. + - *Test to add* — Task 3, step 1, `test_cli.py::test_missing_argument_returns_nonzero`: + - `main([])` returns a non-zero int (does not raise `SystemExit`), and a usage + message is written to stderr. + +2. **`path` exists but is not a readable regular file** — a directory + (`main(["."])`) or a file the process cannot read. `open()` raises + `IsADirectoryError` / `PermissionError`, neither of which is + `FileNotFoundError`, so an error handler that only catches the missing-file + case lets a traceback reach the user. The spec's "message to stderr, return 1" + is the expectation for *any* unreadable path, not just an absent one. + - *Test to add* — Task 3, step 1, `test_cli.py::test_unreadable_path_returns_1`: + - `main([tmpdir])` (a directory) prints a message to stderr and returns 1 with + no traceback; same for a file created with mode `0o000` (skip if running as root). + +3. **Real prose whitespace: runs of spaces, tabs, indentation, leading/trailing + blanks** — e.g. `count_words(" the\tquick brown \n fox ")` == 4 and + `count_words(" \n\t ")` == 0. Only single-space input is tested, so + `text.split(" ")` passes Task 1's tests while counting empty strings as words + on every indented or double-spaced line of a real file. A reasonable person + expects "whitespace-separated tokens" to mean tokens, never empty fragments. + - *Test to add* — Task 1, step 1, `test_counter.py::test_count_words_whitespace_runs`: + - `count_words(" the\tquick brown \n fox ")` == 4; + `count_words(" \n\t ")` == 0; `count_words("\n")` == 0. + +4. **A completely empty file, end to end** — `main([empty_path])`. Zero-byte + files are ordinary (freshly created, truncated, piped-out logs), and the + composed path is untested for them: a formatter or reader that assumes at + least one line, or a `count_lines` implementation built on `count("\n") + 1`, + yields `1` or crashes. The expected result is the report `words: 0`, + `lines: 0`, `chars: 0` on stdout and exit code 0 — an empty file is valid + input, not an error. + - *Test to add* — Task 3, step 1, `test_cli.py::test_empty_file_reports_zeros`: + - `main([empty_path])` prints `"words: 0\nlines: 0\nchars: 0"` and returns 0. + +5. **A file that is not valid UTF-8** — a Latin-1 `.txt`, a stray `.pdf`, or any + binary the user points at by mistake. Text-mode `open()` raises + `UnicodeDecodeError` mid-read, producing a traceback rather than either of the + two outcomes the spec sanctions (a report, or a stderr message and exit 1). + A reasonable person expects the tool to say the file could not be read and + return 1. + - *Test to add* — Task 3, step 1, `test_cli.py::test_undecodable_file_returns_1`: + - write `b"caf\xe9 \xff\xfe"` to a temp file; `main([path])` returns 1 with a + message on stderr, raising no `UnicodeDecodeError`. +``` diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T4-cap-wordstat-3.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T4-cap-wordstat-3.md new file mode 100644 index 000000000..6944351cf --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T4-cap-wordstat-3.md @@ -0,0 +1,23 @@ +```markdown +## Review Focus + +Five input classes the spec implies but no task's tests exercise, most likely to bite first: + +- **Runs of whitespace, tabs, and leading/trailing space in `count_words`.** "Whitespace-separated tokens" means `" the quick\tbrown \n fox "` is 4 words and `" \t\n "` is 0, but the plan only ever feeds single-spaced text, so a `split(" ")` implementation passes Task 1 and then miscounts nearly every real file. + - Task 1, add to `test_counter.py` (step 1): `count_words(" the quick\tbrown \n fox ")` == 4; `count_words(" \t\n ")` == 0. + +- **Missing path produces a message, not a traceback.** The spec asks for "message to stderr, return 1"; Task 3 asserts only the return code, so an implementation that lets `FileNotFoundError` escape (or that prints the error to stdout, corrupting a piped report) still goes green while a user sees a stack trace. + - Task 3, add to `test_cli.py` (step 1): `main(["/no/such/file"])` returns 1 with non-empty stderr mentioning the path, empty stdout, and no exception raised; same for a path that is a directory. + +- **Non-ASCII files and the ambient locale.** `count_chars` counts characters, so a UTF-8 file containing `"héllo\n"` must report 6 chars, not 7 bytes; nothing in the plan opens a non-ASCII file, so a bare `open(path)` that inherits a non-UTF-8 default encoding mangles or crashes on ordinary accented text. + - Task 3, add to `test_cli.py` (step 1): write `"héllo wörld\n"` to a temp file as UTF-8, assert `main([path])` prints `"words: 2\nlines: 1\nchars: 12"` and returns 0. + +- **Blank lines and newline-only text in `count_lines`.** The trailing-newline rule is tested only on `"a\nb\n"`; a reasonable person expects `"a\n\nb"` == 3, `"\n"` == 1, and `"a\n\n"` == 2, which a `count("\n")`-based or naive-`rstrip` implementation gets wrong on any file with paragraph breaks. + - Task 1, add to `test_counter.py` (step 1): `count_lines("a\n\nb")` == 3; `count_lines("\n")` == 1; `count_lines("a\n\n")` == 2. + +- **Invocation with no path, or with extra paths.** The spec types `main(argv)` as returning an `int`; with argparse's default behavior a bare `main([])` raises `SystemExit(2)` instead, so any caller that treats `main` as a function — including a `console_scripts`-style wrapper — gets an exception rather than a usage message and exit code. + - Task 3, add to `test_cli.py` (step 1): `main([])` writes usage to stderr and yields exit status 2 (assert via `SystemExit` code or returned int, whichever `main` is written to do); `main([path, path2])` behaves the same way. + +- **Report shape at the seam between formatter and stdout.** `format_report` returns a 3-line string with no trailing newline, so `print` must add exactly one; Task 2 checks the string and Task 3 checks a happy-path report, but neither pins that the process emits exactly four bytes of newline structure, letting a stray `print(report + "\n")` add a blank line that breaks `wc`-style downstream use. + - Task 3, add to `test_cli.py` (step 1): for an empty temp file, captured stdout is exactly `"words: 0\nlines: 0\nchars: 0\n"` and `main([path])` returns 0. +``` diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T4-cap-wordstat-4.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T4-cap-wordstat-4.md new file mode 100644 index 000000000..cb4e69f53 --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T4-cap-wordstat-4.md @@ -0,0 +1,17 @@ +```markdown +## Review Focus + +The spec's silences that a real user will hit first. Each line names the input, the behavior a reasonable person expects, and the test that pins it — added to the task that owns the code, in that task's step style. + +1. **Words separated by newlines, tabs, or runs of spaces, and text that is only whitespace** — every real file has these; `count_words` must count whitespace-separated tokens, so `"a\n\tb c"` is 3 and `" \n "` is 0, never inflated by the empty strings a single-character split leaves between separators. + - Task 1, step 1, `test_counter.py::test_count_words_splits_on_any_whitespace_run`: assert `count_words("a\n\tb c") == 3`, `count_words(" a ") == 1`, `count_words(" \n ") == 0`. +2. **A `path` that exists but is not a readable file — a directory (`wordstat .`), or a file with read permission removed** — expect the same handled failure as a missing file: message on stderr, nothing on stdout, return 1; not an `IsADirectoryError`/`PermissionError` traceback, since the spec's error path is about "can't read this", not about one errno. + - Task 3, step 1, `test_cli.py::test_unreadable_path_reports_error`: with `tempfile.TemporaryDirectory()` assert `main([tmpdir]) == 1` with captured stdout empty and stderr non-empty; repeat for a temp file after `os.chmod(p, 0o000)`, skipped when `os.geteuid() == 0`. +3. **A file that is not valid UTF-8 — binary data, or latin-1 accented text** — expect a message to stderr and return 1 (or a documented decode fallback), not a `UnicodeDecodeError` escaping `main` and burying the reason in a traceback. + - Task 3, step 1, `test_cli.py::test_undecodable_file_reports_error`: write `b"caf\xe9\n\xff\xfe"` to a temp file, assert `main([path]) == 1` and stderr non-empty, and that no exception propagates. +4. **Wrong argument count — `main([])` when the user forgets the path, or `main(["a", "b"])`** — expect argparse's usage message on stderr and a nonzero exit status, i.e. a `SystemExit(2)` a caller can catch, rather than a `TypeError`, an `IndexError`, or a silent `None` return that a shell reads as success. + - Task 3, step 1, `test_cli.py::test_argument_count_errors`: `with self.assertRaises(SystemExit) as cm: main([])` then assert `cm.exception.code == 2` and stderr mentions `usage`; same for `main(["a", "b"])`. +5. **An empty file, and a file that is exactly one newline** — expect exit 0 and a full three-line report (`"words: 0\nlines: 0\nchars: 0"` for empty), and `count_lines("\n") == 1` — the "trailing newline adds no line" rule must not turn a one-line file into zero lines, and `count_lines("a\n\n") == 2`. + - Task 1, step 1, `test_counter.py::test_count_lines_trailing_newline_boundary`: assert `count_lines("\n") == 1`, `count_lines("a\n\n") == 2`, `count_lines("\n\n") == 2`. + - Task 3, step 1, `test_cli.py::test_empty_file_reports_zeros`: for a zero-byte temp file assert `main([path]) == 0` and captured stdout `== "words: 0\nlines: 0\nchars: 0\n"`. +``` diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T4-cap-wordstat-5.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T4-cap-wordstat-5.md new file mode 100644 index 000000000..16a486dc6 --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T4-cap-wordstat-5.md @@ -0,0 +1,20 @@ +```markdown +## Review Focus + +Five input classes the spec implies but no task's tests exercise, most likely first. Each names the missing test and the task that owns the code; add it to that task's step 1. + +1. **No path argument at all** (`wordstat` with no operand, `main([])`) — argparse's own error path calls `sys.exit(2)`, so `SystemExit` escapes `main` instead of a return code; a reasonable person expects a usage message on stderr and a non-zero exit, never a traceback. + - Task 3, `test_cli.py`: `main([])` writes usage to stderr and exits non-zero without a traceback (assert via `assertRaises(SystemExit)` on the argparse exit, or a returned non-zero code if `main` catches it) — pick one behavior and pin it. + +2. **Path exists but is not a readable regular file** (a directory, e.g. `main(["."])`, or a chmod-000 file) — `open()` raises `IsADirectoryError`/`PermissionError`, which the spec's "missing file" branch does not catch; a reasonable person expects the same stderr message and exit code 1 as a missing file. + - Task 3, `test_cli.py`: `main([tmpdir])` returns 1 and writes a message to stderr; likewise `main([path])` for a file with mode `0o000`. + +3. **Non-UTF-8 or binary input** (a JPEG, a latin-1 text file, `b"\xff\xfe"`) — reading in text mode raises `UnicodeDecodeError` and the tool dies mid-report; a reasonable person expects either a decode-error message on stderr with exit 1, or a documented lenient decode that still prints three counts. + - Task 3, `test_cli.py`: `main([path])` for a temp file containing `b"\xff\xfe\x00"` returns 1 with a message on stderr (no uncaught exception). + +4. **Words separated by anything other than a single space** (tabs, newlines, runs of spaces, leading/trailing whitespace, whitespace-only text) — the spec says whitespace-separated tokens but only a single-spaced string is tested; a reasonable person expects no empty tokens and no off-by-one from indentation. + - Task 1, `test_counter.py`: `count_words(" the\tquick\nbrown fox ")` == 4; `count_words(" \n\t ")` == 0. + +5. **Real multi-line files: newlines inside `count_chars`, blank lines and CRLF inside `count_lines`** — `count_chars("a\nb")` must be 3 (whitespace included) and `count_lines` must not silently under- or over-count `"a\n\nb\n"` or a `\r\n` file; this is silent wrong output, not a crash, so nobody notices. + - Task 1, `test_counter.py`: `count_chars("a\nb")` == 3; `count_lines("a\n\nb\n")` == 3; `count_lines("\n")` == 1; `count_lines("a\r\nb\r\n")` == 2. +``` diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T4-cap-wordstat-6.md b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T4-cap-wordstat-6.md new file mode 100644 index 000000000..81e174dac --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/T4-cap-wordstat-6.md @@ -0,0 +1,18 @@ +## Review Focus + +Five input classes the spec implies but no task's tests exercise, most likely to bite first: + +1. **A path that exists but is not a readable file** — a directory (easy to hit with tab completion) or a file with no read permission. `main` should behave like the missing-file case: one message on stderr, nothing on stdout, exit 1 — not an `IsADirectoryError`/`PermissionError` traceback. The existing missing-file test also never asserts *where* the message goes, so the spec's "message to stderr" is unpinned. + - Task 3, `test_cli.py::test_unreadable_path_reports_to_stderr`: — `main([tmpdir])`, `main([chmod_000_file])`, and `main(["/no/such/file"])` each return 1, print nothing to stdout, and write a non-empty message to stderr (capture both with `contextlib.redirect_stdout`/`redirect_stderr`). + +2. **No path argument, or more than one** — `wordstat` with a bare `main([])` or `main([a, b])`. argparse raises `SystemExit(2)` from inside `main`, so a caller that expects an `int` back gets an exception instead; a person expects a usage message on stderr and a non-zero exit. + - Task 3, `test_cli.py::test_wrong_argument_count_exits_with_usage`: — `with self.assertRaises(SystemExit) as cm: main([])` asserts `cm.exception.code == 2` and that stderr contains `usage:`; same for `main(["a", "b"])`. (If `main` is instead specified to return, assert `== 2` and no traceback.) + +3. **A file that is not valid UTF-8** — any latin-1 or binary file. Reading it raises `UnicodeDecodeError` and the user sees a traceback; expected is the same one-line stderr message and exit 1 as any other unusable input. + - Task 3, `test_cli.py::test_undecodable_file_reports_error`: — write `b"caf\xe9\xff"` to a temp file, assert `main([path])` returns 1 with a message on stderr and no traceback escaping. + +4. **Real-world whitespace: tabs, runs of spaces, leading/trailing blanks, blank interior lines, CRLF endings** — every counter test uses single spaces and bare `\n`. The spec says "whitespace-separated" and gives a trailing-newline rule, so a naive `text.split(" ")` or `text.split("\n")` passes the current tests and still miscounts an ordinary file. + - Task 1, `test_counter.py::test_counts_ignore_whitespace_shape`: — `count_words(" the\tquick \n brown fox ")` == 4; `count_words(" ")` == 0; `count_lines("a\r\nb")` == 2; `count_lines("a\n\nb")` == 3; `count_lines("\n")` == 1; `count_lines("a")` == 1; `count_chars("a\tb\n")` == 4; `count_chars("héllo")` == 5 (characters, not bytes). + +5. **An empty file end to end** — `count_lines("")` is specified as 0, but nothing checks the whole pipeline on a 0-byte file; a user pointed at a freshly created file expects a zeros report and exit 0, not a crash or blank output. + - Task 3, `test_cli.py::test_empty_file_reports_zeros`: — `main([empty_path])` returns 0 and stdout is exactly `"words: 0\nlines: 0\nchars: 0\n"` (also pins that the report is printed with exactly one trailing newline, which matters when piping to a file). diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/override-read.txt b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/override-read.txt new file mode 100644 index 000000000..f10d504af --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/override-read.txt @@ -0,0 +1,97 @@ +T0-current-ledgerlite-1: lines=16 implied=10 override=2 restated=4 + OVERRIDE: **`--rules` pointing at a file that cannot be read** — the spec only promises a message for TRANSACTIONS, but a missing || "If TRANSACTIONS cannot be read, prints `ledgerlite: cannot read : ` to stderr and returns 1." + OVERRIDE: **Missing or misspelled header** — Task 2's step 3 says "validate the header" but nothing says what happens; a file whos || "If any row is malformed — wrong column count, an unparseable date, an amount that is not a decimal number, or an amount with more than two fractional digits (`1.005` is malformed; `1.5` and `1.50` are fine) — prints `ledgerlite: :: ` to stderr and returns 2." +T0-current-ledgerlite-2: lines=20 implied=12 override=2 restated=6 + OVERRIDE: **A missing, misspelled, or reordered header row, and a zero-byte file** — rejected as malformed at line 1, not silently || "If any row is malformed — wrong column count, an unparseable date, an amount that is not a decimal number, or an amount with more than two fractional digits (`1.005` is malformed; `1.5` and `1.50` are fine) — prints `ledgerlite: :: ` to stderr and returns 2." + OVERRIDE: **An unreadable `--rules` path** — the spec defines the error only for TRANSACTIONS, but its silence is not permission t || "If TRANSACTIONS cannot be read, prints `ledgerlite: cannot read : ` to stderr and returns 1." +T0-current-ledgerlite-3: lines=16 implied=10 override=2 restated=4 + OVERRIDE: **An empty file, or a file whose header is missing or misspelled** — the spec guarantees the header row, and Task 2's St || "If any row is malformed — wrong column count, an unparseable date, an amount that is not a decimal number, or an amount with more than two fractional digits (`1.005` is malformed; `1.5` and `1.50` are fine) — prints `ledgerlite: :: ` to stderr and returns 2." + OVERRIDE: **`--rules` pointing at a file that cannot be read** — the spec spells out the `cannot read` message only for TRANSACTIO || "If TRANSACTIONS cannot be read, prints `ledgerlite: cannot read : ` to stderr and returns 1." +T0-current-ledgerlite-4: lines=13 implied=8 override=2 restated=3 + OVERRIDE: **`--rules` pointing at a file that cannot be read** — the same `ledgerlite: cannot read : ` and exit 1 as || "If TRANSACTIONS cannot be read, prints `ledgerlite: cannot read : ` to stderr and returns 1." + OVERRIDE: **A file with a wrong header, or an empty file with no header at all** — malformed at line 1 with exit 2; Task 2's Step || "If any row is malformed — wrong column count, an unparseable date, an amount that is not a decimal number, or an amount with more than two fractional digits (`1.005` is malformed; `1.5` and `1.50` are fine) — prints `ledgerlite: :: ` to stderr and returns 2." +T0-current-ledgerlite-5: lines=15 implied=10 override=3 restated=2 + OVERRIDE: **A missing, misspelled, or reordered header row, and a zero-byte file.** Task 2 Step 3 says "validate the header" with || "If any row is malformed — wrong column count, an unparseable date, an amount that is not a decimal number, or an amount with more than two fractional digits (`1.005` is malformed; `1.5` and `1.50` are fine) — prints `ledgerlite: :: ` to stderr and returns 2." + OVERRIDE: **`--rules` given but unreadable.** The spec's `cannot read` clause is written for TRANSACTIONS; silence about the rules || "If TRANSACTIONS cannot be read, prints `ledgerlite: cannot read : ` to stderr and returns 1." + OVERRIDE: **No transactions at all (header-only CSV): what does the report look like?** The spec fixes the closing balance (the op || "Then a blank line, then `closing balance: `." +T0-current-ledgerlite-6: lines=16 implied=10 override=3 restated=3 + OVERRIDE: **A wrong, missing, or reordered header row (`amount,date,description`, or a data row first)** — Task 2 Step 3 says "val || "If any row is malformed — wrong column count, an unparseable date, an amount that is not a decimal number, or an amount with more than two fractional digits (`1.005` is malformed; `1.5` and `1.50` are fine) — prints `ledgerlite: :: ` to stderr and returns 2." + OVERRIDE: **A header-only CSV run end to end** — the spec's "opening amount if there are none" case produces a report with no cate || "Then a blank line, then `closing balance: `." + OVERRIDE: **`--rules` naming an unreadable file** — the spec's cannot-read message is written for TRANSACTIONS only; expect the sa || "If TRANSACTIONS cannot be read, prints `ledgerlite: cannot read : ` to stderr and returns 1." +T0-current-wordstat-1: lines=13 implied=10 override=2 restated=1 + OVERRIDE: **Path names a directory, or an existing file that can't be read** — `IsADirectoryError` / `PermissionError` should land || "Missing file → message to stderr, return 1." + OVERRIDE: **File whose bytes aren't valid UTF-8** — reading in text mode raises `UnicodeDecodeError`; expected is the same stderr- || "Missing file → message to stderr, return 1." +T0-current-wordstat-2: lines=13 implied=11 override=1 restated=1 + OVERRIDE: **Path exists but is not a readable file** — `main([dir_path])` (and an unreadable file) returns 1 with a message on std || "Missing file → message to stderr, return 1." +T0-current-wordstat-3: lines=11 implied=9 override=1 restated=1 + OVERRIDE: **A path that exists but is not a readable file** — a directory or a permission-denied file raises `IsADirectoryError`/` || "Missing file → message to stderr, return 1." +T0-current-wordstat-4: lines=12 implied=9 override=2 restated=1 + OVERRIDE: **A file that is not valid UTF-8 (binary or another encoding)** — reading it must produce the stderr message and exit 1, || "Missing file → message to stderr, return 1." + OVERRIDE: **A path that exists but cannot be read as a file (a directory, or a mode-000 file)** — same contract as a missing file: || "Missing file → message to stderr, return 1." +T0-current-wordstat-5: lines=11 implied=8 override=2 restated=1 + OVERRIDE: A `path` that exists but is not a readable file (a directory, or a mode-000 file): the spec's "missing file" branch is r || "Missing file → message to stderr, return 1." + OVERRIDE: A file that is not valid UTF-8: reading it must fail as a reported error with exit 1, or be read with an explicit fallba || "Missing file → message to stderr, return 1." +T0-current-wordstat-6: lines=11 implied=8 override=2 restated=1 + OVERRIDE: **A path that exists but is not a readable file** — a directory or a permission-denied file raises `IsADirectoryError`/` || "Missing file → message to stderr, return 1." + OVERRIDE: **A file that is not valid UTF-8** — reading in text mode raises `UnicodeDecodeError` mid-`main`, so an unreadable-encod || "Missing file → message to stderr, return 1." +T1-ruling-cost-ledgerlite-1: lines=22 implied=15 override=4 restated=3 + OVERRIDE: A file with no header row: a zero-byte file, and a file whose first line is already data — malformed, exit 2 — Task 2's || "If any row is malformed — wrong column count, an unparseable date, an amount that is not a decimal number, or an amount with more than two fractional digits (`1.005` is malformed; `1.5` and `1.50` are fine) — prints `ledgerlite: :: ` to stderr and returns 2." + OVERRIDE: Zero transactions (header-only CSV) — `format_report({}, opening)` produces exactly `closing balance: 0.00` with no lead || "Then a blank line, then `closing balance: `." + OVERRIDE: `--rules` pointing at a file that cannot be read — the same treatment the spec gives TRANSACTIONS: `ledgerlite: cannot r || "If TRANSACTIONS cannot be read, prints `ledgerlite: cannot read : ` to stderr and returns 1." + OVERRIDE: `python3 -m ledgerlite report txns.csv` — runs; this is the invocation the spec's Behavior section documents — the packa || "Package layout: ledgerlite/ __init__.py, model.py, parse.py, rules.py, balance.py, report.py, cli.py (no __main__.py listed)" +T1-ruling-cost-ledgerlite-2: lines=21 implied=15 override=3 restated=3 + OVERRIDE: **An empty (0-byte) file, or a file whose header is not exactly `date,amount,description` (missing header, reordered, ex || "If any row is malformed — wrong column count, an unparseable date, an amount that is not a decimal number, or an amount with more than two fractional digits (`1.005` is malformed; `1.5` and `1.50` are fine) — prints `ledgerlite: :: ` to stderr and returns 2." + OVERRIDE: **`--rules` pointing at a path that cannot be read** — an error on stderr and a non-zero exit, never a report — `design. || "If TRANSACTIONS cannot be read, prints `ledgerlite: cannot read : ` to stderr and returns 1." + OVERRIDE: **`format_report` with no categories at all (header-only CSV, or every transaction filtered away)** — `closing balance: || "Then a blank line, then `closing balance: `." +T1-ruling-cost-ledgerlite-3: lines=26 implied=19 override=1 restated=6 + OVERRIDE: `--rules` naming a file that cannot be read — the same `cannot read` message and exit 1 as an unreadable TRANSACTIONS, n || "If TRANSACTIONS cannot be read, prints `ledgerlite: cannot read : ` to stderr and returns 1." +T1-ruling-cost-ledgerlite-4: lines=19 implied=14 override=2 restated=3 + OVERRIDE: **A zero-byte file, and a file whose first row is data rather than the `date,amount,description` header** — a missing or || "If any row is malformed — wrong column count, an unparseable date, an amount that is not a decimal number, or an amount with more than two fractional digits (`1.005` is malformed; `1.5` and `1.50` are fine) — prints `ledgerlite: :: ` to stderr and returns 2." + OVERRIDE: **`--rules` pointing at a file that cannot be read** — the same `ledgerlite: cannot read : ` treatment and || "If TRANSACTIONS cannot be read, prints `ledgerlite: cannot read : ` to stderr and returns 1." +T1-ruling-cost-ledgerlite-5: lines=24 implied=15 override=3 restated=6 + OVERRIDE: Empty file, and a file whose first line is not `date,amount,description` — rejected as malformed (line 1), never an `Ind || "If any row is malformed — wrong column count, an unparseable date, an amount that is not a decimal number, or an amount with more than two fractional digits (`1.005` is malformed; `1.5` and `1.50` are fine) — prints `ledgerlite: :: ` to stderr and returns 2." + OVERRIDE: No transactions at all (header-only CSV) — `closing balance: ` with no category lines and no stray leading blan || "Then a blank line, then `closing balance: `." + OVERRIDE: `--rules /no/such/file` — `ledgerlite: cannot read /no/such/file: ` and exit 1, same as an unreadable TRANSACTIO || "If TRANSACTIONS cannot be read, prints `ledgerlite: cannot read : ` to stderr and returns 1." +T1-ruling-cost-ledgerlite-6: lines=20 implied=13 override=2 restated=5 + OVERRIDE: **A first line that is not `date,amount,description` (header absent because the export was already trimmed, or columns r || "If any row is malformed — wrong column count, an unparseable date, an amount that is not a decimal number, or an amount with more than two fractional digits (`1.005` is malformed; `1.5` and `1.50` are fine) — prints `ledgerlite: :: ` to stderr and returns 2." + OVERRIDE: **`--rules` pointing at a missing or unreadable file** — the same `ledgerlite: cannot read : ` plus exit 1 || "If TRANSACTIONS cannot be read, prints `ledgerlite: cannot read : ` to stderr and returns 1." +T1-ruling-cost-wordstat-1: lines=15 implied=11 override=3 restated=1 + OVERRIDE: Path that exists but is not a readable file (a directory) — the same stderr message and exit 1 as a missing file — catch || "Missing file → message to stderr, return 1." + OVERRIDE: Non-UTF-8 / binary input (e.g. a file of `b"\xff\xfe\x00"`) — a message to stderr and exit 1, not a `UnicodeDecodeError` || "Missing file → message to stderr, return 1." + OVERRIDE: `PermissionError` on an unreadable file — same message-and-exit-1 treatment as the directory case — Reviewer checks: the || "Missing file → message to stderr, return 1." +T1-ruling-cost-wordstat-2: lines=16 implied=15 override=1 restated=0 + OVERRIDE: **A path that exists but cannot be read as text** (a directory, or a binary file with invalid UTF-8) — the same one-line || "Missing file → message to stderr, return 1." +T1-ruling-cost-wordstat-3: lines=12 implied=10 override=2 restated=0 + OVERRIDE: `path` names a directory, or a file the user cannot read (`IsADirectoryError`, `PermissionError`) — the same treatment a || "Missing file → message to stderr, return 1." + OVERRIDE: A file that is not decodable text (binary, or bytes invalid in the read encoding) — a message on stderr and exit 1, not || "Missing file → message to stderr, return 1." +T1-ruling-cost-wordstat-4: lines=17 implied=14 override=2 restated=1 + OVERRIDE: **Path is a directory** — the same treatment as a missing file: message to stderr, return 1 — `open()` raises `IsADirect || "Missing file → message to stderr, return 1." + OVERRIDE: **A file that is not decodable text** (binary, or a legacy 8-bit encoding) — an error message and return 1, not a `Unico || "Missing file → message to stderr, return 1." +T1-ruling-cost-wordstat-5: lines=17 implied=14 override=2 restated=1 + OVERRIDE: `path` names a directory, or a file the user cannot read — same shape as the missing-file case: one-line message to stde || "Missing file → message to stderr, return 1." + OVERRIDE: Binary or non-UTF-8 file — message to stderr, return 1 — an uncaught `UnicodeDecodeError` traceback when someone points || "Missing file → message to stderr, return 1." +T1-ruling-cost-wordstat-6: lines=18 implied=14 override=3 restated=1 + OVERRIDE: **A file whose bytes are not valid UTF-8** — a one-line error on stderr and a nonzero exit, not a `UnicodeDecodeError` t || "Missing file → message to stderr, return 1." + OVERRIDE: **Path names a directory** — the same stderr message and exit 1 as a missing file; "I can't read that" is one story to t || "Missing file → message to stderr, return 1." + OVERRIDE: **Unreadable file (mode `000`)** — message to stderr, exit 1 — same crash class as the directory case and fixed by the s || "Missing file → message to stderr, return 1." +T2-ruling-free-wordstat-1: lines=17 implied=12 override=3 restated=2 + OVERRIDE: **`path` is a directory** — same treatment as a missing file: message naming the path to stderr, return 1 — the spec nam || "Missing file → message to stderr, return 1." + OVERRIDE: **File that isn't valid UTF-8** (binary, or Latin-1 bytes) — a diagnostic to stderr and return 1; the tool declines to c || "Missing file → message to stderr, return 1." + OVERRIDE: **Unreadable file (exists, permission denied)** — same shape as the other read failures: stderr message, return 1, no tr || "Missing file → message to stderr, return 1." +T2-ruling-free-wordstat-2: lines=20 implied=17 override=2 restated=1 + OVERRIDE: `path` is a directory, or an existing file the user cannot read — same treatment as missing: one message to stderr namin || "Missing file → message to stderr, return 1." + OVERRIDE: File containing bytes that are not valid UTF-8 — the CLI reports a readable error and returns 1 rather than raising `Uni || "Missing file → message to stderr, return 1." +T2-ruling-free-wordstat-3: lines=15 implied=12 override=2 restated=1 + OVERRIDE: **A file that is not decodable as UTF-8** (latin-1 or binary bytes) — a message on stderr and exit 1, the same shape as || "Missing file → message to stderr, return 1." + OVERRIDE: **A path that exists but is not a readable file** (a directory; a file with permissions cleared) — stderr message and ex || "Missing file → message to stderr, return 1." +T2-ruling-free-wordstat-5: lines=13 implied=9 override=2 restated=2 + OVERRIDE: A file whose bytes are not valid UTF-8 (binary, or Latin-1 accents) — the tool fails like a tool: a message on stderr an || "Missing file → message to stderr, return 1." + OVERRIDE: `path` names a directory, or an unreadable file — same contract as a missing file: stderr message, exit 1 — `IsADirector || "Missing file → message to stderr, return 1." + +Summary (per-rep override counts, reps in name order; skipped reps with no out.md omitted): +T0-current wordstat: 2 1 1 2 2 2 +T0-current ledgerlite: 2 2 2 2 3 3 +T1-ruling-cost wordstat: 3 1 2 2 2 3 +T1-ruling-cost ledgerlite: 4 3 1 2 3 2 +T2-ruling-free wordstat: 3 2 2 2 +T2-ruling-free ledgerlite: (no reps with out.md) diff --git a/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/summary.txt b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/summary.txt new file mode 100644 index 000000000..d1c2c7f07 --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/results-2026-09-17/summary.txt @@ -0,0 +1,61 @@ +rep items test-lines reviewer-lines decode-tier +T0-current-ledgerlite-1 16 16 0 test +T0-current-ledgerlite-2 20 20 0 test +T0-current-ledgerlite-3 16 16 1 test +T0-current-ledgerlite-4 13 13 1 none +T0-current-ledgerlite-5 15 15 1 test +T0-current-ledgerlite-6 16 16 1 test +T0-current-wordstat-1 13 13 0 test +T0-current-wordstat-2 13 13 0 test +T0-current-wordstat-3 11 11 0 test +T0-current-wordstat-4 12 12 0 test +T0-current-wordstat-5 11 11 0 test +T0-current-wordstat-6 11 3 0 test +T1-ruling-cost-ledgerlite-1 22 17 7 test +T1-ruling-cost-ledgerlite-2 21 17 7 test +T1-ruling-cost-ledgerlite-3 26 20 8 test +T1-ruling-cost-ledgerlite-4 19 14 6 test +T1-ruling-cost-ledgerlite-5 24 16 10 test +T1-ruling-cost-ledgerlite-6 20 16 6 test +T1-ruling-cost-wordstat-1 15 11 5 test +T1-ruling-cost-wordstat-2 16 12 5 test +T1-ruling-cost-wordstat-3 12 8 6 test +T1-ruling-cost-wordstat-4 17 12 5 test +T1-ruling-cost-wordstat-5 17 12 5 test +T1-ruling-cost-wordstat-6 18 14 5 test +T2-ruling-free-ledgerlite-1 22 20 3 test +T2-ruling-free-ledgerlite-2 26 24 4 test +T2-ruling-free-ledgerlite-3 19 18 4 test +T2-ruling-free-ledgerlite-4 22 19 3 test +T2-ruling-free-ledgerlite-5 22 20 4 test +T2-ruling-free-ledgerlite-6 19 16 4 test +T2-ruling-free-wordstat-1 17 14 3 test +T2-ruling-free-wordstat-2 20 15 7 test +T2-ruling-free-wordstat-3 15 13 5 test +T2-ruling-free-wordstat-4 17 14 5 test +T2-ruling-free-wordstat-5 13 11 3 test +T2-ruling-free-wordstat-6 17 15 3 test +T3-per-input-ledgerlite-1 27 27 0 test +T3-per-input-ledgerlite-2 12 2 0 test +T3-per-input-ledgerlite-3 27 27 0 test +T3-per-input-ledgerlite-4 22 22 0 test +T3-per-input-ledgerlite-5 18 18 0 test +T3-per-input-ledgerlite-6 20 20 0 test +T3-per-input-wordstat-1 14 14 0 test +T3-per-input-wordstat-2 18 1 0 test +T3-per-input-wordstat-3 14 14 0 test +T3-per-input-wordstat-4 13 0 0 test +T3-per-input-wordstat-5 18 18 0 test +T3-per-input-wordstat-6 11 11 0 test +T4-cap-ledgerlite-1 6 2 0 none +T4-cap-ledgerlite-2 5 5 3 none +T4-cap-ledgerlite-3 5 4 1 none +T4-cap-ledgerlite-4 10 7 0 none +T4-cap-ledgerlite-5 5 3 0 none +T4-cap-ledgerlite-6 5 4 1 none +T4-cap-wordstat-1 5 5 0 test +T4-cap-wordstat-2 5 0 0 test +T4-cap-wordstat-3 6 1 0 test +T4-cap-wordstat-4 5 0 0 test +T4-cap-wordstat-5 5 1 0 test +T4-cap-wordstat-6 5 2 0 test diff --git a/tests/inline-eval/microtests/review-focus-tiers/run.sh b/tests/inline-eval/microtests/review-focus-tiers/run.sh new file mode 100755 index 000000000..42c46a9b6 --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/run.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Micro-test (writing-skills style) for the writing-plans Review Focus wording: +# does a per-line ruling with a cost field (T1: cost criterion, T2: planner's +# free choice) reduce the number of lines earmarked for tests, versus the +# current test-per-line wording (T0)? One fresh `claude -p` on Opus 5 via +# Bedrock per rep, given a design and a complete plan; the output is the +# Review Focus section alone. Fixtures: wordstat (3 tasks) and ledgerlite (6). +# +# Usage: run.sh runs every arm x fixture, each, 4 at a time +# Env: MICRO_MODEL (default us.anthropic.claude-opus-5), AWS_REGION (default us-east-1) +# Reads: items, items marked Test vs Reviewer, whether undecodable input is named and +# which tier it got. Invented behavior (a line that changes what the spec states) +# needs a manual read of each out.md. +set -euo pipefail +HERE=$(cd "$(dirname "$0")" && pwd); ROOT=$(cd "$HERE/../.." && pwd) +OUT=$1; REPS=$2; MODEL=${MICRO_MODEL:-us.anthropic.claude-opus-5} +mkdir -p "$OUT" +one() { # arm fixture rep + local arm=$1 fx=$2 rep=$3 d="$OUT/$1-$2-$3" + [ -e "$d/result.txt" ] && return 0 + mkdir -p "$d/repo" "$d/cfg" + case "$fx" in + wordstat) cp "$ROOT/microtests/scope-vs-failure/template/plan.md" "$ROOT/microtests/scope-vs-failure/template/design.md" "$d/repo/" ;; + ledgerlite) cp "$ROOT/fixtures/ledgerlite/plan.md" "$ROOT/fixtures/ledgerlite/design.md" "$d/repo/" ;; + esac + python3 -c "import json,os;json.dump({'hasCompletedOnboarding':True,'lastOnboardingVersion':'2.1.273','projects':{os.path.realpath('$d/repo'):{'hasTrustDialogAccepted':True}}},open('$d/cfg/.claude.json','w'))" + ( cd "$d/repo" && CLAUDE_CONFIG_DIR="$d/cfg" CLAUDE_CODE_USE_BEDROCK=1 AWS_REGION=${AWS_REGION:-us-east-1} timeout 400 \ + claude -p "$(cat "$HERE/variants/$arm.txt")" --model "$MODEL" < /dev/null > "$d/out.md" 2> "$d/err.log" ) || true + local items tests reviewer decode decode_tier + items=$(grep -c -E '^(- |[0-9]+\. |\* )' "$d/out.md" || true) + tests=$(grep -E '^(- |[0-9]+\. |\* )' "$d/out.md" | grep -c -i 'test' || true) + reviewer=$(grep -E '^(- |[0-9]+\. |\* )' "$d/out.md" | grep -c -i 'reviewer checks\|reviewer confirms\|no test' || true) + decode=$(grep -E '^(- |[0-9]+\. |\* )' "$d/out.md" | grep -i 'utf-8\|unicode\|decod\|binary\|non-text\|encoding' | head -1 || true) + decode_tier=none; [ -n "$decode" ] && { printf '%s' "$decode" | grep -q -i 'reviewer checks\|reviewer confirms\|no test' && decode_tier=reviewer || decode_tier=test; } + { echo "arm=$arm fixture=$fx rep=$rep"; echo "items: $items"; echo "test-lines: $tests"; echo "reviewer-lines: $reviewer"; echo "decode-tier: $decode_tier"; } > "$d/result.txt" +} +export -f one; export HERE ROOT OUT MODEL +for arm in $(ls "$HERE/variants" | sed 's/\.txt$//'); do for fx in wordstat ledgerlite; do for r in $(seq 1 "$REPS"); do echo "$arm $fx $r"; done; done; done \ + | xargs -P 4 -n 3 bash -c 'one "$0" "$1" "$2"' +echo "done: $(ls "$OUT" | wc -l | tr -d ' ') reps" diff --git a/tests/inline-eval/microtests/review-focus-tiers/variants/T0-current.txt b/tests/inline-eval/microtests/review-focus-tiers/variants/T0-current.txt new file mode 100644 index 000000000..a45f7abb0 --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/variants/T0-current.txt @@ -0,0 +1 @@ +You are the author of plan.md for this package; design.md is the spec. Read design.md and plan.md first. The plan is otherwise complete. Write ONLY the plan's '## Review Focus' section, following this instruction from the writing-plans skill: Input classes and failure modes the spec implies but no task's tests exercise — one line each, naming the input or condition and the behavior a reasonable person would expect, ordered by how likely each is to bite that person. The spec is a vision document: it says what the software must do, not everything it will meet, and its silence on an input is not permission for that input to break the program. Write the list here, once, with the spec in front of you. Then, for each line, add the test that pins it to the task that owns the code, in that task's own step style; a line that stays only a line is one the final reviewer checks deliberately instead of rediscovering. For this exercise, name the task and test on each line instead of editing the tasks. Output the section as markdown and nothing else. diff --git a/tests/inline-eval/microtests/review-focus-tiers/variants/T1-ruling-cost.txt b/tests/inline-eval/microtests/review-focus-tiers/variants/T1-ruling-cost.txt new file mode 100644 index 000000000..5a2431122 --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/variants/T1-ruling-cost.txt @@ -0,0 +1 @@ +You are the author of plan.md for this package; design.md is the spec. Read design.md and plan.md first. The plan is otherwise complete. Write ONLY the plan's '## Review Focus' section, following this instruction from the writing-plans skill: Input classes and failure modes the spec implies but no task's tests exercise. The spec is a vision document: it says what the software must do, not everything it will meet, and its silence on an input is not permission for that input to break the program. Write the list here, once, with the spec in front of you. Each line is a ruling, in the shape the executor uses for plan conflicts: ` — — — `. The cost decides the last field: a cost that person would report as a bug earns a test in the task that owns the code, in that task's own step style; anything less stays a line the final reviewer checks deliberately instead of rediscovering. For this exercise, name the task and test on each line instead of editing the tasks. Output the section as markdown and nothing else. diff --git a/tests/inline-eval/microtests/review-focus-tiers/variants/T2-ruling-free.txt b/tests/inline-eval/microtests/review-focus-tiers/variants/T2-ruling-free.txt new file mode 100644 index 000000000..cb97dff5b --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/variants/T2-ruling-free.txt @@ -0,0 +1 @@ +You are the author of plan.md for this package; design.md is the spec. Read design.md and plan.md first. The plan is otherwise complete. Write ONLY the plan's '## Review Focus' section, following this instruction from the writing-plans skill: Input classes and failure modes the spec implies but no task's tests exercise. The spec is a vision document: it says what the software must do, not everything it will meet, and its silence on an input is not permission for that input to break the program. Write the list here, once, with the spec in front of you. Each line is a ruling, in the shape the executor uses for plan conflicts: ` — — — `. You decide the last field for each line; a test goes in the task that owns the code, in that task's own step style, and a line that stays only a line is one the final reviewer checks deliberately instead of rediscovering. For this exercise, name the task and test on each line instead of editing the tasks. Output the section as markdown and nothing else. diff --git a/tests/inline-eval/microtests/review-focus-tiers/variants/T3-per-input.txt b/tests/inline-eval/microtests/review-focus-tiers/variants/T3-per-input.txt new file mode 100644 index 000000000..fb210d1b7 --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/variants/T3-per-input.txt @@ -0,0 +1 @@ +You are the author of plan.md for this package; design.md is the spec. Read design.md and plan.md first. The plan is otherwise complete. Write ONLY the plan's '## Review Focus' section, following this instruction from the writing-plans skill: One line per input the program takes from the person using it — each file it reads, each argument, each environment value — naming what that person is most likely to hand it that the spec did not foresee, and what a reasonable person expects to happen. The spec is a vision document: it says what the software must do, not everything it will meet, and its silence on an input is not permission for that input to break the program. Write the list here, once, with the spec in front of you. Then, for each line, add the test that pins it to the task that owns the code, in that task's own step style. For this exercise, name the task and test on each line instead of editing the tasks. Output the section as markdown and nothing else. diff --git a/tests/inline-eval/microtests/review-focus-tiers/variants/T4-cap.txt b/tests/inline-eval/microtests/review-focus-tiers/variants/T4-cap.txt new file mode 100644 index 000000000..8a92e7f66 --- /dev/null +++ b/tests/inline-eval/microtests/review-focus-tiers/variants/T4-cap.txt @@ -0,0 +1 @@ +You are the author of plan.md for this package; design.md is the spec. Read design.md and plan.md first. The plan is otherwise complete. Write ONLY the plan's '## Review Focus' section, following this instruction from the writing-plans skill: The five input classes or failure modes the spec implies but no task's tests exercise that are most likely to bite a person using this software — one line each, naming the input or condition and the behavior a reasonable person would expect, most likely first. The spec is a vision document: it says what the software must do, not everything it will meet, and its silence on an input is not permission for that input to break the program. Write the list here, once, with the spec in front of you. Then, for each line, add the test that pins it to the task that owns the code, in that task's own step style. For this exercise, name the task and test on each line instead of editing the tasks. Output the section as markdown and nothing else. diff --git a/tests/inline-eval/microtests/reviewer-scope/repo/design.md b/tests/inline-eval/microtests/reviewer-scope/repo/design.md new file mode 100644 index 000000000..f87e72c5a --- /dev/null +++ b/tests/inline-eval/microtests/reviewer-scope/repo/design.md @@ -0,0 +1,37 @@ +# Design: `wordstat` — a tiny text-stats CLI + +A small Python package that computes simple statistics about a text file and +prints a human-readable report. + +## Package layout + +``` +wordstat/ + __init__.py # package marker (exists) + counter.py # pure stat functions (Task 1) + formatter.py # render a stats dict to a report string (Task 2) + cli.py # argparse entry point tying it together (Task 3) +test_counter.py # tests for counter (Task 1) +test_formatter.py # tests for formatter (Task 2) +test_cli.py # tests for cli (Task 3) +``` + +Tests live at the repo root and are runnable with `python3 -m unittest` +(stdlib only — no third-party test deps). + +## Behavior + +- `counter.count_words(text)` → int: number of whitespace-separated tokens. +- `counter.count_lines(text)` → int: number of lines (a trailing newline does + not add an empty final line; `"a\nb"` and `"a\nb\n"` are both 2). +- `counter.count_chars(text)` → int: number of characters including whitespace. +- `formatter.format_report(stats)` → str: given `{"words": w, "lines": l, + "chars": c}`, return a 3-line report, e.g. `"words: 12\nlines: 3\nchars: 57"`. +- `cli.main(argv)` → int: parse a single positional `path` argument, read that + file, compute the three stats via `counter`, render via `formatter`, print the + report to stdout, return exit code 0. Missing file → message to stderr, return 1. + +## Constraints + +- Standard library only. +- Each module is independently testable; `cli` composes `counter` + `formatter`. diff --git a/tests/inline-eval/microtests/reviewer-scope/repo/plan.md b/tests/inline-eval/microtests/reviewer-scope/repo/plan.md new file mode 100644 index 000000000..c26155ed7 --- /dev/null +++ b/tests/inline-eval/microtests/reviewer-scope/repo/plan.md @@ -0,0 +1,40 @@ +# Implementation plan: `wordstat` + +Design context in `design.md`. Build the package with test-driven development: +for each task write the failing test first, then the implementation, then make it +pass, then commit. Standard library only; tests run with `python3 -m unittest`. + +## Task 1 — `wordstat/counter.py` + +Pure statistics functions. + +1. Write `test_counter.py` (failing) covering: + - `count_words("the quick brown fox")` == 4; `count_words("")` == 0. + - `count_lines("a\nb")` == 2; `count_lines("a\nb\n")` == 2; `count_lines("")` == 0. + - `count_chars("abc")` == 3; `count_chars("a b")` == 3. +2. Implement `wordstat/counter.py` with `count_words`, `count_lines`, + `count_chars`. +3. Run `python3 -m unittest test_counter` → green. Commit. + +## Task 2 — `wordstat/formatter.py` + +Render a stats dict to a report string. + +1. Write `test_formatter.py` (failing) covering: + - `format_report({"words": 12, "lines": 3, "chars": 57})` == + `"words: 12\nlines: 3\nchars: 57"`. +2. Implement `wordstat/formatter.py` with `format_report(stats)`. +3. Run `python3 -m unittest test_formatter` → green. Commit. + +## Task 3 — `wordstat/cli.py` + +Entry point composing the previous two modules. Read `wordstat/counter.py` and +`wordstat/formatter.py` to use their real function signatures. + +1. Write `test_cli.py` (failing) covering: + - `main([path])` for a temp file with known content prints the expected + report and returns 0. + - `main(["/no/such/file"])` returns 1. +2. Implement `wordstat/cli.py` with `main(argv)` using `argparse`, calling + `counter` then `formatter`. +3. Run `python3 -m unittest` (whole suite) → green. Commit. diff --git a/tests/inline-eval/microtests/reviewer-scope/repo/test_cli.py b/tests/inline-eval/microtests/reviewer-scope/repo/test_cli.py new file mode 100644 index 000000000..0793491bc --- /dev/null +++ b/tests/inline-eval/microtests/reviewer-scope/repo/test_cli.py @@ -0,0 +1,30 @@ +import io +import tempfile +import unittest +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path + +from wordstat.cli import main + + +class CliTests(unittest.TestCase): + def test_main_prints_report_for_file(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "sample.txt" + path.write_text("the quick\nbrown fox\n", encoding="utf-8") + stdout = io.StringIO() + + with redirect_stdout(stdout): + result = main([str(path)]) + + self.assertEqual(result, 0) + self.assertEqual(stdout.getvalue(), "words: 4\nlines: 2\nchars: 20\n") + + def test_main_returns_one_for_missing_file(self): + stderr = io.StringIO() + + with redirect_stderr(stderr): + result = main(["/no/such/file"]) + + self.assertEqual(result, 1) + self.assertTrue(stderr.getvalue()) diff --git a/tests/inline-eval/microtests/reviewer-scope/repo/test_counter.py b/tests/inline-eval/microtests/reviewer-scope/repo/test_counter.py new file mode 100644 index 000000000..341b8df83 --- /dev/null +++ b/tests/inline-eval/microtests/reviewer-scope/repo/test_counter.py @@ -0,0 +1,18 @@ +import unittest + +from wordstat.counter import count_chars, count_lines, count_words + + +class CounterTests(unittest.TestCase): + def test_count_words(self): + self.assertEqual(count_words("the quick brown fox"), 4) + self.assertEqual(count_words(""), 0) + + def test_count_lines(self): + self.assertEqual(count_lines("a\nb"), 2) + self.assertEqual(count_lines("a\nb\n"), 2) + self.assertEqual(count_lines(""), 0) + + def test_count_chars(self): + self.assertEqual(count_chars("abc"), 3) + self.assertEqual(count_chars("a b"), 3) diff --git a/tests/inline-eval/microtests/reviewer-scope/repo/test_formatter.py b/tests/inline-eval/microtests/reviewer-scope/repo/test_formatter.py new file mode 100644 index 000000000..925b36e0c --- /dev/null +++ b/tests/inline-eval/microtests/reviewer-scope/repo/test_formatter.py @@ -0,0 +1,11 @@ +import unittest + +from wordstat.formatter import format_report + + +class FormatterTests(unittest.TestCase): + def test_format_report(self): + self.assertEqual( + format_report({"words": 12, "lines": 3, "chars": 57}), + "words: 12\nlines: 3\nchars: 57", + ) diff --git a/tests/inline-eval/microtests/reviewer-scope/repo/wordstat/__init__.py b/tests/inline-eval/microtests/reviewer-scope/repo/wordstat/__init__.py new file mode 100644 index 000000000..62ac5d37c --- /dev/null +++ b/tests/inline-eval/microtests/reviewer-scope/repo/wordstat/__init__.py @@ -0,0 +1 @@ +"""wordstat — tiny text-statistics package. See design.md and plan.md.""" diff --git a/tests/inline-eval/microtests/reviewer-scope/repo/wordstat/cli.py b/tests/inline-eval/microtests/reviewer-scope/repo/wordstat/cli.py new file mode 100644 index 000000000..ec622c37a --- /dev/null +++ b/tests/inline-eval/microtests/reviewer-scope/repo/wordstat/cli.py @@ -0,0 +1,27 @@ +"""Command-line entry point for wordstat.""" + +import argparse +import sys + +from . import counter, formatter + + +def main(argv): + parser = argparse.ArgumentParser(description="Report statistics for a text file.") + parser.add_argument("path") + args = parser.parse_args(argv) + + try: + with open(args.path, encoding="utf-8") as source: + text = source.read() + except OSError as error: + print(f"wordstat: {error}", file=sys.stderr) + return 1 + + stats = { + "words": counter.count_words(text), + "lines": counter.count_lines(text), + "chars": counter.count_chars(text), + } + print(formatter.format_report(stats)) + return 0 diff --git a/tests/inline-eval/microtests/reviewer-scope/repo/wordstat/counter.py b/tests/inline-eval/microtests/reviewer-scope/repo/wordstat/counter.py new file mode 100644 index 000000000..2153d0a50 --- /dev/null +++ b/tests/inline-eval/microtests/reviewer-scope/repo/wordstat/counter.py @@ -0,0 +1,13 @@ +"""Pure text statistics.""" + + +def count_words(text): + return len(text.split()) + + +def count_lines(text): + return len(text.splitlines()) + + +def count_chars(text): + return len(text) diff --git a/tests/inline-eval/microtests/reviewer-scope/repo/wordstat/formatter.py b/tests/inline-eval/microtests/reviewer-scope/repo/wordstat/formatter.py new file mode 100644 index 000000000..d6db16cf9 --- /dev/null +++ b/tests/inline-eval/microtests/reviewer-scope/repo/wordstat/formatter.py @@ -0,0 +1,5 @@ +"""Render text statistics as a human-readable report.""" + + +def format_report(stats): + return "\n".join(f"{name}: {stats[name]}" for name in ("words", "lines", "chars")) diff --git a/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R0-current-1.review.txt b/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R0-current-1.review.txt new file mode 100644 index 000000000..d99c32360 --- /dev/null +++ b/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R0-current-1.review.txt @@ -0,0 +1,316 @@ +I’ll read the plan and spec, inspect the full branch diff, and run the tests without modifying the checkout. +exec +/bin/zsh -lc "pwd; rg --files -g 'AGENTS.md' -g 'plan.md' -g 'design.md' -g '*test*' -g 'pyproject.toml'; git status --short; git diff --stat 370a61dd7955dea6e5d90eb23de7d4237b5e8040..HEAD; git diff 370a61dd7955dea6e5d90eb23de7d4237b5e8040..HEAD" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-1/repo + succeeded in 5526ms: +2026-09-17 12:52:23.171 xcodebuild[41625:99751471] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:23.416 xcodebuild[41625:99751470] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-u1PW3j30' (errno=Operation not permitted) +2026-09-17 12:52:23.900 xcodebuild[41638:99751551] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:24.017 xcodebuild[41638:99751548] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-BLEvcaXE' (errno=Operation not permitted) +2026-09-17 12:52:25.053 xcodebuild[41672:99751726] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:25.179 xcodebuild[41672:99751724] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-dzz7tRr4' (errno=Operation not permitted) +2026-09-17 12:52:25.611 xcodebuild[41691:99751817] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:25.738 xcodebuild[41691:99751814] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. + test_cli.py | 30 ++++++++++++++++++++++++++++++ + test_counter.py | 18 ++++++++++++++++++ + test_formatter.py | 11 +++++++++++ + wordstat/cli.py | 27 +++++++++++++++++++++++++++ + wordstat/counter.py | 13 +++++++++++++ + wordstat/formatter.py | 5 +++++ + 6 files changed, 104 insertions(+) +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-9qOEALii' (errno=Operation not permitted) +2026-09-17 12:52:26.719 xcodebuild[41719:99751985] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:26.859 xcodebuild[41719:99751983] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-eSLsb2Lw' (errno=Operation not permitted) +2026-09-17 12:52:27.304 xcodebuild[41735:99752074] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:27.429 xcodebuild[41735:99752071] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +diff --git a/test_cli.py b/test_cli.py +new file mode 100644 +index 0000000..0793491 +--- /dev/null ++++ b/test_cli.py +@@ -0,0 +1,30 @@ ++import io ++import tempfile ++import unittest ++from contextlib import redirect_stderr, redirect_stdout ++from pathlib import Path ++ ++from wordstat.cli import main ++ ++ ++class CliTests(unittest.TestCase): ++ def test_main_prints_report_for_file(self): ++ with tempfile.TemporaryDirectory() as directory: ++ path = Path(directory) / "sample.txt" ++ path.write_text("the quick\nbrown fox\n", encoding="utf-8") ++ stdout = io.StringIO() ++ ++ with redirect_stdout(stdout): ++ result = main([str(path)]) ++ ++ self.assertEqual(result, 0) ++ self.assertEqual(stdout.getvalue(), "words: 4\nlines: 2\nchars: 20\n") ++ ++ def test_main_returns_one_for_missing_file(self): ++ stderr = io.StringIO() ++ ++ with redirect_stderr(stderr): ++ result = main(["/no/such/file"]) ++ ++ self.assertEqual(result, 1) ++ self.assertTrue(stderr.getvalue()) +diff --git a/test_counter.py b/test_counter.py +new file mode 100644 +index 0000000..341b8df +--- /dev/null ++++ b/test_counter.py +@@ -0,0 +1,18 @@ ++import unittest ++ ++from wordstat.counter import count_chars, count_lines, count_words ++ ++ ++class CounterTests(unittest.TestCase): ++ def test_count_words(self): ++ self.assertEqual(count_words("the quick brown fox"), 4) ++ self.assertEqual(count_words(""), 0) ++ ++ def test_count_lines(self): ++ self.assertEqual(count_lines("a\nb"), 2) ++ self.assertEqual(count_lines("a\nb\n"), 2) ++ self.assertEqual(count_lines(""), 0) ++ ++ def test_count_chars(self): ++ self.assertEqual(count_chars("abc"), 3) ++ self.assertEqual(count_chars("a b"), 3) +diff --git a/test_formatter.py b/test_formatter.py +new file mode 100644 +index 0000000..925b36e +--- /dev/null ++++ b/test_formatter.py +@@ -0,0 +1,11 @@ ++import unittest ++ ++from wordstat.formatter import format_report ++ ++ ++class FormatterTests(unittest.TestCase): ++ def test_format_report(self): ++ self.assertEqual( ++ format_report({"words": 12, "lines": 3, "chars": 57}), ++ "words: 12\nlines: 3\nchars: 57", ++ ) +diff --git a/wordstat/cli.py b/wordstat/cli.py +new file mode 100644 +index 0000000..ec622c3 +--- /dev/null ++++ b/wordstat/cli.py +@@ -0,0 +1,27 @@ ++"""Command-line entry point for wordstat.""" ++ ++import argparse ++import sys ++ ++from . import counter, formatter ++ ++ ++def main(argv): ++ parser = argparse.ArgumentParser(description="Report statistics for a text file.") ++ parser.add_argument("path") ++ args = parser.parse_args(argv) ++ ++ try: ++ with open(args.path, encoding="utf-8") as source: ++ text = source.read() ++ except OSError as error: ++ print(f"wordstat: {error}", file=sys.stderr) ++ return 1 ++ ++ stats = { ++ "words": counter.count_words(text), ++ "lines": counter.count_lines(text), ++ "chars": counter.count_chars(text), ++ } ++ print(formatter.format_report(stats)) ++ return 0 +diff --git a/wordstat/counter.py b/wordstat/counter.py +new file mode 100644 +index 0000000..2153d0a +--- /dev/null ++++ b/wordstat/counter.py +@@ -0,0 +1,13 @@ ++"""Pure text statistics.""" ++ ++ ++def count_words(text): ++ return len(text.split()) ++ ++ ++def count_lines(text): ++ return len(text.splitlines()) ++ ++ ++def count_chars(text): ++ return len(text) +diff --git a/wordstat/formatter.py b/wordstat/formatter.py +new file mode 100644 +index 0000000..d6db16c +--- /dev/null ++++ b/wordstat/formatter.py +@@ -0,0 +1,5 @@ ++"""Render text statistics as a human-readable report.""" ++ ++ ++def format_report(stats): ++ return "\n".join(f"{name}: {stats[name]}" for name in ("words", "lines", "chars")) + +exec +/bin/zsh -lc "rg --files --hidden -g '"'!.git'"'; cat plan.md design.md; ls -la wordstat; rg --files /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad -g AGENTS.md" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-1/repo + succeeded in 753ms: +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-6/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-6/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-6/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-6/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-6/repo/AGENTS.md + +exec +/bin/zsh -lc 'PYTHONDONTWRITEBYTECODE=1 python3 -m unittest -v; cat wordstat/__init__.py; git log --oneline 370a61dd7955dea6e5d90eb23de7d4237b5e8040..HEAD; nl -ba wordstat/cli.py' in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-1/repo + succeeded in 1304ms: +2026-09-17 12:52:41.189 xcodebuild[42042:99753085] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:41.308 xcodebuild[42042:99753084] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-8WyFbmmt' (errno=Operation not permitted) +2026-09-17 12:52:41.659 xcodebuild[42044:99753096] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:41.770 xcodebuild[42044:99753095] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +56ffbda tasks 1-3 + 1 """Command-line entry point for wordstat.""" + 2 + 3 import argparse + 4 import sys + 5 + 6 from . import counter, formatter + 7 + 8 + 9 def main(argv): + 10 parser = argparse.ArgumentParser(description="Report statistics for a text file.") + 11 parser.add_argument("path") + 12 args = parser.parse_args(argv) + 13 + 14 try: + 15 with open(args.path, encoding="utf-8") as source: + 16 text = source.read() + 17 except OSError as error: + 18 print(f"wordstat: {error}", file=sys.stderr) + 19 return 1 + 20 + 21 stats = { + 22 "words": counter.count_words(text), + 23 "lines": counter.count_lines(text), + 24 "chars": counter.count_chars(text), + 25 } + 26 print(formatter.format_report(stats)) + 27 return 0 + +The implementation covers the plan’s explicit test cases. Five tests passed; the file-based CLI test was blocked because the read-only sandbox cannot create temporary files. I’m checking command-line invocation and newline handling separately. The branch also has one combined commit rather than the plan’s three task commits. +exec +/bin/zsh -lc "PYTHONDONTWRITEBYTECODE=1 python3 -c 'import subprocess, sys, io; from contextlib import redirect_stdout; from wordstat.cli import main; from pathlib import Path +p = subprocess.run([sys.executable, \"-B\", \"-m\", \"wordstat.cli\", \"design.md\"], capture_output=True, text=True) +print(\"Module invocation:\", p.returncode, repr(p.stdout), repr(p.stderr)) +for content in [b\"a\\r\\nb\\r\\n\", b\"\\xff\"]: + p = subprocess.run([sys.executable, \"-B\", \"-c\", \"from wordstat.cli import main; import sys; sys.exit(main([\\\"/dev/stdin\\\"]))\"], input=content, capture_output=True) + print(\"Input:\", repr(content), \"exit:\", p.returncode, \"stdout:\", repr(p.stdout), \"stderr:\", p.stderr.decode()) +text = Path(\"wordstat/__init__.py\").read_text(); out = io.StringIO() +with redirect_stdout(out): + code = main([\"wordstat/__init__.py\"]) +expected = \"words: {}\\nlines: {}\\nchars: {}\\n\".format(len(text.split()), len(text.splitlines()), len(text)) +assert code == 0 and out.getvalue() == expected +print(\"Existing-file integration check: PASS\")'" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-1/repo + succeeded in 0ms: +Module invocation: 0 '' '' +Input: b'a\r\nb\r\n' exit: 0 stdout: b'words: 2\nlines: 2\nchars: 4\n' stderr: +Input: b'\xff' exit: 1 stdout: b'' stderr: Traceback (most recent call last): + File "", line 1, in + from wordstat.cli import main; import sys; sys.exit(main(["/dev/stdin"])) + ~~~~^^^^^^^^^^^^^^^^ + File "/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-1/repo/wordstat/cli.py", line 16, in main + text = source.read() + File "", line 325, in decode +UnicodeDecodeError: 'utf-8' codec can't decode byte 0xff in position 0: invalid start byte + +Existing-file integration check: PASS + +### Strengths + +- Clear separation of pure counters, formatting, and CLI orchestration; standard library only. +- All explicit planned test cases are implemented, including exact report formatting and missing-file handling. +- Five tests passed, and a separate integration check using an existing file passed. The remaining test was blocked by the sandbox’s inability to create temporary files. + +### Issues + +#### Critical (Must Fix) + +None found. + +#### Important (Should Fix) + +1. **File reading changes the character count** + - **File:** `wordstat/cli.py:15` + - Default universal-newline translation converts CRLF into LF before counting. Input `a\r\nb\r\n` reports 4 characters instead of 6, contrary to counting characters including whitespace. + - Open with `newline=""` to preserve the original characters, and add a CRLF integration test. + +2. **No executable CLI entry point** + - **File:** `wordstat/cli.py:27` + - `python3 -m wordstat.cli design.md` exits successfully without printing anything because nothing invokes `main`. + - Add `if __name__ == "__main__": sys.exit(main(sys.argv[1:]))` and a subprocess test. + - This is a **plan gap**: the explicit `main(argv)` contract is implemented, but the plan omits wiring it into a runnable command despite describing a CLI. + +#### Minor (Nice to Have) + +1. **Invalid UTF-8 produces an unhandled traceback** + - **File:** `wordstat/cli.py:16` + - `UnicodeDecodeError` escapes the `OSError` handler. Invalid input therefore raises instead of returning a controlled failure. + - Document UTF-8 input and catch `UnicodeDecodeError`, printing a concise stderr message and returning 1. The spec currently leaves encoding-error behavior unspecified. + +### Recommendations + +- Add regression tests for preserved newline characters and actual command-line execution. +- History contains one combined commit (`56ffbda`), rather than the planned per-task commits. Test-first sequencing cannot be verified from that history; no history rewrite is necessary. + +### Assessment + +**Ready to merge? With fixes** + +**Reasoning:** The implementation closely follows the explicit function-level plan, but newline normalization causes incorrect character totals and the advertised CLI lacks executable wiring. Full-suite success remains unverified because one test requires temporary-file access unavailable in this sandbox. diff --git a/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R0-current-2.review.txt b/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R0-current-2.review.txt new file mode 100644 index 000000000..4e8e86e8b --- /dev/null +++ b/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R0-current-2.review.txt @@ -0,0 +1,322 @@ +I’ll read the plan and spec, inspect the full branch diff, and run checks without changing the checkout. +exec +/bin/zsh -lc "pwd; rg --files -g 'AGENTS.md' -g 'plan.md' -g 'design.md' -g 'pyproject.toml' -g '*test*' -g '*.py' -g 'README*'; git diff --stat 370a61dd7955dea6e5d90eb23de7d4237b5e8040..HEAD; git status --short" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-2/repo + succeeded in 3070ms: +2026-09-17 12:52:25.465 xcodebuild[41687:99751801] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:25.594 xcodebuild[41687:99751796] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-BMN89pJB' (errno=Operation not permitted) +2026-09-17 12:52:26.025 xcodebuild[41700:99751890] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:26.145 xcodebuild[41700:99751889] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. + test_cli.py | 30 ++++++++++++++++++++++++++++++ + test_counter.py | 18 ++++++++++++++++++ + test_formatter.py | 11 +++++++++++ + wordstat/cli.py | 27 +++++++++++++++++++++++++++ + wordstat/counter.py | 13 +++++++++++++ + wordstat/formatter.py | 5 +++++ + 6 files changed, 104 insertions(+) +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-UU4sY9K6' (errno=Operation not permitted) +2026-09-17 12:52:27.103 xcodebuild[41728:99752043] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:27.234 xcodebuild[41728:99752042] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-a5liV0NC' (errno=Operation not permitted) +2026-09-17 12:52:27.643 xcodebuild[41740:99752091] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:27.760 xcodebuild[41740:99752090] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. + +exec +/bin/zsh -lc 'cat plan.md design.md; git diff 370a61dd7955dea6e5d90eb23de7d4237b5e8040..HEAD' in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-2/repo + succeeded in 1284ms: +2026-09-17 12:52:28.679 xcodebuild[41767:99752215] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:28.799 xcodebuild[41767:99752206] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-mDaDNciN' (errno=Operation not permitted) +2026-09-17 12:52:29.174 xcodebuild[41837:99752316] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:29.292 xcodebuild[41837:99752315] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +diff --git a/test_cli.py b/test_cli.py +new file mode 100644 +index 0000000..0793491 +--- /dev/null ++++ b/test_cli.py +@@ -0,0 +1,30 @@ ++import io ++import tempfile ++import unittest ++from contextlib import redirect_stderr, redirect_stdout ++from pathlib import Path ++ ++from wordstat.cli import main ++ ++ ++class CliTests(unittest.TestCase): ++ def test_main_prints_report_for_file(self): ++ with tempfile.TemporaryDirectory() as directory: ++ path = Path(directory) / "sample.txt" ++ path.write_text("the quick\nbrown fox\n", encoding="utf-8") ++ stdout = io.StringIO() ++ ++ with redirect_stdout(stdout): ++ result = main([str(path)]) ++ ++ self.assertEqual(result, 0) ++ self.assertEqual(stdout.getvalue(), "words: 4\nlines: 2\nchars: 20\n") ++ ++ def test_main_returns_one_for_missing_file(self): ++ stderr = io.StringIO() ++ ++ with redirect_stderr(stderr): ++ result = main(["/no/such/file"]) ++ ++ self.assertEqual(result, 1) ++ self.assertTrue(stderr.getvalue()) +diff --git a/test_counter.py b/test_counter.py +new file mode 100644 +index 0000000..341b8df +--- /dev/null ++++ b/test_counter.py +@@ -0,0 +1,18 @@ ++import unittest ++ ++from wordstat.counter import count_chars, count_lines, count_words ++ ++ ++class CounterTests(unittest.TestCase): ++ def test_count_words(self): ++ self.assertEqual(count_words("the quick brown fox"), 4) ++ self.assertEqual(count_words(""), 0) ++ ++ def test_count_lines(self): ++ self.assertEqual(count_lines("a\nb"), 2) ++ self.assertEqual(count_lines("a\nb\n"), 2) ++ self.assertEqual(count_lines(""), 0) ++ ++ def test_count_chars(self): ++ self.assertEqual(count_chars("abc"), 3) ++ self.assertEqual(count_chars("a b"), 3) +diff --git a/test_formatter.py b/test_formatter.py +new file mode 100644 +index 0000000..925b36e +--- /dev/null ++++ b/test_formatter.py +@@ -0,0 +1,11 @@ ++import unittest ++ ++from wordstat.formatter import format_report ++ ++ ++class FormatterTests(unittest.TestCase): ++ def test_format_report(self): ++ self.assertEqual( ++ format_report({"words": 12, "lines": 3, "chars": 57}), ++ "words: 12\nlines: 3\nchars: 57", ++ ) +diff --git a/wordstat/cli.py b/wordstat/cli.py +new file mode 100644 +index 0000000..ec622c3 +--- /dev/null ++++ b/wordstat/cli.py +@@ -0,0 +1,27 @@ ++"""Command-line entry point for wordstat.""" ++ ++import argparse ++import sys ++ ++from . import counter, formatter ++ ++ ++def main(argv): ++ parser = argparse.ArgumentParser(description="Report statistics for a text file.") ++ parser.add_argument("path") ++ args = parser.parse_args(argv) ++ ++ try: ++ with open(args.path, encoding="utf-8") as source: ++ text = source.read() ++ except OSError as error: ++ print(f"wordstat: {error}", file=sys.stderr) ++ return 1 ++ ++ stats = { ++ "words": counter.count_words(text), ++ "lines": counter.count_lines(text), ++ "chars": counter.count_chars(text), ++ } ++ print(formatter.format_report(stats)) ++ return 0 +diff --git a/wordstat/counter.py b/wordstat/counter.py +new file mode 100644 +index 0000000..2153d0a +--- /dev/null ++++ b/wordstat/counter.py +@@ -0,0 +1,13 @@ ++"""Pure text statistics.""" ++ ++ ++def count_words(text): ++ return len(text.split()) ++ ++ ++def count_lines(text): ++ return len(text.splitlines()) ++ ++ ++def count_chars(text): ++ return len(text) +diff --git a/wordstat/formatter.py b/wordstat/formatter.py +new file mode 100644 +index 0000000..d6db16c +--- /dev/null ++++ b/wordstat/formatter.py +@@ -0,0 +1,5 @@ ++"""Render text statistics as a human-readable report.""" ++ ++ ++def format_report(stats): ++ return "\n".join(f"{name}: {stats[name]}" for name in ("words", "lines", "chars")) + +exec +/bin/zsh -lc "rg --files --hidden -g '"'!.git'"'; git log --oneline 370a61dd7955dea6e5d90eb23de7d4237b5e8040..HEAD; PYTHONDONTWRITEBYTECODE=1 python3 -B -m unittest -v" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-2/repo + exited 1 in 1351ms: +2026-09-17 12:52:38.210 xcodebuild[42001:99752902] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:38.328 xcodebuild[42001:99752901] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-eT2db5Ti' (errno=Operation not permitted) +2026-09-17 12:52:38.698 xcodebuild[42010:99752935] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:38.804 xcodebuild[42010:99752934] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +56ffbda tasks 1-3 +test_main_prints_report_for_file (test_cli.CliTests.test_main_prints_report_for_file) ... ERROR +test_main_returns_one_for_missing_file (test_cli.CliTests.test_main_returns_one_for_missing_file) ... ok +test_count_chars (test_counter.CounterTests.test_count_chars) ... ok +test_count_lines (test_counter.CounterTests.test_count_lines) ... ok +test_count_words (test_counter.CounterTests.test_count_words) ... ok +test_format_report (test_formatter.FormatterTests.test_format_report) ... ok + +====================================================================== +ERROR: test_main_prints_report_for_file (test_cli.CliTests.test_main_prints_report_for_file) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-2/repo/test_cli.py", line 12, in test_main_prints_report_for_file + with tempfile.TemporaryDirectory() as directory: + ~~~~~~~~~~~~~~~~~~~~~~~~~~~^^ + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 907, in __init__ + self.name = mkdtemp(suffix, prefix, dir) + ~~~~~~~^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 370, in mkdtemp + prefix, suffix, dir, output_type = _sanitize_params(prefix, suffix, dir) + ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 127, in _sanitize_params + dir = gettempdir() + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 312, in gettempdir + return _os.fsdecode(_gettempdir()) + ~~~~~~~~~~~^^ + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 305, in _gettempdir + tempdir = _get_default_tempdir() + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 222, in _get_default_tempdir + raise FileNotFoundError(_errno.ENOENT, + "No usable temporary directory found in %s" % + dirlist) +FileNotFoundError: [Errno 2] No usable temporary directory found in ['/var/folders/43/prgnkdr95317fd_zbljq8thm0000gn/T/', '/tmp', '/var/tmp', '/usr/tmp', '/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-2/repo'] + +---------------------------------------------------------------------- +Ran 6 tests in 0.002s + +FAILED (errors=1) + +exec +/bin/zsh -lc 'PYTHONDONTWRITEBYTECODE=1 python3 -B -m wordstat.cli design.md; PYTHONDONTWRITEBYTECODE=1 python3 -B -m wordstat.cli /no/such/file; nl -ba wordstat/cli.py; cat wordstat/__init__.py' in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-2/repo + succeeded in 0ms: + 1 """Command-line entry point for wordstat.""" + 2 + 3 import argparse + 4 import sys + 5 + 6 from . import counter, formatter + 7 + 8 + 9 def main(argv): + 10 parser = argparse.ArgumentParser(description="Report statistics for a text file.") + 11 parser.add_argument("path") + 12 args = parser.parse_args(argv) + 13 + 14 try: + 15 with open(args.path, encoding="utf-8") as source: + 16 text = source.read() + 17 except OSError as error: + 18 print(f"wordstat: {error}", file=sys.stderr) + 19 return 1 + 20 + 21 stats = { + 22 "words": counter.count_words(text), + 23 "lines": counter.count_lines(text), + 24 "chars": counter.count_chars(text), + 25 } + 26 print(formatter.format_report(stats)) + 27 return 0 +"""wordstat — tiny text-statistics package. See design.md and plan.md.""" + +The functions match the planned examples, but running the CLI module produces no output because it never calls `main`. Five tests passed; the sixth was blocked by the sandbox’s restriction on temporary files. I’m checking file-reading edge cases next. +exec +/bin/zsh -lc "printf 'a\\r\\nb\\r\\n' | PYTHONDONTWRITEBYTECODE=1 python3 -B -c 'from wordstat.cli import main; raise SystemExit(main([\"/dev/stdin\"]))' +printf '\\377' | PYTHONDONTWRITEBYTECODE=1 python3 -B -c 'from wordstat.cli import main; raise SystemExit(main([\"/dev/stdin\"]))' +PYTHONDONTWRITEBYTECODE=1 python3 -B -c 'import contextlib, io; from pathlib import Path; from wordstat.cli import main; text=Path(\"design.md\").read_text(encoding=\"utf-8\"); out=io.StringIO(); expected=f\"words: {len(text.split())}\\nlines: {len(text.splitlines())}\\nchars: {len(text)}\\n\"; +with contextlib.redirect_stdout(out): result=main([\"design.md\"]) +assert result == 0 and out.getvalue() == expected; print(\"Existing-file integration check passed\")'" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-2/repo + succeeded in 0ms: +words: 2 +lines: 2 +chars: 4 +Traceback (most recent call last): + File "", line 1, in + from wordstat.cli import main; raise SystemExit(main(["/dev/stdin"])) + ~~~~^^^^^^^^^^^^^^^^ + File "/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-2/repo/wordstat/cli.py", line 16, in main + text = source.read() + File "", line 325, in decode +UnicodeDecodeError: 'utf-8' codec can't decode byte 0xff in position 0: invalid start byte +Existing-file integration check passed + +### Strengths + +- Clear separation of counting, formatting, and CLI composition; standard library only. +- Counter and formatter implementations satisfy all explicitly planned examples. +- Tests exercise real functions and file I/O. Missing-file handling correctly writes to stderr and returns 1. + +### Issues + +#### Critical (Must Fix) + +None. + +#### Important (Should Fix) + +1. **CLI module never invokes `main`** + - **File:** `wordstat/cli.py:27` + - Running `python3 -m wordstat.cli design.md` produces no output. A nonexistent path also silently exits successfully. + - This leaves the intended CLI unusable from the command line. The plan specifies `main(argv)` but omits its invocation. + - **Fix:** Add `if __name__ == "__main__": sys.exit(main(sys.argv[1:]))` and subprocess tests for successful and missing-file invocations. + +2. **File reading changes the character count** + - **File:** `wordstat/cli.py:15` + - Default newline translation converts CRLF to LF before counting. Input `a\r\nb\r\n` reports 4 characters instead of 6. + - This conflicts with counting characters including whitespace. + - **Fix:** Open with `newline=""` to preserve characters and add a CRLF integration test. + +#### Minor (Nice to Have) + +1. **Decoding failures escape the CLI’s error handling** + - **File:** `wordstat/cli.py:16–17` + - Invalid UTF-8 raises an uncaught `UnicodeDecodeError`, producing a traceback rather than a concise diagnostic. + - Encoding behavior is unspecified in the design, so this is a robustness improvement. + - **Fix:** Document UTF-8 input and catch `UnicodeError`, reporting to stderr and returning 1. + +### Recommendations + +- Add the CLI invocation and CRLF regression tests before merging. +- Five of six tests passed; the remaining test could not create its temporary directory under the read-only sandbox. A separate integration check using an existing file passed. +- History contains one combined `tasks 1-3` commit rather than the three planned commits. Test-first sequencing cannot be verified from this history. + +### Assessment + +**Ready to merge? With fixes** + +**Reasoning:** The core functions and module boundaries match the plan, but the command-line entry point is inactive and newline translation undercounts CRLF input. Fix both and rerun the full suite in an environment that permits temporary files. diff --git a/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R0-current-3.review.txt b/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R0-current-3.review.txt new file mode 100644 index 000000000..e4e13ba82 --- /dev/null +++ b/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R0-current-3.review.txt @@ -0,0 +1,315 @@ +I’ll compare the branch with the plan and spec, read the implementation and tests, and check behavior without changing the checkout. +exec +/bin/zsh -lc "pwd; rg --files -g 'AGENTS.md' -g 'plan.md' -g 'design.md' -g 'pyproject.toml' -g '*test*' -g 'wordstat/**'; git status --short; git diff --stat 370a61dd7955dea6e5d90eb23de7d4237b5e8040..HEAD; git diff 370a61dd7955dea6e5d90eb23de7d4237b5e8040..HEAD" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-3/repo + succeeded in 5914ms: +2026-09-17 12:52:23.171 xcodebuild[41617:99751419] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:23.416 xcodebuild[41617:99751418] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-IuJurtQc' (errno=Operation not permitted) +2026-09-17 12:52:23.900 xcodebuild[41637:99751550] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:24.017 xcodebuild[41637:99751549] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-Zzz9NF7I' (errno=Operation not permitted) +2026-09-17 12:52:25.053 xcodebuild[41671:99751725] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:25.179 xcodebuild[41671:99751723] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-eMg4Av2V' (errno=Operation not permitted) +2026-09-17 12:52:25.611 xcodebuild[41690:99751816] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:25.738 xcodebuild[41690:99751813] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. + test_cli.py | 30 ++++++++++++++++++++++++++++++ + test_counter.py | 18 ++++++++++++++++++ + test_formatter.py | 11 +++++++++++ + wordstat/cli.py | 27 +++++++++++++++++++++++++++ + wordstat/counter.py | 13 +++++++++++++ + wordstat/formatter.py | 5 +++++ + 6 files changed, 104 insertions(+) +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-kwEqiVlC' (errno=Operation not permitted) +2026-09-17 12:52:26.719 xcodebuild[41718:99751984] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:26.858 xcodebuild[41718:99751981] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-WpoLrl2s' (errno=Operation not permitted) +2026-09-17 12:52:27.304 xcodebuild[41736:99752073] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:27.429 xcodebuild[41736:99752072] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +diff --git a/test_cli.py b/test_cli.py +new file mode 100644 +index 0000000..0793491 +--- /dev/null ++++ b/test_cli.py +@@ -0,0 +1,30 @@ ++import io ++import tempfile ++import unittest ++from contextlib import redirect_stderr, redirect_stdout ++from pathlib import Path ++ ++from wordstat.cli import main ++ ++ ++class CliTests(unittest.TestCase): ++ def test_main_prints_report_for_file(self): ++ with tempfile.TemporaryDirectory() as directory: ++ path = Path(directory) / "sample.txt" ++ path.write_text("the quick\nbrown fox\n", encoding="utf-8") ++ stdout = io.StringIO() ++ ++ with redirect_stdout(stdout): ++ result = main([str(path)]) ++ ++ self.assertEqual(result, 0) ++ self.assertEqual(stdout.getvalue(), "words: 4\nlines: 2\nchars: 20\n") ++ ++ def test_main_returns_one_for_missing_file(self): ++ stderr = io.StringIO() ++ ++ with redirect_stderr(stderr): ++ result = main(["/no/such/file"]) ++ ++ self.assertEqual(result, 1) ++ self.assertTrue(stderr.getvalue()) +diff --git a/test_counter.py b/test_counter.py +new file mode 100644 +index 0000000..341b8df +--- /dev/null ++++ b/test_counter.py +@@ -0,0 +1,18 @@ ++import unittest ++ ++from wordstat.counter import count_chars, count_lines, count_words ++ ++ ++class CounterTests(unittest.TestCase): ++ def test_count_words(self): ++ self.assertEqual(count_words("the quick brown fox"), 4) ++ self.assertEqual(count_words(""), 0) ++ ++ def test_count_lines(self): ++ self.assertEqual(count_lines("a\nb"), 2) ++ self.assertEqual(count_lines("a\nb\n"), 2) ++ self.assertEqual(count_lines(""), 0) ++ ++ def test_count_chars(self): ++ self.assertEqual(count_chars("abc"), 3) ++ self.assertEqual(count_chars("a b"), 3) +diff --git a/test_formatter.py b/test_formatter.py +new file mode 100644 +index 0000000..925b36e +--- /dev/null ++++ b/test_formatter.py +@@ -0,0 +1,11 @@ ++import unittest ++ ++from wordstat.formatter import format_report ++ ++ ++class FormatterTests(unittest.TestCase): ++ def test_format_report(self): ++ self.assertEqual( ++ format_report({"words": 12, "lines": 3, "chars": 57}), ++ "words: 12\nlines: 3\nchars: 57", ++ ) +diff --git a/wordstat/cli.py b/wordstat/cli.py +new file mode 100644 +index 0000000..ec622c3 +--- /dev/null ++++ b/wordstat/cli.py +@@ -0,0 +1,27 @@ ++"""Command-line entry point for wordstat.""" ++ ++import argparse ++import sys ++ ++from . import counter, formatter ++ ++ ++def main(argv): ++ parser = argparse.ArgumentParser(description="Report statistics for a text file.") ++ parser.add_argument("path") ++ args = parser.parse_args(argv) ++ ++ try: ++ with open(args.path, encoding="utf-8") as source: ++ text = source.read() ++ except OSError as error: ++ print(f"wordstat: {error}", file=sys.stderr) ++ return 1 ++ ++ stats = { ++ "words": counter.count_words(text), ++ "lines": counter.count_lines(text), ++ "chars": counter.count_chars(text), ++ } ++ print(formatter.format_report(stats)) ++ return 0 +diff --git a/wordstat/counter.py b/wordstat/counter.py +new file mode 100644 +index 0000000..2153d0a +--- /dev/null ++++ b/wordstat/counter.py +@@ -0,0 +1,13 @@ ++"""Pure text statistics.""" ++ ++ ++def count_words(text): ++ return len(text.split()) ++ ++ ++def count_lines(text): ++ return len(text.splitlines()) ++ ++ ++def count_chars(text): ++ return len(text) +diff --git a/wordstat/formatter.py b/wordstat/formatter.py +new file mode 100644 +index 0000000..d6db16c +--- /dev/null ++++ b/wordstat/formatter.py +@@ -0,0 +1,5 @@ ++"""Render text statistics as a human-readable report.""" ++ ++ ++def format_report(stats): ++ return "\n".join(f"{name}: {stats[name]}" for name in ("words", "lines", "chars")) + +exec +/bin/zsh -lc "rg --files --hidden -g '"'!.git'"'; cat plan.md design.md; ls -la wordstat; rg --files /private/tmp/claude-501 -g AGENTS.md" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-3/repo + succeeded in 1990ms: +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-6/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-6/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-6/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/67b7eb56-dd95-43ff-bb80-a38a5d035457/scratchpad/old-local-plugin-dev/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-6/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-6/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-prime-radiant-inc-evener--claude-worktrees-issue-1116-merge-review-1fc3f3/c188f328-8307-42c0-8c58-f2b3e0d8b222/scratchpad/main-probe2/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-prime-radiant-inc-evener--claude-worktrees-issue-1116-merge-review-1fc3f3/c188f328-8307-42c0-8c58-f2b3e0d8b222/scratchpad/main-probe2/mobile-native/AGENTS.md + +exec +/bin/zsh -lc 'PYTHONDONTWRITEBYTECODE=1 python3 -m unittest -v; git log --oneline 370a61dd7955dea6e5d90eb23de7d4237b5e8040..HEAD; cat wordstat/__init__.py; nl -ba wordstat/cli.py; PYTHONDONTWRITEBYTECODE=1 python3 -m wordstat.cli design.md' in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-3/repo + succeeded in 1400ms: +2026-09-17 12:52:38.580 xcodebuild[42007:99752922] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:38.696 xcodebuild[42007:99752921] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-E7cz5M2J' (errno=Operation not permitted) +2026-09-17 12:52:39.076 xcodebuild[42013:99752954] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:39.194 xcodebuild[42013:99752953] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +56ffbda tasks 1-3 +"""wordstat — tiny text-statistics package. See design.md and plan.md.""" + 1 """Command-line entry point for wordstat.""" + 2 + 3 import argparse + 4 import sys + 5 + 6 from . import counter, formatter + 7 + 8 + 9 def main(argv): + 10 parser = argparse.ArgumentParser(description="Report statistics for a text file.") + 11 parser.add_argument("path") + 12 args = parser.parse_args(argv) + 13 + 14 try: + 15 with open(args.path, encoding="utf-8") as source: + 16 text = source.read() + 17 except OSError as error: + 18 print(f"wordstat: {error}", file=sys.stderr) + 19 return 1 + 20 + 21 stats = { + 22 "words": counter.count_words(text), + 23 "lines": counter.count_lines(text), + 24 "chars": counter.count_chars(text), + 25 } + 26 print(formatter.format_report(stats)) + 27 return 0 + +The code matches the functions and test cases listed in the plan. Five tests pass; the file-based test is blocked by the read-only sandbox because it creates a temporary file. I also confirmed that `python3 -m wordstat.cli` exits without running `main`; I’ll check character counting through a read-only input stream next. +exec +/bin/zsh -lc "PYTHONDONTWRITEBYTECODE=1 python3 -c 'import subprocess, sys, io; from contextlib import redirect_stdout; from pathlib import Path; from wordstat.cli import main +text = Path(\"design.md\").read_text(encoding=\"utf-8\") +out = io.StringIO() +with redirect_stdout(out): + code = main([\"design.md\"]) +assert code == 0 +assert out.getvalue() == \"words: {}\\nlines: {}\\nchars: {}\\n\".format(len(text.split()), len(text.splitlines()), len(text)) +print(\"Existing-file integration check: passed\") +for data in [b\"a\\r\\nb\\r\\n\", b\"\\xff\"]: + result = subprocess.run([sys.executable, \"-B\", \"-c\", \"from wordstat.cli import main; raise SystemExit(main([\\\"/dev/stdin\\\"]))\"], input=data, capture_output=True) + print(repr(data), \"exit:\", result.returncode, \"stdout:\", repr(result.stdout), \"stderr:\", result.stderr.decode()[-200:])'" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-3/repo + succeeded in 0ms: +Existing-file integration check: passed +b'a\r\nb\r\n' exit: 0 stdout: b'words: 2\nlines: 2\nchars: 4\n' stderr: +b'\xff' exit: 1 stdout: b'' stderr: po/wordstat/cli.py", line 16, in main + text = source.read() + File "", line 325, in decode +UnicodeDecodeError: 'utf-8' codec can't decode byte 0xff in position 0: invalid start byte + + +### Strengths + +- Clean separation between counting, formatting, and CLI orchestration; standard library only. +- All explicitly planned functions and test cases are implemented, including empty input, trailing newlines, exact report formatting, and missing-file handling. +- Five tests passed. The sixth was blocked by sandbox restrictions on temporary-file creation; a separate integration check using an existing file passed. + +### Issues + +#### Critical (Must Fix) + +None found. + +#### Important (Should Fix) + +1. **CLI module never invokes its entry point** + - **File:** `wordstat/cli.py:27` + - `python3 -m wordstat.cli design.md` exits successfully without printing anything because nothing calls `main`. + - Add `if __name__ == "__main__": sys.exit(main(sys.argv[1:]))` and a subprocess test. + - This is also a plan gap: the plan tests the callable API but omits executable CLI behavior. + +2. **File reading changes character counts** + - **File:** `wordstat/cli.py:15` + - Default newline translation converts CRLF into LF before counting. Input `a\r\nb\r\n` reports **4 characters instead of 6**, contrary to counting characters including whitespace. + - Open with `newline=""` to preserve the original characters, and add a CRLF integration test. + +#### Minor (Nice to Have) + +1. **Invalid UTF-8 produces an unhandled traceback** + - **File:** `wordstat/cli.py:16–17` + - Decoding failures raise `UnicodeDecodeError`, which the `OSError` handler does not catch. + - Document UTF-8 input and catch `UnicodeError` to print a concise diagnostic and return 1. + +### Recommendations + +Add end-to-end tests for module invocation and preserved newline characters. Rerun the full suite in an environment permitting temporary files. + +The history contains one combined `tasks 1-3` commit, rather than the plan’s separate task commits. Test-first development cannot be verified from that history. + +### Assessment + +**Ready to merge? With fixes** + +**Reasoning:** The planned callable interfaces are implemented cleanly, but command-line execution currently does nothing and CRLF input produces incorrect character totals. Fix these before merging. diff --git a/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R0-current-4.review.txt b/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R0-current-4.review.txt new file mode 100644 index 000000000..6f4a720a0 --- /dev/null +++ b/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R0-current-4.review.txt @@ -0,0 +1,410 @@ +I’ll read the plan and spec, inspect the branch diff, and run relevant checks without changing the checkout. +exec +/bin/zsh -lc "pwd; rg --files -g 'AGENTS.md' -g 'plan.md' -g 'design.md' -g 'pyproject.toml' -g '*test*' -g '*.py'; git diff --stat 370a61dd7955dea6e5d90eb23de7d4237b5e8040..HEAD; git diff 370a61dd7955dea6e5d90eb23de7d4237b5e8040..HEAD" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-4/repo + succeeded in 3173ms: +2026-09-17 12:52:24.416 xcodebuild[41656:99751673] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:24.545 xcodebuild[41656:99751671] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-bMlFm37k' (errno=Operation not permitted) +2026-09-17 12:52:24.967 xcodebuild[41666:99751702] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:25.084 xcodebuild[41666:99751699] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. + test_cli.py | 30 ++++++++++++++++++++++++++++++ + test_counter.py | 18 ++++++++++++++++++ + test_formatter.py | 11 +++++++++++ + wordstat/cli.py | 27 +++++++++++++++++++++++++++ + wordstat/counter.py | 13 +++++++++++++ + wordstat/formatter.py | 5 +++++ + 6 files changed, 104 insertions(+) +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-mKJ6K4Dk' (errno=Operation not permitted) +2026-09-17 12:52:26.054 xcodebuild[41702:99751898] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:26.183 xcodebuild[41702:99751897] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-t0GtiCcO' (errno=Operation not permitted) +2026-09-17 12:52:26.622 xcodebuild[41714:99751953] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:26.753 xcodebuild[41714:99751950] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +diff --git a/test_cli.py b/test_cli.py +new file mode 100644 +index 0000000..0793491 +--- /dev/null ++++ b/test_cli.py +@@ -0,0 +1,30 @@ ++import io ++import tempfile ++import unittest ++from contextlib import redirect_stderr, redirect_stdout ++from pathlib import Path ++ ++from wordstat.cli import main ++ ++ ++class CliTests(unittest.TestCase): ++ def test_main_prints_report_for_file(self): ++ with tempfile.TemporaryDirectory() as directory: ++ path = Path(directory) / "sample.txt" ++ path.write_text("the quick\nbrown fox\n", encoding="utf-8") ++ stdout = io.StringIO() ++ ++ with redirect_stdout(stdout): ++ result = main([str(path)]) ++ ++ self.assertEqual(result, 0) ++ self.assertEqual(stdout.getvalue(), "words: 4\nlines: 2\nchars: 20\n") ++ ++ def test_main_returns_one_for_missing_file(self): ++ stderr = io.StringIO() ++ ++ with redirect_stderr(stderr): ++ result = main(["/no/such/file"]) ++ ++ self.assertEqual(result, 1) ++ self.assertTrue(stderr.getvalue()) +diff --git a/test_counter.py b/test_counter.py +new file mode 100644 +index 0000000..341b8df +--- /dev/null ++++ b/test_counter.py +@@ -0,0 +1,18 @@ ++import unittest ++ ++from wordstat.counter import count_chars, count_lines, count_words ++ ++ ++class CounterTests(unittest.TestCase): ++ def test_count_words(self): ++ self.assertEqual(count_words("the quick brown fox"), 4) ++ self.assertEqual(count_words(""), 0) ++ ++ def test_count_lines(self): ++ self.assertEqual(count_lines("a\nb"), 2) ++ self.assertEqual(count_lines("a\nb\n"), 2) ++ self.assertEqual(count_lines(""), 0) ++ ++ def test_count_chars(self): ++ self.assertEqual(count_chars("abc"), 3) ++ self.assertEqual(count_chars("a b"), 3) +diff --git a/test_formatter.py b/test_formatter.py +new file mode 100644 +index 0000000..925b36e +--- /dev/null ++++ b/test_formatter.py +@@ -0,0 +1,11 @@ ++import unittest ++ ++from wordstat.formatter import format_report ++ ++ ++class FormatterTests(unittest.TestCase): ++ def test_format_report(self): ++ self.assertEqual( ++ format_report({"words": 12, "lines": 3, "chars": 57}), ++ "words: 12\nlines: 3\nchars: 57", ++ ) +diff --git a/wordstat/cli.py b/wordstat/cli.py +new file mode 100644 +index 0000000..ec622c3 +--- /dev/null ++++ b/wordstat/cli.py +@@ -0,0 +1,27 @@ ++"""Command-line entry point for wordstat.""" ++ ++import argparse ++import sys ++ ++from . import counter, formatter ++ ++ ++def main(argv): ++ parser = argparse.ArgumentParser(description="Report statistics for a text file.") ++ parser.add_argument("path") ++ args = parser.parse_args(argv) ++ ++ try: ++ with open(args.path, encoding="utf-8") as source: ++ text = source.read() ++ except OSError as error: ++ print(f"wordstat: {error}", file=sys.stderr) ++ return 1 ++ ++ stats = { ++ "words": counter.count_words(text), ++ "lines": counter.count_lines(text), ++ "chars": counter.count_chars(text), ++ } ++ print(formatter.format_report(stats)) ++ return 0 +diff --git a/wordstat/counter.py b/wordstat/counter.py +new file mode 100644 +index 0000000..2153d0a +--- /dev/null ++++ b/wordstat/counter.py +@@ -0,0 +1,13 @@ ++"""Pure text statistics.""" ++ ++ ++def count_words(text): ++ return len(text.split()) ++ ++ ++def count_lines(text): ++ return len(text.splitlines()) ++ ++ ++def count_chars(text): ++ return len(text) +diff --git a/wordstat/formatter.py b/wordstat/formatter.py +new file mode 100644 +index 0000000..d6db16c +--- /dev/null ++++ b/wordstat/formatter.py +@@ -0,0 +1,5 @@ ++"""Render text statistics as a human-readable report.""" ++ ++ ++def format_report(stats): ++ return "\n".join(f"{name}: {stats[name]}" for name in ("words", "lines", "chars")) + +exec +/bin/zsh -lc "rg --files --hidden -g '"'!.git'"'; cat plan.md design.md; ls -la; cat wordstat/__init__.py" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-4/repo + succeeded in 0ms: +test_cli.py +test_counter.py +design.md +plan.md +test_formatter.py +wordstat/counter.py +wordstat/cli.py +wordstat/__init__.py +wordstat/formatter.py +# Implementation plan: `wordstat` + +Design context in `design.md`. Build the package with test-driven development: +for each task write the failing test first, then the implementation, then make it +pass, then commit. Standard library only; tests run with `python3 -m unittest`. + +## Task 1 — `wordstat/counter.py` + +Pure statistics functions. + +1. Write `test_counter.py` (failing) covering: + - `count_words("the quick brown fox")` == 4; `count_words("")` == 0. + - `count_lines("a\nb")` == 2; `count_lines("a\nb\n")` == 2; `count_lines("")` == 0. + - `count_chars("abc")` == 3; `count_chars("a b")` == 3. +2. Implement `wordstat/counter.py` with `count_words`, `count_lines`, + `count_chars`. +3. Run `python3 -m unittest test_counter` → green. Commit. + +## Task 2 — `wordstat/formatter.py` + +Render a stats dict to a report string. + +1. Write `test_formatter.py` (failing) covering: + - `format_report({"words": 12, "lines": 3, "chars": 57})` == + `"words: 12\nlines: 3\nchars: 57"`. +2. Implement `wordstat/formatter.py` with `format_report(stats)`. +3. Run `python3 -m unittest test_formatter` → green. Commit. + +## Task 3 — `wordstat/cli.py` + +Entry point composing the previous two modules. Read `wordstat/counter.py` and +`wordstat/formatter.py` to use their real function signatures. + +1. Write `test_cli.py` (failing) covering: + - `main([path])` for a temp file with known content prints the expected + report and returns 0. + - `main(["/no/such/file"])` returns 1. +2. Implement `wordstat/cli.py` with `main(argv)` using `argparse`, calling + `counter` then `formatter`. +3. Run `python3 -m unittest` (whole suite) → green. Commit. +# Design: `wordstat` — a tiny text-stats CLI + +A small Python package that computes simple statistics about a text file and +prints a human-readable report. + +## Package layout + +``` +wordstat/ + __init__.py # package marker (exists) + counter.py # pure stat functions (Task 1) + formatter.py # render a stats dict to a report string (Task 2) + cli.py # argparse entry point tying it together (Task 3) +test_counter.py # tests for counter (Task 1) +test_formatter.py # tests for formatter (Task 2) +test_cli.py # tests for cli (Task 3) +``` + +Tests live at the repo root and are runnable with `python3 -m unittest` +(stdlib only — no third-party test deps). + +## Behavior + +- `counter.count_words(text)` → int: number of whitespace-separated tokens. +- `counter.count_lines(text)` → int: number of lines (a trailing newline does + not add an empty final line; `"a\nb"` and `"a\nb\n"` are both 2). +- `counter.count_chars(text)` → int: number of characters including whitespace. +- `formatter.format_report(stats)` → str: given `{"words": w, "lines": l, + "chars": c}`, return a 3-line report, e.g. `"words: 12\nlines: 3\nchars: 57"`. +- `cli.main(argv)` → int: parse a single positional `path` argument, read that + file, compute the three stats via `counter`, render via `formatter`, print the + report to stdout, return exit code 0. Missing file → message to stderr, return 1. + +## Constraints + +- Standard library only. +- Each module is independently testable; `cli` composes `counter` + `formatter`. +total 40 +drwxr-xr-x@ 9 jesse wheel 288 Sep 17 12:52 . +drwxr-xr-x@ 5 jesse wheel 160 Sep 17 12:52 .. +drwxr-xr-x@ 12 jesse wheel 384 Sep 17 12:52 .git +-rw-r--r--@ 1 jesse wheel 1551 Sep 17 12:52 design.md +-rw-r--r--@ 1 jesse wheel 1652 Sep 17 12:52 plan.md +-rw-r--r--@ 1 jesse wheel 905 Sep 17 12:52 test_cli.py +-rw-r--r--@ 1 jesse wheel 573 Sep 17 12:52 test_counter.py +-rw-r--r--@ 1 jesse wheel 288 Sep 17 12:52 test_formatter.py +drwxr-xr-x@ 6 jesse wheel 192 Sep 17 12:52 wordstat +"""wordstat — tiny text-statistics package. See design.md and plan.md.""" + +exec +/bin/zsh -lc 'PYTHONDONTWRITEBYTECODE=1 python3 -B -m unittest -v; PYTHONDONTWRITEBYTECODE=1 python3 -B -m wordstat.cli design.md; git log --oneline 370a61dd7955dea6e5d90eb23de7d4237b5e8040..HEAD; nl -ba wordstat/cli.py' in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-4/repo + succeeded in 1541ms: +test_main_prints_report_for_file (test_cli.CliTests.test_main_prints_report_for_file) ... ERROR +test_main_returns_one_for_missing_file (test_cli.CliTests.test_main_returns_one_for_missing_file) ... ok +test_count_chars (test_counter.CounterTests.test_count_chars) ... ok +test_count_lines (test_counter.CounterTests.test_count_lines) ... ok +test_count_words (test_counter.CounterTests.test_count_words) ... ok +test_format_report (test_formatter.FormatterTests.test_format_report) ... ok + +====================================================================== +ERROR: test_main_prints_report_for_file (test_cli.CliTests.test_main_prints_report_for_file) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-4/repo/test_cli.py", line 12, in test_main_prints_report_for_file + with tempfile.TemporaryDirectory() as directory: + ~~~~~~~~~~~~~~~~~~~~~~~~~~~^^ + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 907, in __init__ + self.name = mkdtemp(suffix, prefix, dir) + ~~~~~~~^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 370, in mkdtemp + prefix, suffix, dir, output_type = _sanitize_params(prefix, suffix, dir) + ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 127, in _sanitize_params + dir = gettempdir() + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 312, in gettempdir + return _os.fsdecode(_gettempdir()) + ~~~~~~~~~~~^^ + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 305, in _gettempdir + tempdir = _get_default_tempdir() + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 222, in _get_default_tempdir + raise FileNotFoundError(_errno.ENOENT, + "No usable temporary directory found in %s" % + dirlist) +FileNotFoundError: [Errno 2] No usable temporary directory found in ['/var/folders/43/prgnkdr95317fd_zbljq8thm0000gn/T/', '/tmp', '/var/tmp', '/usr/tmp', '/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-4/repo'] + +---------------------------------------------------------------------- +Ran 6 tests in 0.002s + +FAILED (errors=1) +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-PXCBUCgq' (errno=Operation not permitted) +2026-09-17 12:52:36.853 xcodebuild[41985:99752837] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:36.969 xcodebuild[41985:99752836] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-i7eiwJRq' (errno=Operation not permitted) +2026-09-17 12:52:37.327 xcodebuild[41988:99752856] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:37.440 xcodebuild[41988:99752855] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +56ffbda tasks 1-3 + 1 """Command-line entry point for wordstat.""" + 2 + 3 import argparse + 4 import sys + 5 + 6 from . import counter, formatter + 7 + 8 + 9 def main(argv): + 10 parser = argparse.ArgumentParser(description="Report statistics for a text file.") + 11 parser.add_argument("path") + 12 args = parser.parse_args(argv) + 13 + 14 try: + 15 with open(args.path, encoding="utf-8") as source: + 16 text = source.read() + 17 except OSError as error: + 18 print(f"wordstat: {error}", file=sys.stderr) + 19 return 1 + 20 + 21 stats = { + 22 "words": counter.count_words(text), + 23 "lines": counter.count_lines(text), + 24 "chars": counter.count_chars(text), + 25 } + 26 print(formatter.format_report(stats)) + 27 return 0 + +The functions match the plan’s explicit examples, but `python3 -m wordstat.cli design.md` produces no output because the module never calls `main`. Five tests passed; the file integration test was blocked by the environment’s read-only restriction on temporary files. I’ll check the file-reading path using an existing file. +exec +/bin/zsh -lc "PYTHONDONTWRITEBYTECODE=1 python3 -B -c 'import contextlib, io, subprocess, sys; from pathlib import Path; from wordstat.cli import main; text = Path(\"design.md\").read_text(encoding=\"utf-8\"); output = io.StringIO(); +with contextlib.redirect_stdout(output): + result = main([\"design.md\"]) +expected = \"words: {}\\nlines: {}\\nchars: {}\\n\".format(len(text.split()), len(text.splitlines()), len(text)); assert result == 0 and output.getvalue() == expected; print(\"Existing-file integration check passed:\", repr(output.getvalue())); +for args in ([\"design.md\"], [\"/no/such/file\"], [\"--help\"]): + proc = subprocess.run([sys.executable, \"-B\", \"-m\", \"wordstat.cli\", *args], capture_output=True, text=True); print(\"Module invocation:\", args, \"exit:\", proc.returncode, \"stdout:\", repr(proc.stdout), \"stderr:\", repr(proc.stderr)) +raw = b\"a\\r\\nb\\r\\n\" +with io.TextIOWrapper(io.BytesIO(raw), encoding=\"utf-8\") as source: + actual = source.read() +print(\"Default text reading CRLF:\", len(raw.decode(\"utf-8\")), \"characters become\", len(actual))'" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-4/repo + succeeded in 0ms: +Existing-file integration check passed: 'words: 224\nlines: 37\nchars: 1535\n' +Module invocation: ['design.md'] exit: 0 stdout: '' stderr: '' +Module invocation: ['/no/such/file'] exit: 0 stdout: '' stderr: '' +Module invocation: ['--help'] exit: 0 stdout: '' stderr: '' +Default text reading CRLF: 6 characters become 4 + +### Strengths + +- Clean separation between pure counters, formatting, and CLI orchestration. +- All explicitly planned test cases are implemented using only the standard library. +- Report ordering and missing-file handling match the spec when calling `main` directly. +- Five tests passed, and an additional check against an existing file passed. The remaining test was blocked by sandbox restrictions on temporary-file creation, not an implementation failure. + +### Issues + +#### Critical (Must Fix) + +None. + +#### Important (Should Fix) + +1. **The CLI has no executable entry point** + - **File:** `wordstat/cli.py:27` + - **Problem:** The module defines `main` but never invokes it, and no other launcher exists. Verified that `python3 -m wordstat.cli design.md`, a missing-file invocation, and `--help` all exit 0 with no output. + - **Impact:** Users cannot run the advertised CLI through its module. + - **Fix:** Add `if __name__ == "__main__": sys.exit(main(sys.argv[1:]))` and subprocess tests for output and exit status. + - **Plan alignment:** The plan specifies testing `main` directly but omits executable invocation; this is also a gap in the plan. + +2. **File reading silently changes character counts** + - **File:** `wordstat/cli.py:15` + - **Problem:** Default universal-newline translation converts CRLF to LF before counting. A file containing `a\r\nb\r\n` has six characters but reports four. + - **Impact:** The CLI undercounts characters in Windows-format files despite the spec including whitespace in character counts. + - **Fix:** Open with `newline=""` to preserve characters, and add a CRLF integration test. + +#### Minor (Nice to Have) + +1. **Invalid UTF-8 produces an unhandled traceback** + - **File:** `wordstat/cli.py:16–17` + - **Problem:** `UnicodeDecodeError` is not caught by `except OSError`. + - **Impact:** Unreadable text encoding receives less useful error handling than filesystem failures. + - **Fix:** Catch `UnicodeError` alongside `OSError` and document the UTF-8 requirement. + +### Recommendations + +- Add the executable-invocation and CRLF regression tests before merging. +- Rerun the complete suite in an environment allowing temporary files. +- Confirm the process deviation: the reviewed range contains one commit, `56ffbda`, rather than the three task commits required by the plan. Test-first sequencing cannot be verified from that history. + +### Assessment + +**Ready to merge? With fixes** + +**Reasoning:** The core functions satisfy the planned examples and compose cleanly. The missing executable entry point and newline normalization leave observable CLI behavior incomplete or incorrect. diff --git a/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R0-current-5.review.txt b/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R0-current-5.review.txt new file mode 100644 index 000000000..785048f5c --- /dev/null +++ b/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R0-current-5.review.txt @@ -0,0 +1,457 @@ +I’ll read the plan and spec, review the full branch diff, and run checks without modifying the checkout. +exec +/bin/zsh -lc "pwd; rg --files -g 'AGENTS.md' -g 'plan.md' -g 'design.md' -g 'pyproject.toml' -g '*test*' -g '*.py'; git status --short; git diff --stat 370a61dd7955dea6e5d90eb23de7d4237b5e8040..HEAD; git diff 370a61dd7955dea6e5d90eb23de7d4237b5e8040..HEAD" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-5/repo + succeeded in 4679ms: +2026-09-17 12:52:23.794 xcodebuild[41633:99751530] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:23.932 xcodebuild[41633:99751529] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-RbsEMi6r' (errno=Operation not permitted) +2026-09-17 12:52:24.323 xcodebuild[41643:99751589] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:24.451 xcodebuild[41643:99751568] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-dYMjWwoq' (errno=Operation not permitted) +2026-09-17 12:52:25.385 xcodebuild[41677:99751761] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:25.508 xcodebuild[41677:99751758] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-in47yLl6' (errno=Operation not permitted) +2026-09-17 12:52:25.929 xcodebuild[41694:99751841] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:26.049 xcodebuild[41694:99751836] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. + test_cli.py | 30 ++++++++++++++++++++++++++++++ + test_counter.py | 18 ++++++++++++++++++ + test_formatter.py | 11 +++++++++++ + wordstat/cli.py | 27 +++++++++++++++++++++++++++ + wordstat/counter.py | 13 +++++++++++++ + wordstat/formatter.py | 5 +++++ + 6 files changed, 104 insertions(+) +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-KHyXPYR9' (errno=Operation not permitted) +2026-09-17 12:52:27.001 xcodebuild[41726:99752033] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:27.123 xcodebuild[41726:99752032] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-Ryha2dnj' (errno=Operation not permitted) +2026-09-17 12:52:27.551 xcodebuild[41738:99752083] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:27.669 xcodebuild[41738:99752082] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +diff --git a/test_cli.py b/test_cli.py +new file mode 100644 +index 0000000..0793491 +--- /dev/null ++++ b/test_cli.py +@@ -0,0 +1,30 @@ ++import io ++import tempfile ++import unittest ++from contextlib import redirect_stderr, redirect_stdout ++from pathlib import Path ++ ++from wordstat.cli import main ++ ++ ++class CliTests(unittest.TestCase): ++ def test_main_prints_report_for_file(self): ++ with tempfile.TemporaryDirectory() as directory: ++ path = Path(directory) / "sample.txt" ++ path.write_text("the quick\nbrown fox\n", encoding="utf-8") ++ stdout = io.StringIO() ++ ++ with redirect_stdout(stdout): ++ result = main([str(path)]) ++ ++ self.assertEqual(result, 0) ++ self.assertEqual(stdout.getvalue(), "words: 4\nlines: 2\nchars: 20\n") ++ ++ def test_main_returns_one_for_missing_file(self): ++ stderr = io.StringIO() ++ ++ with redirect_stderr(stderr): ++ result = main(["/no/such/file"]) ++ ++ self.assertEqual(result, 1) ++ self.assertTrue(stderr.getvalue()) +diff --git a/test_counter.py b/test_counter.py +new file mode 100644 +index 0000000..341b8df +--- /dev/null ++++ b/test_counter.py +@@ -0,0 +1,18 @@ ++import unittest ++ ++from wordstat.counter import count_chars, count_lines, count_words ++ ++ ++class CounterTests(unittest.TestCase): ++ def test_count_words(self): ++ self.assertEqual(count_words("the quick brown fox"), 4) ++ self.assertEqual(count_words(""), 0) ++ ++ def test_count_lines(self): ++ self.assertEqual(count_lines("a\nb"), 2) ++ self.assertEqual(count_lines("a\nb\n"), 2) ++ self.assertEqual(count_lines(""), 0) ++ ++ def test_count_chars(self): ++ self.assertEqual(count_chars("abc"), 3) ++ self.assertEqual(count_chars("a b"), 3) +diff --git a/test_formatter.py b/test_formatter.py +new file mode 100644 +index 0000000..925b36e +--- /dev/null ++++ b/test_formatter.py +@@ -0,0 +1,11 @@ ++import unittest ++ ++from wordstat.formatter import format_report ++ ++ ++class FormatterTests(unittest.TestCase): ++ def test_format_report(self): ++ self.assertEqual( ++ format_report({"words": 12, "lines": 3, "chars": 57}), ++ "words: 12\nlines: 3\nchars: 57", ++ ) +diff --git a/wordstat/cli.py b/wordstat/cli.py +new file mode 100644 +index 0000000..ec622c3 +--- /dev/null ++++ b/wordstat/cli.py +@@ -0,0 +1,27 @@ ++"""Command-line entry point for wordstat.""" ++ ++import argparse ++import sys ++ ++from . import counter, formatter ++ ++ ++def main(argv): ++ parser = argparse.ArgumentParser(description="Report statistics for a text file.") ++ parser.add_argument("path") ++ args = parser.parse_args(argv) ++ ++ try: ++ with open(args.path, encoding="utf-8") as source: ++ text = source.read() ++ except OSError as error: ++ print(f"wordstat: {error}", file=sys.stderr) ++ return 1 ++ ++ stats = { ++ "words": counter.count_words(text), ++ "lines": counter.count_lines(text), ++ "chars": counter.count_chars(text), ++ } ++ print(formatter.format_report(stats)) ++ return 0 +diff --git a/wordstat/counter.py b/wordstat/counter.py +new file mode 100644 +index 0000000..2153d0a +--- /dev/null ++++ b/wordstat/counter.py +@@ -0,0 +1,13 @@ ++"""Pure text statistics.""" ++ ++ ++def count_words(text): ++ return len(text.split()) ++ ++ ++def count_lines(text): ++ return len(text.splitlines()) ++ ++ ++def count_chars(text): ++ return len(text) +diff --git a/wordstat/formatter.py b/wordstat/formatter.py +new file mode 100644 +index 0000000..d6db16c +--- /dev/null ++++ b/wordstat/formatter.py +@@ -0,0 +1,5 @@ ++"""Render text statistics as a human-readable report.""" ++ ++ ++def format_report(stats): ++ return "\n".join(f"{name}: {stats[name]}" for name in ("words", "lines", "chars")) + +exec +/bin/zsh -lc "ls -la; cat plan.md design.md; rg --files --hidden -g '"'! .git'"' -g '"'!**/.git/**'"'; cat wordstat/__init__.py; command -v python3; for f in wordstat/*.py test_*.py; do nl -ba \""'$f"; done' in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-5/repo + succeeded in 0ms: +total 40 +drwxr-xr-x@ 9 jesse wheel 288 Sep 17 12:52 . +drwxr-xr-x@ 5 jesse wheel 160 Sep 17 12:52 .. +drwxr-xr-x@ 12 jesse wheel 384 Sep 17 12:52 .git +-rw-r--r--@ 1 jesse wheel 1551 Sep 17 12:52 design.md +-rw-r--r--@ 1 jesse wheel 1652 Sep 17 12:52 plan.md +-rw-r--r--@ 1 jesse wheel 905 Sep 17 12:52 test_cli.py +-rw-r--r--@ 1 jesse wheel 573 Sep 17 12:52 test_counter.py +-rw-r--r--@ 1 jesse wheel 288 Sep 17 12:52 test_formatter.py +drwxr-xr-x@ 6 jesse wheel 192 Sep 17 12:52 wordstat +# Implementation plan: `wordstat` + +Design context in `design.md`. Build the package with test-driven development: +for each task write the failing test first, then the implementation, then make it +pass, then commit. Standard library only; tests run with `python3 -m unittest`. + +## Task 1 — `wordstat/counter.py` + +Pure statistics functions. + +1. Write `test_counter.py` (failing) covering: + - `count_words("the quick brown fox")` == 4; `count_words("")` == 0. + - `count_lines("a\nb")` == 2; `count_lines("a\nb\n")` == 2; `count_lines("")` == 0. + - `count_chars("abc")` == 3; `count_chars("a b")` == 3. +2. Implement `wordstat/counter.py` with `count_words`, `count_lines`, + `count_chars`. +3. Run `python3 -m unittest test_counter` → green. Commit. + +## Task 2 — `wordstat/formatter.py` + +Render a stats dict to a report string. + +1. Write `test_formatter.py` (failing) covering: + - `format_report({"words": 12, "lines": 3, "chars": 57})` == + `"words: 12\nlines: 3\nchars: 57"`. +2. Implement `wordstat/formatter.py` with `format_report(stats)`. +3. Run `python3 -m unittest test_formatter` → green. Commit. + +## Task 3 — `wordstat/cli.py` + +Entry point composing the previous two modules. Read `wordstat/counter.py` and +`wordstat/formatter.py` to use their real function signatures. + +1. Write `test_cli.py` (failing) covering: + - `main([path])` for a temp file with known content prints the expected + report and returns 0. + - `main(["/no/such/file"])` returns 1. +2. Implement `wordstat/cli.py` with `main(argv)` using `argparse`, calling + `counter` then `formatter`. +3. Run `python3 -m unittest` (whole suite) → green. Commit. +# Design: `wordstat` — a tiny text-stats CLI + +A small Python package that computes simple statistics about a text file and +prints a human-readable report. + +## Package layout + +``` +wordstat/ + __init__.py # package marker (exists) + counter.py # pure stat functions (Task 1) + formatter.py # render a stats dict to a report string (Task 2) + cli.py # argparse entry point tying it together (Task 3) +test_counter.py # tests for counter (Task 1) +test_formatter.py # tests for formatter (Task 2) +test_cli.py # tests for cli (Task 3) +``` + +Tests live at the repo root and are runnable with `python3 -m unittest` +(stdlib only — no third-party test deps). + +## Behavior + +- `counter.count_words(text)` → int: number of whitespace-separated tokens. +- `counter.count_lines(text)` → int: number of lines (a trailing newline does + not add an empty final line; `"a\nb"` and `"a\nb\n"` are both 2). +- `counter.count_chars(text)` → int: number of characters including whitespace. +- `formatter.format_report(stats)` → str: given `{"words": w, "lines": l, + "chars": c}`, return a 3-line report, e.g. `"words: 12\nlines: 3\nchars: 57"`. +- `cli.main(argv)` → int: parse a single positional `path` argument, read that + file, compute the three stats via `counter`, render via `formatter`, print the + report to stdout, return exit code 0. Missing file → message to stderr, return 1. + +## Constraints + +- Standard library only. +- Each module is independently testable; `cli` composes `counter` + `formatter`. +test_cli.py +plan.md +design.md +test_counter.py +test_formatter.py +wordstat/counter.py +wordstat/cli.py +wordstat/__init__.py +wordstat/formatter.py +"""wordstat — tiny text-statistics package. See design.md and plan.md.""" +/usr/local/bin/python3 + 1 """wordstat — tiny text-statistics package. See design.md and plan.md.""" + 1 """Command-line entry point for wordstat.""" + 2 + 3 import argparse + 4 import sys + 5 + 6 from . import counter, formatter + 7 + 8 + 9 def main(argv): + 10 parser = argparse.ArgumentParser(description="Report statistics for a text file.") + 11 parser.add_argument("path") + 12 args = parser.parse_args(argv) + 13 + 14 try: + 15 with open(args.path, encoding="utf-8") as source: + 16 text = source.read() + 17 except OSError as error: + 18 print(f"wordstat: {error}", file=sys.stderr) + 19 return 1 + 20 + 21 stats = { + 22 "words": counter.count_words(text), + 23 "lines": counter.count_lines(text), + 24 "chars": counter.count_chars(text), + 25 } + 26 print(formatter.format_report(stats)) + 27 return 0 + 1 """Pure text statistics.""" + 2 + 3 + 4 def count_words(text): + 5 return len(text.split()) + 6 + 7 + 8 def count_lines(text): + 9 return len(text.splitlines()) + 10 + 11 + 12 def count_chars(text): + 13 return len(text) + 1 """Render text statistics as a human-readable report.""" + 2 + 3 + 4 def format_report(stats): + 5 return "\n".join(f"{name}: {stats[name]}" for name in ("words", "lines", "chars")) + 1 import io + 2 import tempfile + 3 import unittest + 4 from contextlib import redirect_stderr, redirect_stdout + 5 from pathlib import Path + 6 + 7 from wordstat.cli import main + 8 + 9 + 10 class CliTests(unittest.TestCase): + 11 def test_main_prints_report_for_file(self): + 12 with tempfile.TemporaryDirectory() as directory: + 13 path = Path(directory) / "sample.txt" + 14 path.write_text("the quick\nbrown fox\n", encoding="utf-8") + 15 stdout = io.StringIO() + 16 + 17 with redirect_stdout(stdout): + 18 result = main([str(path)]) + 19 + 20 self.assertEqual(result, 0) + 21 self.assertEqual(stdout.getvalue(), "words: 4\nlines: 2\nchars: 20\n") + 22 + 23 def test_main_returns_one_for_missing_file(self): + 24 stderr = io.StringIO() + 25 + 26 with redirect_stderr(stderr): + 27 result = main(["/no/such/file"]) + 28 + 29 self.assertEqual(result, 1) + 30 self.assertTrue(stderr.getvalue()) + 1 import unittest + 2 + 3 from wordstat.counter import count_chars, count_lines, count_words + 4 + 5 + 6 class CounterTests(unittest.TestCase): + 7 def test_count_words(self): + 8 self.assertEqual(count_words("the quick brown fox"), 4) + 9 self.assertEqual(count_words(""), 0) + 10 + 11 def test_count_lines(self): + 12 self.assertEqual(count_lines("a\nb"), 2) + 13 self.assertEqual(count_lines("a\nb\n"), 2) + 14 self.assertEqual(count_lines(""), 0) + 15 + 16 def test_count_chars(self): + 17 self.assertEqual(count_chars("abc"), 3) + 18 self.assertEqual(count_chars("a b"), 3) + 1 import unittest + 2 + 3 from wordstat.formatter import format_report + 4 + 5 + 6 class FormatterTests(unittest.TestCase): + 7 def test_format_report(self): + 8 self.assertEqual( + 9 format_report({"words": 12, "lines": 3, "chars": 57}), + 10 "words: 12\nlines: 3\nchars: 57", + 11 ) + +exec +/bin/zsh -lc "PYTHONDONTWRITEBYTECODE=1 python3 -m unittest -v; PYTHONDONTWRITEBYTECODE=1 python3 - <<'PY' +import subprocess, sys, os +base = [sys.executable, '-B'] +for label, command, data in [ + ('module entry point', ['-m', 'wordstat.cli', 'design.md'], None), + ('CRLF input', ['-c', 'from wordstat.cli import main; raise SystemExit(main([\"/dev/stdin\"]))'], b'a\\r\\nb\\r\\n'), + ('invalid UTF-8', ['-c', 'from wordstat.cli import main; raise SystemExit(main([\"/dev/stdin\"]))'], b'\\xff'), +]: + result = subprocess.run(base + command, input=data, capture_output=True) + print(label, 'exit=', result.returncode, 'stdout=', repr(result.stdout), 'stderr=', repr(result.stderr)) +PY + git log --oneline 370a61dd7955dea6e5d90eb23de7d4237b5e8040..HEAD" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-5/repo + succeeded in 1398ms: +2026-09-17 12:52:45.584 xcodebuild[42065:99753197] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:45.695 xcodebuild[42065:99753196] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-IuqnU7j4' (errno=Operation not permitted) +2026-09-17 12:52:46.078 xcodebuild[42069:99753239] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:46.197 xcodebuild[42069:99753238] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +56ffbda tasks 1-3 + +exec +/bin/zsh -lc "python3 -B -c 'import subprocess, sys +for label, command, data in [(\"module entry point\", [\"-m\", \"wordstat.cli\", \"design.md\"], None), (\"LF input\", [\"-c\", \"from wordstat.cli import main; raise SystemExit(main([\\\"/dev/stdin\\\"]))\"], b\"the quick\\nbrown fox\\n\"), (\"CRLF input\", [\"-c\", \"from wordstat.cli import main; raise SystemExit(main([\\\"/dev/stdin\\\"]))\"], b\"a\\r\\nb\\r\\n\"), (\"invalid UTF-8\", [\"-c\", \"from wordstat.cli import main; raise SystemExit(main([\\\"/dev/stdin\\\"]))\"], b\"\\xff\")]: + result = subprocess.run([sys.executable, \"-B\"] + command, input=data, capture_output=True) + print(label, \"exit=\", result.returncode, \"stdout=\", repr(result.stdout), \"stderr=\", repr(result.stderr))'" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-5/repo + succeeded in 18ms: +module entry point exit= 0 stdout= b'' stderr= b'' +LF input exit= 0 stdout= b'words: 4\nlines: 2\nchars: 20\n' stderr= b'' +CRLF input exit= 0 stdout= b'words: 2\nlines: 2\nchars: 4\n' stderr= b'' +invalid UTF-8 exit= 1 stdout= b'' stderr= b'Traceback (most recent call last):\n File "", line 1, in \n from wordstat.cli import main; raise SystemExit(main(["/dev/stdin"]))\n ~~~~^^^^^^^^^^^^^^^^\n File "/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-5/repo/wordstat/cli.py", line 16, in main\n text = source.read()\n File "", line 325, in decode\nUnicodeDecodeError: \'utf-8\' codec can\'t decode byte 0xff in position 0: invalid start byte\n' + +### Strengths + +- Clear separation of pure counters, formatting, and CLI orchestration. +- Uses only the standard library and implements every explicitly planned function and test case. +- Tests exercise real implementations; formatting order and missing-file handling are correct. + +### Issues + +#### Critical (Must Fix) + +None found. + +#### Important (Should Fix) + +1. **No executable CLI entry point** — `wordstat/cli.py:27` + - `python3 -m wordstat.cli design.md` exits successfully without printing anything because nothing invokes `main`. + - Add `if __name__ == "__main__": sys.exit(main(sys.argv[1:]))` and a subprocess test. + - This is a gap in the plan as well: it specifies the callable API but omits how users launch the advertised CLI. + +2. **CRLF files have incorrect character counts** — `wordstat/cli.py:15` + - Default newline translation changes `\r\n` into `\n` before counting. Verified that `a\r\nb\r\n` reports 4 characters instead of 6. + - This conflicts with counting characters including whitespace. + - Open with `newline=""` to preserve original characters and add a CRLF regression test. + +#### Minor (Nice to Have) + +1. **Decode failures escape error handling** — `wordstat/cli.py:16–17` + - Invalid UTF-8 raises `UnicodeDecodeError`, producing a traceback instead of a concise diagnostic. + - Catch `UnicodeError` alongside `OSError`, document the UTF-8 requirement, and test the failure path. + +2. **Commit sequence differs from the plan** — `plan.md`, Tasks 1–3 + - The reviewed range contains one commit, `56ffbda` (“tasks 1-3”), rather than three task commits. Test-first development cannot be verified from this history. + - Confirm whether consolidation was intentional; this does not affect runtime behavior. + +### Recommendations + +Add process-level CLI coverage and newline-preservation tests. Five tests passed; the remaining test was blocked by the sandbox’s prohibition on temporary-file creation. A separate read-only check reproduced its expected LF report successfully. + +### Assessment + +**Ready to merge? With fixes** + +**Reasoning:** The planned APIs are implemented cleanly, but the CLI cannot currently be launched normally and silently undercounts CRLF characters. Fix these behaviors before merging. diff --git a/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R0-current-6.review.txt b/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R0-current-6.review.txt new file mode 100644 index 000000000..07c9afd4d --- /dev/null +++ b/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R0-current-6.review.txt @@ -0,0 +1,311 @@ +I’ll read the plan and spec, review the full branch diff, and run the tests without changing the checkout. +exec +/bin/zsh -lc "pwd; rg --files -g 'AGENTS.md' -g 'plan.md' -g 'design.md' -g 'pyproject.toml' -g '*test*' -g 'README*'; git status --short; git diff --stat 370a61dd7955dea6e5d90eb23de7d4237b5e8040..HEAD; git diff 370a61dd7955dea6e5d90eb23de7d4237b5e8040..HEAD" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-6/repo + succeeded in 4668ms: +2026-09-17 12:52:24.416 xcodebuild[41657:99751674] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:24.544 xcodebuild[41657:99751672] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-agMc7OZI' (errno=Operation not permitted) +2026-09-17 12:52:24.967 xcodebuild[41665:99751701] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:25.084 xcodebuild[41665:99751700] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-pleIX4jG' (errno=Operation not permitted) +2026-09-17 12:52:26.054 xcodebuild[41701:99751899] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:26.183 xcodebuild[41701:99751896] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-hfR9QZNJ' (errno=Operation not permitted) +2026-09-17 12:52:26.622 xcodebuild[41715:99751954] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:26.753 xcodebuild[41715:99751951] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. + test_cli.py | 30 ++++++++++++++++++++++++++++++ + test_counter.py | 18 ++++++++++++++++++ + test_formatter.py | 11 +++++++++++ + wordstat/cli.py | 27 +++++++++++++++++++++++++++ + wordstat/counter.py | 13 +++++++++++++ + wordstat/formatter.py | 5 +++++ + 6 files changed, 104 insertions(+) +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-jzMkjmY7' (errno=Operation not permitted) +2026-09-17 12:52:27.728 xcodebuild[41743:99752104] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:27.844 xcodebuild[41743:99752103] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-0F2E6wop' (errno=Operation not permitted) +2026-09-17 12:52:28.265 xcodebuild[41757:99752152] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:28.380 xcodebuild[41757:99752151] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +diff --git a/test_cli.py b/test_cli.py +new file mode 100644 +index 0000000..0793491 +--- /dev/null ++++ b/test_cli.py +@@ -0,0 +1,30 @@ ++import io ++import tempfile ++import unittest ++from contextlib import redirect_stderr, redirect_stdout ++from pathlib import Path ++ ++from wordstat.cli import main ++ ++ ++class CliTests(unittest.TestCase): ++ def test_main_prints_report_for_file(self): ++ with tempfile.TemporaryDirectory() as directory: ++ path = Path(directory) / "sample.txt" ++ path.write_text("the quick\nbrown fox\n", encoding="utf-8") ++ stdout = io.StringIO() ++ ++ with redirect_stdout(stdout): ++ result = main([str(path)]) ++ ++ self.assertEqual(result, 0) ++ self.assertEqual(stdout.getvalue(), "words: 4\nlines: 2\nchars: 20\n") ++ ++ def test_main_returns_one_for_missing_file(self): ++ stderr = io.StringIO() ++ ++ with redirect_stderr(stderr): ++ result = main(["/no/such/file"]) ++ ++ self.assertEqual(result, 1) ++ self.assertTrue(stderr.getvalue()) +diff --git a/test_counter.py b/test_counter.py +new file mode 100644 +index 0000000..341b8df +--- /dev/null ++++ b/test_counter.py +@@ -0,0 +1,18 @@ ++import unittest ++ ++from wordstat.counter import count_chars, count_lines, count_words ++ ++ ++class CounterTests(unittest.TestCase): ++ def test_count_words(self): ++ self.assertEqual(count_words("the quick brown fox"), 4) ++ self.assertEqual(count_words(""), 0) ++ ++ def test_count_lines(self): ++ self.assertEqual(count_lines("a\nb"), 2) ++ self.assertEqual(count_lines("a\nb\n"), 2) ++ self.assertEqual(count_lines(""), 0) ++ ++ def test_count_chars(self): ++ self.assertEqual(count_chars("abc"), 3) ++ self.assertEqual(count_chars("a b"), 3) +diff --git a/test_formatter.py b/test_formatter.py +new file mode 100644 +index 0000000..925b36e +--- /dev/null ++++ b/test_formatter.py +@@ -0,0 +1,11 @@ ++import unittest ++ ++from wordstat.formatter import format_report ++ ++ ++class FormatterTests(unittest.TestCase): ++ def test_format_report(self): ++ self.assertEqual( ++ format_report({"words": 12, "lines": 3, "chars": 57}), ++ "words: 12\nlines: 3\nchars: 57", ++ ) +diff --git a/wordstat/cli.py b/wordstat/cli.py +new file mode 100644 +index 0000000..ec622c3 +--- /dev/null ++++ b/wordstat/cli.py +@@ -0,0 +1,27 @@ ++"""Command-line entry point for wordstat.""" ++ ++import argparse ++import sys ++ ++from . import counter, formatter ++ ++ ++def main(argv): ++ parser = argparse.ArgumentParser(description="Report statistics for a text file.") ++ parser.add_argument("path") ++ args = parser.parse_args(argv) ++ ++ try: ++ with open(args.path, encoding="utf-8") as source: ++ text = source.read() ++ except OSError as error: ++ print(f"wordstat: {error}", file=sys.stderr) ++ return 1 ++ ++ stats = { ++ "words": counter.count_words(text), ++ "lines": counter.count_lines(text), ++ "chars": counter.count_chars(text), ++ } ++ print(formatter.format_report(stats)) ++ return 0 +diff --git a/wordstat/counter.py b/wordstat/counter.py +new file mode 100644 +index 0000000..2153d0a +--- /dev/null ++++ b/wordstat/counter.py +@@ -0,0 +1,13 @@ ++"""Pure text statistics.""" ++ ++ ++def count_words(text): ++ return len(text.split()) ++ ++ ++def count_lines(text): ++ return len(text.splitlines()) ++ ++ ++def count_chars(text): ++ return len(text) +diff --git a/wordstat/formatter.py b/wordstat/formatter.py +new file mode 100644 +index 0000000..d6db16c +--- /dev/null ++++ b/wordstat/formatter.py +@@ -0,0 +1,5 @@ ++"""Render text statistics as a human-readable report.""" ++ ++ ++def format_report(stats): ++ return "\n".join(f"{name}: {stats[name]}" for name in ("words", "lines", "chars")) + +exec +/bin/zsh -lc "rg --files --hidden -g '"'!.git'"'; cat plan.md design.md; ls -la wordstat; git log --oneline 370a61dd7955dea6e5d90eb23de7d4237b5e8040..HEAD" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-6/repo + succeeded in 2062ms: +2026-09-17 12:52:33.876 xcodebuild[41950:99752619] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:33.989 xcodebuild[41950:99752618] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-6atgQiL2' (errno=Operation not permitted) +2026-09-17 12:52:34.358 xcodebuild[41960:99752673] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:52:34.468 xcodebuild[41960:99752670] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +56ffbda tasks 1-3 + +exec +/bin/zsh -lc 'PYTHONDONTWRITEBYTECODE=1 python3 -m unittest -v; PYTHONDONTWRITEBYTECODE=1 python3 -m wordstat.cli /no/such/file; cat wordstat/__init__.py; nl -ba wordstat/cli.py' in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-6/repo + succeeded in 0ms: +test_main_prints_report_for_file (test_cli.CliTests.test_main_prints_report_for_file) ... ERROR +test_main_returns_one_for_missing_file (test_cli.CliTests.test_main_returns_one_for_missing_file) ... ok +test_count_chars (test_counter.CounterTests.test_count_chars) ... ok +test_count_lines (test_counter.CounterTests.test_count_lines) ... ok +test_count_words (test_counter.CounterTests.test_count_words) ... ok +test_format_report (test_formatter.FormatterTests.test_format_report) ... ok + +====================================================================== +ERROR: test_main_prints_report_for_file (test_cli.CliTests.test_main_prints_report_for_file) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-6/repo/test_cli.py", line 12, in test_main_prints_report_for_file + with tempfile.TemporaryDirectory() as directory: + ~~~~~~~~~~~~~~~~~~~~~~~~~~~^^ + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 907, in __init__ + self.name = mkdtemp(suffix, prefix, dir) + ~~~~~~~^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 370, in mkdtemp + prefix, suffix, dir, output_type = _sanitize_params(prefix, suffix, dir) + ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 127, in _sanitize_params + dir = gettempdir() + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 312, in gettempdir + return _os.fsdecode(_gettempdir()) + ~~~~~~~~~~~^^ + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 305, in _gettempdir + tempdir = _get_default_tempdir() + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 222, in _get_default_tempdir + raise FileNotFoundError(_errno.ENOENT, + "No usable temporary directory found in %s" % + dirlist) +FileNotFoundError: [Errno 2] No usable temporary directory found in ['/var/folders/43/prgnkdr95317fd_zbljq8thm0000gn/T/', '/tmp', '/var/tmp', '/usr/tmp', '/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-6/repo'] + +---------------------------------------------------------------------- +Ran 6 tests in 0.002s + +FAILED (errors=1) +"""wordstat — tiny text-statistics package. See design.md and plan.md.""" + 1 """Command-line entry point for wordstat.""" + 2 + 3 import argparse + 4 import sys + 5 + 6 from . import counter, formatter + 7 + 8 + 9 def main(argv): + 10 parser = argparse.ArgumentParser(description="Report statistics for a text file.") + 11 parser.add_argument("path") + 12 args = parser.parse_args(argv) + 13 + 14 try: + 15 with open(args.path, encoding="utf-8") as source: + 16 text = source.read() + 17 except OSError as error: + 18 print(f"wordstat: {error}", file=sys.stderr) + 19 return 1 + 20 + 21 stats = { + 22 "words": counter.count_words(text), + 23 "lines": counter.count_lines(text), + 24 "chars": counter.count_chars(text), + 25 } + 26 print(formatter.format_report(stats)) + 27 return 0 + +The functions match the plan’s examples, but `python3 -m wordstat.cli` exits silently without calling `main`. Five tests passed; the file-based CLI test was blocked by the sandbox’s restriction on temporary files. I’m checking file-reading edge cases using pipes. +exec +/bin/zsh -lc "PYTHONDONTWRITEBYTECODE=1 python3 -c 'import subprocess, sys; code = \"from wordstat.cli import main; import sys; sys.exit(main([\\\"/dev/stdin\\\"]))\"; inputs = [b\"the quick\\nbrown fox\\n\", b\"a\\r\\nb\\r\\n\", b\"\\xff\"]; [(print(repr(data), subprocess.run([sys.executable, \"-B\", \"-c\", code], input=data, capture_output=True))) for data in inputs]; [(print(args, subprocess.run([sys.executable, \"-B\", \"-m\", \"wordstat.cli\", *args], capture_output=True))) for args in [[\"design.md\"], [\"/no/such/file\"], [\"--help\"]]]'" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-6/repo + succeeded in 32ms: +b'the quick\nbrown fox\n' CompletedProcess(args=['/usr/local/bin/python3', '-B', '-c', 'from wordstat.cli import main; import sys; sys.exit(main(["/dev/stdin"]))'], returncode=0, stdout=b'words: 4\nlines: 2\nchars: 20\n', stderr=b'') +b'a\r\nb\r\n' CompletedProcess(args=['/usr/local/bin/python3', '-B', '-c', 'from wordstat.cli import main; import sys; sys.exit(main(["/dev/stdin"]))'], returncode=0, stdout=b'words: 2\nlines: 2\nchars: 4\n', stderr=b'') +b'\xff' CompletedProcess(args=['/usr/local/bin/python3', '-B', '-c', 'from wordstat.cli import main; import sys; sys.exit(main(["/dev/stdin"]))'], returncode=1, stdout=b'', stderr=b'Traceback (most recent call last):\n File "", line 1, in \n from wordstat.cli import main; import sys; sys.exit(main(["/dev/stdin"]))\n ~~~~^^^^^^^^^^^^^^^^\n File "/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R0-current-6/repo/wordstat/cli.py", line 16, in main\n text = source.read()\n File "", line 325, in decode\nUnicodeDecodeError: \'utf-8\' codec can\'t decode byte 0xff in position 0: invalid start byte\n') +['design.md'] CompletedProcess(args=['/usr/local/bin/python3', '-B', '-m', 'wordstat.cli', 'design.md'], returncode=0, stdout=b'', stderr=b'') +['/no/such/file'] CompletedProcess(args=['/usr/local/bin/python3', '-B', '-m', 'wordstat.cli', '/no/such/file'], returncode=0, stdout=b'', stderr=b'') +['--help'] CompletedProcess(args=['/usr/local/bin/python3', '-B', '-m', 'wordstat.cli', '--help'], returncode=0, stdout=b'', stderr=b'') + +### Strengths + +- Clean separation of pure counters, formatting, and CLI orchestration; standard library only. +- All explicitly planned test cases are present, with real file integration coverage. +- Report ordering is deterministic, and filesystem errors produce stderr messages and return 1. +- Five tests passed. The remaining test was blocked by sandbox restrictions on temporary files; equivalent input passed through a pipe. + +### Issues + +#### Critical (Must Fix) + +None. + +#### Important (Should Fix) + +1. **CLI module never invokes its entry point** + - **File:** `wordstat/cli.py:27` + - Running `python3 -m wordstat.cli design.md`, `--help`, or a missing path produces no output and exits 0. + - The callable meets the explicit `main(argv)` contract, but the CLI product lacks an executable entry point—an omission in the plan as well. + - **Fix:** Add an `if __name__ == "__main__":` guard calling `sys.exit(main(sys.argv[1:]))`, plus subprocess coverage. + +2. **File reading silently changes character counts** + - **File:** `wordstat/cli.py:15` + - Default universal-newline translation converts CRLF to LF before counting. Confirmed that `a\r\nb\r\n` reports 4 characters instead of 6. + - This loses whitespace characters despite the specification requiring characters including whitespace. + - **Fix:** Open with `newline=""` and add a CRLF integration test. + +#### Minor (Nice to Have) + +1. **Invalid UTF-8 produces an unhandled traceback** + - **File:** `wordstat/cli.py:16–17` + - `UnicodeDecodeError` is not caught by `except OSError`; confirmed with invalid UTF-8 input. + - The specification leaves encoding policy undefined, but a concise diagnostic would improve CLI behavior. + - **Fix:** Document UTF-8 input and catch `UnicodeError`, returning 1 with a stderr message. + +### Recommendations + +- Add regression coverage for module invocation and CRLF preservation. +- Rerun the complete suite in an environment permitting temporary files. +- History contains one combined `tasks 1-3` commit, rather than the plan’s per-task commits; test-first sequencing cannot be verified. + +### Assessment + +**Ready to merge? With fixes** + +**Reasoning:** The planned function-level behavior is implemented cleanly, but command-line execution is currently inert and CRLF files receive incorrect character counts. Address those issues before merging. diff --git a/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R1-slot-1.review.txt b/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R1-slot-1.review.txt new file mode 100644 index 000000000..5ad12a96b --- /dev/null +++ b/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R1-slot-1.review.txt @@ -0,0 +1,373 @@ +I’ll read the plan and spec, inspect the full branch diff, and run the tests without modifying the checkout. I’ll also list any behaviors I set aside as outside scope before the verdict. + +exec +/bin/zsh -lc "pwd; rg --files -g 'AGENTS.md' -g 'plan.md' -g 'design.md' -g 'pyproject.toml' -g '*test*' -g '*.py'; git diff --stat 7dbb5f20b33cffe7971ed1c0b0f68d76e18ffa89..HEAD; git status --short" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R1-slot-1/repo + succeeded in 2903ms: +2026-09-17 12:53:19.352 xcodebuild[45278:99760110] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:53:19.514 xcodebuild[45278:99760109] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-e8dGcWoy' (errno=Operation not permitted) +2026-09-17 12:53:19.930 xcodebuild[45281:99760124] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:53:20.053 xcodebuild[45281:99760123] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. + test_cli.py | 30 ++++++++++++++++++++++++++++++ + test_counter.py | 18 ++++++++++++++++++ + test_formatter.py | 11 +++++++++++ + wordstat/cli.py | 27 +++++++++++++++++++++++++++ + wordstat/counter.py | 13 +++++++++++++ + wordstat/formatter.py | 5 +++++ + 6 files changed, 104 insertions(+) +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-54nb3Wax' (errno=Operation not permitted) +2026-09-17 12:53:20.942 xcodebuild[45379:99760282] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:53:21.052 xcodebuild[45379:99760281] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-PE3DAtpg' (errno=Operation not permitted) +2026-09-17 12:53:21.409 xcodebuild[45382:99760298] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:53:21.526 xcodebuild[45382:99760297] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. + +exec +/bin/zsh -lc 'cat plan.md design.md; git diff 7dbb5f20b33cffe7971ed1c0b0f68d76e18ffa89..HEAD' in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R1-slot-1/repo + succeeded in 1354ms: +2026-09-17 12:53:22.382 xcodebuild[45390:99760363] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:53:22.501 xcodebuild[45390:99760362] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-iwU3ugI2' (errno=Operation not permitted) +2026-09-17 12:53:22.871 xcodebuild[45393:99760373] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:53:22.986 xcodebuild[45393:99760372] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +diff --git a/test_cli.py b/test_cli.py +new file mode 100644 +index 0000000..0793491 +--- /dev/null ++++ b/test_cli.py +@@ -0,0 +1,30 @@ ++import io ++import tempfile ++import unittest ++from contextlib import redirect_stderr, redirect_stdout ++from pathlib import Path ++ ++from wordstat.cli import main ++ ++ ++class CliTests(unittest.TestCase): ++ def test_main_prints_report_for_file(self): ++ with tempfile.TemporaryDirectory() as directory: ++ path = Path(directory) / "sample.txt" ++ path.write_text("the quick\nbrown fox\n", encoding="utf-8") ++ stdout = io.StringIO() ++ ++ with redirect_stdout(stdout): ++ result = main([str(path)]) ++ ++ self.assertEqual(result, 0) ++ self.assertEqual(stdout.getvalue(), "words: 4\nlines: 2\nchars: 20\n") ++ ++ def test_main_returns_one_for_missing_file(self): ++ stderr = io.StringIO() ++ ++ with redirect_stderr(stderr): ++ result = main(["/no/such/file"]) ++ ++ self.assertEqual(result, 1) ++ self.assertTrue(stderr.getvalue()) +diff --git a/test_counter.py b/test_counter.py +new file mode 100644 +index 0000000..341b8df +--- /dev/null ++++ b/test_counter.py +@@ -0,0 +1,18 @@ ++import unittest ++ ++from wordstat.counter import count_chars, count_lines, count_words ++ ++ ++class CounterTests(unittest.TestCase): ++ def test_count_words(self): ++ self.assertEqual(count_words("the quick brown fox"), 4) ++ self.assertEqual(count_words(""), 0) ++ ++ def test_count_lines(self): ++ self.assertEqual(count_lines("a\nb"), 2) ++ self.assertEqual(count_lines("a\nb\n"), 2) ++ self.assertEqual(count_lines(""), 0) ++ ++ def test_count_chars(self): ++ self.assertEqual(count_chars("abc"), 3) ++ self.assertEqual(count_chars("a b"), 3) +diff --git a/test_formatter.py b/test_formatter.py +new file mode 100644 +index 0000000..925b36e +--- /dev/null ++++ b/test_formatter.py +@@ -0,0 +1,11 @@ ++import unittest ++ ++from wordstat.formatter import format_report ++ ++ ++class FormatterTests(unittest.TestCase): ++ def test_format_report(self): ++ self.assertEqual( ++ format_report({"words": 12, "lines": 3, "chars": 57}), ++ "words: 12\nlines: 3\nchars: 57", ++ ) +diff --git a/wordstat/cli.py b/wordstat/cli.py +new file mode 100644 +index 0000000..ec622c3 +--- /dev/null ++++ b/wordstat/cli.py +@@ -0,0 +1,27 @@ ++"""Command-line entry point for wordstat.""" ++ ++import argparse ++import sys ++ ++from . import counter, formatter ++ ++ ++def main(argv): ++ parser = argparse.ArgumentParser(description="Report statistics for a text file.") ++ parser.add_argument("path") ++ args = parser.parse_args(argv) ++ ++ try: ++ with open(args.path, encoding="utf-8") as source: ++ text = source.read() ++ except OSError as error: ++ print(f"wordstat: {error}", file=sys.stderr) ++ return 1 ++ ++ stats = { ++ "words": counter.count_words(text), ++ "lines": counter.count_lines(text), ++ "chars": counter.count_chars(text), ++ } ++ print(formatter.format_report(stats)) ++ return 0 +diff --git a/wordstat/counter.py b/wordstat/counter.py +new file mode 100644 +index 0000000..2153d0a +--- /dev/null ++++ b/wordstat/counter.py +@@ -0,0 +1,13 @@ ++"""Pure text statistics.""" ++ ++ ++def count_words(text): ++ return len(text.split()) ++ ++ ++def count_lines(text): ++ return len(text.splitlines()) ++ ++ ++def count_chars(text): ++ return len(text) +diff --git a/wordstat/formatter.py b/wordstat/formatter.py +new file mode 100644 +index 0000000..d6db16c +--- /dev/null ++++ b/wordstat/formatter.py +@@ -0,0 +1,5 @@ ++"""Render text statistics as a human-readable report.""" ++ ++ ++def format_report(stats): ++ return "\n".join(f"{name}: {stats[name]}" for name in ("words", "lines", "chars")) + +exec +/bin/zsh -lc "rg --files --hidden -g '"'!.git/**'"'; git log --oneline 7dbb5f20b33cffe7971ed1c0b0f68d76e18ffa89..HEAD; PYTHONDONTWRITEBYTECODE=1 python3 -B -m unittest -v; PYTHONDONTWRITEBYTECODE=1 python3 -B -m wordstat.cli --help" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R1-slot-1/repo + succeeded in 1510ms: +2026-09-17 12:53:28.884 xcodebuild[46718:99762818] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:53:28.999 xcodebuild[46718:99762811] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-5MG0mylB' (errno=Operation not permitted) +2026-09-17 12:53:29.400 xcodebuild[46732:99762878] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:53:29.536 xcodebuild[46732:99762873] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +c9c25e8 tasks 1-3 +test_main_prints_report_for_file (test_cli.CliTests.test_main_prints_report_for_file) ... ERROR +test_main_returns_one_for_missing_file (test_cli.CliTests.test_main_returns_one_for_missing_file) ... ok +test_count_chars (test_counter.CounterTests.test_count_chars) ... ok +test_count_lines (test_counter.CounterTests.test_count_lines) ... ok +test_count_words (test_counter.CounterTests.test_count_words) ... ok +test_format_report (test_formatter.FormatterTests.test_format_report) ... ok + +====================================================================== +ERROR: test_main_prints_report_for_file (test_cli.CliTests.test_main_prints_report_for_file) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R1-slot-1/repo/test_cli.py", line 12, in test_main_prints_report_for_file + with tempfile.TemporaryDirectory() as directory: + ~~~~~~~~~~~~~~~~~~~~~~~~~~~^^ + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 907, in __init__ + self.name = mkdtemp(suffix, prefix, dir) + ~~~~~~~^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 370, in mkdtemp + prefix, suffix, dir, output_type = _sanitize_params(prefix, suffix, dir) + ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 127, in _sanitize_params + dir = gettempdir() + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 312, in gettempdir + return _os.fsdecode(_gettempdir()) + ~~~~~~~~~~~^^ + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 305, in _gettempdir + tempdir = _get_default_tempdir() + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 222, in _get_default_tempdir + raise FileNotFoundError(_errno.ENOENT, + "No usable temporary directory found in %s" % + dirlist) +FileNotFoundError: [Errno 2] No usable temporary directory found in ['/var/folders/43/prgnkdr95317fd_zbljq8thm0000gn/T/', '/tmp', '/var/tmp', '/usr/tmp', '/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R1-slot-1/repo'] + +---------------------------------------------------------------------- +Ran 6 tests in 0.002s + +FAILED (errors=1) + +The code covers the planned function examples, but `python3 -m wordstat.cli` exits without calling `main`. Five tests passed; the file-based test was blocked by the sandbox’s prohibition on creating temporary files. I’m checking the CLI’s character count using a pipe, which needs no filesystem writes. + +exec +/bin/zsh -lc "PYTHONDONTWRITEBYTECODE=1 python3 -B - <<'PY' +import subprocess, sys, io +from contextlib import redirect_stdout +from pathlib import Path +from wordstat.cli import main +text = Path('design.md').read_text(encoding='utf-8') +out = io.StringIO() +with redirect_stdout(out): + result = main(['design.md']) +assert result == 0 +assert out.getvalue() == f'words: {len(text.split())}\\nlines: {len(text.splitlines())}\\nchars: {len(text)}\\n' +print('Real existing-file integration: passed') +p = subprocess.run([sys.executable, '-B', '-c', 'from wordstat.cli import main; raise SystemExit(main([\"/dev/stdin\"]))'], input=b'a\\r\\nb\\r\\n', capture_output=True) +print('CRLF input: 6 characters; actual:', repr(p.stdout.decode()), 'exit:', p.returncode) +p = subprocess.run([sys.executable, '-B', '-m', 'wordstat.cli', 'design.md'], capture_output=True) +print('Module invocation:', p.returncode, repr(p.stdout), repr(p.stderr)) +PY +nl -ba wordstat/cli.py; nl -ba plan.md" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R1-slot-1/repo + succeeded in 0ms: +zsh:1: can't create temp file for here document: operation not permitted + 1 """Command-line entry point for wordstat.""" + 2 + 3 import argparse + 4 import sys + 5 + 6 from . import counter, formatter + 7 + 8 + 9 def main(argv): + 10 parser = argparse.ArgumentParser(description="Report statistics for a text file.") + 11 parser.add_argument("path") + 12 args = parser.parse_args(argv) + 13 + 14 try: + 15 with open(args.path, encoding="utf-8") as source: + 16 text = source.read() + 17 except OSError as error: + 18 print(f"wordstat: {error}", file=sys.stderr) + 19 return 1 + 20 + 21 stats = { + 22 "words": counter.count_words(text), + 23 "lines": counter.count_lines(text), + 24 "chars": counter.count_chars(text), + 25 } + 26 print(formatter.format_report(stats)) + 27 return 0 + 1 # Implementation plan: `wordstat` + 2 + 3 Design context in `design.md`. Build the package with test-driven development: + 4 for each task write the failing test first, then the implementation, then make it + 5 pass, then commit. Standard library only; tests run with `python3 -m unittest`. + 6 + 7 ## Task 1 — `wordstat/counter.py` + 8 + 9 Pure statistics functions. + 10 + 11 1. Write `test_counter.py` (failing) covering: + 12 - `count_words("the quick brown fox")` == 4; `count_words("")` == 0. + 13 - `count_lines("a\nb")` == 2; `count_lines("a\nb\n")` == 2; `count_lines("")` == 0. + 14 - `count_chars("abc")` == 3; `count_chars("a b")` == 3. + 15 2. Implement `wordstat/counter.py` with `count_words`, `count_lines`, + 16 `count_chars`. + 17 3. Run `python3 -m unittest test_counter` → green. Commit. + 18 + 19 ## Task 2 — `wordstat/formatter.py` + 20 + 21 Render a stats dict to a report string. + 22 + 23 1. Write `test_formatter.py` (failing) covering: + 24 - `format_report({"words": 12, "lines": 3, "chars": 57})` == + 25 `"words: 12\nlines: 3\nchars: 57"`. + 26 2. Implement `wordstat/formatter.py` with `format_report(stats)`. + 27 3. Run `python3 -m unittest test_formatter` → green. Commit. + 28 + 29 ## Task 3 — `wordstat/cli.py` + 30 + 31 Entry point composing the previous two modules. Read `wordstat/counter.py` and + 32 `wordstat/formatter.py` to use their real function signatures. + 33 + 34 1. Write `test_cli.py` (failing) covering: + 35 - `main([path])` for a temp file with known content prints the expected + 36 report and returns 0. + 37 - `main(["/no/such/file"])` returns 1. + 38 2. Implement `wordstat/cli.py` with `main(argv)` using `argparse`, calling + 39 `counter` then `formatter`. + 40 3. Run `python3 -m unittest` (whole suite) → green. Commit. + +exec +/bin/zsh -lc "PYTHONDONTWRITEBYTECODE=1 python3 -B -c 'import subprocess, sys, io; from contextlib import redirect_stdout; from pathlib import Path; from wordstat.cli import main; text = Path(\"design.md\").read_text(encoding=\"utf-8\"); out = io.StringIO(); ctx = redirect_stdout(out); ctx.__enter__(); result = main([\"design.md\"]); ctx.__exit__(None,None,None); assert result == 0; assert out.getvalue() == \"words: {}\\nlines: {}\\nchars: {}\\n\".format(len(text.split()),len(text.splitlines()),len(text)); print(\"Existing-file integration passed\"); p = subprocess.run([sys.executable,\"-B\",\"-c\",\"from wordstat.cli import main; raise SystemExit(main([\\\"/dev/stdin\\\"]))\"],input=b\"a\\r\\nb\\r\\n\",capture_output=True); print(\"CRLF input has 6 characters:\",p.returncode,repr(p.stdout),repr(p.stderr)); p = subprocess.run([sys.executable,\"-B\",\"-m\",\"wordstat.cli\",\"design.md\"],capture_output=True); print(\"Module invocation:\",p.returncode,repr(p.stdout),repr(p.stderr))'" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R1-slot-1/repo + succeeded in 0ms: +Existing-file integration passed +CRLF input has 6 characters: 0 b'words: 2\nlines: 2\nchars: 4\n' b'' +Module invocation: 0 b'' b'' + +### Strengths + +- Clear separation of counting, formatting, and CLI composition; standard library only. +- All explicitly planned test cases are present, including missing-file stderr handling. +- Five tests passed. The sixth was blocked by sandbox restrictions on temporary files; a separate integration check against an existing file passed. + +### Issues + +#### Critical (Must Fix) + +None. + +#### Important (Should Fix) + +1. **CLI has no executable entry point** + - **File:** `wordstat/cli.py:27` + - `python3 -m wordstat.cli design.md` exits 0 without printing anything; `--help` also does nothing. + - This leaves the advertised CLI usable only through Python imports. The plan specifies `main(argv)` but omits its executable wiring—a plan gap carried into implementation. + - Add `if __name__ == "__main__": sys.exit(main(sys.argv[1:]))` and a subprocess integration test. + +2. **File reading silently changes character counts** + - **File:** `wordstat/cli.py:15` + - Default newline translation converts CRLF to LF before counting. Confirmed: input `a\r\nb\r\n` contains six characters but reports `chars: 4`. + - This undermines the specified character count including whitespace. + - Open with `newline=""` to preserve characters, and add a CRLF regression test. + +#### Minor (Nice to Have) + +1. **Commit structure deviates from the plan** + - **Reference:** `plan.md:3–5`; commit `c9c25e8` + - The reviewed range contains one combined “tasks 1-3” commit, whereas the plan requires a commit after each task. + - This reduces task-level reviewability. Confirm whether consolidation was intentional; test-first execution cannot be established from this history. + +### Recommendations + +Add subprocess coverage for actual CLI invocation and preserve newline characters when reading. Rerun the complete suite in an environment permitting temporary files. + +### Declined to judge + +- Non-UTF-8 input and decoding-error behavior: supported encodings and malformed-input handling are unspecified. +- Streaming very large files: no size or memory requirements are specified. +- Special Unicode line-separator semantics: the spec does not define line boundaries beyond its newline examples. +- Validation of malformed formatter dictionaries: the contract supplies all three required keys. +- Installable console scripts and `python -m wordstat`: packaging and these invocation forms are unspecified. + +### Assessment + +**Ready to merge? With fixes** + +**Reasoning:** The core functions match the planned examples, but executable CLI wiring is missing and newline normalization produces incorrect character totals. Full-suite success remains unverified because of the sandbox limitation. diff --git a/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R1-slot-2.review.txt b/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R1-slot-2.review.txt new file mode 100644 index 000000000..af40d571e --- /dev/null +++ b/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R1-slot-2.review.txt @@ -0,0 +1,305 @@ +I’ll compare the branch with the plan and spec, then run the tests and check edge cases without changing the checkout. +exec +/bin/zsh -lc "pwd; rg --files -g 'AGENTS.md' -g 'plan.md' -g 'design.md' -g 'pyproject.toml' -g '*test*'; git diff --stat d17a997a45063ebc81e1aa214382c25ed0e3e340..HEAD; git diff d17a997a45063ebc81e1aa214382c25ed0e3e340..HEAD" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R1-slot-2/repo + succeeded in 2904ms: +2026-09-17 12:53:25.645 xcodebuild[46473:99762146] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:53:25.760 xcodebuild[46473:99762141] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-o76qNnfx' (errno=Operation not permitted) +2026-09-17 12:53:26.164 xcodebuild[46528:99762298] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:53:26.292 xcodebuild[46528:99762295] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. + test_cli.py | 30 ++++++++++++++++++++++++++++++ + test_counter.py | 18 ++++++++++++++++++ + test_formatter.py | 11 +++++++++++ + wordstat/cli.py | 27 +++++++++++++++++++++++++++ + wordstat/counter.py | 13 +++++++++++++ + wordstat/formatter.py | 5 +++++ + 6 files changed, 104 insertions(+) +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-UhoyrRRL' (errno=Operation not permitted) +2026-09-17 12:53:27.165 xcodebuild[46590:99762466] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:53:27.289 xcodebuild[46590:99762464] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-G32hluxE' (errno=Operation not permitted) +2026-09-17 12:53:27.673 xcodebuild[46641:99762576] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:53:27.785 xcodebuild[46641:99762575] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +diff --git a/test_cli.py b/test_cli.py +new file mode 100644 +index 0000000..0793491 +--- /dev/null ++++ b/test_cli.py +@@ -0,0 +1,30 @@ ++import io ++import tempfile ++import unittest ++from contextlib import redirect_stderr, redirect_stdout ++from pathlib import Path ++ ++from wordstat.cli import main ++ ++ ++class CliTests(unittest.TestCase): ++ def test_main_prints_report_for_file(self): ++ with tempfile.TemporaryDirectory() as directory: ++ path = Path(directory) / "sample.txt" ++ path.write_text("the quick\nbrown fox\n", encoding="utf-8") ++ stdout = io.StringIO() ++ ++ with redirect_stdout(stdout): ++ result = main([str(path)]) ++ ++ self.assertEqual(result, 0) ++ self.assertEqual(stdout.getvalue(), "words: 4\nlines: 2\nchars: 20\n") ++ ++ def test_main_returns_one_for_missing_file(self): ++ stderr = io.StringIO() ++ ++ with redirect_stderr(stderr): ++ result = main(["/no/such/file"]) ++ ++ self.assertEqual(result, 1) ++ self.assertTrue(stderr.getvalue()) +diff --git a/test_counter.py b/test_counter.py +new file mode 100644 +index 0000000..341b8df +--- /dev/null ++++ b/test_counter.py +@@ -0,0 +1,18 @@ ++import unittest ++ ++from wordstat.counter import count_chars, count_lines, count_words ++ ++ ++class CounterTests(unittest.TestCase): ++ def test_count_words(self): ++ self.assertEqual(count_words("the quick brown fox"), 4) ++ self.assertEqual(count_words(""), 0) ++ ++ def test_count_lines(self): ++ self.assertEqual(count_lines("a\nb"), 2) ++ self.assertEqual(count_lines("a\nb\n"), 2) ++ self.assertEqual(count_lines(""), 0) ++ ++ def test_count_chars(self): ++ self.assertEqual(count_chars("abc"), 3) ++ self.assertEqual(count_chars("a b"), 3) +diff --git a/test_formatter.py b/test_formatter.py +new file mode 100644 +index 0000000..925b36e +--- /dev/null ++++ b/test_formatter.py +@@ -0,0 +1,11 @@ ++import unittest ++ ++from wordstat.formatter import format_report ++ ++ ++class FormatterTests(unittest.TestCase): ++ def test_format_report(self): ++ self.assertEqual( ++ format_report({"words": 12, "lines": 3, "chars": 57}), ++ "words: 12\nlines: 3\nchars: 57", ++ ) +diff --git a/wordstat/cli.py b/wordstat/cli.py +new file mode 100644 +index 0000000..ec622c3 +--- /dev/null ++++ b/wordstat/cli.py +@@ -0,0 +1,27 @@ ++"""Command-line entry point for wordstat.""" ++ ++import argparse ++import sys ++ ++from . import counter, formatter ++ ++ ++def main(argv): ++ parser = argparse.ArgumentParser(description="Report statistics for a text file.") ++ parser.add_argument("path") ++ args = parser.parse_args(argv) ++ ++ try: ++ with open(args.path, encoding="utf-8") as source: ++ text = source.read() ++ except OSError as error: ++ print(f"wordstat: {error}", file=sys.stderr) ++ return 1 ++ ++ stats = { ++ "words": counter.count_words(text), ++ "lines": counter.count_lines(text), ++ "chars": counter.count_chars(text), ++ } ++ print(formatter.format_report(stats)) ++ return 0 +diff --git a/wordstat/counter.py b/wordstat/counter.py +new file mode 100644 +index 0000000..2153d0a +--- /dev/null ++++ b/wordstat/counter.py +@@ -0,0 +1,13 @@ ++"""Pure text statistics.""" ++ ++ ++def count_words(text): ++ return len(text.split()) ++ ++ ++def count_lines(text): ++ return len(text.splitlines()) ++ ++ ++def count_chars(text): ++ return len(text) +diff --git a/wordstat/formatter.py b/wordstat/formatter.py +new file mode 100644 +index 0000000..d6db16c +--- /dev/null ++++ b/wordstat/formatter.py +@@ -0,0 +1,5 @@ ++"""Render text statistics as a human-readable report.""" ++ ++ ++def format_report(stats): ++ return "\n".join(f"{name}: {stats[name]}" for name in ("words", "lines", "chars")) + +exec +/bin/zsh -lc "rg --files --hidden -g '"'!.git'"'; cat plan.md design.md; ls -la wordstat; rg --files /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad -g AGENTS.md" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R1-slot-2/repo + succeeded in 648ms: +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-6/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-6/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-6/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-6/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-6/repo/AGENTS.md + +exec +/bin/zsh -lc 'PYTHONDONTWRITEBYTECODE=1 python3 -m unittest -v; git log --oneline d17a997a45063ebc81e1aa214382c25ed0e3e340..HEAD; cat wordstat/__init__.py; nl -ba wordstat/cli.py' in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R1-slot-2/repo + succeeded in 1330ms: +2026-09-17 12:53:40.728 xcodebuild[46958:99763877] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:53:40.840 xcodebuild[46958:99763875] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-KPy1fwdO' (errno=Operation not permitted) +2026-09-17 12:53:41.193 xcodebuild[46961:99763890] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:53:41.314 xcodebuild[46961:99763889] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +979058c tasks 1-3 +"""wordstat — tiny text-statistics package. See design.md and plan.md.""" + 1 """Command-line entry point for wordstat.""" + 2 + 3 import argparse + 4 import sys + 5 + 6 from . import counter, formatter + 7 + 8 + 9 def main(argv): + 10 parser = argparse.ArgumentParser(description="Report statistics for a text file.") + 11 parser.add_argument("path") + 12 args = parser.parse_args(argv) + 13 + 14 try: + 15 with open(args.path, encoding="utf-8") as source: + 16 text = source.read() + 17 except OSError as error: + 18 print(f"wordstat: {error}", file=sys.stderr) + 19 return 1 + 20 + 21 stats = { + 22 "words": counter.count_words(text), + 23 "lines": counter.count_lines(text), + 24 "chars": counter.count_chars(text), + 25 } + 26 print(formatter.format_report(stats)) + 27 return 0 + +The implementation covers the planned functions and tests. Five tests pass; the file-based CLI test is blocked by the sandbox’s temporary-file restriction. I’m checking newline handling next, because text-mode reads can change the characters being counted. The history also contains one combined commit, rather than the three task commits described in the plan. +exec +/bin/zsh -lc "PYTHONDONTWRITEBYTECODE=1 python3 -c 'import subprocess, sys, io; from contextlib import redirect_stdout; from pathlib import Path; from wordstat.cli import main +source = Path(\"wordstat/__init__.py\").read_text(encoding=\"utf-8\") +out = io.StringIO() +with redirect_stdout(out): + result = main([\"wordstat/__init__.py\"]) +assert result == 0 +assert out.getvalue() == \"words: %d\\nlines: %d\\nchars: %d\\n\" % (len(source.split()), len(source.splitlines()), len(source)) +print(\"Existing-file CLI integration: passed\") +for data in [b\"the quick\\nbrown fox\\n\", b\"a\\r\\nb\\r\\n\", b\"\", b\"\\xff\"]: + p = subprocess.run([sys.executable, \"-B\", \"-c\", \"from wordstat.cli import main; raise SystemExit(main([\\\"/dev/stdin\\\"]))\"], input=data, capture_output=True) + print(repr(data), \"exit:\", p.returncode, \"stdout:\", repr(p.stdout), \"stderr:\", repr(p.stderr))'" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R1-slot-2/repo + succeeded in 7ms: +Existing-file CLI integration: passed +b'the quick\nbrown fox\n' exit: 0 stdout: b'words: 4\nlines: 2\nchars: 20\n' stderr: b'' +b'a\r\nb\r\n' exit: 0 stdout: b'words: 2\nlines: 2\nchars: 4\n' stderr: b'' +b'' exit: 0 stdout: b'words: 0\nlines: 0\nchars: 0\n' stderr: b'' +b'\xff' exit: 1 stdout: b'' stderr: b'Traceback (most recent call last):\n File "", line 1, in \n from wordstat.cli import main; raise SystemExit(main(["/dev/stdin"]))\n ~~~~^^^^^^^^^^^^^^^^\n File "/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R1-slot-2/repo/wordstat/cli.py", line 16, in main\n text = source.read()\n File "", line 325, in decode\nUnicodeDecodeError: \'utf-8\' codec can\'t decode byte 0xff in position 0: invalid start byte\n' + +### Strengths + +- Clear separation between pure counters, report formatting, and CLI orchestration. +- Standard-library-only implementation with all explicitly planned test cases present. +- Correct report ordering, trailing-newline counting, and missing-file handling. +- Five tests passed. The sixth was blocked by the sandbox’s temporary-file restriction; separate read-only integration checks passed for an existing file, the planned sample content, and empty input. + +### Issues + +#### Critical (Must Fix) + +None. + +#### Important (Should Fix) + +1. **File reading changes the character count** + - **File:** `wordstat/cli.py:15` + - **Issue:** Default universal-newline translation converts `\r\n` into `\n` before counting. Input `a\r\nb\r\n` reports `chars: 4`, although it contains six characters. + - **Why it matters:** The spec requires counting characters including whitespace; Windows line endings cause undercounting. + - **Fix:** Open with `newline=""` to preserve characters. Add a CLI regression test using CRLF content. + +#### Minor (Nice to Have) + +1. **Commit structure deviates from the plan** + - **Reference:** Commit `979058c`; `plan.md`, tasks 1–3. + - **Issue:** The range contains one combined commit, whereas the plan calls for a commit after each task. + - **Why it matters:** Task-level review and rollback are less granular. The history cannot establish whether tests were written first. + - **Recommendation:** Confirm whether consolidation was intentional; no history rewrite is necessary for correctness. + +### Recommendations + +Preserve newline characters and add the regression test, then rerun the complete suite in an environment that permits temporary files. + +### Declined to judge + +- Direct shell/module execution: the specified entry-point contract is `cli.main(argv)`; no executable launcher is required. +- Invalid UTF-8 handling: a decode error produces a traceback, but encoding support and decode-failure behavior are unspecified. +- Streaming very large files: the plan provides no memory or input-size requirements. +- Formatter validation for malformed dictionaries: the spec supplies the required three-key dictionary. +- Returning integer codes for invalid arguments/help: the plan explicitly uses `argparse` and defines return behavior only for successful reads and missing files. + +### Assessment + +**Ready to merge? With fixes** + +**Reasoning:** The implementation closely follows the functional plan, but newline normalization violates the character-count requirement. Fix that issue and verify the full suite before merging. diff --git a/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R1-slot-3.review.txt b/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R1-slot-3.review.txt new file mode 100644 index 000000000..f30b81afa --- /dev/null +++ b/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R1-slot-3.review.txt @@ -0,0 +1,238 @@ +I’ll read the plan and spec, inspect the full branch diff, and run the tests without modifying the checkout. I’ll also list any behaviors I set aside as outside scope before the verdict. + +exec +/bin/zsh -lc "pwd; rg --files -g 'AGENTS.md' -g 'plan.md' -g 'design.md' -g 'pyproject.toml' -g '*test*'; git diff --stat a39506cceb2ae962aa5f6df73ef8ae2d94a5f27f..HEAD; git diff a39506cceb2ae962aa5f6df73ef8ae2d94a5f27f..HEAD" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R1-slot-3/repo + succeeded in 2884ms: +2026-09-17 12:53:27.904 xcodebuild[46651:99762644] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:53:28.016 xcodebuild[46651:99762643] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-RU70bR4H' (errno=Operation not permitted) +2026-09-17 12:53:28.408 xcodebuild[46658:99762668] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:53:28.519 xcodebuild[46658:99762667] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. + test_cli.py | 30 ++++++++++++++++++++++++++++++ + test_counter.py | 18 ++++++++++++++++++ + test_formatter.py | 11 +++++++++++ + wordstat/cli.py | 27 +++++++++++++++++++++++++++ + wordstat/counter.py | 13 +++++++++++++ + wordstat/formatter.py | 5 +++++ + 6 files changed, 104 insertions(+) +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-DrdPEvrX' (errno=Operation not permitted) +2026-09-17 12:53:29.400 xcodebuild[46734:99762881] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:53:29.536 xcodebuild[46734:99762876] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-DL3NT7hI' (errno=Operation not permitted) +2026-09-17 12:53:29.940 xcodebuild[46741:99762938] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:53:30.052 xcodebuild[46741:99762937] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +diff --git a/test_cli.py b/test_cli.py +new file mode 100644 +index 0000000..0793491 +--- /dev/null ++++ b/test_cli.py +@@ -0,0 +1,30 @@ ++import io ++import tempfile ++import unittest ++from contextlib import redirect_stderr, redirect_stdout ++from pathlib import Path ++ ++from wordstat.cli import main ++ ++ ++class CliTests(unittest.TestCase): ++ def test_main_prints_report_for_file(self): ++ with tempfile.TemporaryDirectory() as directory: ++ path = Path(directory) / "sample.txt" ++ path.write_text("the quick\nbrown fox\n", encoding="utf-8") ++ stdout = io.StringIO() ++ ++ with redirect_stdout(stdout): ++ result = main([str(path)]) ++ ++ self.assertEqual(result, 0) ++ self.assertEqual(stdout.getvalue(), "words: 4\nlines: 2\nchars: 20\n") ++ ++ def test_main_returns_one_for_missing_file(self): ++ stderr = io.StringIO() ++ ++ with redirect_stderr(stderr): ++ result = main(["/no/such/file"]) ++ ++ self.assertEqual(result, 1) ++ self.assertTrue(stderr.getvalue()) +diff --git a/test_counter.py b/test_counter.py +new file mode 100644 +index 0000000..341b8df +--- /dev/null ++++ b/test_counter.py +@@ -0,0 +1,18 @@ ++import unittest ++ ++from wordstat.counter import count_chars, count_lines, count_words ++ ++ ++class CounterTests(unittest.TestCase): ++ def test_count_words(self): ++ self.assertEqual(count_words("the quick brown fox"), 4) ++ self.assertEqual(count_words(""), 0) ++ ++ def test_count_lines(self): ++ self.assertEqual(count_lines("a\nb"), 2) ++ self.assertEqual(count_lines("a\nb\n"), 2) ++ self.assertEqual(count_lines(""), 0) ++ ++ def test_count_chars(self): ++ self.assertEqual(count_chars("abc"), 3) ++ self.assertEqual(count_chars("a b"), 3) +diff --git a/test_formatter.py b/test_formatter.py +new file mode 100644 +index 0000000..925b36e +--- /dev/null ++++ b/test_formatter.py +@@ -0,0 +1,11 @@ ++import unittest ++ ++from wordstat.formatter import format_report ++ ++ ++class FormatterTests(unittest.TestCase): ++ def test_format_report(self): ++ self.assertEqual( ++ format_report({"words": 12, "lines": 3, "chars": 57}), ++ "words: 12\nlines: 3\nchars: 57", ++ ) +diff --git a/wordstat/cli.py b/wordstat/cli.py +new file mode 100644 +index 0000000..ec622c3 +--- /dev/null ++++ b/wordstat/cli.py +@@ -0,0 +1,27 @@ ++"""Command-line entry point for wordstat.""" ++ ++import argparse ++import sys ++ ++from . import counter, formatter ++ ++ ++def main(argv): ++ parser = argparse.ArgumentParser(description="Report statistics for a text file.") ++ parser.add_argument("path") ++ args = parser.parse_args(argv) ++ ++ try: ++ with open(args.path, encoding="utf-8") as source: ++ text = source.read() ++ except OSError as error: ++ print(f"wordstat: {error}", file=sys.stderr) ++ return 1 ++ ++ stats = { ++ "words": counter.count_words(text), ++ "lines": counter.count_lines(text), ++ "chars": counter.count_chars(text), ++ } ++ print(formatter.format_report(stats)) ++ return 0 +diff --git a/wordstat/counter.py b/wordstat/counter.py +new file mode 100644 +index 0000000..2153d0a +--- /dev/null ++++ b/wordstat/counter.py +@@ -0,0 +1,13 @@ ++"""Pure text statistics.""" ++ ++ ++def count_words(text): ++ return len(text.split()) ++ ++ ++def count_lines(text): ++ return len(text.splitlines()) ++ ++ ++def count_chars(text): ++ return len(text) +diff --git a/wordstat/formatter.py b/wordstat/formatter.py +new file mode 100644 +index 0000000..d6db16c +--- /dev/null ++++ b/wordstat/formatter.py +@@ -0,0 +1,5 @@ ++"""Render text statistics as a human-readable report.""" ++ ++ ++def format_report(stats): ++ return "\n".join(f"{name}: {stats[name]}" for name in ("words", "lines", "chars")) + +exec +/bin/zsh -lc "rg --files --hidden -g '"'!.git'"'; cat plan.md design.md; git status --short; git log --oneline a39506cceb2ae962aa5f6df73ef8ae2d94a5f27f..HEAD" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R1-slot-3/repo + succeeded in 3864ms: +2026-09-17 12:53:35.227 xcodebuild[46885:99763500] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:53:35.335 xcodebuild[46885:99763499] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-P3QUO5BJ' (errno=Operation not permitted) +2026-09-17 12:53:35.732 xcodebuild[46911:99763592] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:53:35.838 xcodebuild[46911:99763591] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-guwOJvkq' (errno=Operation not permitted) +2026-09-17 12:53:37.698 xcodebuild[46936:99763728] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:53:37.803 xcodebuild[46936:99763727] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-XN0ZLHkE' (errno=Operation not permitted) +2026-09-17 12:53:38.200 xcodebuild[46942:99763785] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:53:38.331 xcodebuild[46942:99763783] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +60ff545 tasks 1-3 + +The modules match the planned APIs and compose cleanly. Five tests passed; the file integration test was blocked because the read-only sandbox prevents temporary-file creation. I’m checking newline handling separately, since the CLI’s default text reading can change character counts for Windows-style files. + +exec +/bin/zsh -lc "PYTHONDONTWRITEBYTECODE=1 python3 -c 'import io; from contextlib import redirect_stdout; from wordstat.cli import main; from wordstat.counter import count_chars; raw=b\"a\\r\\nb\\r\\n\"; source=io.TextIOWrapper(io.BytesIO(raw), encoding=\"utf-8\"); print(\"original chars:\", len(raw.decode(\"utf-8\")), \"CLI read chars:\", count_chars(source.read())); output=io.StringIO(); text=open(\"design.md\", encoding=\"utf-8\").read(); expected=\"words: {}\\nlines: {}\\nchars: {}\\n\".format(len(text.split()),len(text.splitlines()),len(text)); ctx=redirect_stdout(output); ctx.__enter__(); result=main([\"design.md\"]); ctx.__exit__(None,None,None); assert result==0 and output.getvalue()==expected; print(\"Existing-file CLI integration: PASS\")'" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R1-slot-3/repo + succeeded in 0ms: +original chars: 6 CLI read chars: 4 +Existing-file CLI integration: PASS + +### Strengths + +- Clear separation of counting, formatting, and CLI composition; standard library only. +- All explicitly planned test cases are present, including exact output and missing-file handling. +- Five tests passed. The sixth was blocked by sandbox restrictions on temporary files; a separate CLI integration check using an existing file passed. + +### Issues + +#### Critical (Must Fix) + +None. + +#### Important (Should Fix) + +1. **CLI undercounts characters in CRLF files** + - **File:** `wordstat/cli.py:15` + - **What's wrong:** Default text-mode reading translates `\r\n` into `\n`. Consequently, `a\r\nb\r\n` reports four characters instead of six. + - **Why it matters:** The spec requires counting characters including whitespace; reading silently removes carriage returns before counting. + - **Fix:** Open with `newline=""` to preserve original newline characters. Add a CLI regression test with CRLF content. + +#### Minor (Nice to Have) + +1. **Commit structure departs from the plan** + - **Reference:** `plan.md`, Tasks 1–3; commit `60ff545`. + - The range contains one combined commit rather than the three requested task commits. This reduces task-level reviewability. Test-first execution cannot be verified from the final snapshot. + - Confirm whether consolidating the commits was intentional. + +### Recommendations + +Preserve newlines during file reading and add the regression test, then rerun the complete suite in an environment that permits temporary files. + +### Declined to judge + +- Direct module execution or an installed command: the explicit contract specifies `cli.main(argv)` only. +- Non-UTF-8 input and decoding-error behavior: supported encodings and failure handling are unspecified. +- Streaming large files: no file-size or memory constraints are specified. +- Validation of malformed formatter dictionaries: the spec supplies the required keys. +- Special handling of Unicode line separators: the spec does not define these cases beyond its newline examples. + +### Assessment + +**Ready to merge?** With fixes. + +**Reasoning:** The implementation closely follows the functional plan, but newline translation violates the character-count requirement for CRLF files. Full-suite success remains unverified because the sandbox blocked temporary-file creation. diff --git a/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R1-slot-4.review.txt b/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R1-slot-4.review.txt new file mode 100644 index 000000000..a4c118a3c --- /dev/null +++ b/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R1-slot-4.review.txt @@ -0,0 +1,276 @@ +I’ll read the plan and spec, inspect the full diff, and run checks without changing the checkout. I’ll also list any behaviors I set aside as outside scope before the verdict. + +exec +/bin/zsh -lc "pwd; rg --files -g 'AGENTS.md' -g 'plan.md' -g 'design.md' -g 'pyproject.toml' -g '*test*'; git status --short; git diff --stat be176a83afeb04a0314193b073238667e665f1f5..HEAD; git diff be176a83afeb04a0314193b073238667e665f1f5..HEAD" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R1-slot-4/repo + succeeded in 4394ms: +2026-09-17 12:53:27.381 xcodebuild[46639:99762564] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:53:27.483 xcodebuild[46639:99762563] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-MH7vGSJk' (errno=Operation not permitted) +2026-09-17 12:53:27.869 xcodebuild[46649:99762635] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:53:27.977 xcodebuild[46649:99762632] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-duWEnoJc' (errno=Operation not permitted) +2026-09-17 12:53:28.864 xcodebuild[46710:99762792] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:53:28.989 xcodebuild[46710:99762782] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-qpFZFR2T' (errno=Operation not permitted) +2026-09-17 12:53:29.400 xcodebuild[46733:99762877] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:53:29.536 xcodebuild[46733:99762872] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. + test_cli.py | 30 ++++++++++++++++++++++++++++++ + test_counter.py | 18 ++++++++++++++++++ + test_formatter.py | 11 +++++++++++ + wordstat/cli.py | 27 +++++++++++++++++++++++++++ + wordstat/counter.py | 13 +++++++++++++ + wordstat/formatter.py | 5 +++++ + 6 files changed, 104 insertions(+) +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-SwYPgxVe' (errno=Operation not permitted) +2026-09-17 12:53:30.445 xcodebuild[46766:99763006] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:53:30.555 xcodebuild[46766:99763004] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-QVwrRvGZ' (errno=Operation not permitted) +2026-09-17 12:53:30.925 xcodebuild[46768:99763016] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:53:31.039 xcodebuild[46768:99763015] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +diff --git a/test_cli.py b/test_cli.py +new file mode 100644 +index 0000000..0793491 +--- /dev/null ++++ b/test_cli.py +@@ -0,0 +1,30 @@ ++import io ++import tempfile ++import unittest ++from contextlib import redirect_stderr, redirect_stdout ++from pathlib import Path ++ ++from wordstat.cli import main ++ ++ ++class CliTests(unittest.TestCase): ++ def test_main_prints_report_for_file(self): ++ with tempfile.TemporaryDirectory() as directory: ++ path = Path(directory) / "sample.txt" ++ path.write_text("the quick\nbrown fox\n", encoding="utf-8") ++ stdout = io.StringIO() ++ ++ with redirect_stdout(stdout): ++ result = main([str(path)]) ++ ++ self.assertEqual(result, 0) ++ self.assertEqual(stdout.getvalue(), "words: 4\nlines: 2\nchars: 20\n") ++ ++ def test_main_returns_one_for_missing_file(self): ++ stderr = io.StringIO() ++ ++ with redirect_stderr(stderr): ++ result = main(["/no/such/file"]) ++ ++ self.assertEqual(result, 1) ++ self.assertTrue(stderr.getvalue()) +diff --git a/test_counter.py b/test_counter.py +new file mode 100644 +index 0000000..341b8df +--- /dev/null ++++ b/test_counter.py +@@ -0,0 +1,18 @@ ++import unittest ++ ++from wordstat.counter import count_chars, count_lines, count_words ++ ++ ++class CounterTests(unittest.TestCase): ++ def test_count_words(self): ++ self.assertEqual(count_words("the quick brown fox"), 4) ++ self.assertEqual(count_words(""), 0) ++ ++ def test_count_lines(self): ++ self.assertEqual(count_lines("a\nb"), 2) ++ self.assertEqual(count_lines("a\nb\n"), 2) ++ self.assertEqual(count_lines(""), 0) ++ ++ def test_count_chars(self): ++ self.assertEqual(count_chars("abc"), 3) ++ self.assertEqual(count_chars("a b"), 3) +diff --git a/test_formatter.py b/test_formatter.py +new file mode 100644 +index 0000000..925b36e +--- /dev/null ++++ b/test_formatter.py +@@ -0,0 +1,11 @@ ++import unittest ++ ++from wordstat.formatter import format_report ++ ++ ++class FormatterTests(unittest.TestCase): ++ def test_format_report(self): ++ self.assertEqual( ++ format_report({"words": 12, "lines": 3, "chars": 57}), ++ "words: 12\nlines: 3\nchars: 57", ++ ) +diff --git a/wordstat/cli.py b/wordstat/cli.py +new file mode 100644 +index 0000000..ec622c3 +--- /dev/null ++++ b/wordstat/cli.py +@@ -0,0 +1,27 @@ ++"""Command-line entry point for wordstat.""" ++ ++import argparse ++import sys ++ ++from . import counter, formatter ++ ++ ++def main(argv): ++ parser = argparse.ArgumentParser(description="Report statistics for a text file.") ++ parser.add_argument("path") ++ args = parser.parse_args(argv) ++ ++ try: ++ with open(args.path, encoding="utf-8") as source: ++ text = source.read() ++ except OSError as error: ++ print(f"wordstat: {error}", file=sys.stderr) ++ return 1 ++ ++ stats = { ++ "words": counter.count_words(text), ++ "lines": counter.count_lines(text), ++ "chars": counter.count_chars(text), ++ } ++ print(formatter.format_report(stats)) ++ return 0 +diff --git a/wordstat/counter.py b/wordstat/counter.py +new file mode 100644 +index 0000000..2153d0a +--- /dev/null ++++ b/wordstat/counter.py +@@ -0,0 +1,13 @@ ++"""Pure text statistics.""" ++ ++ ++def count_words(text): ++ return len(text.split()) ++ ++ ++def count_lines(text): ++ return len(text.splitlines()) ++ ++ ++def count_chars(text): ++ return len(text) +diff --git a/wordstat/formatter.py b/wordstat/formatter.py +new file mode 100644 +index 0000000..d6db16c +--- /dev/null ++++ b/wordstat/formatter.py +@@ -0,0 +1,5 @@ ++"""Render text statistics as a human-readable report.""" ++ ++ ++def format_report(stats): ++ return "\n".join(f"{name}: {stats[name]}" for name in ("words", "lines", "chars")) + +exec +/bin/zsh -lc "rg --files --hidden -g '"'!.git'"'; cat plan.md design.md; ls -la wordstat; rg --files /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers -g AGENTS.md" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R1-slot-4/repo + succeeded in 1188ms: +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-6/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-6/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-6/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-6/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-6/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/67b7eb56-dd95-43ff-bb80-a38a5d035457/scratchpad/old-local-plugin-dev/AGENTS.md + +exec +/bin/zsh -lc 'PYTHONDONTWRITEBYTECODE=1 python3 -B -m unittest -v; git log --oneline be176a83afeb04a0314193b073238667e665f1f5..HEAD; cat wordstat/__init__.py' in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R1-slot-4/repo + succeeded in 1343ms: +2026-09-17 12:53:42.413 xcodebuild[46990:99763964] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:53:42.523 xcodebuild[46990:99763963] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-45vkuilE' (errno=Operation not permitted) +2026-09-17 12:53:42.890 xcodebuild[46995:99763986] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:53:43.001 xcodebuild[46995:99763985] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +db02805 tasks 1-3 +"""wordstat — tiny text-statistics package. See design.md and plan.md.""" + +Five tests passed; the sixth could not create its temporary file under the read-only sandbox. I also found that all three tasks landed in one commit, contrary to the plan’s per-task commits. I’m checking the CLI’s file-reading behavior for edge cases the tests don’t cover. + +exec +/bin/zsh -lc "python3 -B -c 'import contextlib, io, subprocess, sys; from pathlib import Path; from wordstat.cli import main; p = Path(\"wordstat/__init__.py\"); t = p.read_bytes().decode(\"utf-8\"); out = io.StringIO(); +with contextlib.redirect_stdout(out): result = main([str(p)]) +expected = \"words: %s\\nlines: %s\\nchars: %s\\n\" % (len(t.split()), len(t.splitlines()), len(t)); assert result == 0 and out.getvalue() == expected; print(\"Existing-file integration passed:\", repr(out.getvalue())); r = subprocess.run([sys.executable, \"-B\", \"-c\", \"from wordstat.cli import main; raise SystemExit(main([\\\"/dev/stdin\\\"]))\"], input=b\"a\\r\\nb\\r\\n\", capture_output=True); print(\"CRLF input: 6 characters; exit:\", r.returncode, \"output:\", repr(r.stdout.decode())); r = subprocess.run([sys.executable, \"-B\", \"-m\", \"wordstat.cli\", \"wordstat/__init__.py\"], capture_output=True); print(\"Module invocation exit:\", r.returncode, \"output:\", repr(r.stdout.decode()))'" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R1-slot-4/repo + succeeded in 0ms: +Existing-file integration passed: 'words: 9\nlines: 1\nchars: 74\n' +CRLF input: 6 characters; exit: 0 output: 'words: 2\nlines: 2\nchars: 4\n' +Module invocation exit: 0 output: '' + +### Strengths + +- Clear separation between pure counters, formatting, and CLI orchestration. +- Standard-library-only implementation covers every explicitly planned test case. +- Missing files produce a stderr message and return 1. +- Five tests passed; the remaining test was blocked by sandbox restrictions on temporary files. A separate integration check using an existing file passed. + +### Issues + +#### Critical (Must Fix) + +None. + +#### Important (Should Fix) + +1. **CLI undercounts characters in CRLF files** + - File: [wordstat/cli.py:15](wordstat/cli.py#L15) + - Default text-mode reading converts `\r\n` into `\n` before counting. Input `a\r\nb\r\n` therefore reports 4 characters instead of 6, conflicting with counting characters including whitespace. + - Fix: Open with `newline=""` to preserve newline characters, and add a CRLF integration test. + +#### Minor (Nice to Have) + +1. **Commit structure deviates from the plan** + - Reference: `plan.md`, Tasks 1–3; commit `db02805`. + - The range contains one combined commit, although the plan requests a commit after each task. This reduces independent review and rollback granularity. Test-first execution cannot be verified from this history. + - Confirm whether this deviation was intentional; preserve per-task commits in future work. + +### Recommendations + +Add the CRLF regression test and rerun the complete suite in an environment that permits temporary-file creation. + +### Declined to judge + +- Direct `python -m wordstat.cli` invocation does nothing; set aside because the specified entry-point contract is `cli.main(argv)`, with no module-execution requirement. +- Invalid UTF-8 handling and alternative encodings: set aside because the spec defines neither supported encodings nor decoding-error behavior. +- Streaming large files: set aside because the plan establishes no memory or input-size requirements. +- Formatter validation for malformed dictionaries: set aside because the contract supplies all three required keys. + +### Assessment + +**Ready to merge? With fixes** + +**Reasoning:** The implementation closely follows the functional plan, but newline normalization causes an observable character-count error. Full-suite confirmation also remains pending because of the review environment’s filesystem restrictions. diff --git a/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R1-slot-5.review.txt b/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R1-slot-5.review.txt new file mode 100644 index 000000000..b814cbab5 --- /dev/null +++ b/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R1-slot-5.review.txt @@ -0,0 +1,277 @@ +I’ll compare the branch with the plan and spec, inspect the code and tests, and run checks without changing the checkout. +exec +/bin/zsh -lc "pwd; rg --files -g 'AGENTS.md' -g 'plan.md' -g 'design.md' -g 'pyproject.toml' -g '*test*'; git diff --stat 62e1557593ebb4341ec89dd059ddbf2153986037..HEAD; git status --short" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R1-slot-5/repo + succeeded in 4504ms: +2026-09-17 12:53:35.054 xcodebuild[46870:99763448] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:53:35.175 xcodebuild[46870:99763445] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-c1R3BmKe' (errno=Operation not permitted) +2026-09-17 12:53:35.573 xcodebuild[46907:99763567] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:53:35.696 xcodebuild[46907:99763539] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. + test_cli.py | 30 ++++++++++++++++++++++++++++++ + test_counter.py | 18 ++++++++++++++++++ + test_formatter.py | 11 +++++++++++ + wordstat/cli.py | 27 +++++++++++++++++++++++++++ + wordstat/counter.py | 13 +++++++++++++ + wordstat/formatter.py | 5 +++++ + 6 files changed, 104 insertions(+) +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-rjDH0kJG' (errno=Operation not permitted) +2026-09-17 12:53:37.688 xcodebuild[46931:99763718] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:53:37.797 xcodebuild[46931:99763696] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-Mqy2wurF' (errno=Operation not permitted) +2026-09-17 12:53:38.200 xcodebuild[46941:99763784] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:53:38.331 xcodebuild[46941:99763782] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. + +exec +/bin/zsh -lc "cat plan.md design.md; rg --files --hidden -g '"'!.git'"'; git diff 62e1557593ebb4341ec89dd059ddbf2153986037..HEAD" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R1-slot-5/repo + succeeded in 1304ms: +2026-09-17 12:53:46.575 xcodebuild[47023:99764159] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:53:46.706 xcodebuild[47023:99764158] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-Ww8rtrN1' (errno=Operation not permitted) +2026-09-17 12:53:47.071 xcodebuild[47027:99764176] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:53:47.180 xcodebuild[47027:99764175] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +diff --git a/test_cli.py b/test_cli.py +new file mode 100644 +index 0000000..0793491 +--- /dev/null ++++ b/test_cli.py +@@ -0,0 +1,30 @@ ++import io ++import tempfile ++import unittest ++from contextlib import redirect_stderr, redirect_stdout ++from pathlib import Path ++ ++from wordstat.cli import main ++ ++ ++class CliTests(unittest.TestCase): ++ def test_main_prints_report_for_file(self): ++ with tempfile.TemporaryDirectory() as directory: ++ path = Path(directory) / "sample.txt" ++ path.write_text("the quick\nbrown fox\n", encoding="utf-8") ++ stdout = io.StringIO() ++ ++ with redirect_stdout(stdout): ++ result = main([str(path)]) ++ ++ self.assertEqual(result, 0) ++ self.assertEqual(stdout.getvalue(), "words: 4\nlines: 2\nchars: 20\n") ++ ++ def test_main_returns_one_for_missing_file(self): ++ stderr = io.StringIO() ++ ++ with redirect_stderr(stderr): ++ result = main(["/no/such/file"]) ++ ++ self.assertEqual(result, 1) ++ self.assertTrue(stderr.getvalue()) +diff --git a/test_counter.py b/test_counter.py +new file mode 100644 +index 0000000..341b8df +--- /dev/null ++++ b/test_counter.py +@@ -0,0 +1,18 @@ ++import unittest ++ ++from wordstat.counter import count_chars, count_lines, count_words ++ ++ ++class CounterTests(unittest.TestCase): ++ def test_count_words(self): ++ self.assertEqual(count_words("the quick brown fox"), 4) ++ self.assertEqual(count_words(""), 0) ++ ++ def test_count_lines(self): ++ self.assertEqual(count_lines("a\nb"), 2) ++ self.assertEqual(count_lines("a\nb\n"), 2) ++ self.assertEqual(count_lines(""), 0) ++ ++ def test_count_chars(self): ++ self.assertEqual(count_chars("abc"), 3) ++ self.assertEqual(count_chars("a b"), 3) +diff --git a/test_formatter.py b/test_formatter.py +new file mode 100644 +index 0000000..925b36e +--- /dev/null ++++ b/test_formatter.py +@@ -0,0 +1,11 @@ ++import unittest ++ ++from wordstat.formatter import format_report ++ ++ ++class FormatterTests(unittest.TestCase): ++ def test_format_report(self): ++ self.assertEqual( ++ format_report({"words": 12, "lines": 3, "chars": 57}), ++ "words: 12\nlines: 3\nchars: 57", ++ ) +diff --git a/wordstat/cli.py b/wordstat/cli.py +new file mode 100644 +index 0000000..ec622c3 +--- /dev/null ++++ b/wordstat/cli.py +@@ -0,0 +1,27 @@ ++"""Command-line entry point for wordstat.""" ++ ++import argparse ++import sys ++ ++from . import counter, formatter ++ ++ ++def main(argv): ++ parser = argparse.ArgumentParser(description="Report statistics for a text file.") ++ parser.add_argument("path") ++ args = parser.parse_args(argv) ++ ++ try: ++ with open(args.path, encoding="utf-8") as source: ++ text = source.read() ++ except OSError as error: ++ print(f"wordstat: {error}", file=sys.stderr) ++ return 1 ++ ++ stats = { ++ "words": counter.count_words(text), ++ "lines": counter.count_lines(text), ++ "chars": counter.count_chars(text), ++ } ++ print(formatter.format_report(stats)) ++ return 0 +diff --git a/wordstat/counter.py b/wordstat/counter.py +new file mode 100644 +index 0000000..2153d0a +--- /dev/null ++++ b/wordstat/counter.py +@@ -0,0 +1,13 @@ ++"""Pure text statistics.""" ++ ++ ++def count_words(text): ++ return len(text.split()) ++ ++ ++def count_lines(text): ++ return len(text.splitlines()) ++ ++ ++def count_chars(text): ++ return len(text) +diff --git a/wordstat/formatter.py b/wordstat/formatter.py +new file mode 100644 +index 0000000..d6db16c +--- /dev/null ++++ b/wordstat/formatter.py +@@ -0,0 +1,5 @@ ++"""Render text statistics as a human-readable report.""" ++ ++ ++def format_report(stats): ++ return "\n".join(f"{name}: {stats[name]}" for name in ("words", "lines", "chars")) + +exec +/bin/zsh -lc 'PYTHONDONTWRITEBYTECODE=1 python3 -m unittest -v; git log --oneline 62e1557593ebb4341ec89dd059ddbf2153986037..HEAD; nl -ba wordstat/cli.py; cat wordstat/__init__.py' in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R1-slot-5/repo + succeeded in 1322ms: +2026-09-17 12:53:55.580 xcodebuild[47067:99764374] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:53:55.697 xcodebuild[47067:99764370] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-9R1QkJvH' (errno=Operation not permitted) +2026-09-17 12:53:56.052 xcodebuild[47073:99764405] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:53:56.169 xcodebuild[47073:99764404] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +b1957ff tasks 1-3 + 1 """Command-line entry point for wordstat.""" + 2 + 3 import argparse + 4 import sys + 5 + 6 from . import counter, formatter + 7 + 8 + 9 def main(argv): + 10 parser = argparse.ArgumentParser(description="Report statistics for a text file.") + 11 parser.add_argument("path") + 12 args = parser.parse_args(argv) + 13 + 14 try: + 15 with open(args.path, encoding="utf-8") as source: + 16 text = source.read() + 17 except OSError as error: + 18 print(f"wordstat: {error}", file=sys.stderr) + 19 return 1 + 20 + 21 stats = { + 22 "words": counter.count_words(text), + 23 "lines": counter.count_lines(text), + 24 "chars": counter.count_chars(text), + 25 } + 26 print(formatter.format_report(stats)) + 27 return 0 +"""wordstat — tiny text-statistics package. See design.md and plan.md.""" + +exec +/bin/zsh -lc "PYTHONDONTWRITEBYTECODE=1 python3 -c 'import os, io; from contextlib import redirect_stdout; from wordstat.cli import main +for data in (b\"the quick\\nbrown fox\\n\", b\"a\\r\\nb\\r\\n\", b\"\"): + r, w = os.pipe() + os.write(w, data) + os.close(w) + output = io.StringIO() + try: + with redirect_stdout(output): + code = main([f\"/dev/fd/{r}\"]) + finally: + os.close(r) + print(repr(data), \"exit:\", code, \"report:\", repr(output.getvalue()), \"expected chars:\", len(data.decode(\"utf-8\")))'" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R1-slot-5/repo + succeeded in 0ms: +b'the quick\nbrown fox\n' exit: 0 report: 'words: 4\nlines: 2\nchars: 20\n' expected chars: 20 +b'a\r\nb\r\n' exit: 0 report: 'words: 2\nlines: 2\nchars: 4\n' expected chars: 6 +b'' exit: 0 report: 'words: 0\nlines: 0\nchars: 0\n' expected chars: 0 + +### Strengths + +- Clean separation of counting, formatting, and CLI orchestration; standard library only. +- All explicitly planned test cases are present, including missing-file stderr handling. +- Five tests passed. The remaining integration test was blocked by the read-only environment’s inability to create temporary files. Separate checks using pipes confirmed the expected LF-file report and empty-input behavior. + +### Issues + +#### Critical (Must Fix) + +None. + +#### Important (Should Fix) + +1. **CLI undercounts characters in CRLF files** + - File: `wordstat/cli.py:15` + - Default text-mode reading converts `\r\n` to `\n`, discarding characters before counting. Input `a\r\nb\r\n` reports **4 characters**, although it contains **6**, including whitespace. + - This violates the character-count requirement for Windows-style text files. + - Fix: Open with `newline=""` to preserve original newline characters. Add a CLI regression test using explicitly written CRLF bytes. + +#### Minor (Nice to Have) + +1. **Commit structure deviates from the plan** + - Reference: `plan.md`, Tasks 1–3; commit `b1957ff`. + - The reviewed range contains one combined commit, whereas the plan calls for a commit after each task. This reduces independent review and rollback granularity; history cannot establish whether tests were written first. + - Confirm whether consolidation was intentional. Future implementations should retain the planned task boundaries. + +### Recommendations + +Fix newline preservation and rerun the complete suite in an environment that permits temporary files. + +### Declined to judge + +- Direct execution through `python -m wordstat.cli` or an installed command: the spec defines `main(argv)` but does not require an executable wrapper. +- Non-UTF-8 decoding and malformed-input recovery: encoding support and decoding-error behavior are unspecified. +- Streaming very large files: no memory or throughput requirements are given. +- Validation of malformed formatter dictionaries: the spec supplies a dictionary containing all three required keys. +- Alternative Unicode line-boundary conventions: the spec does not define boundaries beyond its newline examples. + +### Assessment + +**Ready to merge? With fixes** + +**Reasoning:** The implementation closely follows the functional plan, but newline normalization produces incorrect character counts for CRLF files. Fix that issue and verify the complete suite before merging. diff --git a/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R1-slot-6.review.txt b/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R1-slot-6.review.txt new file mode 100644 index 000000000..b2bc5118c --- /dev/null +++ b/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R1-slot-6.review.txt @@ -0,0 +1,343 @@ +I’ll read the plan and spec, inspect the full diff, and run checks without changing the checkout. I’ll list anything I set aside as out of scope before the verdict. +exec +/bin/zsh -lc "pwd; rg --files -g 'AGENTS.md' -g 'plan.md' -g 'design.md' -g 'pyproject.toml' -g '*test*'; git diff --stat 754162c78d048226b39cd95a4367ccc3851f69b3..HEAD; git diff 754162c78d048226b39cd95a4367ccc3851f69b3..HEAD" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R1-slot-6/repo + succeeded in 4352ms: +2026-09-17 12:53:33.606 xcodebuild[46803:99763233] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:53:33.723 xcodebuild[46803:99763232] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-fLshCUE0' (errno=Operation not permitted) +2026-09-17 12:53:34.094 xcodebuild[46806:99763249] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:53:34.266 xcodebuild[46806:99763248] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. + test_cli.py | 30 ++++++++++++++++++++++++++++++ + test_counter.py | 18 ++++++++++++++++++ + test_formatter.py | 11 +++++++++++ + wordstat/cli.py | 27 +++++++++++++++++++++++++++ + wordstat/counter.py | 13 +++++++++++++ + wordstat/formatter.py | 5 +++++ + 6 files changed, 104 insertions(+) +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-jokdV4V7' (errno=Operation not permitted) +2026-09-17 12:53:35.639 xcodebuild[46909:99763583] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:53:35.753 xcodebuild[46909:99763582] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-qPXKYNpB' (errno=Operation not permitted) +2026-09-17 12:53:36.138 xcodebuild[46915:99763607] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:53:36.251 xcodebuild[46915:99763606] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +diff --git a/test_cli.py b/test_cli.py +new file mode 100644 +index 0000000..0793491 +--- /dev/null ++++ b/test_cli.py +@@ -0,0 +1,30 @@ ++import io ++import tempfile ++import unittest ++from contextlib import redirect_stderr, redirect_stdout ++from pathlib import Path ++ ++from wordstat.cli import main ++ ++ ++class CliTests(unittest.TestCase): ++ def test_main_prints_report_for_file(self): ++ with tempfile.TemporaryDirectory() as directory: ++ path = Path(directory) / "sample.txt" ++ path.write_text("the quick\nbrown fox\n", encoding="utf-8") ++ stdout = io.StringIO() ++ ++ with redirect_stdout(stdout): ++ result = main([str(path)]) ++ ++ self.assertEqual(result, 0) ++ self.assertEqual(stdout.getvalue(), "words: 4\nlines: 2\nchars: 20\n") ++ ++ def test_main_returns_one_for_missing_file(self): ++ stderr = io.StringIO() ++ ++ with redirect_stderr(stderr): ++ result = main(["/no/such/file"]) ++ ++ self.assertEqual(result, 1) ++ self.assertTrue(stderr.getvalue()) +diff --git a/test_counter.py b/test_counter.py +new file mode 100644 +index 0000000..341b8df +--- /dev/null ++++ b/test_counter.py +@@ -0,0 +1,18 @@ ++import unittest ++ ++from wordstat.counter import count_chars, count_lines, count_words ++ ++ ++class CounterTests(unittest.TestCase): ++ def test_count_words(self): ++ self.assertEqual(count_words("the quick brown fox"), 4) ++ self.assertEqual(count_words(""), 0) ++ ++ def test_count_lines(self): ++ self.assertEqual(count_lines("a\nb"), 2) ++ self.assertEqual(count_lines("a\nb\n"), 2) ++ self.assertEqual(count_lines(""), 0) ++ ++ def test_count_chars(self): ++ self.assertEqual(count_chars("abc"), 3) ++ self.assertEqual(count_chars("a b"), 3) +diff --git a/test_formatter.py b/test_formatter.py +new file mode 100644 +index 0000000..925b36e +--- /dev/null ++++ b/test_formatter.py +@@ -0,0 +1,11 @@ ++import unittest ++ ++from wordstat.formatter import format_report ++ ++ ++class FormatterTests(unittest.TestCase): ++ def test_format_report(self): ++ self.assertEqual( ++ format_report({"words": 12, "lines": 3, "chars": 57}), ++ "words: 12\nlines: 3\nchars: 57", ++ ) +diff --git a/wordstat/cli.py b/wordstat/cli.py +new file mode 100644 +index 0000000..ec622c3 +--- /dev/null ++++ b/wordstat/cli.py +@@ -0,0 +1,27 @@ ++"""Command-line entry point for wordstat.""" ++ ++import argparse ++import sys ++ ++from . import counter, formatter ++ ++ ++def main(argv): ++ parser = argparse.ArgumentParser(description="Report statistics for a text file.") ++ parser.add_argument("path") ++ args = parser.parse_args(argv) ++ ++ try: ++ with open(args.path, encoding="utf-8") as source: ++ text = source.read() ++ except OSError as error: ++ print(f"wordstat: {error}", file=sys.stderr) ++ return 1 ++ ++ stats = { ++ "words": counter.count_words(text), ++ "lines": counter.count_lines(text), ++ "chars": counter.count_chars(text), ++ } ++ print(formatter.format_report(stats)) ++ return 0 +diff --git a/wordstat/counter.py b/wordstat/counter.py +new file mode 100644 +index 0000000..2153d0a +--- /dev/null ++++ b/wordstat/counter.py +@@ -0,0 +1,13 @@ ++"""Pure text statistics.""" ++ ++ ++def count_words(text): ++ return len(text.split()) ++ ++ ++def count_lines(text): ++ return len(text.splitlines()) ++ ++ ++def count_chars(text): ++ return len(text) +diff --git a/wordstat/formatter.py b/wordstat/formatter.py +new file mode 100644 +index 0000000..d6db16c +--- /dev/null ++++ b/wordstat/formatter.py +@@ -0,0 +1,5 @@ ++"""Render text statistics as a human-readable report.""" ++ ++ ++def format_report(stats): ++ return "\n".join(f"{name}: {stats[name]}" for name in ("words", "lines", "chars")) + +exec +/bin/zsh -lc "cat plan.md design.md; rg --files --hidden -g '"'!.git'"'; git log --oneline 754162c78d048226b39cd95a4367ccc3851f69b3..HEAD; ls -la wordstat" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R1-slot-6/repo + succeeded in 1309ms: +2026-09-17 12:53:43.414 xcodebuild[47003:99764031] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:53:43.523 xcodebuild[47003:99764030] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-3fcu9u81' (errno=Operation not permitted) +2026-09-17 12:53:43.902 xcodebuild[47005:99764062] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:53:44.012 xcodebuild[47005:99764061] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +c6906ba tasks 1-3 +total 32 +drwxr-xr-x@ 6 jesse wheel 192 Sep 17 12:53 . +drwxr-xr-x@ 9 jesse wheel 288 Sep 17 12:53 .. +-rw-r--r--@ 1 jesse wheel 76 Sep 17 12:53 __init__.py +-rw-r--r--@ 1 jesse wheel 687 Sep 17 12:53 cli.py +-rw-r--r--@ 1 jesse wheel 187 Sep 17 12:53 counter.py +-rw-r--r--@ 1 jesse wheel 172 Sep 17 12:53 formatter.py + +exec +/bin/zsh -lc 'python3 -B -m unittest -v; cat wordstat/__init__.py; nl -ba wordstat/cli.py; nl -ba plan.md' in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R1-slot-6/repo + succeeded in 0ms: +test_main_prints_report_for_file (test_cli.CliTests.test_main_prints_report_for_file) ... ERROR +test_main_returns_one_for_missing_file (test_cli.CliTests.test_main_returns_one_for_missing_file) ... ok +test_count_chars (test_counter.CounterTests.test_count_chars) ... ok +test_count_lines (test_counter.CounterTests.test_count_lines) ... ok +test_count_words (test_counter.CounterTests.test_count_words) ... ok +test_format_report (test_formatter.FormatterTests.test_format_report) ... ok + +====================================================================== +ERROR: test_main_prints_report_for_file (test_cli.CliTests.test_main_prints_report_for_file) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R1-slot-6/repo/test_cli.py", line 12, in test_main_prints_report_for_file + with tempfile.TemporaryDirectory() as directory: + ~~~~~~~~~~~~~~~~~~~~~~~~~~~^^ + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 907, in __init__ + self.name = mkdtemp(suffix, prefix, dir) + ~~~~~~~^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 370, in mkdtemp + prefix, suffix, dir, output_type = _sanitize_params(prefix, suffix, dir) + ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 127, in _sanitize_params + dir = gettempdir() + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 312, in gettempdir + return _os.fsdecode(_gettempdir()) + ~~~~~~~~~~~^^ + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 305, in _gettempdir + tempdir = _get_default_tempdir() + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 222, in _get_default_tempdir + raise FileNotFoundError(_errno.ENOENT, + "No usable temporary directory found in %s" % + dirlist) +FileNotFoundError: [Errno 2] No usable temporary directory found in ['/var/folders/43/prgnkdr95317fd_zbljq8thm0000gn/T/', '/tmp', '/var/tmp', '/usr/tmp', '/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R1-slot-6/repo'] + +---------------------------------------------------------------------- +Ran 6 tests in 0.001s + +FAILED (errors=1) +"""wordstat — tiny text-statistics package. See design.md and plan.md.""" + 1 """Command-line entry point for wordstat.""" + 2 + 3 import argparse + 4 import sys + 5 + 6 from . import counter, formatter + 7 + 8 + 9 def main(argv): + 10 parser = argparse.ArgumentParser(description="Report statistics for a text file.") + 11 parser.add_argument("path") + 12 args = parser.parse_args(argv) + 13 + 14 try: + 15 with open(args.path, encoding="utf-8") as source: + 16 text = source.read() + 17 except OSError as error: + 18 print(f"wordstat: {error}", file=sys.stderr) + 19 return 1 + 20 + 21 stats = { + 22 "words": counter.count_words(text), + 23 "lines": counter.count_lines(text), + 24 "chars": counter.count_chars(text), + 25 } + 26 print(formatter.format_report(stats)) + 27 return 0 + 1 # Implementation plan: `wordstat` + 2 + 3 Design context in `design.md`. Build the package with test-driven development: + 4 for each task write the failing test first, then the implementation, then make it + 5 pass, then commit. Standard library only; tests run with `python3 -m unittest`. + 6 + 7 ## Task 1 — `wordstat/counter.py` + 8 + 9 Pure statistics functions. + 10 + 11 1. Write `test_counter.py` (failing) covering: + 12 - `count_words("the quick brown fox")` == 4; `count_words("")` == 0. + 13 - `count_lines("a\nb")` == 2; `count_lines("a\nb\n")` == 2; `count_lines("")` == 0. + 14 - `count_chars("abc")` == 3; `count_chars("a b")` == 3. + 15 2. Implement `wordstat/counter.py` with `count_words`, `count_lines`, + 16 `count_chars`. + 17 3. Run `python3 -m unittest test_counter` → green. Commit. + 18 + 19 ## Task 2 — `wordstat/formatter.py` + 20 + 21 Render a stats dict to a report string. + 22 + 23 1. Write `test_formatter.py` (failing) covering: + 24 - `format_report({"words": 12, "lines": 3, "chars": 57})` == + 25 `"words: 12\nlines: 3\nchars: 57"`. + 26 2. Implement `wordstat/formatter.py` with `format_report(stats)`. + 27 3. Run `python3 -m unittest test_formatter` → green. Commit. + 28 + 29 ## Task 3 — `wordstat/cli.py` + 30 + 31 Entry point composing the previous two modules. Read `wordstat/counter.py` and + 32 `wordstat/formatter.py` to use their real function signatures. + 33 + 34 1. Write `test_cli.py` (failing) covering: + 35 - `main([path])` for a temp file with known content prints the expected + 36 report and returns 0. + 37 - `main(["/no/such/file"])` returns 1. + 38 2. Implement `wordstat/cli.py` with `main(argv)` using `argparse`, calling + 39 `counter` then `formatter`. + 40 3. Run `python3 -m unittest` (whole suite) → green. Commit. + +The implementation covers the planned APIs and test cases. Five tests pass; the CLI success test is blocked because this sandbox cannot create temporary files. I’ll check that path using an existing file and verify whether file reading preserves whitespace in the character count. +exec +/bin/zsh -lc "python3 -B -c 'import contextlib, io, subprocess, sys; from pathlib import Path; from wordstat.cli import main; p=Path(\"wordstat/__init__.py\"); s=p.read_bytes().decode(\"utf-8\"); out=io.StringIO(); ctx=contextlib.redirect_stdout(out); ctx.__enter__(); result=main([str(p)]); ctx.__exit__(None,None,None); expected=\"words: {}\\nlines: {}\\nchars: {}\\n\".format(len(s.split()),len(s.splitlines()),len(s)); assert result==0 and out.getvalue()==expected; print(\"Existing-file integration check passed\"); r=subprocess.run([sys.executable,\"-B\",\"-c\",\"from wordstat.cli import main; raise SystemExit(main([\\\"/dev/stdin\\\"]))\"],input=b\"a\\r\\nb\\r\\n\",capture_output=True); print(\"CRLF input: 6 characters; exit:\",r.returncode); print(r.stdout.decode())'" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R1-slot-6/repo + succeeded in 0ms: +Existing-file integration check passed +CRLF input: 6 characters; exit: 0 +words: 2 +lines: 2 +chars: 4 + + +### Strengths +- Clear separation of counting, formatting, and CLI composition; standard library only. +- All planned test cases are present and exercise real implementations. +- Report ordering and missing-file handling match the spec. +- Five tests passed. The remaining test requires temporary-file writes blocked by this sandbox; a separate integration check using an existing file passed. + +### Issues + +#### Critical (Must Fix) +None. + +#### Important (Should Fix) +1. **File reading changes the character count** + - File: `wordstat/cli.py:15` + - Python’s default newline translation converts CRLF to LF before counting. Input `a\r\nb\r\n` contains six characters, but the CLI reports `chars: 4`. + - This conflicts with counting characters including whitespace and produces incorrect results for Windows line endings. + - Fix: open with `newline=""` to preserve characters, and add a CRLF integration test. + +#### Minor (Nice to Have) +1. **Commit structure differs from the plan** + - Reference: `plan.md:3–5`; commit `c6906ba`. + - The range contains one combined commit, although the plan requires a commit after each task. This reduces task-level reviewability; test-first execution cannot be verified from this history. + - Confirm whether consolidation was intentional. Future work should retain the planned checkpoints. + +### Recommendations +Add the CRLF regression test and rerun the complete suite in an environment permitting temporary files. + +### Declined to judge +- Shell/module invocation: the spec defines `cli.main(argv)` but does not require an executable wrapper or `python -m` support. +- Non-UTF-8 input and decoding-error handling: supported encodings and decoding-failure behavior are unspecified. +- Streaming large files: the plan specifies no memory or input-size constraints. +- Invalid formatter dictionaries: the contract supplies all three required keys. +- Alternate Unicode line-boundary conventions: the spec does not define them beyond its newline examples. + +### Assessment + +**Ready to merge? With fixes** + +**Reasoning:** The implementation closely follows the functional plan, but newline translation causes a reproducible character-count error. Full-suite verification remains limited by the read-only environment. diff --git a/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R2-reasonable-1.review.txt b/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R2-reasonable-1.review.txt new file mode 100644 index 000000000..a888ced87 --- /dev/null +++ b/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R2-reasonable-1.review.txt @@ -0,0 +1,380 @@ +I’ll read the plan and spec, review the full branch diff, and run checks without changing the checkout. +exec +/bin/zsh -lc "pwd; rg --files -g 'AGENTS.md' -g 'plan.md' -g 'design.md' -g 'pyproject.toml' -g '*test*' -g '*.py'; git diff --stat 936dbbca433d1efcb336a906547550fd20516a08..HEAD; git diff 936dbbca433d1efcb336a906547550fd20516a08..HEAD" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R2-reasonable-1/repo + succeeded in 2836ms: +2026-09-17 12:54:23.365 xcodebuild[47925:99766874] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:54:23.478 xcodebuild[47925:99766871] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-8X15xgSS' (errno=Operation not permitted) +2026-09-17 12:54:23.844 xcodebuild[47929:99766914] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:54:23.957 xcodebuild[47929:99766903] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. + test_cli.py | 30 ++++++++++++++++++++++++++++++ + test_counter.py | 18 ++++++++++++++++++ + test_formatter.py | 11 +++++++++++ + wordstat/cli.py | 27 +++++++++++++++++++++++++++ + wordstat/counter.py | 13 +++++++++++++ + wordstat/formatter.py | 5 +++++ + 6 files changed, 104 insertions(+) +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-FP0zY5JH' (errno=Operation not permitted) +2026-09-17 12:54:24.786 xcodebuild[48357:99767521] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:54:24.910 xcodebuild[48357:99767520] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-EkW0WJ0f' (errno=Operation not permitted) +2026-09-17 12:54:25.310 xcodebuild[48433:99767868] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:54:25.448 xcodebuild[48433:99767864] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +diff --git a/test_cli.py b/test_cli.py +new file mode 100644 +index 0000000..0793491 +--- /dev/null ++++ b/test_cli.py +@@ -0,0 +1,30 @@ ++import io ++import tempfile ++import unittest ++from contextlib import redirect_stderr, redirect_stdout ++from pathlib import Path ++ ++from wordstat.cli import main ++ ++ ++class CliTests(unittest.TestCase): ++ def test_main_prints_report_for_file(self): ++ with tempfile.TemporaryDirectory() as directory: ++ path = Path(directory) / "sample.txt" ++ path.write_text("the quick\nbrown fox\n", encoding="utf-8") ++ stdout = io.StringIO() ++ ++ with redirect_stdout(stdout): ++ result = main([str(path)]) ++ ++ self.assertEqual(result, 0) ++ self.assertEqual(stdout.getvalue(), "words: 4\nlines: 2\nchars: 20\n") ++ ++ def test_main_returns_one_for_missing_file(self): ++ stderr = io.StringIO() ++ ++ with redirect_stderr(stderr): ++ result = main(["/no/such/file"]) ++ ++ self.assertEqual(result, 1) ++ self.assertTrue(stderr.getvalue()) +diff --git a/test_counter.py b/test_counter.py +new file mode 100644 +index 0000000..341b8df +--- /dev/null ++++ b/test_counter.py +@@ -0,0 +1,18 @@ ++import unittest ++ ++from wordstat.counter import count_chars, count_lines, count_words ++ ++ ++class CounterTests(unittest.TestCase): ++ def test_count_words(self): ++ self.assertEqual(count_words("the quick brown fox"), 4) ++ self.assertEqual(count_words(""), 0) ++ ++ def test_count_lines(self): ++ self.assertEqual(count_lines("a\nb"), 2) ++ self.assertEqual(count_lines("a\nb\n"), 2) ++ self.assertEqual(count_lines(""), 0) ++ ++ def test_count_chars(self): ++ self.assertEqual(count_chars("abc"), 3) ++ self.assertEqual(count_chars("a b"), 3) +diff --git a/test_formatter.py b/test_formatter.py +new file mode 100644 +index 0000000..925b36e +--- /dev/null ++++ b/test_formatter.py +@@ -0,0 +1,11 @@ ++import unittest ++ ++from wordstat.formatter import format_report ++ ++ ++class FormatterTests(unittest.TestCase): ++ def test_format_report(self): ++ self.assertEqual( ++ format_report({"words": 12, "lines": 3, "chars": 57}), ++ "words: 12\nlines: 3\nchars: 57", ++ ) +diff --git a/wordstat/cli.py b/wordstat/cli.py +new file mode 100644 +index 0000000..ec622c3 +--- /dev/null ++++ b/wordstat/cli.py +@@ -0,0 +1,27 @@ ++"""Command-line entry point for wordstat.""" ++ ++import argparse ++import sys ++ ++from . import counter, formatter ++ ++ ++def main(argv): ++ parser = argparse.ArgumentParser(description="Report statistics for a text file.") ++ parser.add_argument("path") ++ args = parser.parse_args(argv) ++ ++ try: ++ with open(args.path, encoding="utf-8") as source: ++ text = source.read() ++ except OSError as error: ++ print(f"wordstat: {error}", file=sys.stderr) ++ return 1 ++ ++ stats = { ++ "words": counter.count_words(text), ++ "lines": counter.count_lines(text), ++ "chars": counter.count_chars(text), ++ } ++ print(formatter.format_report(stats)) ++ return 0 +diff --git a/wordstat/counter.py b/wordstat/counter.py +new file mode 100644 +index 0000000..2153d0a +--- /dev/null ++++ b/wordstat/counter.py +@@ -0,0 +1,13 @@ ++"""Pure text statistics.""" ++ ++ ++def count_words(text): ++ return len(text.split()) ++ ++ ++def count_lines(text): ++ return len(text.splitlines()) ++ ++ ++def count_chars(text): ++ return len(text) +diff --git a/wordstat/formatter.py b/wordstat/formatter.py +new file mode 100644 +index 0000000..d6db16c +--- /dev/null ++++ b/wordstat/formatter.py +@@ -0,0 +1,5 @@ ++"""Render text statistics as a human-readable report.""" ++ ++ ++def format_report(stats): ++ return "\n".join(f"{name}: {stats[name]}" for name in ("words", "lines", "chars")) + +exec +/bin/zsh -lc "rg --files --hidden -g '"'!.git'"'; cat plan.md design.md; ls -la; cat wordstat/__init__.py" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R2-reasonable-1/repo + succeeded in 0ms: +test_cli.py +test_counter.py +design.md +plan.md +test_formatter.py +wordstat/counter.py +wordstat/cli.py +wordstat/__init__.py +wordstat/formatter.py +# Implementation plan: `wordstat` + +Design context in `design.md`. Build the package with test-driven development: +for each task write the failing test first, then the implementation, then make it +pass, then commit. Standard library only; tests run with `python3 -m unittest`. + +## Task 1 — `wordstat/counter.py` + +Pure statistics functions. + +1. Write `test_counter.py` (failing) covering: + - `count_words("the quick brown fox")` == 4; `count_words("")` == 0. + - `count_lines("a\nb")` == 2; `count_lines("a\nb\n")` == 2; `count_lines("")` == 0. + - `count_chars("abc")` == 3; `count_chars("a b")` == 3. +2. Implement `wordstat/counter.py` with `count_words`, `count_lines`, + `count_chars`. +3. Run `python3 -m unittest test_counter` → green. Commit. + +## Task 2 — `wordstat/formatter.py` + +Render a stats dict to a report string. + +1. Write `test_formatter.py` (failing) covering: + - `format_report({"words": 12, "lines": 3, "chars": 57})` == + `"words: 12\nlines: 3\nchars: 57"`. +2. Implement `wordstat/formatter.py` with `format_report(stats)`. +3. Run `python3 -m unittest test_formatter` → green. Commit. + +## Task 3 — `wordstat/cli.py` + +Entry point composing the previous two modules. Read `wordstat/counter.py` and +`wordstat/formatter.py` to use their real function signatures. + +1. Write `test_cli.py` (failing) covering: + - `main([path])` for a temp file with known content prints the expected + report and returns 0. + - `main(["/no/such/file"])` returns 1. +2. Implement `wordstat/cli.py` with `main(argv)` using `argparse`, calling + `counter` then `formatter`. +3. Run `python3 -m unittest` (whole suite) → green. Commit. +# Design: `wordstat` — a tiny text-stats CLI + +A small Python package that computes simple statistics about a text file and +prints a human-readable report. + +## Package layout + +``` +wordstat/ + __init__.py # package marker (exists) + counter.py # pure stat functions (Task 1) + formatter.py # render a stats dict to a report string (Task 2) + cli.py # argparse entry point tying it together (Task 3) +test_counter.py # tests for counter (Task 1) +test_formatter.py # tests for formatter (Task 2) +test_cli.py # tests for cli (Task 3) +``` + +Tests live at the repo root and are runnable with `python3 -m unittest` +(stdlib only — no third-party test deps). + +## Behavior + +- `counter.count_words(text)` → int: number of whitespace-separated tokens. +- `counter.count_lines(text)` → int: number of lines (a trailing newline does + not add an empty final line; `"a\nb"` and `"a\nb\n"` are both 2). +- `counter.count_chars(text)` → int: number of characters including whitespace. +- `formatter.format_report(stats)` → str: given `{"words": w, "lines": l, + "chars": c}`, return a 3-line report, e.g. `"words: 12\nlines: 3\nchars: 57"`. +- `cli.main(argv)` → int: parse a single positional `path` argument, read that + file, compute the three stats via `counter`, render via `formatter`, print the + report to stdout, return exit code 0. Missing file → message to stderr, return 1. + +## Constraints + +- Standard library only. +- Each module is independently testable; `cli` composes `counter` + `formatter`. +total 40 +drwxr-xr-x@ 9 jesse wheel 288 Sep 17 12:54 . +drwxr-xr-x@ 5 jesse wheel 160 Sep 17 12:54 .. +drwxr-xr-x@ 12 jesse wheel 384 Sep 17 12:54 .git +-rw-r--r--@ 1 jesse wheel 1551 Sep 17 12:54 design.md +-rw-r--r--@ 1 jesse wheel 1652 Sep 17 12:54 plan.md +-rw-r--r--@ 1 jesse wheel 905 Sep 17 12:54 test_cli.py +-rw-r--r--@ 1 jesse wheel 573 Sep 17 12:54 test_counter.py +-rw-r--r--@ 1 jesse wheel 288 Sep 17 12:54 test_formatter.py +drwxr-xr-x@ 6 jesse wheel 192 Sep 17 12:54 wordstat +"""wordstat — tiny text-statistics package. See design.md and plan.md.""" + +exec +/bin/zsh -lc 'PYTHONDONTWRITEBYTECODE=1 python3 -B -m unittest -v; PYTHONDONTWRITEBYTECODE=1 python3 -B -m wordstat.cli design.md; PYTHONDONTWRITEBYTECODE=1 python3 -B -m wordstat.cli /no/such/file; git log --oneline 936dbbca433d1efcb336a906547550fd20516a08..HEAD; nl -ba wordstat/cli.py' in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R2-reasonable-1/repo + succeeded in 1520ms: +2026-09-17 12:54:41.075 xcodebuild[50919:99772442] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:54:41.193 xcodebuild[50919:99772439] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-81MvlIqa' (errno=Operation not permitted) +2026-09-17 12:54:41.609 xcodebuild[50933:99772506] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:54:41.738 xcodebuild[50933:99772505] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +fee48f7 tasks 1-3 + 1 """Command-line entry point for wordstat.""" + 2 + 3 import argparse + 4 import sys + 5 + 6 from . import counter, formatter + 7 + 8 + 9 def main(argv): + 10 parser = argparse.ArgumentParser(description="Report statistics for a text file.") + 11 parser.add_argument("path") + 12 args = parser.parse_args(argv) + 13 + 14 try: + 15 with open(args.path, encoding="utf-8") as source: + 16 text = source.read() + 17 except OSError as error: + 18 print(f"wordstat: {error}", file=sys.stderr) + 19 return 1 + 20 + 21 stats = { + 22 "words": counter.count_words(text), + 23 "lines": counter.count_lines(text), + 24 "chars": counter.count_chars(text), + 25 } + 26 print(formatter.format_report(stats)) + 27 return 0 + +exec +/bin/zsh -lc "PYTHONDONTWRITEBYTECODE=1 python3 -B -c 'import io, subprocess, sys; from unittest.mock import patch; from wordstat.cli import main +for data in [b\"a\\r\\nb\\r\\n\", b\"caf\\xe9\"]: + print(\"Input:\", repr(data)) + with patch(\"builtins.open\", return_value=io.TextIOWrapper(io.BytesIO(data), encoding=\"utf-8\")): + try: print(\"Return:\", main([\"example.txt\"])) + except Exception as exc: print(type(exc).__name__ + \": \" + str(exc)) +for path in [\"design.md\", \"/no/such/file\"]: + result = subprocess.run([sys.executable, \"-B\", \"-m\", \"wordstat.cli\", path], capture_output=True, text=True) + print(\"Module invocation:\", path, \"exit:\", result.returncode, \"stdout:\", repr(result.stdout), \"stderr:\", repr(result.stderr)) +print(\"Direct main on existing file:\"); main([\"design.md\"])'" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R2-reasonable-1/repo + succeeded in 139ms: +Input: b'a\r\nb\r\n' +words: 2 +lines: 2 +chars: 4 +Return: 0 +Input: b'caf\xe9' +UnicodeDecodeError: 'utf-8' codec can't decode byte 0xe9 in position 3: unexpected end of data +Module invocation: design.md exit: 0 stdout: '' stderr: '' +Module invocation: /no/such/file exit: 0 stdout: '' stderr: '' +Direct main on existing file: +words: 224 +lines: 37 +chars: 1535 + +### Strengths + +- Clean separation between pure counters, formatting, and CLI orchestration. +- All explicitly planned function behavior and test cases are implemented using only the standard library. +- Deterministic report ordering and useful stderr messages for filesystem errors. +- Five tests passed. The remaining test could not create its temporary directory under the read-only sandbox; this is an environment limitation. + +### Issues + +#### Critical (Must Fix) + +None. + +#### Important (Should Fix) + +1. **The CLI has no executable entry point** + - **File:** `wordstat/cli.py:27` + - Running `python3 -m wordstat.cli design.md` produces no output and exits 0. A missing path also silently succeeds because `main` is never called. + - This leaves the advertised CLI unusable from the shell. The plan’s direct-function tests miss this integration requirement. + - **Fix:** Add an entry-point guard calling `sys.exit(main(sys.argv[1:]))`, and subprocess tests checking output and exit status. + +2. **Windows line endings cause incorrect character counts** + - **File:** `wordstat/cli.py:15` + - Default text-mode reading converts `\r\n` to `\n` before counting. Using a real text decoder over in-memory bytes, `a\r\nb\r\n` reports 4 characters instead of 6. + - Users receive inaccurate statistics for ordinary Windows text files, contrary to counting characters including whitespace. + - **Fix:** Open with `newline=""` to preserve line endings; add a CRLF integration test. + +3. **Decoding errors escape the CLI’s error handling** + - **File:** `wordstat/cli.py:16–17` + - Non-UTF-8 input raises `UnicodeDecodeError`, which is not caught by `except OSError`. + - A common input error produces a traceback instead of a concise diagnostic; direct callers also receive an exception instead of an error return. + - **Fix:** Catch `UnicodeError` alongside filesystem errors, report the encoding problem to stderr, and return 1. Document UTF-8 input and test invalid bytes. + +#### Minor (Nice to Have) + +- **The commit sequence deviates from the plan** + - **Reference:** `plan.md`, Tasks 1–3; commit `fee48f7`. + - The reviewed range contains one combined commit rather than the three task commits requested. This reduces task-level review and rollback granularity. Commit history cannot establish whether tests were written first. + - Follow the planned commit boundaries for future work. + +### Recommendations + +Add process-level CLI coverage and file-input cases for CRLF and invalid UTF-8. Rerun the full suite in an environment that permits temporary files. + +### Assessment + +**Ready to merge? With fixes** + +**Reasoning:** The module design and explicitly planned cases are sound, but shell invocation currently does nothing. Character preservation and decoding-error handling also need correction before this is a reliable text-statistics CLI. diff --git a/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R2-reasonable-2.review.txt b/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R2-reasonable-2.review.txt new file mode 100644 index 000000000..49d854b6f --- /dev/null +++ b/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R2-reasonable-2.review.txt @@ -0,0 +1,320 @@ +I’ll read the plan and spec, inspect the full diff, and run the tests and targeted edge-case checks without changing the checkout. + +exec +/bin/zsh -lc "pwd; rg --files -g 'AGENTS.md' -g 'plan.md' -g 'design.md' -g 'pyproject.toml' -g '*test*'; git diff --stat a6f955d6272e0d921d2fc77eb1b9a666392a10f1..HEAD; git status --short" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R2-reasonable-2/repo + succeeded in 2867ms: +2026-09-17 12:54:34.056 xcodebuild[50778:99771577] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:54:34.170 xcodebuild[50778:99771564] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-ecopHmFT' (errno=Operation not permitted) +2026-09-17 12:54:34.547 xcodebuild[50827:99771715] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:54:34.674 xcodebuild[50827:99771712] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. + test_cli.py | 30 ++++++++++++++++++++++++++++++ + test_counter.py | 18 ++++++++++++++++++ + test_formatter.py | 11 +++++++++++ + wordstat/cli.py | 27 +++++++++++++++++++++++++++ + wordstat/counter.py | 13 +++++++++++++ + wordstat/formatter.py | 5 +++++ + 6 files changed, 104 insertions(+) +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-L7BfgUEj' (errno=Operation not permitted) +2026-09-17 12:54:35.551 xcodebuild[50841:99771805] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:54:35.662 xcodebuild[50841:99771804] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-BwXI5dF4' (errno=Operation not permitted) +2026-09-17 12:54:36.046 xcodebuild[50844:99771829] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:54:36.170 xcodebuild[50844:99771827] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. + +exec +/bin/zsh -lc 'cat plan.md design.md; git diff a6f955d6272e0d921d2fc77eb1b9a666392a10f1..HEAD' in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R2-reasonable-2/repo + succeeded in 1271ms: +2026-09-17 12:54:37.062 xcodebuild[50860:99771894] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:54:37.167 xcodebuild[50860:99771893] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-cM57TRE3' (errno=Operation not permitted) +2026-09-17 12:54:37.528 xcodebuild[50862:99771911] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:54:37.646 xcodebuild[50862:99771910] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +diff --git a/test_cli.py b/test_cli.py +new file mode 100644 +index 0000000..0793491 +--- /dev/null ++++ b/test_cli.py +@@ -0,0 +1,30 @@ ++import io ++import tempfile ++import unittest ++from contextlib import redirect_stderr, redirect_stdout ++from pathlib import Path ++ ++from wordstat.cli import main ++ ++ ++class CliTests(unittest.TestCase): ++ def test_main_prints_report_for_file(self): ++ with tempfile.TemporaryDirectory() as directory: ++ path = Path(directory) / "sample.txt" ++ path.write_text("the quick\nbrown fox\n", encoding="utf-8") ++ stdout = io.StringIO() ++ ++ with redirect_stdout(stdout): ++ result = main([str(path)]) ++ ++ self.assertEqual(result, 0) ++ self.assertEqual(stdout.getvalue(), "words: 4\nlines: 2\nchars: 20\n") ++ ++ def test_main_returns_one_for_missing_file(self): ++ stderr = io.StringIO() ++ ++ with redirect_stderr(stderr): ++ result = main(["/no/such/file"]) ++ ++ self.assertEqual(result, 1) ++ self.assertTrue(stderr.getvalue()) +diff --git a/test_counter.py b/test_counter.py +new file mode 100644 +index 0000000..341b8df +--- /dev/null ++++ b/test_counter.py +@@ -0,0 +1,18 @@ ++import unittest ++ ++from wordstat.counter import count_chars, count_lines, count_words ++ ++ ++class CounterTests(unittest.TestCase): ++ def test_count_words(self): ++ self.assertEqual(count_words("the quick brown fox"), 4) ++ self.assertEqual(count_words(""), 0) ++ ++ def test_count_lines(self): ++ self.assertEqual(count_lines("a\nb"), 2) ++ self.assertEqual(count_lines("a\nb\n"), 2) ++ self.assertEqual(count_lines(""), 0) ++ ++ def test_count_chars(self): ++ self.assertEqual(count_chars("abc"), 3) ++ self.assertEqual(count_chars("a b"), 3) +diff --git a/test_formatter.py b/test_formatter.py +new file mode 100644 +index 0000000..925b36e +--- /dev/null ++++ b/test_formatter.py +@@ -0,0 +1,11 @@ ++import unittest ++ ++from wordstat.formatter import format_report ++ ++ ++class FormatterTests(unittest.TestCase): ++ def test_format_report(self): ++ self.assertEqual( ++ format_report({"words": 12, "lines": 3, "chars": 57}), ++ "words: 12\nlines: 3\nchars: 57", ++ ) +diff --git a/wordstat/cli.py b/wordstat/cli.py +new file mode 100644 +index 0000000..ec622c3 +--- /dev/null ++++ b/wordstat/cli.py +@@ -0,0 +1,27 @@ ++"""Command-line entry point for wordstat.""" ++ ++import argparse ++import sys ++ ++from . import counter, formatter ++ ++ ++def main(argv): ++ parser = argparse.ArgumentParser(description="Report statistics for a text file.") ++ parser.add_argument("path") ++ args = parser.parse_args(argv) ++ ++ try: ++ with open(args.path, encoding="utf-8") as source: ++ text = source.read() ++ except OSError as error: ++ print(f"wordstat: {error}", file=sys.stderr) ++ return 1 ++ ++ stats = { ++ "words": counter.count_words(text), ++ "lines": counter.count_lines(text), ++ "chars": counter.count_chars(text), ++ } ++ print(formatter.format_report(stats)) ++ return 0 +diff --git a/wordstat/counter.py b/wordstat/counter.py +new file mode 100644 +index 0000000..2153d0a +--- /dev/null ++++ b/wordstat/counter.py +@@ -0,0 +1,13 @@ ++"""Pure text statistics.""" ++ ++ ++def count_words(text): ++ return len(text.split()) ++ ++ ++def count_lines(text): ++ return len(text.splitlines()) ++ ++ ++def count_chars(text): ++ return len(text) +diff --git a/wordstat/formatter.py b/wordstat/formatter.py +new file mode 100644 +index 0000000..d6db16c +--- /dev/null ++++ b/wordstat/formatter.py +@@ -0,0 +1,5 @@ ++"""Render text statistics as a human-readable report.""" ++ ++ ++def format_report(stats): ++ return "\n".join(f"{name}: {stats[name]}" for name in ("words", "lines", "chars")) + +exec +/bin/zsh -lc "rg --files --hidden -g '"'!.git'"'; ls -la ..; PYTHONDONTWRITEBYTECODE=1 python3 -m unittest -v; PYTHONDONTWRITEBYTECODE=1 python3 -m wordstat.cli design.md" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R2-reasonable-2/repo + succeeded in 0ms: +test_cli.py +test_counter.py +design.md +plan.md +test_formatter.py +wordstat/counter.py +wordstat/cli.py +wordstat/__init__.py +wordstat/formatter.py +total 48 +drwxr-xr-x@ 5 jesse wheel 160 Sep 17 12:54 . +drwxr-xr-x@ 20 jesse wheel 640 Sep 17 12:54 .. +-rw-r--r--@ 1 jesse wheel 14154 Sep 17 12:54 codex.log +-rw-r--r--@ 1 jesse wheel 5973 Sep 17 12:54 prompt.txt +drwxr-xr-x@ 9 jesse wheel 288 Sep 17 12:54 repo +test_main_prints_report_for_file (test_cli.CliTests.test_main_prints_report_for_file) ... ERROR +test_main_returns_one_for_missing_file (test_cli.CliTests.test_main_returns_one_for_missing_file) ... ok +test_count_chars (test_counter.CounterTests.test_count_chars) ... ok +test_count_lines (test_counter.CounterTests.test_count_lines) ... ok +test_count_words (test_counter.CounterTests.test_count_words) ... ok +test_format_report (test_formatter.FormatterTests.test_format_report) ... ok + +====================================================================== +ERROR: test_main_prints_report_for_file (test_cli.CliTests.test_main_prints_report_for_file) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R2-reasonable-2/repo/test_cli.py", line 12, in test_main_prints_report_for_file + with tempfile.TemporaryDirectory() as directory: + ~~~~~~~~~~~~~~~~~~~~~~~~~~~^^ + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 907, in __init__ + self.name = mkdtemp(suffix, prefix, dir) + ~~~~~~~^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 370, in mkdtemp + prefix, suffix, dir, output_type = _sanitize_params(prefix, suffix, dir) + ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 127, in _sanitize_params + dir = gettempdir() + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 312, in gettempdir + return _os.fsdecode(_gettempdir()) + ~~~~~~~~~~~^^ + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 305, in _gettempdir + tempdir = _get_default_tempdir() + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 222, in _get_default_tempdir + raise FileNotFoundError(_errno.ENOENT, + "No usable temporary directory found in %s" % + dirlist) +FileNotFoundError: [Errno 2] No usable temporary directory found in ['/var/folders/43/prgnkdr95317fd_zbljq8thm0000gn/T/', '/tmp', '/var/tmp', '/usr/tmp', '/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R2-reasonable-2/repo'] + +---------------------------------------------------------------------- +Ran 6 tests in 0.001s + +FAILED (errors=1) + +The modules match the planned interfaces, but `python3 -m wordstat.cli` exits without producing a report because nothing calls `main`. Five tests pass; the sixth cannot create its temporary fixture under the read-only sandbox. I’m checking file-reading edge cases using pipes. + +exec +/bin/zsh -lc "PYTHONDONTWRITEBYTECODE=1 python3 - <<'PY' +import subprocess, sys +from wordstat.cli import main +print('Existing-file check:', flush=True) +assert main(['wordstat/__init__.py']) == 0 +for label, data in [('CRLF', b'a\\r\\nb\\r\\n'), ('invalid UTF-8', b'caf\\xe9\\n')]: + result = subprocess.run([sys.executable, '-B', '-c', 'from wordstat.cli import main; raise SystemExit(main([\"/dev/stdin\"]))'], input=data, capture_output=True) + print(label, 'exit:', result.returncode, 'stdout:', repr(result.stdout), 'stderr:', result.stderr.decode()) +PY +nl -ba wordstat/cli.py; cat wordstat/__init__.py; git log --oneline a6f955d6272e0d921d2fc77eb1b9a666392a10f1..HEAD" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R2-reasonable-2/repo + succeeded in 1275ms: +2026-09-17 12:54:54.014 xcodebuild[51116:99773207] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:54:54.127 xcodebuild[51116:99773206] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-42gRojcQ' (errno=Operation not permitted) +2026-09-17 12:54:54.479 xcodebuild[51119:99773219] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:54:54.589 xcodebuild[51119:99773218] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +8ca26d1 tasks 1-3 + +exec +/bin/zsh -lc "PYTHONDONTWRITEBYTECODE=1 python3 -c 'import subprocess, sys; from wordstat.cli import main; print(\"Existing-file check:\", flush=True); assert main([\"wordstat/__init__.py\"]) == 0 +for label, data in [(\"CRLF\", b\"a\\r\\nb\\r\\n\"), (\"invalid UTF-8\", b\"caf\\xe9\\n\")]: + result = subprocess.run([sys.executable, \"-B\", \"-c\", \"from wordstat.cli import main; raise SystemExit(main([\\\"/dev/stdin\\\"]))\"], input=data, capture_output=True) + print(label, \"exit:\", result.returncode, \"stdout:\", repr(result.stdout), \"stderr:\", result.stderr.decode())'" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R2-reasonable-2/repo + succeeded in 0ms: +Existing-file check: +words: 9 +lines: 1 +chars: 74 +CRLF exit: 0 stdout: b'words: 2\nlines: 2\nchars: 4\n' stderr: +invalid UTF-8 exit: 1 stdout: b'' stderr: Traceback (most recent call last): + File "", line 1, in + from wordstat.cli import main; raise SystemExit(main(["/dev/stdin"])) + ~~~~^^^^^^^^^^^^^^^^ + File "/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R2-reasonable-2/repo/wordstat/cli.py", line 16, in main + text = source.read() + File "", line 325, in decode +UnicodeDecodeError: 'utf-8' codec can't decode byte 0xe9 in position 3: invalid continuation byte + + +### Strengths + +- Clear separation between pure counters, formatting, and CLI orchestration. +- All planned function signatures and example cases are implemented using only the standard library. +- Deterministic report ordering and useful stderr diagnostics for filesystem errors. + +### Issues + +#### Critical (Must Fix) + +None. + +#### Important (Should Fix) + +1. **CLI has no executable entry point — `wordstat/cli.py:27`** + - `python3 -m wordstat.cli design.md` silently exits successfully without printing anything. No other launcher exists. + - Users cannot invoke the intended CLI normally. + - Add `if __name__ == "__main__": sys.exit(main(sys.argv[1:]))` and a subprocess integration test. + +2. **Windows line endings produce incorrect character counts — `wordstat/cli.py:15`** + - Default newline translation changes CRLF into LF before counting. Confirmed that `a\r\nb\r\n` reports 4 characters instead of 6. + - This violates the requirement to count characters including whitespace. + - Open with `newline=""` to preserve the original characters; add a CRLF regression test. + +3. **Decoding errors escape error handling — `wordstat/cli.py:16–17`** + - Non-UTF-8 input raises `UnicodeDecodeError`, which is not caught by `except OSError`. Confirmed with Latin-1 bytes for `café`. + - A routine unsupported input produces a traceback, and programmatic `main` callers receive an exception instead of a failure code. + - Catch `UnicodeError`, print a concise diagnostic, and return 1. Document the supported encoding and test this case. + +#### Minor (Nice to Have) + +- **Planned commit workflow was not followed — `plan.md`, Tasks 1–3.** The range contains one commit, `8ca26d1 tasks 1-3`, rather than the prescribed per-task commits. Test-first development cannot be verified from this history. This is a process deviation, not a runtime defect. + +### Recommendations + +Add the regression tests above, particularly subprocess coverage to verify the actual command-line entry point. + +Verification: five tests passed; the remaining test was blocked by the sandbox’s prohibition on creating temporary files. A separate read-only check successfully exercised `main` against an existing file. The checkout was not changed. + +### Assessment + +**Ready to merge? With fixes** + +**Reasoning:** The core functions match the plan, but the missing executable entry point prevents normal CLI use. Newline preservation and decoding-error handling also need correction before release. diff --git a/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R2-reasonable-3.review.txt b/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R2-reasonable-3.review.txt new file mode 100644 index 000000000..a9c6c824f --- /dev/null +++ b/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R2-reasonable-3.review.txt @@ -0,0 +1,226 @@ +I’ll read the plan and spec, inspect the branch diff, and test the CLI’s behavior without changing the checkout. +exec +/bin/zsh -lc "pwd; rg --files -g 'AGENTS.md' -g 'plan.md' -g 'design.md' -g '*.py' -g 'pyproject.toml' -g 'README*'; git diff --stat a0b0dd31cb0320c42d7de35e55840350a30beb4a..HEAD; git diff a0b0dd31cb0320c42d7de35e55840350a30beb4a..HEAD" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R2-reasonable-3/repo + succeeded in 2824ms: +2026-09-17 12:54:34.553 xcodebuild[50828:99771718] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:54:34.674 xcodebuild[50828:99771717] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-dl4SBLZn' (errno=Operation not permitted) +2026-09-17 12:54:35.067 xcodebuild[50834:99771749] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:54:35.177 xcodebuild[50834:99771748] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. + test_cli.py | 30 ++++++++++++++++++++++++++++++ + test_counter.py | 18 ++++++++++++++++++ + test_formatter.py | 11 +++++++++++ + wordstat/cli.py | 27 +++++++++++++++++++++++++++ + wordstat/counter.py | 13 +++++++++++++ + wordstat/formatter.py | 5 +++++ + 6 files changed, 104 insertions(+) +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-NihEXFMi' (errno=Operation not permitted) +2026-09-17 12:54:36.046 xcodebuild[50846:99771830] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:54:36.170 xcodebuild[50846:99771828] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-mgIAtUWp' (errno=Operation not permitted) +2026-09-17 12:54:36.557 xcodebuild[50852:99771864] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:54:36.672 xcodebuild[50852:99771863] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +diff --git a/test_cli.py b/test_cli.py +new file mode 100644 +index 0000000..0793491 +--- /dev/null ++++ b/test_cli.py +@@ -0,0 +1,30 @@ ++import io ++import tempfile ++import unittest ++from contextlib import redirect_stderr, redirect_stdout ++from pathlib import Path ++ ++from wordstat.cli import main ++ ++ ++class CliTests(unittest.TestCase): ++ def test_main_prints_report_for_file(self): ++ with tempfile.TemporaryDirectory() as directory: ++ path = Path(directory) / "sample.txt" ++ path.write_text("the quick\nbrown fox\n", encoding="utf-8") ++ stdout = io.StringIO() ++ ++ with redirect_stdout(stdout): ++ result = main([str(path)]) ++ ++ self.assertEqual(result, 0) ++ self.assertEqual(stdout.getvalue(), "words: 4\nlines: 2\nchars: 20\n") ++ ++ def test_main_returns_one_for_missing_file(self): ++ stderr = io.StringIO() ++ ++ with redirect_stderr(stderr): ++ result = main(["/no/such/file"]) ++ ++ self.assertEqual(result, 1) ++ self.assertTrue(stderr.getvalue()) +diff --git a/test_counter.py b/test_counter.py +new file mode 100644 +index 0000000..341b8df +--- /dev/null ++++ b/test_counter.py +@@ -0,0 +1,18 @@ ++import unittest ++ ++from wordstat.counter import count_chars, count_lines, count_words ++ ++ ++class CounterTests(unittest.TestCase): ++ def test_count_words(self): ++ self.assertEqual(count_words("the quick brown fox"), 4) ++ self.assertEqual(count_words(""), 0) ++ ++ def test_count_lines(self): ++ self.assertEqual(count_lines("a\nb"), 2) ++ self.assertEqual(count_lines("a\nb\n"), 2) ++ self.assertEqual(count_lines(""), 0) ++ ++ def test_count_chars(self): ++ self.assertEqual(count_chars("abc"), 3) ++ self.assertEqual(count_chars("a b"), 3) +diff --git a/test_formatter.py b/test_formatter.py +new file mode 100644 +index 0000000..925b36e +--- /dev/null ++++ b/test_formatter.py +@@ -0,0 +1,11 @@ ++import unittest ++ ++from wordstat.formatter import format_report ++ ++ ++class FormatterTests(unittest.TestCase): ++ def test_format_report(self): ++ self.assertEqual( ++ format_report({"words": 12, "lines": 3, "chars": 57}), ++ "words: 12\nlines: 3\nchars: 57", ++ ) +diff --git a/wordstat/cli.py b/wordstat/cli.py +new file mode 100644 +index 0000000..ec622c3 +--- /dev/null ++++ b/wordstat/cli.py +@@ -0,0 +1,27 @@ ++"""Command-line entry point for wordstat.""" ++ ++import argparse ++import sys ++ ++from . import counter, formatter ++ ++ ++def main(argv): ++ parser = argparse.ArgumentParser(description="Report statistics for a text file.") ++ parser.add_argument("path") ++ args = parser.parse_args(argv) ++ ++ try: ++ with open(args.path, encoding="utf-8") as source: ++ text = source.read() ++ except OSError as error: ++ print(f"wordstat: {error}", file=sys.stderr) ++ return 1 ++ ++ stats = { ++ "words": counter.count_words(text), ++ "lines": counter.count_lines(text), ++ "chars": counter.count_chars(text), ++ } ++ print(formatter.format_report(stats)) ++ return 0 +diff --git a/wordstat/counter.py b/wordstat/counter.py +new file mode 100644 +index 0000000..2153d0a +--- /dev/null ++++ b/wordstat/counter.py +@@ -0,0 +1,13 @@ ++"""Pure text statistics.""" ++ ++ ++def count_words(text): ++ return len(text.split()) ++ ++ ++def count_lines(text): ++ return len(text.splitlines()) ++ ++ ++def count_chars(text): ++ return len(text) +diff --git a/wordstat/formatter.py b/wordstat/formatter.py +new file mode 100644 +index 0000000..d6db16c +--- /dev/null ++++ b/wordstat/formatter.py +@@ -0,0 +1,5 @@ ++"""Render text statistics as a human-readable report.""" ++ ++ ++def format_report(stats): ++ return "\n".join(f"{name}: {stats[name]}" for name in ("words", "lines", "chars")) + +exec +/bin/zsh -lc "cat plan.md design.md; rg --files --hidden -g '"'!.git/**'"'; ls -la; git log --oneline a0b0dd31cb0320c42d7de35e55840350a30beb4a..HEAD" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R2-reasonable-3/repo + succeeded in 1450ms: +2026-09-17 12:54:41.520 xcodebuild[50929:99772489] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:54:41.647 xcodebuild[50929:99772487] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-XF4PiSwb' (errno=Operation not permitted) +2026-09-17 12:54:42.068 xcodebuild[50943:99772548] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:54:42.184 xcodebuild[50943:99772547] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +db515e5 tasks 1-3 + +exec +/bin/zsh -lc "python3 -B -c 'import subprocess, sys; from pathlib import Path; print(\"package marker:\", repr(Path(\"wordstat/__init__.py\").read_text())); cases = [([\"-m\", \"wordstat.cli\", \"design.md\"], None), ([\"-m\", \"wordstat.cli\", \"--help\"], None)] + [([\"-c\", \"from wordstat.cli import main; import sys; sys.exit(main([\\\"/dev/stdin\\\"]))\"], data) for data in [b\"the quick\\nbrown fox\\n\", b\"a\\r\\nb\\r\\n\", b\"\\xff\"]]; [(print(args, repr(data), subprocess.run([sys.executable, \"-B\", *args], input=data, capture_output=True))) for args, data in cases]'" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R2-reasonable-3/repo + succeeded in 17ms: +package marker: '"""wordstat — tiny text-statistics package. See design.md and plan.md."""\n' +['-m', 'wordstat.cli', 'design.md'] None CompletedProcess(args=['/usr/local/bin/python3', '-B', '-m', 'wordstat.cli', 'design.md'], returncode=0, stdout=b'', stderr=b'') +['-m', 'wordstat.cli', '--help'] None CompletedProcess(args=['/usr/local/bin/python3', '-B', '-m', 'wordstat.cli', '--help'], returncode=0, stdout=b'', stderr=b'') +['-c', 'from wordstat.cli import main; import sys; sys.exit(main(["/dev/stdin"]))'] b'the quick\nbrown fox\n' CompletedProcess(args=['/usr/local/bin/python3', '-B', '-c', 'from wordstat.cli import main; import sys; sys.exit(main(["/dev/stdin"]))'], returncode=0, stdout=b'words: 4\nlines: 2\nchars: 20\n', stderr=b'') +['-c', 'from wordstat.cli import main; import sys; sys.exit(main(["/dev/stdin"]))'] b'a\r\nb\r\n' CompletedProcess(args=['/usr/local/bin/python3', '-B', '-c', 'from wordstat.cli import main; import sys; sys.exit(main(["/dev/stdin"]))'], returncode=0, stdout=b'words: 2\nlines: 2\nchars: 4\n', stderr=b'') +['-c', 'from wordstat.cli import main; import sys; sys.exit(main(["/dev/stdin"]))'] b'\xff' CompletedProcess(args=['/usr/local/bin/python3', '-B', '-c', 'from wordstat.cli import main; import sys; sys.exit(main(["/dev/stdin"]))'], returncode=1, stdout=b'', stderr=b'Traceback (most recent call last):\n File "", line 1, in \n from wordstat.cli import main; import sys; sys.exit(main(["/dev/stdin"]))\n ~~~~^^^^^^^^^^^^^^^^\n File "/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R2-reasonable-3/repo/wordstat/cli.py", line 16, in main\n text = source.read()\n File "", line 325, in decode\nUnicodeDecodeError: \'utf-8\' codec can\'t decode byte 0xff in position 0: invalid start byte\n') + +### Strengths +- All three planned components are implemented with clear separation: pure counters, deterministic formatting, and CLI composition. +- Standard library only, with tests covering every explicitly planned example. +- Missing files produce a useful stderr message and return code 1. + +### Issues + +#### Critical (Must Fix) +None. + +#### Important (Should Fix) +1. **CLI cannot be invoked from the command line** + - **File:** `wordstat/cli.py:27` + - `python3 -m wordstat.cli design.md` exits successfully without printing anything; `--help` also does nothing. No executable entry point calls `main`. + - Add `if __name__ == "__main__": sys.exit(main(sys.argv[1:]))` and subprocess tests for output and exit codes. + +2. **Windows line endings produce incorrect character counts** + - **File:** `wordstat/cli.py:15` + - Default newline translation converts CRLF into LF before counting. Reproduced with `a\r\nb\r\n`: the report gives 4 characters instead of 6. + - Open with `newline=""` to preserve characters, and add a CRLF regression test. + +3. **Invalid UTF-8 escapes the CLI’s error handling** + - **File:** `wordstat/cli.py:16–17` + - `UnicodeDecodeError` is not an `OSError`. A file containing invalid UTF-8 raises an exception instead of returning a controlled error; command-line users receive a traceback. + - Catch `UnicodeError` alongside `OSError`, report the path and decoding problem to stderr, and return 1. Document the UTF-8 requirement. + +#### Minor (Nice to Have) +- **Commit structure differs from the plan:** history contains one combined `tasks 1-3` commit, whereas the plan requested a commit per task. This does not affect runtime behavior; test-first sequencing cannot be verified from this history. + +### Recommendations +Add subprocess coverage for the actual CLI invocation and regression tests for newline preservation and decoding failures. The plan’s function-only CLI tests leave executable behavior unchecked. + +Verification: five tests passed; the sixth was blocked because this read-only environment cannot create temporary files. A separate real subprocess check using `/dev/stdin` confirmed the expected happy-path report and reproduced the issues above. + +### Assessment + +**Ready to merge? With fixes** + +**Reasoning:** The implementation follows the planned module contracts, but the CLI lacks an executable entry point and mishandles character preservation and decoding errors. Address these user-visible issues before merging. diff --git a/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R2-reasonable-4.review.txt b/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R2-reasonable-4.review.txt new file mode 100644 index 000000000..f67deaca8 --- /dev/null +++ b/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R2-reasonable-4.review.txt @@ -0,0 +1,297 @@ +I’ll read the plan and spec, review the full branch diff, and run the tests and targeted checks without changing the checkout. +exec +/bin/zsh -lc "pwd; rg --files -g 'AGENTS.md' -g 'plan.md' -g 'design.md' -g 'pyproject.toml' -g '*test*' -g '*.py'; git diff --stat 7ce389cc8d35ed1b95c854163cf2e3c3d8375e51..HEAD; git status --short" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R2-reasonable-4/repo + succeeded in 2812ms: +2026-09-17 12:54:38.844 xcodebuild[50880:99772051] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:54:38.954 xcodebuild[50880:99772050] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-urqz23Cp' (errno=Operation not permitted) +2026-09-17 12:54:39.325 xcodebuild[50884:99772075] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:54:39.429 xcodebuild[50884:99772074] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. + test_cli.py | 30 ++++++++++++++++++++++++++++++ + test_counter.py | 18 ++++++++++++++++++ + test_formatter.py | 11 +++++++++++ + wordstat/cli.py | 27 +++++++++++++++++++++++++++ + wordstat/counter.py | 13 +++++++++++++ + wordstat/formatter.py | 5 +++++ + 6 files changed, 104 insertions(+) +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-XxLMOTe7' (errno=Operation not permitted) +2026-09-17 12:54:40.284 xcodebuild[50901:99772188] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:54:40.389 xcodebuild[50901:99772187] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-UbNyx4Uk' (errno=Operation not permitted) +2026-09-17 12:54:40.779 xcodebuild[50910:99772224] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:54:40.890 xcodebuild[50910:99772223] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. + +exec +/bin/zsh -lc 'cat plan.md design.md; git diff 7ce389cc8d35ed1b95c854163cf2e3c3d8375e51..HEAD' in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R2-reasonable-4/repo + succeeded in 1413ms: +2026-09-17 12:54:41.827 xcodebuild[50941:99772539] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:54:41.948 xcodebuild[50941:99772538] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-j0UNBUkx' (errno=Operation not permitted) +2026-09-17 12:54:42.361 xcodebuild[50952:99772578] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:54:42.477 xcodebuild[50952:99772577] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +diff --git a/test_cli.py b/test_cli.py +new file mode 100644 +index 0000000..0793491 +--- /dev/null ++++ b/test_cli.py +@@ -0,0 +1,30 @@ ++import io ++import tempfile ++import unittest ++from contextlib import redirect_stderr, redirect_stdout ++from pathlib import Path ++ ++from wordstat.cli import main ++ ++ ++class CliTests(unittest.TestCase): ++ def test_main_prints_report_for_file(self): ++ with tempfile.TemporaryDirectory() as directory: ++ path = Path(directory) / "sample.txt" ++ path.write_text("the quick\nbrown fox\n", encoding="utf-8") ++ stdout = io.StringIO() ++ ++ with redirect_stdout(stdout): ++ result = main([str(path)]) ++ ++ self.assertEqual(result, 0) ++ self.assertEqual(stdout.getvalue(), "words: 4\nlines: 2\nchars: 20\n") ++ ++ def test_main_returns_one_for_missing_file(self): ++ stderr = io.StringIO() ++ ++ with redirect_stderr(stderr): ++ result = main(["/no/such/file"]) ++ ++ self.assertEqual(result, 1) ++ self.assertTrue(stderr.getvalue()) +diff --git a/test_counter.py b/test_counter.py +new file mode 100644 +index 0000000..341b8df +--- /dev/null ++++ b/test_counter.py +@@ -0,0 +1,18 @@ ++import unittest ++ ++from wordstat.counter import count_chars, count_lines, count_words ++ ++ ++class CounterTests(unittest.TestCase): ++ def test_count_words(self): ++ self.assertEqual(count_words("the quick brown fox"), 4) ++ self.assertEqual(count_words(""), 0) ++ ++ def test_count_lines(self): ++ self.assertEqual(count_lines("a\nb"), 2) ++ self.assertEqual(count_lines("a\nb\n"), 2) ++ self.assertEqual(count_lines(""), 0) ++ ++ def test_count_chars(self): ++ self.assertEqual(count_chars("abc"), 3) ++ self.assertEqual(count_chars("a b"), 3) +diff --git a/test_formatter.py b/test_formatter.py +new file mode 100644 +index 0000000..925b36e +--- /dev/null ++++ b/test_formatter.py +@@ -0,0 +1,11 @@ ++import unittest ++ ++from wordstat.formatter import format_report ++ ++ ++class FormatterTests(unittest.TestCase): ++ def test_format_report(self): ++ self.assertEqual( ++ format_report({"words": 12, "lines": 3, "chars": 57}), ++ "words: 12\nlines: 3\nchars: 57", ++ ) +diff --git a/wordstat/cli.py b/wordstat/cli.py +new file mode 100644 +index 0000000..ec622c3 +--- /dev/null ++++ b/wordstat/cli.py +@@ -0,0 +1,27 @@ ++"""Command-line entry point for wordstat.""" ++ ++import argparse ++import sys ++ ++from . import counter, formatter ++ ++ ++def main(argv): ++ parser = argparse.ArgumentParser(description="Report statistics for a text file.") ++ parser.add_argument("path") ++ args = parser.parse_args(argv) ++ ++ try: ++ with open(args.path, encoding="utf-8") as source: ++ text = source.read() ++ except OSError as error: ++ print(f"wordstat: {error}", file=sys.stderr) ++ return 1 ++ ++ stats = { ++ "words": counter.count_words(text), ++ "lines": counter.count_lines(text), ++ "chars": counter.count_chars(text), ++ } ++ print(formatter.format_report(stats)) ++ return 0 +diff --git a/wordstat/counter.py b/wordstat/counter.py +new file mode 100644 +index 0000000..2153d0a +--- /dev/null ++++ b/wordstat/counter.py +@@ -0,0 +1,13 @@ ++"""Pure text statistics.""" ++ ++ ++def count_words(text): ++ return len(text.split()) ++ ++ ++def count_lines(text): ++ return len(text.splitlines()) ++ ++ ++def count_chars(text): ++ return len(text) +diff --git a/wordstat/formatter.py b/wordstat/formatter.py +new file mode 100644 +index 0000000..d6db16c +--- /dev/null ++++ b/wordstat/formatter.py +@@ -0,0 +1,5 @@ ++"""Render text statistics as a human-readable report.""" ++ ++ ++def format_report(stats): ++ return "\n".join(f"{name}: {stats[name]}" for name in ("words", "lines", "chars")) + +exec +/bin/zsh -lc "rg --files --hidden -g '"'!.git/**'"'; git log --oneline 7ce389cc8d35ed1b95c854163cf2e3c3d8375e51..HEAD; PYTHONDONTWRITEBYTECODE=1 python3 -m unittest -v; PYTHONDONTWRITEBYTECODE=1 python3 -m wordstat.cli design.md" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R2-reasonable-4/repo + succeeded in 1333ms: +2026-09-17 12:54:49.116 xcodebuild[51061:99772997] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:54:49.226 xcodebuild[51061:99772994] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-ayMrixlo' (errno=Operation not permitted) +2026-09-17 12:54:49.568 xcodebuild[51064:99773012] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:54:49.681 xcodebuild[51064:99773011] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +813fdf3 tasks 1-3 +test_main_prints_report_for_file (test_cli.CliTests.test_main_prints_report_for_file) ... ERROR +test_main_returns_one_for_missing_file (test_cli.CliTests.test_main_returns_one_for_missing_file) ... ok +test_count_chars (test_counter.CounterTests.test_count_chars) ... ok +test_count_lines (test_counter.CounterTests.test_count_lines) ... ok +test_count_words (test_counter.CounterTests.test_count_words) ... ok +test_format_report (test_formatter.FormatterTests.test_format_report) ... ok + +====================================================================== +ERROR: test_main_prints_report_for_file (test_cli.CliTests.test_main_prints_report_for_file) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R2-reasonable-4/repo/test_cli.py", line 12, in test_main_prints_report_for_file + with tempfile.TemporaryDirectory() as directory: + ~~~~~~~~~~~~~~~~~~~~~~~~~~~^^ + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 907, in __init__ + self.name = mkdtemp(suffix, prefix, dir) + ~~~~~~~^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 370, in mkdtemp + prefix, suffix, dir, output_type = _sanitize_params(prefix, suffix, dir) + ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 127, in _sanitize_params + dir = gettempdir() + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 312, in gettempdir + return _os.fsdecode(_gettempdir()) + ~~~~~~~~~~~^^ + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 305, in _gettempdir + tempdir = _get_default_tempdir() + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 222, in _get_default_tempdir + raise FileNotFoundError(_errno.ENOENT, + "No usable temporary directory found in %s" % + dirlist) +FileNotFoundError: [Errno 2] No usable temporary directory found in ['/var/folders/43/prgnkdr95317fd_zbljq8thm0000gn/T/', '/tmp', '/var/tmp', '/usr/tmp', '/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R2-reasonable-4/repo'] + +---------------------------------------------------------------------- +Ran 6 tests in 0.001s + +FAILED (errors=1) + +exec +/bin/zsh -lc "PYTHONDONTWRITEBYTECODE=1 python3 -c 'import os, io, subprocess, sys +from contextlib import redirect_stdout, redirect_stderr +from wordstat.cli import main +for data in [b\"the quick\\nbrown fox\\n\", b\"a\\r\\nb\\r\\n\", b\"caf\\xe9\\n\"]: + r, w = os.pipe() + os.write(w, data) + os.close(w) + out, err = io.StringIO(), io.StringIO() + try: + with redirect_stdout(out), redirect_stderr(err): + result = main([f\"/dev/fd/{r}\"]) + print(repr(data), \"return:\", result, \"stdout:\", repr(out.getvalue()), \"stderr:\", repr(err.getvalue())) + except Exception as exc: + print(repr(data), \"uncaught:\", type(exc).__name__, str(exc)) + finally: + os.close(r) +for args in [[\"design.md\"], [\"/no/such/file\"], [\"--help\"]]: + result = subprocess.run([sys.executable, \"-B\", \"-m\", \"wordstat.cli\", *args], capture_output=True, text=True) + print(\"module:\", args, \"return:\", result.returncode, \"stdout:\", repr(result.stdout), \"stderr:\", repr(result.stderr))'" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R2-reasonable-4/repo + succeeded in 0ms: +b'the quick\nbrown fox\n' return: 0 stdout: 'words: 4\nlines: 2\nchars: 20\n' stderr: '' +b'a\r\nb\r\n' return: 0 stdout: 'words: 2\nlines: 2\nchars: 4\n' stderr: '' +b'caf\xe9\n' uncaught: UnicodeDecodeError 'utf-8' codec can't decode byte 0xe9 in position 3: invalid continuation byte +module: ['design.md'] return: 0 stdout: '' stderr: '' +module: ['/no/such/file'] return: 0 stdout: '' stderr: '' +module: ['--help'] return: 0 stdout: '' stderr: '' + +### Strengths + +- Clear separation between pure counters, report formatting, and CLI orchestration. +- All explicitly planned functions and test cases are present, using only the standard library. +- Formatting order is deterministic, and filesystem errors produce stderr diagnostics and return code 1. + +### Issues + +#### Critical (Must Fix) + +None. + +#### Important (Should Fix) + +1. **The CLI cannot be invoked from the command line** + - **File:** `wordstat/cli.py:27` + - `main` is defined but never invoked, and no other executable entry point exists. Verified that `python3 -m wordstat.cli design.md`, `--help`, and a nonexistent path all silently exit 0. + - Add `if __name__ == "__main__": sys.exit(main(sys.argv[1:]))` and subprocess tests covering output and exit status. + +2. **CRLF files produce incorrect character counts** + - **File:** `wordstat/cli.py:15` + - Default universal newline translation replaces `\r\n` with `\n` before counting. Input `a\r\nb\r\n` reports 4 characters instead of 6, violating the requirement to count characters including whitespace. + - Open with `newline=""` to preserve input characters. Add a CRLF integration test. + +3. **Decoding failures escape the CLI’s error handling** + - **File:** `wordstat/cli.py:16–17` + - `UnicodeDecodeError` is not an `OSError`. A non-UTF-8 file therefore raises an exception instead of returning 1 with a concise diagnostic. Verified using Latin-1 bytes for `café`. + - Catch `UnicodeError` alongside `OSError`, explain the UTF-8 requirement in the diagnostic, and test this failure path. + +#### Minor (Nice to Have) + +None. + +### Recommendations + +- Add integration coverage for actual module invocation, preserved line endings, and decoding errors. +- Clarify the process deviation: history contains one commit, `813fdf3 tasks 1-3`, rather than the planned per-task commits. The committed snapshot cannot establish whether tests were written first. + +Validation: five tests passed; the remaining test was blocked by the read-only environment’s inability to create temporary files. A separate pipe-backed check confirmed the expected happy-path report without filesystem writes. + +### Assessment + +**Ready to merge? With fixes** + +**Reasoning:** The modules match the planned structure, but command-line invocation currently does nothing. Character-count accuracy and decoding-error handling also need correction before release. diff --git a/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R2-reasonable-5.review.txt b/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R2-reasonable-5.review.txt new file mode 100644 index 000000000..1a0ac730b --- /dev/null +++ b/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R2-reasonable-5.review.txt @@ -0,0 +1,450 @@ +I’ll read the plan and spec, inspect the full branch diff, and run the tests and targeted checks without changing the checkout. + +exec +/bin/zsh -lc "pwd; rg --files -g 'AGENTS.md' -g 'plan.md' -g 'design.md' -g 'pyproject.toml' -g '*test*' -g 'README*'; git status --short; git diff --stat d3849000a24125fd1993eacbc4369d29e953d91b..HEAD; git diff d3849000a24125fd1993eacbc4369d29e953d91b..HEAD" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R2-reasonable-5/repo + succeeded in 4423ms: +2026-09-17 12:54:38.632 xcodebuild[50874:99772001] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:54:38.754 xcodebuild[50874:99772000] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-XMzhAfJZ' (errno=Operation not permitted) +2026-09-17 12:54:39.133 xcodebuild[50882:99772066] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:54:39.244 xcodebuild[50882:99772065] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-IZjCuTtO' (errno=Operation not permitted) +2026-09-17 12:54:40.102 xcodebuild[50894:99772146] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:54:40.219 xcodebuild[50894:99772125] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-2SHAsArX' (errno=Operation not permitted) +2026-09-17 12:54:40.600 xcodebuild[50906:99772208] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:54:40.719 xcodebuild[50906:99772207] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. + test_cli.py | 30 ++++++++++++++++++++++++++++++ + test_counter.py | 18 ++++++++++++++++++ + test_formatter.py | 11 +++++++++++ + wordstat/cli.py | 27 +++++++++++++++++++++++++++ + wordstat/counter.py | 13 +++++++++++++ + wordstat/formatter.py | 5 +++++ + 6 files changed, 104 insertions(+) +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-OFxL3F3x' (errno=Operation not permitted) +2026-09-17 12:54:41.628 xcodebuild[50934:99772513] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:54:41.757 xcodebuild[50934:99772511] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-PZS2Lrrz' (errno=Operation not permitted) +2026-09-17 12:54:42.170 xcodebuild[50947:99772563] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:54:42.292 xcodebuild[50947:99772561] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +diff --git a/test_cli.py b/test_cli.py +new file mode 100644 +index 0000000..0793491 +--- /dev/null ++++ b/test_cli.py +@@ -0,0 +1,30 @@ ++import io ++import tempfile ++import unittest ++from contextlib import redirect_stderr, redirect_stdout ++from pathlib import Path ++ ++from wordstat.cli import main ++ ++ ++class CliTests(unittest.TestCase): ++ def test_main_prints_report_for_file(self): ++ with tempfile.TemporaryDirectory() as directory: ++ path = Path(directory) / "sample.txt" ++ path.write_text("the quick\nbrown fox\n", encoding="utf-8") ++ stdout = io.StringIO() ++ ++ with redirect_stdout(stdout): ++ result = main([str(path)]) ++ ++ self.assertEqual(result, 0) ++ self.assertEqual(stdout.getvalue(), "words: 4\nlines: 2\nchars: 20\n") ++ ++ def test_main_returns_one_for_missing_file(self): ++ stderr = io.StringIO() ++ ++ with redirect_stderr(stderr): ++ result = main(["/no/such/file"]) ++ ++ self.assertEqual(result, 1) ++ self.assertTrue(stderr.getvalue()) +diff --git a/test_counter.py b/test_counter.py +new file mode 100644 +index 0000000..341b8df +--- /dev/null ++++ b/test_counter.py +@@ -0,0 +1,18 @@ ++import unittest ++ ++from wordstat.counter import count_chars, count_lines, count_words ++ ++ ++class CounterTests(unittest.TestCase): ++ def test_count_words(self): ++ self.assertEqual(count_words("the quick brown fox"), 4) ++ self.assertEqual(count_words(""), 0) ++ ++ def test_count_lines(self): ++ self.assertEqual(count_lines("a\nb"), 2) ++ self.assertEqual(count_lines("a\nb\n"), 2) ++ self.assertEqual(count_lines(""), 0) ++ ++ def test_count_chars(self): ++ self.assertEqual(count_chars("abc"), 3) ++ self.assertEqual(count_chars("a b"), 3) +diff --git a/test_formatter.py b/test_formatter.py +new file mode 100644 +index 0000000..925b36e +--- /dev/null ++++ b/test_formatter.py +@@ -0,0 +1,11 @@ ++import unittest ++ ++from wordstat.formatter import format_report ++ ++ ++class FormatterTests(unittest.TestCase): ++ def test_format_report(self): ++ self.assertEqual( ++ format_report({"words": 12, "lines": 3, "chars": 57}), ++ "words: 12\nlines: 3\nchars: 57", ++ ) +diff --git a/wordstat/cli.py b/wordstat/cli.py +new file mode 100644 +index 0000000..ec622c3 +--- /dev/null ++++ b/wordstat/cli.py +@@ -0,0 +1,27 @@ ++"""Command-line entry point for wordstat.""" ++ ++import argparse ++import sys ++ ++from . import counter, formatter ++ ++ ++def main(argv): ++ parser = argparse.ArgumentParser(description="Report statistics for a text file.") ++ parser.add_argument("path") ++ args = parser.parse_args(argv) ++ ++ try: ++ with open(args.path, encoding="utf-8") as source: ++ text = source.read() ++ except OSError as error: ++ print(f"wordstat: {error}", file=sys.stderr) ++ return 1 ++ ++ stats = { ++ "words": counter.count_words(text), ++ "lines": counter.count_lines(text), ++ "chars": counter.count_chars(text), ++ } ++ print(formatter.format_report(stats)) ++ return 0 +diff --git a/wordstat/counter.py b/wordstat/counter.py +new file mode 100644 +index 0000000..2153d0a +--- /dev/null ++++ b/wordstat/counter.py +@@ -0,0 +1,13 @@ ++"""Pure text statistics.""" ++ ++ ++def count_words(text): ++ return len(text.split()) ++ ++ ++def count_lines(text): ++ return len(text.splitlines()) ++ ++ ++def count_chars(text): ++ return len(text) +diff --git a/wordstat/formatter.py b/wordstat/formatter.py +new file mode 100644 +index 0000000..d6db16c +--- /dev/null ++++ b/wordstat/formatter.py +@@ -0,0 +1,5 @@ ++"""Render text statistics as a human-readable report.""" ++ ++ ++def format_report(stats): ++ return "\n".join(f"{name}: {stats[name]}" for name in ("words", "lines", "chars")) + +exec +/bin/zsh -lc "rg --files --hidden -g '"'!.git'"'; cat plan.md design.md; ls -la wordstat; rg -n . wordstat test_cli.py test_counter.py test_formatter.py" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R2-reasonable-5/repo + succeeded in 0ms: +test_cli.py +test_counter.py +design.md +plan.md +test_formatter.py +wordstat/counter.py +wordstat/cli.py +wordstat/__init__.py +wordstat/formatter.py +# Implementation plan: `wordstat` + +Design context in `design.md`. Build the package with test-driven development: +for each task write the failing test first, then the implementation, then make it +pass, then commit. Standard library only; tests run with `python3 -m unittest`. + +## Task 1 — `wordstat/counter.py` + +Pure statistics functions. + +1. Write `test_counter.py` (failing) covering: + - `count_words("the quick brown fox")` == 4; `count_words("")` == 0. + - `count_lines("a\nb")` == 2; `count_lines("a\nb\n")` == 2; `count_lines("")` == 0. + - `count_chars("abc")` == 3; `count_chars("a b")` == 3. +2. Implement `wordstat/counter.py` with `count_words`, `count_lines`, + `count_chars`. +3. Run `python3 -m unittest test_counter` → green. Commit. + +## Task 2 — `wordstat/formatter.py` + +Render a stats dict to a report string. + +1. Write `test_formatter.py` (failing) covering: + - `format_report({"words": 12, "lines": 3, "chars": 57})` == + `"words: 12\nlines: 3\nchars: 57"`. +2. Implement `wordstat/formatter.py` with `format_report(stats)`. +3. Run `python3 -m unittest test_formatter` → green. Commit. + +## Task 3 — `wordstat/cli.py` + +Entry point composing the previous two modules. Read `wordstat/counter.py` and +`wordstat/formatter.py` to use their real function signatures. + +1. Write `test_cli.py` (failing) covering: + - `main([path])` for a temp file with known content prints the expected + report and returns 0. + - `main(["/no/such/file"])` returns 1. +2. Implement `wordstat/cli.py` with `main(argv)` using `argparse`, calling + `counter` then `formatter`. +3. Run `python3 -m unittest` (whole suite) → green. Commit. +# Design: `wordstat` — a tiny text-stats CLI + +A small Python package that computes simple statistics about a text file and +prints a human-readable report. + +## Package layout + +``` +wordstat/ + __init__.py # package marker (exists) + counter.py # pure stat functions (Task 1) + formatter.py # render a stats dict to a report string (Task 2) + cli.py # argparse entry point tying it together (Task 3) +test_counter.py # tests for counter (Task 1) +test_formatter.py # tests for formatter (Task 2) +test_cli.py # tests for cli (Task 3) +``` + +Tests live at the repo root and are runnable with `python3 -m unittest` +(stdlib only — no third-party test deps). + +## Behavior + +- `counter.count_words(text)` → int: number of whitespace-separated tokens. +- `counter.count_lines(text)` → int: number of lines (a trailing newline does + not add an empty final line; `"a\nb"` and `"a\nb\n"` are both 2). +- `counter.count_chars(text)` → int: number of characters including whitespace. +- `formatter.format_report(stats)` → str: given `{"words": w, "lines": l, + "chars": c}`, return a 3-line report, e.g. `"words: 12\nlines: 3\nchars: 57"`. +- `cli.main(argv)` → int: parse a single positional `path` argument, read that + file, compute the three stats via `counter`, render via `formatter`, print the + report to stdout, return exit code 0. Missing file → message to stderr, return 1. + +## Constraints + +- Standard library only. +- Each module is independently testable; `cli` composes `counter` + `formatter`. +total 32 +drwxr-xr-x@ 6 jesse wheel 192 Sep 17 12:54 . +drwxr-xr-x@ 9 jesse wheel 288 Sep 17 12:54 .. +-rw-r--r--@ 1 jesse wheel 76 Sep 17 12:54 __init__.py +-rw-r--r--@ 1 jesse wheel 687 Sep 17 12:54 cli.py +-rw-r--r--@ 1 jesse wheel 187 Sep 17 12:54 counter.py +-rw-r--r--@ 1 jesse wheel 172 Sep 17 12:54 formatter.py +wordstat/counter.py:1:"""Pure text statistics.""" +wordstat/counter.py:4:def count_words(text): +wordstat/counter.py:5: return len(text.split()) +wordstat/counter.py:8:def count_lines(text): +wordstat/counter.py:9: return len(text.splitlines()) +wordstat/counter.py:12:def count_chars(text): +wordstat/counter.py:13: return len(text) +test_cli.py:1:import io +test_cli.py:2:import tempfile +test_cli.py:3:import unittest +test_cli.py:4:from contextlib import redirect_stderr, redirect_stdout +test_cli.py:5:from pathlib import Path +test_cli.py:7:from wordstat.cli import main +test_cli.py:10:class CliTests(unittest.TestCase): +test_cli.py:11: def test_main_prints_report_for_file(self): +test_cli.py:12: with tempfile.TemporaryDirectory() as directory: +test_cli.py:13: path = Path(directory) / "sample.txt" +test_cli.py:14: path.write_text("the quick\nbrown fox\n", encoding="utf-8") +test_cli.py:15: stdout = io.StringIO() +test_cli.py:17: with redirect_stdout(stdout): +test_cli.py:18: result = main([str(path)]) +test_cli.py:20: self.assertEqual(result, 0) +test_cli.py:21: self.assertEqual(stdout.getvalue(), "words: 4\nlines: 2\nchars: 20\n") +test_cli.py:23: def test_main_returns_one_for_missing_file(self): +test_cli.py:24: stderr = io.StringIO() +test_cli.py:26: with redirect_stderr(stderr): +test_cli.py:27: result = main(["/no/such/file"]) +test_cli.py:29: self.assertEqual(result, 1) +test_cli.py:30: self.assertTrue(stderr.getvalue()) +test_counter.py:1:import unittest +test_counter.py:3:from wordstat.counter import count_chars, count_lines, count_words +test_counter.py:6:class CounterTests(unittest.TestCase): +test_counter.py:7: def test_count_words(self): +test_counter.py:8: self.assertEqual(count_words("the quick brown fox"), 4) +test_counter.py:9: self.assertEqual(count_words(""), 0) +test_counter.py:11: def test_count_lines(self): +test_counter.py:12: self.assertEqual(count_lines("a\nb"), 2) +test_counter.py:13: self.assertEqual(count_lines("a\nb\n"), 2) +test_counter.py:14: self.assertEqual(count_lines(""), 0) +test_counter.py:16: def test_count_chars(self): +test_counter.py:17: self.assertEqual(count_chars("abc"), 3) +test_counter.py:18: self.assertEqual(count_chars("a b"), 3) +test_formatter.py:1:import unittest +test_formatter.py:3:from wordstat.formatter import format_report +test_formatter.py:6:class FormatterTests(unittest.TestCase): +test_formatter.py:7: def test_format_report(self): +test_formatter.py:8: self.assertEqual( +test_formatter.py:9: format_report({"words": 12, "lines": 3, "chars": 57}), +test_formatter.py:10: "words: 12\nlines: 3\nchars: 57", +test_formatter.py:11: ) +wordstat/cli.py:1:"""Command-line entry point for wordstat.""" +wordstat/cli.py:3:import argparse +wordstat/cli.py:4:import sys +wordstat/cli.py:6:from . import counter, formatter +wordstat/cli.py:9:def main(argv): +wordstat/cli.py:10: parser = argparse.ArgumentParser(description="Report statistics for a text file.") +wordstat/cli.py:11: parser.add_argument("path") +wordstat/cli.py:12: args = parser.parse_args(argv) +wordstat/cli.py:14: try: +wordstat/cli.py:15: with open(args.path, encoding="utf-8") as source: +wordstat/cli.py:16: text = source.read() +wordstat/cli.py:17: except OSError as error: +wordstat/cli.py:18: print(f"wordstat: {error}", file=sys.stderr) +wordstat/cli.py:19: return 1 +wordstat/cli.py:21: stats = { +wordstat/cli.py:22: "words": counter.count_words(text), +wordstat/cli.py:23: "lines": counter.count_lines(text), +wordstat/cli.py:24: "chars": counter.count_chars(text), +wordstat/cli.py:25: } +wordstat/cli.py:26: print(formatter.format_report(stats)) +wordstat/cli.py:27: return 0 +wordstat/__init__.py:1:"""wordstat — tiny text-statistics package. See design.md and plan.md.""" +wordstat/formatter.py:1:"""Render text statistics as a human-readable report.""" +wordstat/formatter.py:4:def format_report(stats): +wordstat/formatter.py:5: return "\n".join(f"{name}: {stats[name]}" for name in ("words", "lines", "chars")) + +exec +/bin/zsh -lc 'PYTHONDONTWRITEBYTECODE=1 python3 -m unittest -v' in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R2-reasonable-5/repo + exited 1 in 0ms: +test_main_prints_report_for_file (test_cli.CliTests.test_main_prints_report_for_file) ... ERROR +test_main_returns_one_for_missing_file (test_cli.CliTests.test_main_returns_one_for_missing_file) ... ok +test_count_chars (test_counter.CounterTests.test_count_chars) ... ok +test_count_lines (test_counter.CounterTests.test_count_lines) ... ok +test_count_words (test_counter.CounterTests.test_count_words) ... ok +test_format_report (test_formatter.FormatterTests.test_format_report) ... ok + +====================================================================== +ERROR: test_main_prints_report_for_file (test_cli.CliTests.test_main_prints_report_for_file) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R2-reasonable-5/repo/test_cli.py", line 12, in test_main_prints_report_for_file + with tempfile.TemporaryDirectory() as directory: + ~~~~~~~~~~~~~~~~~~~~~~~~~~~^^ + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 907, in __init__ + self.name = mkdtemp(suffix, prefix, dir) + ~~~~~~~^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 370, in mkdtemp + prefix, suffix, dir, output_type = _sanitize_params(prefix, suffix, dir) + ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 127, in _sanitize_params + dir = gettempdir() + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 312, in gettempdir + return _os.fsdecode(_gettempdir()) + ~~~~~~~~~~~^^ + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 305, in _gettempdir + tempdir = _get_default_tempdir() + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 222, in _get_default_tempdir + raise FileNotFoundError(_errno.ENOENT, + "No usable temporary directory found in %s" % + dirlist) +FileNotFoundError: [Errno 2] No usable temporary directory found in ['/var/folders/43/prgnkdr95317fd_zbljq8thm0000gn/T/', '/tmp', '/var/tmp', '/usr/tmp', '/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R2-reasonable-5/repo'] + +---------------------------------------------------------------------- +Ran 6 tests in 0.001s + +FAILED (errors=1) + +exec +/bin/zsh -lc "python3 -B -c 'import subprocess, sys +base = [sys.executable, \"-B\"] +entry = \"from wordstat.cli import main; raise SystemExit(main([\\\"/dev/stdin\\\"]))\" +cases = [(\"module invocation\", [\"-m\", \"wordstat.cli\", \"design.md\"], None), (\"module help\", [\"-m\", \"wordstat.cli\", \"--help\"], None), (\"LF happy path\", [\"-c\", entry], b\"the quick\\nbrown fox\\n\"), (\"CRLF character count\", [\"-c\", entry], b\"a\\r\\nb\\r\\n\"), (\"invalid UTF-8\", [\"-c\", entry], b\"caf\\xe9\\n\")] +for label, args, data in cases: + result = subprocess.run(base + args, input=data, capture_output=True) + print(label, \"exit=\", result.returncode, \"stdout=\", repr(result.stdout), \"stderr=\", repr(result.stderr))'" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R2-reasonable-5/repo + succeeded in 4ms: +module invocation exit= 0 stdout= b'' stderr= b'' +module help exit= 0 stdout= b'' stderr= b'' +LF happy path exit= 0 stdout= b'words: 4\nlines: 2\nchars: 20\n' stderr= b'' +CRLF character count exit= 0 stdout= b'words: 2\nlines: 2\nchars: 4\n' stderr= b'' +invalid UTF-8 exit= 1 stdout= b'' stderr= b'Traceback (most recent call last):\n File "", line 1, in \n from wordstat.cli import main; raise SystemExit(main(["/dev/stdin"]))\n ~~~~^^^^^^^^^^^^^^^^\n File "/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R2-reasonable-5/repo/wordstat/cli.py", line 16, in main\n text = source.read()\n File "", line 325, in decode\nUnicodeDecodeError: \'utf-8\' codec can\'t decode byte 0xe9 in position 3: invalid continuation byte\n' + +### Strengths + +- All three planned modules and test files are present, with clean separation between counting, formatting, and CLI orchestration. +- The implementation uses only the standard library and calls the real counter and formatter functions. +- Report ordering is deterministic, and filesystem errors produce a diagnostic and return code 1. + +### Issues + +#### Critical (Must Fix) + +None. + +#### Important (Should Fix) + +1. **No runnable CLI entry point** + - **File:** `wordstat/cli.py:27` + - `python3 -m wordstat.cli design.md` exits successfully without printing anything; `--help` also produces nothing. No other executable entry point exists. + - The planned callable API is implemented, but users cannot run the advertised CLI normally. + - Add `if __name__ == "__main__": sys.exit(main(sys.argv[1:]))` and subprocess tests covering reports, help, and failure exit codes. This also closes an omission in the plan. + +2. **Windows line endings silently reduce character counts** + - **File:** `wordstat/cli.py:15` + - Default newline translation converts `\r\n` into `\n` before counting. Verified that `a\r\nb\r\n` reports 4 characters instead of 6. + - This violates the requirement to count characters including whitespace. + - Open with `newline=""` to preserve the input characters, and add a CRLF regression test. + +3. **Decoding errors escape the CLI’s error handling** + - **File:** `wordstat/cli.py:16–17` + - Invalid UTF-8 raises `UnicodeDecodeError`, which the `OSError` handler does not catch. Verified that a Latin-1 encoded `café` produces a traceback. + - A routine unsupported input should produce a readable error and return 1 through `main`, rather than raise unexpectedly. + - Catch `UnicodeError` alongside filesystem errors, document UTF-8 input, and test the diagnostic and return value. + +#### Minor (Nice to Have) + +None. + +### Recommendations + +Extend tests beyond the plan’s examples to cover actual command invocation, preserved line endings, and decoding failures. + +Validation: five tests passed; the sixth could not create its temporary directory under the read-only sandbox. Its equivalent happy path passed using piped input. The three findings above were reproduced without modifying the checkout. + +### Assessment + +**Ready to merge? With fixes** + +**Reasoning:** The implementation closely follows the explicit plan, but the advertised CLI has no executable entry point, and file reading introduces incorrect counts and unhandled input errors. Address these issues before merging. diff --git a/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R2-reasonable-6.review.txt b/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R2-reasonable-6.review.txt new file mode 100644 index 000000000..6e520c51f --- /dev/null +++ b/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R2-reasonable-6.review.txt @@ -0,0 +1,314 @@ +I’ll read the plan and spec, inspect the full branch diff, and check tests and edge cases without changing the checkout. +exec +/bin/zsh -lc "pwd; rg --files -g 'AGENTS.md' -g 'plan.md' -g 'design.md' -g 'pyproject.toml' -g '*test*' -g '*.py'; git diff --stat 79f40dbd273644fe8a15bfba9a0496373a48df42..HEAD; git status --short" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R2-reasonable-6/repo + succeeded in 2899ms: +2026-09-17 12:54:40.192 xcodebuild[50899:99772177] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:54:40.299 xcodebuild[50899:99772174] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-HaMBQDjP' (errno=Operation not permitted) +2026-09-17 12:54:40.683 xcodebuild[50908:99772216] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:54:40.793 xcodebuild[50908:99772215] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. + test_cli.py | 30 ++++++++++++++++++++++++++++++ + test_counter.py | 18 ++++++++++++++++++ + test_formatter.py | 11 +++++++++++ + wordstat/cli.py | 27 +++++++++++++++++++++++++++ + wordstat/counter.py | 13 +++++++++++++ + wordstat/formatter.py | 5 +++++ + 6 files changed, 104 insertions(+) +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-4wRwJF0U' (errno=Operation not permitted) +2026-09-17 12:54:41.688 xcodebuild[50936:99772521] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:54:41.811 xcodebuild[50936:99772520] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-uMw5EJyL' (errno=Operation not permitted) +2026-09-17 12:54:42.216 xcodebuild[50950:99772570] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:54:42.336 xcodebuild[50950:99772569] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. + +exec +/bin/zsh -lc 'cat plan.md design.md; git diff 79f40dbd273644fe8a15bfba9a0496373a48df42..HEAD' in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R2-reasonable-6/repo + succeeded in 1257ms: +2026-09-17 12:54:43.246 xcodebuild[50973:99772693] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:54:43.359 xcodebuild[50973:99772691] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-fHq5T5fc' (errno=Operation not permitted) +2026-09-17 12:54:43.716 xcodebuild[50975:99772703] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:54:43.827 xcodebuild[50975:99772702] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +diff --git a/test_cli.py b/test_cli.py +new file mode 100644 +index 0000000..0793491 +--- /dev/null ++++ b/test_cli.py +@@ -0,0 +1,30 @@ ++import io ++import tempfile ++import unittest ++from contextlib import redirect_stderr, redirect_stdout ++from pathlib import Path ++ ++from wordstat.cli import main ++ ++ ++class CliTests(unittest.TestCase): ++ def test_main_prints_report_for_file(self): ++ with tempfile.TemporaryDirectory() as directory: ++ path = Path(directory) / "sample.txt" ++ path.write_text("the quick\nbrown fox\n", encoding="utf-8") ++ stdout = io.StringIO() ++ ++ with redirect_stdout(stdout): ++ result = main([str(path)]) ++ ++ self.assertEqual(result, 0) ++ self.assertEqual(stdout.getvalue(), "words: 4\nlines: 2\nchars: 20\n") ++ ++ def test_main_returns_one_for_missing_file(self): ++ stderr = io.StringIO() ++ ++ with redirect_stderr(stderr): ++ result = main(["/no/such/file"]) ++ ++ self.assertEqual(result, 1) ++ self.assertTrue(stderr.getvalue()) +diff --git a/test_counter.py b/test_counter.py +new file mode 100644 +index 0000000..341b8df +--- /dev/null ++++ b/test_counter.py +@@ -0,0 +1,18 @@ ++import unittest ++ ++from wordstat.counter import count_chars, count_lines, count_words ++ ++ ++class CounterTests(unittest.TestCase): ++ def test_count_words(self): ++ self.assertEqual(count_words("the quick brown fox"), 4) ++ self.assertEqual(count_words(""), 0) ++ ++ def test_count_lines(self): ++ self.assertEqual(count_lines("a\nb"), 2) ++ self.assertEqual(count_lines("a\nb\n"), 2) ++ self.assertEqual(count_lines(""), 0) ++ ++ def test_count_chars(self): ++ self.assertEqual(count_chars("abc"), 3) ++ self.assertEqual(count_chars("a b"), 3) +diff --git a/test_formatter.py b/test_formatter.py +new file mode 100644 +index 0000000..925b36e +--- /dev/null ++++ b/test_formatter.py +@@ -0,0 +1,11 @@ ++import unittest ++ ++from wordstat.formatter import format_report ++ ++ ++class FormatterTests(unittest.TestCase): ++ def test_format_report(self): ++ self.assertEqual( ++ format_report({"words": 12, "lines": 3, "chars": 57}), ++ "words: 12\nlines: 3\nchars: 57", ++ ) +diff --git a/wordstat/cli.py b/wordstat/cli.py +new file mode 100644 +index 0000000..ec622c3 +--- /dev/null ++++ b/wordstat/cli.py +@@ -0,0 +1,27 @@ ++"""Command-line entry point for wordstat.""" ++ ++import argparse ++import sys ++ ++from . import counter, formatter ++ ++ ++def main(argv): ++ parser = argparse.ArgumentParser(description="Report statistics for a text file.") ++ parser.add_argument("path") ++ args = parser.parse_args(argv) ++ ++ try: ++ with open(args.path, encoding="utf-8") as source: ++ text = source.read() ++ except OSError as error: ++ print(f"wordstat: {error}", file=sys.stderr) ++ return 1 ++ ++ stats = { ++ "words": counter.count_words(text), ++ "lines": counter.count_lines(text), ++ "chars": counter.count_chars(text), ++ } ++ print(formatter.format_report(stats)) ++ return 0 +diff --git a/wordstat/counter.py b/wordstat/counter.py +new file mode 100644 +index 0000000..2153d0a +--- /dev/null ++++ b/wordstat/counter.py +@@ -0,0 +1,13 @@ ++"""Pure text statistics.""" ++ ++ ++def count_words(text): ++ return len(text.split()) ++ ++ ++def count_lines(text): ++ return len(text.splitlines()) ++ ++ ++def count_chars(text): ++ return len(text) +diff --git a/wordstat/formatter.py b/wordstat/formatter.py +new file mode 100644 +index 0000000..d6db16c +--- /dev/null ++++ b/wordstat/formatter.py +@@ -0,0 +1,5 @@ ++"""Render text statistics as a human-readable report.""" ++ ++ ++def format_report(stats): ++ return "\n".join(f"{name}: {stats[name]}" for name in ("words", "lines", "chars")) + +exec +/bin/zsh -lc "rg --files --hidden -g '"'!.git'"'; git log --oneline 79f40dbd273644fe8a15bfba9a0496373a48df42..HEAD; nl -ba wordstat/cli.py; cat wordstat/__init__.py; PYTHONDONTWRITEBYTECODE=1 python3 -m unittest -v" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R2-reasonable-6/repo + exited 1 in 1300ms: +2026-09-17 12:54:50.665 xcodebuild[51073:99773061] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:54:50.778 xcodebuild[51073:99773060] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-clKHiBrB' (errno=Operation not permitted) +2026-09-17 12:54:51.139 xcodebuild[51076:99773074] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:54:51.248 xcodebuild[51076:99773073] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +a581192 tasks 1-3 + 1 """Command-line entry point for wordstat.""" + 2 + 3 import argparse + 4 import sys + 5 + 6 from . import counter, formatter + 7 + 8 + 9 def main(argv): + 10 parser = argparse.ArgumentParser(description="Report statistics for a text file.") + 11 parser.add_argument("path") + 12 args = parser.parse_args(argv) + 13 + 14 try: + 15 with open(args.path, encoding="utf-8") as source: + 16 text = source.read() + 17 except OSError as error: + 18 print(f"wordstat: {error}", file=sys.stderr) + 19 return 1 + 20 + 21 stats = { + 22 "words": counter.count_words(text), + 23 "lines": counter.count_lines(text), + 24 "chars": counter.count_chars(text), + 25 } + 26 print(formatter.format_report(stats)) + 27 return 0 +"""wordstat — tiny text-statistics package. See design.md and plan.md.""" +test_main_prints_report_for_file (test_cli.CliTests.test_main_prints_report_for_file) ... ERROR +test_main_returns_one_for_missing_file (test_cli.CliTests.test_main_returns_one_for_missing_file) ... ok +test_count_chars (test_counter.CounterTests.test_count_chars) ... ok +test_count_lines (test_counter.CounterTests.test_count_lines) ... ok +test_count_words (test_counter.CounterTests.test_count_words) ... ok +test_format_report (test_formatter.FormatterTests.test_format_report) ... ok + +====================================================================== +ERROR: test_main_prints_report_for_file (test_cli.CliTests.test_main_prints_report_for_file) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R2-reasonable-6/repo/test_cli.py", line 12, in test_main_prints_report_for_file + with tempfile.TemporaryDirectory() as directory: + ~~~~~~~~~~~~~~~~~~~~~~~~~~~^^ + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 907, in __init__ + self.name = mkdtemp(suffix, prefix, dir) + ~~~~~~~^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 370, in mkdtemp + prefix, suffix, dir, output_type = _sanitize_params(prefix, suffix, dir) + ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 127, in _sanitize_params + dir = gettempdir() + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 312, in gettempdir + return _os.fsdecode(_gettempdir()) + ~~~~~~~~~~~^^ + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 305, in _gettempdir + tempdir = _get_default_tempdir() + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 222, in _get_default_tempdir + raise FileNotFoundError(_errno.ENOENT, + "No usable temporary directory found in %s" % + dirlist) +FileNotFoundError: [Errno 2] No usable temporary directory found in ['/var/folders/43/prgnkdr95317fd_zbljq8thm0000gn/T/', '/tmp', '/var/tmp', '/usr/tmp', '/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R2-reasonable-6/repo'] + +---------------------------------------------------------------------- +Ran 6 tests in 0.001s + +FAILED (errors=1) + +exec +/bin/zsh -lc "PYTHONDONTWRITEBYTECODE=1 python3 -c 'import subprocess, sys +cases = [(\"module invocation\", [\"-m\", \"wordstat.cli\", \"design.md\"], None), (\"module help\", [\"-m\", \"wordstat.cli\", \"--help\"], None)] +for label, data in [(\"LF input\", b\"the quick\\nbrown fox\\n\"), (\"CRLF input (6 characters)\", b\"a\\r\\nb\\r\\n\"), (\"invalid UTF-8\", b\"caf\\xe9\\n\")]: + cases.append((label, [\"-c\", \"from wordstat.cli import main; raise SystemExit(main([\\\"/dev/stdin\\\"]))\"], data)) +for label, args, data in cases: + result = subprocess.run([sys.executable, \"-B\", *args], input=data, capture_output=True) + print(label, \"exit=\", result.returncode, \"stdout=\", repr(result.stdout.decode()), \"stderr=\", repr(result.stderr.decode()))'" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R2-reasonable-6/repo + succeeded in 31ms: +module invocation exit= 0 stdout= '' stderr= '' +module help exit= 0 stdout= '' stderr= '' +LF input exit= 0 stdout= 'words: 4\nlines: 2\nchars: 20\n' stderr= '' +CRLF input (6 characters) exit= 0 stdout= 'words: 2\nlines: 2\nchars: 4\n' stderr= '' +invalid UTF-8 exit= 1 stdout= '' stderr= 'Traceback (most recent call last):\n File "", line 1, in \n from wordstat.cli import main; raise SystemExit(main(["/dev/stdin"]))\n ~~~~^^^^^^^^^^^^^^^^\n File "/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R2-reasonable-6/repo/wordstat/cli.py", line 16, in main\n text = source.read()\n File "", line 325, in decode\nUnicodeDecodeError: \'utf-8\' codec can\'t decode byte 0xe9 in position 3: invalid continuation byte\n' + +### Strengths + +- Clear separation of pure counters, report formatting, and CLI orchestration; all planned functions are present. +- Uses only the standard library, closes files reliably, and handles filesystem errors with stderr diagnostics. +- Tests cover every example requested by the plan and exercise real module integration. + +### Issues + +#### Critical (Must Fix) + +None. + +#### Important (Should Fix) + +1. **The CLI never runs when invoked as a module** + - **File:** `wordstat/cli.py:27` + - `python3 -m wordstat.cli design.md` exits successfully without output. `--help` also does nothing because nothing invokes `main`. + - This leaves users without a working command-line entry point. + - Add an `if __name__ == "__main__":` guard calling `sys.exit(main(sys.argv[1:]))`, and test invocation through a subprocess. + +2. **Windows line endings produce incorrect character counts** + - **File:** `wordstat/cli.py:15` + - Text-mode reading translates CRLF into LF before counting. Reproduced with `a\r\nb\r\n`: the report says `chars: 4` although the input contains six characters. + - This silently violates the requirement to count characters including whitespace. + - Open with `newline=""` to preserve original line endings, and add a CRLF integration test. + +3. **Undecodable input escapes the CLI’s error handling** + - **File:** `wordstat/cli.py:16-17` + - Reading non-UTF-8 text raises `UnicodeDecodeError`, which the `OSError` handler does not catch. Reproduced with Latin-1 `café`: `main` raises and the caller gets a traceback. + - A user encountering an unsupported encoding should receive a concise diagnostic and failure status. + - Catch `UnicodeError`, return 1, and explain the expected encoding in the diagnostic. + +#### Minor (Nice to Have) + +None. + +### Recommendations + +- Add regression tests for the three issues above. +- Five existing tests passed; the remaining test was blocked by the sandbox’s prohibition on temporary-file creation. A separate read-only check reproduced the expected successful report. +- The branch contains one combined commit, whereas the plan requested a commit per task. Test-first development cannot be verified from this history. + +### Assessment + +**Ready to merge?** With fixes. + +**Reasoning:** The implementation closely follows the planned module structure, but command-line invocation is currently inert. Character-count corruption and unhandled decoding errors also need correction before release. diff --git a/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R3-both-1.review.txt b/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R3-both-1.review.txt new file mode 100644 index 000000000..0eaa991ba --- /dev/null +++ b/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R3-both-1.review.txt @@ -0,0 +1,367 @@ +I’ll read the plan, spec, and branch diff, then check the tests and edge cases without changing the checkout. + +exec +/bin/zsh -lc "pwd; rg --files -g 'AGENTS.md' -g 'plan.md' -g 'design.md' -g 'pyproject.toml' -g '*test*'; git status --short; git diff --stat 5e08e1da65b3e9ba429565690025a8a366f328a8..HEAD; git diff 5e08e1da65b3e9ba429565690025a8a366f328a8..HEAD" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R3-both-1/repo + succeeded in 4528ms: +2026-09-17 12:55:23.083 xcodebuild[52989:99777120] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:55:23.255 xcodebuild[52989:99777119] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-9KXpv88H' (errno=Operation not permitted) +2026-09-17 12:55:23.677 xcodebuild[53051:99777217] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:55:23.799 xcodebuild[53051:99777216] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-iSixxvPs' (errno=Operation not permitted) +2026-09-17 12:55:24.680 xcodebuild[53058:99777247] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:55:24.795 xcodebuild[53058:99777246] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-vUf2sOvc' (errno=Operation not permitted) +2026-09-17 12:55:25.173 xcodebuild[53062:99777277] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:55:25.299 xcodebuild[53062:99777274] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. + test_cli.py | 30 ++++++++++++++++++++++++++++++ + test_counter.py | 18 ++++++++++++++++++ + test_formatter.py | 11 +++++++++++ + wordstat/cli.py | 27 +++++++++++++++++++++++++++ + wordstat/counter.py | 13 +++++++++++++ + wordstat/formatter.py | 5 +++++ + 6 files changed, 104 insertions(+) +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-SPLVTNHM' (errno=Operation not permitted) +2026-09-17 12:55:26.185 xcodebuild[53532:99777967] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:55:26.315 xcodebuild[53532:99777965] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-OhbxD6fz' (errno=Operation not permitted) +2026-09-17 12:55:26.721 xcodebuild[53535:99778068] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:55:26.849 xcodebuild[53535:99778067] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +diff --git a/test_cli.py b/test_cli.py +new file mode 100644 +index 0000000..0793491 +--- /dev/null ++++ b/test_cli.py +@@ -0,0 +1,30 @@ ++import io ++import tempfile ++import unittest ++from contextlib import redirect_stderr, redirect_stdout ++from pathlib import Path ++ ++from wordstat.cli import main ++ ++ ++class CliTests(unittest.TestCase): ++ def test_main_prints_report_for_file(self): ++ with tempfile.TemporaryDirectory() as directory: ++ path = Path(directory) / "sample.txt" ++ path.write_text("the quick\nbrown fox\n", encoding="utf-8") ++ stdout = io.StringIO() ++ ++ with redirect_stdout(stdout): ++ result = main([str(path)]) ++ ++ self.assertEqual(result, 0) ++ self.assertEqual(stdout.getvalue(), "words: 4\nlines: 2\nchars: 20\n") ++ ++ def test_main_returns_one_for_missing_file(self): ++ stderr = io.StringIO() ++ ++ with redirect_stderr(stderr): ++ result = main(["/no/such/file"]) ++ ++ self.assertEqual(result, 1) ++ self.assertTrue(stderr.getvalue()) +diff --git a/test_counter.py b/test_counter.py +new file mode 100644 +index 0000000..341b8df +--- /dev/null ++++ b/test_counter.py +@@ -0,0 +1,18 @@ ++import unittest ++ ++from wordstat.counter import count_chars, count_lines, count_words ++ ++ ++class CounterTests(unittest.TestCase): ++ def test_count_words(self): ++ self.assertEqual(count_words("the quick brown fox"), 4) ++ self.assertEqual(count_words(""), 0) ++ ++ def test_count_lines(self): ++ self.assertEqual(count_lines("a\nb"), 2) ++ self.assertEqual(count_lines("a\nb\n"), 2) ++ self.assertEqual(count_lines(""), 0) ++ ++ def test_count_chars(self): ++ self.assertEqual(count_chars("abc"), 3) ++ self.assertEqual(count_chars("a b"), 3) +diff --git a/test_formatter.py b/test_formatter.py +new file mode 100644 +index 0000000..925b36e +--- /dev/null ++++ b/test_formatter.py +@@ -0,0 +1,11 @@ ++import unittest ++ ++from wordstat.formatter import format_report ++ ++ ++class FormatterTests(unittest.TestCase): ++ def test_format_report(self): ++ self.assertEqual( ++ format_report({"words": 12, "lines": 3, "chars": 57}), ++ "words: 12\nlines: 3\nchars: 57", ++ ) +diff --git a/wordstat/cli.py b/wordstat/cli.py +new file mode 100644 +index 0000000..ec622c3 +--- /dev/null ++++ b/wordstat/cli.py +@@ -0,0 +1,27 @@ ++"""Command-line entry point for wordstat.""" ++ ++import argparse ++import sys ++ ++from . import counter, formatter ++ ++ ++def main(argv): ++ parser = argparse.ArgumentParser(description="Report statistics for a text file.") ++ parser.add_argument("path") ++ args = parser.parse_args(argv) ++ ++ try: ++ with open(args.path, encoding="utf-8") as source: ++ text = source.read() ++ except OSError as error: ++ print(f"wordstat: {error}", file=sys.stderr) ++ return 1 ++ ++ stats = { ++ "words": counter.count_words(text), ++ "lines": counter.count_lines(text), ++ "chars": counter.count_chars(text), ++ } ++ print(formatter.format_report(stats)) ++ return 0 +diff --git a/wordstat/counter.py b/wordstat/counter.py +new file mode 100644 +index 0000000..2153d0a +--- /dev/null ++++ b/wordstat/counter.py +@@ -0,0 +1,13 @@ ++"""Pure text statistics.""" ++ ++ ++def count_words(text): ++ return len(text.split()) ++ ++ ++def count_lines(text): ++ return len(text.splitlines()) ++ ++ ++def count_chars(text): ++ return len(text) +diff --git a/wordstat/formatter.py b/wordstat/formatter.py +new file mode 100644 +index 0000000..d6db16c +--- /dev/null ++++ b/wordstat/formatter.py +@@ -0,0 +1,5 @@ ++"""Render text statistics as a human-readable report.""" ++ ++ ++def format_report(stats): ++ return "\n".join(f"{name}: {stats[name]}" for name in ("words", "lines", "chars")) + +exec +/bin/zsh -lc "rg --files --hidden -g '"'!.git'"'; cat plan.md design.md; ls -la wordstat; rg --files /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad -g AGENTS.md" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R3-both-1/repo + succeeded in 628ms: +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-6/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v3/v3-boundary-list-6/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-6/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v2-deletion-only-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v1-scope-vs-failure-6/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/v0-current-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-5/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-2/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-3/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-4/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-1/repo/AGENTS.md +/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out-v4/v4-boundary-slot-6/repo/AGENTS.md + +exec +/bin/zsh -lc 'PYTHONDONTWRITEBYTECODE=1 python3 -m unittest -v' in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R3-both-1/repo + exited 1 in 0ms: +test_main_prints_report_for_file (test_cli.CliTests.test_main_prints_report_for_file) ... ERROR +test_main_returns_one_for_missing_file (test_cli.CliTests.test_main_returns_one_for_missing_file) ... ok +test_count_chars (test_counter.CounterTests.test_count_chars) ... ok +test_count_lines (test_counter.CounterTests.test_count_lines) ... ok +test_count_words (test_counter.CounterTests.test_count_words) ... ok +test_format_report (test_formatter.FormatterTests.test_format_report) ... ok + +====================================================================== +ERROR: test_main_prints_report_for_file (test_cli.CliTests.test_main_prints_report_for_file) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R3-both-1/repo/test_cli.py", line 12, in test_main_prints_report_for_file + with tempfile.TemporaryDirectory() as directory: + ~~~~~~~~~~~~~~~~~~~~~~~~~~~^^ + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 907, in __init__ + self.name = mkdtemp(suffix, prefix, dir) + ~~~~~~~^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 370, in mkdtemp + prefix, suffix, dir, output_type = _sanitize_params(prefix, suffix, dir) + ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 127, in _sanitize_params + dir = gettempdir() + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 312, in gettempdir + return _os.fsdecode(_gettempdir()) + ~~~~~~~~~~~^^ + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 305, in _gettempdir + tempdir = _get_default_tempdir() + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 222, in _get_default_tempdir + raise FileNotFoundError(_errno.ENOENT, + "No usable temporary directory found in %s" % + dirlist) +FileNotFoundError: [Errno 2] No usable temporary directory found in ['/var/folders/43/prgnkdr95317fd_zbljq8thm0000gn/T/', '/tmp', '/var/tmp', '/usr/tmp', '/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R3-both-1/repo'] + +---------------------------------------------------------------------- +Ran 6 tests in 0.001s + +FAILED (errors=1) + +exec +/bin/zsh -lc "PYTHONDONTWRITEBYTECODE=1 python3 -c 'import os, subprocess, sys +from pathlib import Path +p = Path.cwd() +for parent in [p, *p.parents]: + f = parent / \"AGENTS.md\" + if f.is_file(): print(str(f), f.read_text()) +for args in [[\"-m\",\"wordstat.cli\",\"design.md\"], [\"-m\",\"wordstat.cli\",\"--help\"], [\"-m\",\"wordstat.cli\",\"/no/such/file\"]]: + r = subprocess.run([sys.executable, \"-B\", *args], capture_output=True, text=True) + print(args, \"status=\",r.returncode,\"stdout=\",repr(r.stdout),\"stderr=\",repr(r.stderr)) +for data in [b\"the quick\\nbrown fox\\n\", b\"a\\r\\nb\\r\\n\", b\"\\xff\"]: + read_fd, write_fd = os.pipe() + os.write(write_fd, data) + os.close(write_fd) + r = subprocess.run([sys.executable, \"-B\", \"-c\", \"import sys; from wordstat.cli import main; sys.exit(main(sys.argv[1:]))\", f\"/dev/fd/{read_fd}\"], pass_fds=(read_fd,), capture_output=True, text=True) + os.close(read_fd) + print(\"input=\",repr(data),\"status=\",r.returncode,\"stdout=\",repr(r.stdout),\"stderr=\",repr(r.stderr))'" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R3-both-1/repo + succeeded in 50ms: +['-m', 'wordstat.cli', 'design.md'] status= 0 stdout= '' stderr= '' +['-m', 'wordstat.cli', '--help'] status= 0 stdout= '' stderr= '' +['-m', 'wordstat.cli', '/no/such/file'] status= 0 stdout= '' stderr= '' +input= b'the quick\nbrown fox\n' status= 0 stdout= 'words: 4\nlines: 2\nchars: 20\n' stderr= '' +input= b'a\r\nb\r\n' status= 0 stdout= 'words: 2\nlines: 2\nchars: 4\n' stderr= '' +input= b'\xff' status= 1 stdout= '' stderr= 'Traceback (most recent call last):\n File "", line 1, in \n import sys; from wordstat.cli import main; sys.exit(main(sys.argv[1:]))\n ~~~~^^^^^^^^^^^^^^\n File "/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R3-both-1/repo/wordstat/cli.py", line 16, in main\n text = source.read()\n File "", line 325, in decode\nUnicodeDecodeError: \'utf-8\' codec can\'t decode byte 0xff in position 0: invalid start byte\n' + +exec +/bin/zsh -lc 'git log --oneline 5e08e1da65b3e9ba429565690025a8a366f328a8..HEAD; nl -ba wordstat/cli.py; cat wordstat/__init__.py' in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R3-both-1/repo + succeeded in 1347ms: +2026-09-17 12:56:00.994 xcodebuild[55060:99781801] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:56:01.108 xcodebuild[55060:99781800] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-MzAWorP9' (errno=Operation not permitted) +2026-09-17 12:56:01.485 xcodebuild[55064:99781820] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:56:01.601 xcodebuild[55064:99781819] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +9b3fc67 tasks 1-3 + 1 """Command-line entry point for wordstat.""" + 2 + 3 import argparse + 4 import sys + 5 + 6 from . import counter, formatter + 7 + 8 + 9 def main(argv): + 10 parser = argparse.ArgumentParser(description="Report statistics for a text file.") + 11 parser.add_argument("path") + 12 args = parser.parse_args(argv) + 13 + 14 try: + 15 with open(args.path, encoding="utf-8") as source: + 16 text = source.read() + 17 except OSError as error: + 18 print(f"wordstat: {error}", file=sys.stderr) + 19 return 1 + 20 + 21 stats = { + 22 "words": counter.count_words(text), + 23 "lines": counter.count_lines(text), + 24 "chars": counter.count_chars(text), + 25 } + 26 print(formatter.format_report(stats)) + 27 return 0 +"""wordstat — tiny text-statistics package. See design.md and plan.md.""" + +### Strengths + +- Clean separation between pure counters, formatting, and CLI orchestration. +- Standard-library-only implementation matches the planned function signatures and report format. +- Tests exercise real module integration. Five tests passed; the sixth could not create its temporary directory under the read-only sandbox. Its expected successful output was independently reproduced using a pipe. + +### Issues + +#### Critical (Must Fix) + +None. + +#### Important (Should Fix) + +1. **The CLI never runs when invoked as a module** + - **File:** `wordstat/cli.py:27` + - `python3 -m wordstat.cli design.md` exits successfully without printing anything. `--help` and a missing path also silently exit 0 because nothing invokes `main`. + - This leaves the advertised CLI unusable through the available module entry point. + - Add `if __name__ == "__main__": sys.exit(main(sys.argv[1:]))` and subprocess tests checking output and exit status. + +2. **Windows line endings cause incorrect character counts** + - **File:** `wordstat/cli.py:15` + - Default newline translation converts CRLF into LF before counting. Input `a\r\nb\r\n` reports `chars: 4`, although it contains six characters. + - This violates counting characters including whitespace and makes CLI results differ from directly counting the original text. + - Open with `newline=""` to preserve characters, and add a CRLF integration test. + +3. **Decoding failures escape the CLI’s error handling** + - **File:** `wordstat/cli.py:16–19` + - A file containing invalid UTF-8 raises an uncaught `UnicodeDecodeError`; `except OSError` does not catch it. + - Users encountering an incompatible text encoding receive a traceback, and callers of `main` receive an exception instead of an error code. + - Catch `UnicodeDecodeError`, print a concise diagnostic identifying the file and expected encoding, and return 1. Add a regression test. + +#### Minor (Nice to Have) + +None. + +### Recommendations + +- Add the three regression cases above; the planned examples alone miss important end-to-end behavior. +- The reviewed range contains one commit, `9b3fc67` (`tasks 1-3`), rather than the plan’s separate task commits. Confirm whether that process deviation was intentional. The snapshot cannot establish whether tests were written first. + +### Declined to judge + +- Streaming files larger than available memory: the plan specifies whole-text functions and sets no large-file capacity requirement. +- Supporting additional encodings or automatic encoding detection: an optional capability; graceful rejection of undecodable input remains required and is reported above. +- Stdin, multiple input files, and installation packaging: extensions beyond the specified single-path interface; executable module invocation remains required and is reported above. +- Validation of malformed formatter dictionaries or non-string counter arguments: outside the documented function input contracts. + +### Assessment + +**Ready to merge? With fixes** + +**Reasoning:** The core functions match the plan, but command-line invocation is inert, CRLF input is miscounted, and decoding errors are unhandled. Fix these and verify the full suite in an environment permitting temporary files before merging. diff --git a/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R3-both-2.review.txt b/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R3-both-2.review.txt new file mode 100644 index 000000000..6df35a4d5 --- /dev/null +++ b/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R3-both-2.review.txt @@ -0,0 +1,371 @@ +I’ll read the plan, spec, and branch diff, then check the tests and user-facing edge cases without changing the checkout. +exec +/bin/zsh -lc "pwd; rg --files -g 'AGENTS.md' -g 'plan.md' -g 'design.md' -g '*.py' -g 'pyproject.toml'; git diff --stat 0948b1fbedd52bc7d7512ac5d9a6faefa74ca449..HEAD; git diff 0948b1fbedd52bc7d7512ac5d9a6faefa74ca449..HEAD" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R3-both-2/repo + succeeded in 2771ms: +2026-09-17 12:55:28.689 xcodebuild[53643:99778321] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:55:28.809 xcodebuild[53643:99778320] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-gHTVtPn7' (errno=Operation not permitted) +2026-09-17 12:55:29.173 xcodebuild[53645:99778356] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:55:29.299 xcodebuild[53645:99778355] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. + test_cli.py | 30 ++++++++++++++++++++++++++++++ + test_counter.py | 18 ++++++++++++++++++ + test_formatter.py | 11 +++++++++++ + wordstat/cli.py | 27 +++++++++++++++++++++++++++ + wordstat/counter.py | 13 +++++++++++++ + wordstat/formatter.py | 5 +++++ + 6 files changed, 104 insertions(+) +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-tYyaG4jy' (errno=Operation not permitted) +2026-09-17 12:55:30.129 xcodebuild[53659:99778450] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:55:30.239 xcodebuild[53659:99778447] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-a5tnbBD5' (errno=Operation not permitted) +2026-09-17 12:55:30.609 xcodebuild[53664:99778468] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:55:30.717 xcodebuild[53664:99778467] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +diff --git a/test_cli.py b/test_cli.py +new file mode 100644 +index 0000000..0793491 +--- /dev/null ++++ b/test_cli.py +@@ -0,0 +1,30 @@ ++import io ++import tempfile ++import unittest ++from contextlib import redirect_stderr, redirect_stdout ++from pathlib import Path ++ ++from wordstat.cli import main ++ ++ ++class CliTests(unittest.TestCase): ++ def test_main_prints_report_for_file(self): ++ with tempfile.TemporaryDirectory() as directory: ++ path = Path(directory) / "sample.txt" ++ path.write_text("the quick\nbrown fox\n", encoding="utf-8") ++ stdout = io.StringIO() ++ ++ with redirect_stdout(stdout): ++ result = main([str(path)]) ++ ++ self.assertEqual(result, 0) ++ self.assertEqual(stdout.getvalue(), "words: 4\nlines: 2\nchars: 20\n") ++ ++ def test_main_returns_one_for_missing_file(self): ++ stderr = io.StringIO() ++ ++ with redirect_stderr(stderr): ++ result = main(["/no/such/file"]) ++ ++ self.assertEqual(result, 1) ++ self.assertTrue(stderr.getvalue()) +diff --git a/test_counter.py b/test_counter.py +new file mode 100644 +index 0000000..341b8df +--- /dev/null ++++ b/test_counter.py +@@ -0,0 +1,18 @@ ++import unittest ++ ++from wordstat.counter import count_chars, count_lines, count_words ++ ++ ++class CounterTests(unittest.TestCase): ++ def test_count_words(self): ++ self.assertEqual(count_words("the quick brown fox"), 4) ++ self.assertEqual(count_words(""), 0) ++ ++ def test_count_lines(self): ++ self.assertEqual(count_lines("a\nb"), 2) ++ self.assertEqual(count_lines("a\nb\n"), 2) ++ self.assertEqual(count_lines(""), 0) ++ ++ def test_count_chars(self): ++ self.assertEqual(count_chars("abc"), 3) ++ self.assertEqual(count_chars("a b"), 3) +diff --git a/test_formatter.py b/test_formatter.py +new file mode 100644 +index 0000000..925b36e +--- /dev/null ++++ b/test_formatter.py +@@ -0,0 +1,11 @@ ++import unittest ++ ++from wordstat.formatter import format_report ++ ++ ++class FormatterTests(unittest.TestCase): ++ def test_format_report(self): ++ self.assertEqual( ++ format_report({"words": 12, "lines": 3, "chars": 57}), ++ "words: 12\nlines: 3\nchars: 57", ++ ) +diff --git a/wordstat/cli.py b/wordstat/cli.py +new file mode 100644 +index 0000000..ec622c3 +--- /dev/null ++++ b/wordstat/cli.py +@@ -0,0 +1,27 @@ ++"""Command-line entry point for wordstat.""" ++ ++import argparse ++import sys ++ ++from . import counter, formatter ++ ++ ++def main(argv): ++ parser = argparse.ArgumentParser(description="Report statistics for a text file.") ++ parser.add_argument("path") ++ args = parser.parse_args(argv) ++ ++ try: ++ with open(args.path, encoding="utf-8") as source: ++ text = source.read() ++ except OSError as error: ++ print(f"wordstat: {error}", file=sys.stderr) ++ return 1 ++ ++ stats = { ++ "words": counter.count_words(text), ++ "lines": counter.count_lines(text), ++ "chars": counter.count_chars(text), ++ } ++ print(formatter.format_report(stats)) ++ return 0 +diff --git a/wordstat/counter.py b/wordstat/counter.py +new file mode 100644 +index 0000000..2153d0a +--- /dev/null ++++ b/wordstat/counter.py +@@ -0,0 +1,13 @@ ++"""Pure text statistics.""" ++ ++ ++def count_words(text): ++ return len(text.split()) ++ ++ ++def count_lines(text): ++ return len(text.splitlines()) ++ ++ ++def count_chars(text): ++ return len(text) +diff --git a/wordstat/formatter.py b/wordstat/formatter.py +new file mode 100644 +index 0000000..d6db16c +--- /dev/null ++++ b/wordstat/formatter.py +@@ -0,0 +1,5 @@ ++"""Render text statistics as a human-readable report.""" ++ ++ ++def format_report(stats): ++ return "\n".join(f"{name}: {stats[name]}" for name in ("words", "lines", "chars")) + +exec +/bin/zsh -lc "cat plan.md design.md; rg --files --hidden -g '"'!.git/**'"'; ls -la ..; nl -ba wordstat/cli.py" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R3-both-2/repo + succeeded in 0ms: +# Implementation plan: `wordstat` + +Design context in `design.md`. Build the package with test-driven development: +for each task write the failing test first, then the implementation, then make it +pass, then commit. Standard library only; tests run with `python3 -m unittest`. + +## Task 1 — `wordstat/counter.py` + +Pure statistics functions. + +1. Write `test_counter.py` (failing) covering: + - `count_words("the quick brown fox")` == 4; `count_words("")` == 0. + - `count_lines("a\nb")` == 2; `count_lines("a\nb\n")` == 2; `count_lines("")` == 0. + - `count_chars("abc")` == 3; `count_chars("a b")` == 3. +2. Implement `wordstat/counter.py` with `count_words`, `count_lines`, + `count_chars`. +3. Run `python3 -m unittest test_counter` → green. Commit. + +## Task 2 — `wordstat/formatter.py` + +Render a stats dict to a report string. + +1. Write `test_formatter.py` (failing) covering: + - `format_report({"words": 12, "lines": 3, "chars": 57})` == + `"words: 12\nlines: 3\nchars: 57"`. +2. Implement `wordstat/formatter.py` with `format_report(stats)`. +3. Run `python3 -m unittest test_formatter` → green. Commit. + +## Task 3 — `wordstat/cli.py` + +Entry point composing the previous two modules. Read `wordstat/counter.py` and +`wordstat/formatter.py` to use their real function signatures. + +1. Write `test_cli.py` (failing) covering: + - `main([path])` for a temp file with known content prints the expected + report and returns 0. + - `main(["/no/such/file"])` returns 1. +2. Implement `wordstat/cli.py` with `main(argv)` using `argparse`, calling + `counter` then `formatter`. +3. Run `python3 -m unittest` (whole suite) → green. Commit. +# Design: `wordstat` — a tiny text-stats CLI + +A small Python package that computes simple statistics about a text file and +prints a human-readable report. + +## Package layout + +``` +wordstat/ + __init__.py # package marker (exists) + counter.py # pure stat functions (Task 1) + formatter.py # render a stats dict to a report string (Task 2) + cli.py # argparse entry point tying it together (Task 3) +test_counter.py # tests for counter (Task 1) +test_formatter.py # tests for formatter (Task 2) +test_cli.py # tests for cli (Task 3) +``` + +Tests live at the repo root and are runnable with `python3 -m unittest` +(stdlib only — no third-party test deps). + +## Behavior + +- `counter.count_words(text)` → int: number of whitespace-separated tokens. +- `counter.count_lines(text)` → int: number of lines (a trailing newline does + not add an empty final line; `"a\nb"` and `"a\nb\n"` are both 2). +- `counter.count_chars(text)` → int: number of characters including whitespace. +- `formatter.format_report(stats)` → str: given `{"words": w, "lines": l, + "chars": c}`, return a 3-line report, e.g. `"words: 12\nlines: 3\nchars: 57"`. +- `cli.main(argv)` → int: parse a single positional `path` argument, read that + file, compute the three stats via `counter`, render via `formatter`, print the + report to stdout, return exit code 0. Missing file → message to stderr, return 1. + +## Constraints + +- Standard library only. +- Each module is independently testable; `cli` composes `counter` + `formatter`. +test_cli.py +test_counter.py +design.md +plan.md +test_formatter.py +wordstat/counter.py +wordstat/cli.py +wordstat/__init__.py +wordstat/formatter.py +total 48 +drwxr-xr-x@ 5 jesse wheel 160 Sep 17 12:55 . +drwxr-xr-x@ 26 jesse wheel 832 Sep 17 12:55 .. +-rw-r--r--@ 1 jesse wheel 13277 Sep 17 12:55 codex.log +-rw-r--r--@ 1 jesse wheel 6246 Sep 17 12:55 prompt.txt +drwxr-xr-x@ 9 jesse wheel 288 Sep 17 12:55 repo + 1 """Command-line entry point for wordstat.""" + 2 + 3 import argparse + 4 import sys + 5 + 6 from . import counter, formatter + 7 + 8 + 9 def main(argv): + 10 parser = argparse.ArgumentParser(description="Report statistics for a text file.") + 11 parser.add_argument("path") + 12 args = parser.parse_args(argv) + 13 + 14 try: + 15 with open(args.path, encoding="utf-8") as source: + 16 text = source.read() + 17 except OSError as error: + 18 print(f"wordstat: {error}", file=sys.stderr) + 19 return 1 + 20 + 21 stats = { + 22 "words": counter.count_words(text), + 23 "lines": counter.count_lines(text), + 24 "chars": counter.count_chars(text), + 25 } + 26 print(formatter.format_report(stats)) + 27 return 0 + +exec +/bin/zsh -lc 'PYTHONDONTWRITEBYTECODE=1 python3 -m unittest -v; PYTHONDONTWRITEBYTECODE=1 python3 -m wordstat.cli design.md; git log --oneline 0948b1fbedd52bc7d7512ac5d9a6faefa74ca449..HEAD' in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R3-both-2/repo + succeeded in 1481ms: +2026-09-17 12:55:44.269 xcodebuild[54905:99781026] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:55:44.394 xcodebuild[54905:99781025] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-bfGZ7Fyq' (errno=Operation not permitted) +2026-09-17 12:55:44.786 xcodebuild[54915:99781091] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:55:44.896 xcodebuild[54915:99781090] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +8f0442a tasks 1-3 + +The core functions match the planned examples, but running the CLI module produces no output because it never calls `main`. Five tests passed; the file-based test was blocked by the read-only environment. I’m checking newline handling and decoding errors next. +exec +/bin/zsh -lc "PYTHONDONTWRITEBYTECODE=1 python3 -c 'import io; from unittest.mock import patch; from wordstat.cli import main +for data in (b\"a\\r\\nb\\r\\n\", b\"caf\\xe9\"): + print(\"input:\", repr(data)) + with patch(\"builtins.open\", return_value=io.TextIOWrapper(io.BytesIO(data), encoding=\"utf-8\")): + try: print(\"return:\", main([\"sample.txt\"])) + except Exception as exc: print(type(exc).__name__, str(exc)) +'; PYTHONDONTWRITEBYTECODE=1 python3 -c 'from wordstat.cli import main; raise SystemExit(main([\"design.md\"]))'" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R3-both-2/repo + succeeded in 0ms: +input: b'a\r\nb\r\n' +words: 2 +lines: 2 +chars: 4 +return: 0 +input: b'caf\xe9' +UnicodeDecodeError 'utf-8' codec can't decode byte 0xe9 in position 3: unexpected end of data +words: 224 +lines: 37 +chars: 1535 + +### Strengths + +- Clear separation between pure counters, formatting, and CLI orchestration. +- All planned example cases are covered, using only the standard library. +- Report ordering is deterministic; filesystem errors produce stderr diagnostics and return 1. + +### Issues + +#### Critical (Must Fix) + +None. + +#### Important (Should Fix) + +1. **CLI module never invokes its entry point** + - **File:** `wordstat/cli.py:27` + - `python3 -m wordstat.cli design.md` exits successfully without printing anything. There is no other executable entry point. + - Add `if __name__ == "__main__": sys.exit(main(sys.argv[1:]))` and subprocess tests verifying output and exit codes. + +2. **Windows newlines cause incorrect character counts** + - **File:** `wordstat/cli.py:15` + - Default text-mode reading converts CRLF to LF before counting. Input `a\r\nb\r\n` reports 4 characters instead of 6, contrary to counting all characters including whitespace. + - Open with `newline=""` to preserve characters; add a CRLF integration test. + +3. **Invalid UTF-8 escapes the error handler** + - **File:** `wordstat/cli.py:16-17` + - A decoding failure raises `UnicodeDecodeError`, which is not an `OSError`. Users get an uncaught exception instead of a concise diagnostic and the normal failure return. + - Catch `UnicodeError` alongside filesystem errors, document UTF-8 input, and test malformed input. + +#### Minor (Nice to Have) + +None. + +### Recommendations + +- Extend CLI tests beyond direct `main()` calls to cover actual command execution and the input cases above. +- Five tests passed; the remaining test could not create its temporary directory under this read-only environment. This is a verification limitation, not a product defect. +- History contains one combined `tasks 1-3` commit, deviating from the plan’s per-task commits. Test-first sequencing cannot be verified from that history. + +### Declined to judge + +- Additional encodings or automatic encoding detection: no requirement establishes support; graceful decoding failure is covered above. +- Streaming arbitrarily large files: the plan establishes no size or bounded-memory requirement. +- Stdin and multiple-file support: the specified interface accepts one positional file path. + +### Assessment + +**Ready to merge? With fixes** + +**Reasoning:** The module APIs satisfy the planned examples, but command execution is currently inert, CRLF input is miscounted, and decoding failures bypass error handling. Fix these user-facing issues and rerun the full suite in a writable test environment. diff --git a/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R3-both-3.review.txt b/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R3-both-3.review.txt new file mode 100644 index 000000000..69126242b --- /dev/null +++ b/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R3-both-3.review.txt @@ -0,0 +1,373 @@ +I’ll read the plan, spec, and full branch diff, then run the tests and check edge cases without changing the checkout. +exec +/bin/zsh -lc "pwd; rg --files -g 'AGENTS.md' -g 'plan.md' -g 'design.md' -g 'pyproject.toml'; git status --short; git diff --stat 9c69927ffa51b027cde1906f979f3eebb4eab41d..HEAD; git diff 9c69927ffa51b027cde1906f979f3eebb4eab41d..HEAD" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R3-both-3/repo + succeeded in 4956ms: +2026-09-17 12:55:30.066 xcodebuild[53657:99778437] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:55:30.185 xcodebuild[53657:99778436] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-BttJHzxU' (errno=Operation not permitted) +2026-09-17 12:55:30.567 xcodebuild[53662:99778462] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:55:30.684 xcodebuild[53662:99778459] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-Mj3AfLFV' (errno=Operation not permitted) +2026-09-17 12:55:31.580 xcodebuild[53673:99778518] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:55:31.699 xcodebuild[53673:99778517] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-jlzkPGeW' (errno=Operation not permitted) +2026-09-17 12:55:32.075 xcodebuild[53677:99778541] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:55:32.193 xcodebuild[53677:99778540] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. + test_cli.py | 30 ++++++++++++++++++++++++++++++ + test_counter.py | 18 ++++++++++++++++++ + test_formatter.py | 11 +++++++++++ + wordstat/cli.py | 27 +++++++++++++++++++++++++++ + wordstat/counter.py | 13 +++++++++++++ + wordstat/formatter.py | 5 +++++ + 6 files changed, 104 insertions(+) +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-6vLJX52F' (errno=Operation not permitted) +2026-09-17 12:55:33.537 xcodebuild[53932:99779203] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:55:33.664 xcodebuild[53932:99779080] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-PXqHIcFa' (errno=Operation not permitted) +2026-09-17 12:55:34.094 xcodebuild[54170:99779538] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:55:34.215 xcodebuild[54170:99779537] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +diff --git a/test_cli.py b/test_cli.py +new file mode 100644 +index 0000000..0793491 +--- /dev/null ++++ b/test_cli.py +@@ -0,0 +1,30 @@ ++import io ++import tempfile ++import unittest ++from contextlib import redirect_stderr, redirect_stdout ++from pathlib import Path ++ ++from wordstat.cli import main ++ ++ ++class CliTests(unittest.TestCase): ++ def test_main_prints_report_for_file(self): ++ with tempfile.TemporaryDirectory() as directory: ++ path = Path(directory) / "sample.txt" ++ path.write_text("the quick\nbrown fox\n", encoding="utf-8") ++ stdout = io.StringIO() ++ ++ with redirect_stdout(stdout): ++ result = main([str(path)]) ++ ++ self.assertEqual(result, 0) ++ self.assertEqual(stdout.getvalue(), "words: 4\nlines: 2\nchars: 20\n") ++ ++ def test_main_returns_one_for_missing_file(self): ++ stderr = io.StringIO() ++ ++ with redirect_stderr(stderr): ++ result = main(["/no/such/file"]) ++ ++ self.assertEqual(result, 1) ++ self.assertTrue(stderr.getvalue()) +diff --git a/test_counter.py b/test_counter.py +new file mode 100644 +index 0000000..341b8df +--- /dev/null ++++ b/test_counter.py +@@ -0,0 +1,18 @@ ++import unittest ++ ++from wordstat.counter import count_chars, count_lines, count_words ++ ++ ++class CounterTests(unittest.TestCase): ++ def test_count_words(self): ++ self.assertEqual(count_words("the quick brown fox"), 4) ++ self.assertEqual(count_words(""), 0) ++ ++ def test_count_lines(self): ++ self.assertEqual(count_lines("a\nb"), 2) ++ self.assertEqual(count_lines("a\nb\n"), 2) ++ self.assertEqual(count_lines(""), 0) ++ ++ def test_count_chars(self): ++ self.assertEqual(count_chars("abc"), 3) ++ self.assertEqual(count_chars("a b"), 3) +diff --git a/test_formatter.py b/test_formatter.py +new file mode 100644 +index 0000000..925b36e +--- /dev/null ++++ b/test_formatter.py +@@ -0,0 +1,11 @@ ++import unittest ++ ++from wordstat.formatter import format_report ++ ++ ++class FormatterTests(unittest.TestCase): ++ def test_format_report(self): ++ self.assertEqual( ++ format_report({"words": 12, "lines": 3, "chars": 57}), ++ "words: 12\nlines: 3\nchars: 57", ++ ) +diff --git a/wordstat/cli.py b/wordstat/cli.py +new file mode 100644 +index 0000000..ec622c3 +--- /dev/null ++++ b/wordstat/cli.py +@@ -0,0 +1,27 @@ ++"""Command-line entry point for wordstat.""" ++ ++import argparse ++import sys ++ ++from . import counter, formatter ++ ++ ++def main(argv): ++ parser = argparse.ArgumentParser(description="Report statistics for a text file.") ++ parser.add_argument("path") ++ args = parser.parse_args(argv) ++ ++ try: ++ with open(args.path, encoding="utf-8") as source: ++ text = source.read() ++ except OSError as error: ++ print(f"wordstat: {error}", file=sys.stderr) ++ return 1 ++ ++ stats = { ++ "words": counter.count_words(text), ++ "lines": counter.count_lines(text), ++ "chars": counter.count_chars(text), ++ } ++ print(formatter.format_report(stats)) ++ return 0 +diff --git a/wordstat/counter.py b/wordstat/counter.py +new file mode 100644 +index 0000000..2153d0a +--- /dev/null ++++ b/wordstat/counter.py +@@ -0,0 +1,13 @@ ++"""Pure text statistics.""" ++ ++ ++def count_words(text): ++ return len(text.split()) ++ ++ ++def count_lines(text): ++ return len(text.splitlines()) ++ ++ ++def count_chars(text): ++ return len(text) +diff --git a/wordstat/formatter.py b/wordstat/formatter.py +new file mode 100644 +index 0000000..d6db16c +--- /dev/null ++++ b/wordstat/formatter.py +@@ -0,0 +1,5 @@ ++"""Render text statistics as a human-readable report.""" ++ ++ ++def format_report(stats): ++ return "\n".join(f"{name}: {stats[name]}" for name in ("words", "lines", "chars")) + +exec +/bin/zsh -lc "rg --files --hidden -g '"'!.git'"'; cat plan.md design.md; ls -la wordstat; command -v python3; for f in wordstat/__init__.py wordstat/__main__.py README.md; do if test -f \""'$f"; then nl -ba "$f"; fi; done' in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R3-both-3/repo + succeeded in 0ms: +test_cli.py +test_counter.py +design.md +plan.md +test_formatter.py +wordstat/counter.py +wordstat/cli.py +wordstat/__init__.py +wordstat/formatter.py +# Implementation plan: `wordstat` + +Design context in `design.md`. Build the package with test-driven development: +for each task write the failing test first, then the implementation, then make it +pass, then commit. Standard library only; tests run with `python3 -m unittest`. + +## Task 1 — `wordstat/counter.py` + +Pure statistics functions. + +1. Write `test_counter.py` (failing) covering: + - `count_words("the quick brown fox")` == 4; `count_words("")` == 0. + - `count_lines("a\nb")` == 2; `count_lines("a\nb\n")` == 2; `count_lines("")` == 0. + - `count_chars("abc")` == 3; `count_chars("a b")` == 3. +2. Implement `wordstat/counter.py` with `count_words`, `count_lines`, + `count_chars`. +3. Run `python3 -m unittest test_counter` → green. Commit. + +## Task 2 — `wordstat/formatter.py` + +Render a stats dict to a report string. + +1. Write `test_formatter.py` (failing) covering: + - `format_report({"words": 12, "lines": 3, "chars": 57})` == + `"words: 12\nlines: 3\nchars: 57"`. +2. Implement `wordstat/formatter.py` with `format_report(stats)`. +3. Run `python3 -m unittest test_formatter` → green. Commit. + +## Task 3 — `wordstat/cli.py` + +Entry point composing the previous two modules. Read `wordstat/counter.py` and +`wordstat/formatter.py` to use their real function signatures. + +1. Write `test_cli.py` (failing) covering: + - `main([path])` for a temp file with known content prints the expected + report and returns 0. + - `main(["/no/such/file"])` returns 1. +2. Implement `wordstat/cli.py` with `main(argv)` using `argparse`, calling + `counter` then `formatter`. +3. Run `python3 -m unittest` (whole suite) → green. Commit. +# Design: `wordstat` — a tiny text-stats CLI + +A small Python package that computes simple statistics about a text file and +prints a human-readable report. + +## Package layout + +``` +wordstat/ + __init__.py # package marker (exists) + counter.py # pure stat functions (Task 1) + formatter.py # render a stats dict to a report string (Task 2) + cli.py # argparse entry point tying it together (Task 3) +test_counter.py # tests for counter (Task 1) +test_formatter.py # tests for formatter (Task 2) +test_cli.py # tests for cli (Task 3) +``` + +Tests live at the repo root and are runnable with `python3 -m unittest` +(stdlib only — no third-party test deps). + +## Behavior + +- `counter.count_words(text)` → int: number of whitespace-separated tokens. +- `counter.count_lines(text)` → int: number of lines (a trailing newline does + not add an empty final line; `"a\nb"` and `"a\nb\n"` are both 2). +- `counter.count_chars(text)` → int: number of characters including whitespace. +- `formatter.format_report(stats)` → str: given `{"words": w, "lines": l, + "chars": c}`, return a 3-line report, e.g. `"words: 12\nlines: 3\nchars: 57"`. +- `cli.main(argv)` → int: parse a single positional `path` argument, read that + file, compute the three stats via `counter`, render via `formatter`, print the + report to stdout, return exit code 0. Missing file → message to stderr, return 1. + +## Constraints + +- Standard library only. +- Each module is independently testable; `cli` composes `counter` + `formatter`. +total 32 +drwxr-xr-x@ 6 jesse wheel 192 Sep 17 12:55 . +drwxr-xr-x@ 9 jesse wheel 288 Sep 17 12:55 .. +-rw-r--r--@ 1 jesse wheel 76 Sep 17 12:55 __init__.py +-rw-r--r--@ 1 jesse wheel 687 Sep 17 12:55 cli.py +-rw-r--r--@ 1 jesse wheel 187 Sep 17 12:55 counter.py +-rw-r--r--@ 1 jesse wheel 172 Sep 17 12:55 formatter.py +/usr/local/bin/python3 + 1 """wordstat — tiny text-statistics package. See design.md and plan.md.""" + +exec +/bin/zsh -lc "PYTHONDONTWRITEBYTECODE=1 python3 -c 'import os, subprocess, sys +from wordstat.cli import main +r = subprocess.run([sys.executable, \"-B\", \"-m\", \"wordstat.cli\", \"design.md\"], capture_output=True, text=True) +print(\"module invocation:\", r.returncode, repr(r.stdout), repr(r.stderr)) +for label, data in [(\"happy path\", b\"the quick\\nbrown fox\\n\"), (\"CRLF\", b\"a\\r\\nb\\r\\n\"), (\"invalid UTF-8\", b\"\\xff\")]: + read_fd, write_fd = os.pipe() + os.write(write_fd, data) + os.close(write_fd) + print(label, \"input:\", repr(data)) + try: + print(\"result:\", main([\"/dev/fd/\" + str(read_fd)])) + except Exception as error: + print(type(error).__name__ + \":\", str(error)) + finally: + os.close(read_fd) +'; git log --oneline 9c69927ffa51b027cde1906f979f3eebb4eab41d..HEAD; nl -ba wordstat/cli.py" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R3-both-3/repo + succeeded in 1462ms: +2026-09-17 12:56:00.865 xcodebuild[55053:99781782] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:56:00.984 xcodebuild[55053:99781780] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-dummDu7o' (errno=Operation not permitted) +2026-09-17 12:56:01.366 xcodebuild[55062:99781812] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:56:01.493 xcodebuild[55062:99781811] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +986f3a3 tasks 1-3 + 1 """Command-line entry point for wordstat.""" + 2 + 3 import argparse + 4 import sys + 5 + 6 from . import counter, formatter + 7 + 8 + 9 def main(argv): + 10 parser = argparse.ArgumentParser(description="Report statistics for a text file.") + 11 parser.add_argument("path") + 12 args = parser.parse_args(argv) + 13 + 14 try: + 15 with open(args.path, encoding="utf-8") as source: + 16 text = source.read() + 17 except OSError as error: + 18 print(f"wordstat: {error}", file=sys.stderr) + 19 return 1 + 20 + 21 stats = { + 22 "words": counter.count_words(text), + 23 "lines": counter.count_lines(text), + 24 "chars": counter.count_chars(text), + 25 } + 26 print(formatter.format_report(stats)) + 27 return 0 + +### Strengths +- Clear separation between pure counters, formatting, and CLI orchestration. +- All explicitly planned test cases are present; formatting is deterministic and filesystem errors receive useful diagnostics. +- Standard-library-only implementation with no unnecessary abstraction. + +### Issues + +#### Critical (Must Fix) +None. + +#### Important (Should Fix) +1. **CLI invocation silently does nothing** + - **File:** `wordstat/cli.py:27` + - `python3 -m wordstat.cli design.md` exits 0 without output. No executable entry point calls `main`. + - Users cannot run the delivered CLI normally. + - Add `if __name__ == "__main__": sys.exit(main(sys.argv[1:]))` and subprocess tests covering reports and failure exit codes. + +2. **Windows newlines cause incorrect character counts** + - **File:** `wordstat/cli.py:15` + - Default newline translation converts `\r\n` to `\n` before counting. Input `a\r\nb\r\n` reports 4 characters instead of 6. + - This violates counting characters including whitespace and makes CLI results inconsistent with the counter. + - Open with `newline=""` and add a CRLF regression test. + +3. **Decoding failures escape the CLI error handling** + - **File:** `wordstat/cli.py:16–17` + - A file containing invalid UTF-8 raises `UnicodeDecodeError`, which `except OSError` does not catch. + - Users encountering another encoding get an exception instead of a controlled diagnostic and return code. + - Catch `UnicodeError`, report the encoding problem to stderr, and return 1. Test this path. + +#### Minor (Nice to Have) +- **Commit sequence differs from the plan.** The reviewed range contains one commit, `986f3a3`, combining all tasks; the plan requests a commit after each task. This does not affect runtime behavior, but test-first execution cannot be established from the history. Confirm whether consolidation was intentional. + +### Recommendations +Add regression tests for the three issues above, especially actual command invocation. + +Validation: five tests passed; the sixth could not create its temporary directory under the read-only sandbox. A separate pipe-backed check reproduced its expected report successfully. All three reported runtime defects were reproduced without modifying the checkout. + +### Declined to judge +- Streaming very large files: the plan explicitly reads the complete file and defines no bounded-memory requirement. +- Stdin and multiple-file support: the specified interface accepts one positional file path. +- Automatic encoding detection: no supported encoding set is specified; graceful decoding failure remains required and is reported above. + +### Assessment + +**Ready to merge? With fixes** + +**Reasoning:** The planned functions and test cases are implemented cleanly, but the CLI lacks an executable entry point, undercounts CRLF input, and mishandles decoding errors. These should be corrected before merging. diff --git a/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R3-both-4.review.txt b/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R3-both-4.review.txt new file mode 100644 index 000000000..69c8e9553 --- /dev/null +++ b/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R3-both-4.review.txt @@ -0,0 +1,303 @@ +I’ll read the plan and spec, inspect the full branch diff, and check behavior and tests without changing the checkout. + +exec +/bin/zsh -lc "pwd; rg --files -g '"'!*.pyc'"' -g '"'!__pycache__'"' -g '"'!*egg-info*'"'; git diff --stat 30af6466a45c0482442bab16c1408af45b9b98db..HEAD; git diff 30af6466a45c0482442bab16c1408af45b9b98db..HEAD" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R3-both-4/repo + succeeded in 3119ms: +2026-09-17 12:55:33.897 xcodebuild[54166:99779458] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:55:34.035 xcodebuild[54166:99779421] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-W8qyhZ3a' (errno=Operation not permitted) +2026-09-17 12:55:34.470 xcodebuild[54211:99779605] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:55:34.603 xcodebuild[54211:99779602] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. + test_cli.py | 30 ++++++++++++++++++++++++++++++ + test_counter.py | 18 ++++++++++++++++++ + test_formatter.py | 11 +++++++++++ + wordstat/cli.py | 27 +++++++++++++++++++++++++++ + wordstat/counter.py | 13 +++++++++++++ + wordstat/formatter.py | 5 +++++ + 6 files changed, 104 insertions(+) +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-CB03otz1' (errno=Operation not permitted) +2026-09-17 12:55:35.605 xcodebuild[54744:99780438] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:55:35.737 xcodebuild[54744:99780437] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-qwW57TBx' (errno=Operation not permitted) +2026-09-17 12:55:36.136 xcodebuild[54757:99780482] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:55:36.258 xcodebuild[54757:99780481] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +diff --git a/test_cli.py b/test_cli.py +new file mode 100644 +index 0000000..0793491 +--- /dev/null ++++ b/test_cli.py +@@ -0,0 +1,30 @@ ++import io ++import tempfile ++import unittest ++from contextlib import redirect_stderr, redirect_stdout ++from pathlib import Path ++ ++from wordstat.cli import main ++ ++ ++class CliTests(unittest.TestCase): ++ def test_main_prints_report_for_file(self): ++ with tempfile.TemporaryDirectory() as directory: ++ path = Path(directory) / "sample.txt" ++ path.write_text("the quick\nbrown fox\n", encoding="utf-8") ++ stdout = io.StringIO() ++ ++ with redirect_stdout(stdout): ++ result = main([str(path)]) ++ ++ self.assertEqual(result, 0) ++ self.assertEqual(stdout.getvalue(), "words: 4\nlines: 2\nchars: 20\n") ++ ++ def test_main_returns_one_for_missing_file(self): ++ stderr = io.StringIO() ++ ++ with redirect_stderr(stderr): ++ result = main(["/no/such/file"]) ++ ++ self.assertEqual(result, 1) ++ self.assertTrue(stderr.getvalue()) +diff --git a/test_counter.py b/test_counter.py +new file mode 100644 +index 0000000..341b8df +--- /dev/null ++++ b/test_counter.py +@@ -0,0 +1,18 @@ ++import unittest ++ ++from wordstat.counter import count_chars, count_lines, count_words ++ ++ ++class CounterTests(unittest.TestCase): ++ def test_count_words(self): ++ self.assertEqual(count_words("the quick brown fox"), 4) ++ self.assertEqual(count_words(""), 0) ++ ++ def test_count_lines(self): ++ self.assertEqual(count_lines("a\nb"), 2) ++ self.assertEqual(count_lines("a\nb\n"), 2) ++ self.assertEqual(count_lines(""), 0) ++ ++ def test_count_chars(self): ++ self.assertEqual(count_chars("abc"), 3) ++ self.assertEqual(count_chars("a b"), 3) +diff --git a/test_formatter.py b/test_formatter.py +new file mode 100644 +index 0000000..925b36e +--- /dev/null ++++ b/test_formatter.py +@@ -0,0 +1,11 @@ ++import unittest ++ ++from wordstat.formatter import format_report ++ ++ ++class FormatterTests(unittest.TestCase): ++ def test_format_report(self): ++ self.assertEqual( ++ format_report({"words": 12, "lines": 3, "chars": 57}), ++ "words: 12\nlines: 3\nchars: 57", ++ ) +diff --git a/wordstat/cli.py b/wordstat/cli.py +new file mode 100644 +index 0000000..ec622c3 +--- /dev/null ++++ b/wordstat/cli.py +@@ -0,0 +1,27 @@ ++"""Command-line entry point for wordstat.""" ++ ++import argparse ++import sys ++ ++from . import counter, formatter ++ ++ ++def main(argv): ++ parser = argparse.ArgumentParser(description="Report statistics for a text file.") ++ parser.add_argument("path") ++ args = parser.parse_args(argv) ++ ++ try: ++ with open(args.path, encoding="utf-8") as source: ++ text = source.read() ++ except OSError as error: ++ print(f"wordstat: {error}", file=sys.stderr) ++ return 1 ++ ++ stats = { ++ "words": counter.count_words(text), ++ "lines": counter.count_lines(text), ++ "chars": counter.count_chars(text), ++ } ++ print(formatter.format_report(stats)) ++ return 0 +diff --git a/wordstat/counter.py b/wordstat/counter.py +new file mode 100644 +index 0000000..2153d0a +--- /dev/null ++++ b/wordstat/counter.py +@@ -0,0 +1,13 @@ ++"""Pure text statistics.""" ++ ++ ++def count_words(text): ++ return len(text.split()) ++ ++ ++def count_lines(text): ++ return len(text.splitlines()) ++ ++ ++def count_chars(text): ++ return len(text) +diff --git a/wordstat/formatter.py b/wordstat/formatter.py +new file mode 100644 +index 0000000..d6db16c +--- /dev/null ++++ b/wordstat/formatter.py +@@ -0,0 +1,5 @@ ++"""Render text statistics as a human-readable report.""" ++ ++ ++def format_report(stats): ++ return "\n".join(f"{name}: {stats[name]}" for name in ("words", "lines", "chars")) + +exec +/bin/zsh -lc "cat plan.md design.md wordstat/__init__.py; rg --files --hidden -g '"'! .git'"' -g 'AGENTS.md' -g 'pyproject.toml' -g 'setup*' -g 'README*'; command -v python3; git status --short" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R3-both-4/repo + succeeded in 1372ms: +2026-09-17 12:55:41.083 xcodebuild[54874:99780771] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:55:41.207 xcodebuild[54874:99780770] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-vKeH7doa' (errno=Operation not permitted) +2026-09-17 12:55:41.602 xcodebuild[54877:99780791] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:55:41.720 xcodebuild[54877:99780790] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. + +exec +/bin/zsh -lc 'PYTHONDONTWRITEBYTECODE=1 python3 -m unittest -v; git log --oneline 30af6466a45c0482442bab16c1408af45b9b98db..HEAD; nl -ba wordstat/cli.py' in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R3-both-4/repo + succeeded in 1338ms: +2026-09-17 12:55:55.573 xcodebuild[55017:99781590] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:55:55.686 xcodebuild[55017:99781586] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-sUk2zguO' (errno=Operation not permitted) +2026-09-17 12:55:56.050 xcodebuild[55023:99781620] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:55:56.168 xcodebuild[55023:99781619] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +c6d45b3 tasks 1-3 + 1 """Command-line entry point for wordstat.""" + 2 + 3 import argparse + 4 import sys + 5 + 6 from . import counter, formatter + 7 + 8 + 9 def main(argv): + 10 parser = argparse.ArgumentParser(description="Report statistics for a text file.") + 11 parser.add_argument("path") + 12 args = parser.parse_args(argv) + 13 + 14 try: + 15 with open(args.path, encoding="utf-8") as source: + 16 text = source.read() + 17 except OSError as error: + 18 print(f"wordstat: {error}", file=sys.stderr) + 19 return 1 + 20 + 21 stats = { + 22 "words": counter.count_words(text), + 23 "lines": counter.count_lines(text), + 24 "chars": counter.count_chars(text), + 25 } + 26 print(formatter.format_report(stats)) + 27 return 0 + +exec +/bin/zsh -lc "PYTHONDONTWRITEBYTECODE=1 python3 -c 'import os, subprocess, sys +from wordstat.cli import main +for path in (\"design.md\", \"/no/such/file\"): + p = subprocess.run([sys.executable,\"-B\",\"-m\",\"wordstat.cli\",path], capture_output=True, text=True) + print(\"MODULE\",path,\"exit=\",p.returncode,\"stdout=\",repr(p.stdout),\"stderr=\",repr(p.stderr)) +for data in (b\"the quick\\nbrown fox\\n\", b\"a\\r\\nb\\r\\n\", b\"caf\\xe9\\n\"): + r,w=os.pipe() + os.write(w,data) + os.close(w) + print(\"INPUT\",repr(data),\"length=\",len(data)) + try: + print(\"return=\",main([\"/dev/fd/\"+str(r)])) + except Exception as error: + print(type(error).__name__,str(error)) + finally: + os.close(r)'" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R3-both-4/repo + succeeded in 0ms: +MODULE design.md exit= 0 stdout= '' stderr= '' +MODULE /no/such/file exit= 0 stdout= '' stderr= '' +INPUT b'the quick\nbrown fox\n' length= 20 +words: 4 +lines: 2 +chars: 20 +return= 0 +INPUT b'a\r\nb\r\n' length= 6 +words: 2 +lines: 2 +chars: 4 +return= 0 +INPUT b'caf\xe9\n' length= 5 +UnicodeDecodeError 'utf-8' codec can't decode byte 0xe9 in position 3: invalid continuation byte + +### Strengths + +- Clear separation between pure counters, formatting, and CLI orchestration; standard-library-only constraint is satisfied. +- All explicitly planned test cases are present, including exact report formatting and missing-file handling. +- Counter implementations directly express the specified behavior without unnecessary abstraction. + +### Issues + +#### Critical (Must Fix) + +None. + +#### Important (Should Fix) + +1. **The CLI never runs when invoked as a module** + - **File:** `wordstat/cli.py:27` + - `python3 -m wordstat.cli design.md` produces no output and exits 0. A missing path also silently succeeds. There is no other executable entry point. + - This leaves users unable to use the package as the advertised CLI. + - Add `if __name__ == "__main__": sys.exit(main(sys.argv[1:]))` and subprocess tests verifying output and exit codes. + +2. **Reading files silently removes characters** + - **File:** `wordstat/cli.py:15` + - Default universal-newline translation converts CRLF to LF before counting. Input `a\r\nb\r\n` contains six characters, but the report says `chars: 4`. + - This violates the character-count requirement, including whitespace, for ordinary Windows text files. + - Open with `newline=""` to preserve characters and add a CRLF integration test. + +3. **Undecodable input escapes error handling** + - **File:** `wordstat/cli.py:16–17` + - A non-UTF-8 file raises `UnicodeDecodeError`, which the `OSError` handler does not catch. + - Users encounter an uncaught exception instead of a concise diagnostic and controlled failure. + - Catch `UnicodeError` alongside `OSError`, return 1, and test invalid UTF-8. Document the accepted encoding. + +#### Minor (Nice to Have) + +1. **Commit structure differs from the plan** + - **Reference:** Commit `c6d45b3`; `plan.md`, Tasks 1–3. + - The range contains one combined commit, although the plan requests a commit after each task. + - This reduces independent review and rollback granularity. Confirm whether consolidation was intentional; history alone cannot establish test-first execution. + +### Recommendations + +Add the three behavioral regression tests above, especially subprocess coverage: calling `main()` directly cannot detect a missing executable entry point. + +Validation: five of six tests passed. The remaining test was blocked by the environment’s prohibition on temporary-file creation, before exercising application code. A separate read-only pipe check reproduced its expected successful report. The three reported behavioral defects were also reproduced. + +### Declined to judge + +- Streaming very large files: deferred because the plan defines whole-string counters and specifies no input-size or memory target. +- Additional input modes and configurable encodings: deferred because the planned interface accepts one file path; graceful rejection of unsupported encoding is covered above. + +### Assessment + +**Ready to merge? With fixes** + +**Reasoning:** The implementation closely follows the planned functions, but the advertised CLI lacks an executable entry point, CRLF files receive incorrect character counts, and decoding failures escape error handling. These require fixes before merging. diff --git a/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R3-both-5.review.txt b/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R3-both-5.review.txt new file mode 100644 index 000000000..0bc3d089b --- /dev/null +++ b/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R3-both-5.review.txt @@ -0,0 +1,375 @@ +I’ll read the plan and spec, review the full diff, and run checks without changing the checkout. +exec +/bin/zsh -lc "pwd; rg --files -g 'AGENTS.md' -g 'plan.md' -g 'design.md' -g 'pyproject.toml' -g '*test*'; git diff --stat 80fb626a30f5b7e12e73e4758925537d7e119179..HEAD; git status --short; cat plan.md design.md; git diff 80fb626a30f5b7e12e73e4758925537d7e119179..HEAD" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R3-both-5/repo + succeeded in 4491ms: +2026-09-17 12:55:44.115 xcodebuild[54901:99781008] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:55:44.273 xcodebuild[54901:99781006] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-3r6MfaKS' (errno=Operation not permitted) +2026-09-17 12:55:44.677 xcodebuild[54913:99781083] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:55:44.788 xcodebuild[54913:99781082] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. + test_cli.py | 30 ++++++++++++++++++++++++++++++ + test_counter.py | 18 ++++++++++++++++++ + test_formatter.py | 11 +++++++++++ + wordstat/cli.py | 27 +++++++++++++++++++++++++++ + wordstat/counter.py | 13 +++++++++++++ + wordstat/formatter.py | 5 +++++ + 6 files changed, 104 insertions(+) +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-oWjI8jwY' (errno=Operation not permitted) +2026-09-17 12:55:45.704 xcodebuild[54930:99781141] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:55:45.828 xcodebuild[54930:99781140] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-j0bg39Qg' (errno=Operation not permitted) +2026-09-17 12:55:46.234 xcodebuild[54935:99781171] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:55:46.362 xcodebuild[54935:99781170] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +# Implementation plan: `wordstat` + +Design context in `design.md`. Build the package with test-driven development: +for each task write the failing test first, then the implementation, then make it +pass, then commit. Standard library only; tests run with `python3 -m unittest`. + +## Task 1 — `wordstat/counter.py` + +Pure statistics functions. + +1. Write `test_counter.py` (failing) covering: + - `count_words("the quick brown fox")` == 4; `count_words("")` == 0. + - `count_lines("a\nb")` == 2; `count_lines("a\nb\n")` == 2; `count_lines("")` == 0. + - `count_chars("abc")` == 3; `count_chars("a b")` == 3. +2. Implement `wordstat/counter.py` with `count_words`, `count_lines`, + `count_chars`. +3. Run `python3 -m unittest test_counter` → green. Commit. + +## Task 2 — `wordstat/formatter.py` + +Render a stats dict to a report string. + +1. Write `test_formatter.py` (failing) covering: + - `format_report({"words": 12, "lines": 3, "chars": 57})` == + `"words: 12\nlines: 3\nchars: 57"`. +2. Implement `wordstat/formatter.py` with `format_report(stats)`. +3. Run `python3 -m unittest test_formatter` → green. Commit. + +## Task 3 — `wordstat/cli.py` + +Entry point composing the previous two modules. Read `wordstat/counter.py` and +`wordstat/formatter.py` to use their real function signatures. + +1. Write `test_cli.py` (failing) covering: + - `main([path])` for a temp file with known content prints the expected + report and returns 0. + - `main(["/no/such/file"])` returns 1. +2. Implement `wordstat/cli.py` with `main(argv)` using `argparse`, calling + `counter` then `formatter`. +3. Run `python3 -m unittest` (whole suite) → green. Commit. +# Design: `wordstat` — a tiny text-stats CLI + +A small Python package that computes simple statistics about a text file and +prints a human-readable report. + +## Package layout + +``` +wordstat/ + __init__.py # package marker (exists) + counter.py # pure stat functions (Task 1) + formatter.py # render a stats dict to a report string (Task 2) + cli.py # argparse entry point tying it together (Task 3) +test_counter.py # tests for counter (Task 1) +test_formatter.py # tests for formatter (Task 2) +test_cli.py # tests for cli (Task 3) +``` + +Tests live at the repo root and are runnable with `python3 -m unittest` +(stdlib only — no third-party test deps). + +## Behavior + +- `counter.count_words(text)` → int: number of whitespace-separated tokens. +- `counter.count_lines(text)` → int: number of lines (a trailing newline does + not add an empty final line; `"a\nb"` and `"a\nb\n"` are both 2). +- `counter.count_chars(text)` → int: number of characters including whitespace. +- `formatter.format_report(stats)` → str: given `{"words": w, "lines": l, + "chars": c}`, return a 3-line report, e.g. `"words: 12\nlines: 3\nchars: 57"`. +- `cli.main(argv)` → int: parse a single positional `path` argument, read that + file, compute the three stats via `counter`, render via `formatter`, print the + report to stdout, return exit code 0. Missing file → message to stderr, return 1. + +## Constraints + +- Standard library only. +- Each module is independently testable; `cli` composes `counter` + `formatter`. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-3rHIuuik' (errno=Operation not permitted) +2026-09-17 12:55:47.269 xcodebuild[54950:99781256] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:55:47.388 xcodebuild[54950:99781255] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-UHpxhMFe' (errno=Operation not permitted) +2026-09-17 12:55:47.759 xcodebuild[54952:99781266] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:55:47.873 xcodebuild[54952:99781265] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +diff --git a/test_cli.py b/test_cli.py +new file mode 100644 +index 0000000..0793491 +--- /dev/null ++++ b/test_cli.py +@@ -0,0 +1,30 @@ ++import io ++import tempfile ++import unittest ++from contextlib import redirect_stderr, redirect_stdout ++from pathlib import Path ++ ++from wordstat.cli import main ++ ++ ++class CliTests(unittest.TestCase): ++ def test_main_prints_report_for_file(self): ++ with tempfile.TemporaryDirectory() as directory: ++ path = Path(directory) / "sample.txt" ++ path.write_text("the quick\nbrown fox\n", encoding="utf-8") ++ stdout = io.StringIO() ++ ++ with redirect_stdout(stdout): ++ result = main([str(path)]) ++ ++ self.assertEqual(result, 0) ++ self.assertEqual(stdout.getvalue(), "words: 4\nlines: 2\nchars: 20\n") ++ ++ def test_main_returns_one_for_missing_file(self): ++ stderr = io.StringIO() ++ ++ with redirect_stderr(stderr): ++ result = main(["/no/such/file"]) ++ ++ self.assertEqual(result, 1) ++ self.assertTrue(stderr.getvalue()) +diff --git a/test_counter.py b/test_counter.py +new file mode 100644 +index 0000000..341b8df +--- /dev/null ++++ b/test_counter.py +@@ -0,0 +1,18 @@ ++import unittest ++ ++from wordstat.counter import count_chars, count_lines, count_words ++ ++ ++class CounterTests(unittest.TestCase): ++ def test_count_words(self): ++ self.assertEqual(count_words("the quick brown fox"), 4) ++ self.assertEqual(count_words(""), 0) ++ ++ def test_count_lines(self): ++ self.assertEqual(count_lines("a\nb"), 2) ++ self.assertEqual(count_lines("a\nb\n"), 2) ++ self.assertEqual(count_lines(""), 0) ++ ++ def test_count_chars(self): ++ self.assertEqual(count_chars("abc"), 3) ++ self.assertEqual(count_chars("a b"), 3) +diff --git a/test_formatter.py b/test_formatter.py +new file mode 100644 +index 0000000..925b36e +--- /dev/null ++++ b/test_formatter.py +@@ -0,0 +1,11 @@ ++import unittest ++ ++from wordstat.formatter import format_report ++ ++ ++class FormatterTests(unittest.TestCase): ++ def test_format_report(self): ++ self.assertEqual( ++ format_report({"words": 12, "lines": 3, "chars": 57}), ++ "words: 12\nlines: 3\nchars: 57", ++ ) +diff --git a/wordstat/cli.py b/wordstat/cli.py +new file mode 100644 +index 0000000..ec622c3 +--- /dev/null ++++ b/wordstat/cli.py +@@ -0,0 +1,27 @@ ++"""Command-line entry point for wordstat.""" ++ ++import argparse ++import sys ++ ++from . import counter, formatter ++ ++ ++def main(argv): ++ parser = argparse.ArgumentParser(description="Report statistics for a text file.") ++ parser.add_argument("path") ++ args = parser.parse_args(argv) ++ ++ try: ++ with open(args.path, encoding="utf-8") as source: ++ text = source.read() ++ except OSError as error: ++ print(f"wordstat: {error}", file=sys.stderr) ++ return 1 ++ ++ stats = { ++ "words": counter.count_words(text), ++ "lines": counter.count_lines(text), ++ "chars": counter.count_chars(text), ++ } ++ print(formatter.format_report(stats)) ++ return 0 +diff --git a/wordstat/counter.py b/wordstat/counter.py +new file mode 100644 +index 0000000..2153d0a +--- /dev/null ++++ b/wordstat/counter.py +@@ -0,0 +1,13 @@ ++"""Pure text statistics.""" ++ ++ ++def count_words(text): ++ return len(text.split()) ++ ++ ++def count_lines(text): ++ return len(text.splitlines()) ++ ++ ++def count_chars(text): ++ return len(text) +diff --git a/wordstat/formatter.py b/wordstat/formatter.py +new file mode 100644 +index 0000000..d6db16c +--- /dev/null ++++ b/wordstat/formatter.py +@@ -0,0 +1,5 @@ ++"""Render text statistics as a human-readable report.""" ++ ++ ++def format_report(stats): ++ return "\n".join(f"{name}: {stats[name]}" for name in ("words", "lines", "chars")) + +exec +/bin/zsh -lc "rg --files --hidden -g '"'!.git'"'; git log --oneline 80fb626a30f5b7e12e73e4758925537d7e119179..HEAD; PYTHONDONTWRITEBYTECODE=1 python3 -B -m unittest -v; PYTHONDONTWRITEBYTECODE=1 python3 -B -m wordstat.cli design.md" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R3-both-5/repo + succeeded in 1421ms: +2026-09-17 12:55:54.151 xcodebuild[54999:99781485] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:55:54.267 xcodebuild[54999:99781484] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-jDs9fI9g' (errno=Operation not permitted) +2026-09-17 12:55:54.651 xcodebuild[55005:99781538] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:55:54.772 xcodebuild[55005:99781535] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +9077d12 tasks 1-3 +test_main_prints_report_for_file (test_cli.CliTests.test_main_prints_report_for_file) ... ERROR +test_main_returns_one_for_missing_file (test_cli.CliTests.test_main_returns_one_for_missing_file) ... ok +test_count_chars (test_counter.CounterTests.test_count_chars) ... ok +test_count_lines (test_counter.CounterTests.test_count_lines) ... ok +test_count_words (test_counter.CounterTests.test_count_words) ... ok +test_format_report (test_formatter.FormatterTests.test_format_report) ... ok + +====================================================================== +ERROR: test_main_prints_report_for_file (test_cli.CliTests.test_main_prints_report_for_file) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R3-both-5/repo/test_cli.py", line 12, in test_main_prints_report_for_file + with tempfile.TemporaryDirectory() as directory: + ~~~~~~~~~~~~~~~~~~~~~~~~~~~^^ + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 907, in __init__ + self.name = mkdtemp(suffix, prefix, dir) + ~~~~~~~^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 370, in mkdtemp + prefix, suffix, dir, output_type = _sanitize_params(prefix, suffix, dir) + ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^ + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 127, in _sanitize_params + dir = gettempdir() + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 312, in gettempdir + return _os.fsdecode(_gettempdir()) + ~~~~~~~~~~~^^ + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 305, in _gettempdir + tempdir = _get_default_tempdir() + File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py", line 222, in _get_default_tempdir + raise FileNotFoundError(_errno.ENOENT, + "No usable temporary directory found in %s" % + dirlist) +FileNotFoundError: [Errno 2] No usable temporary directory found in ['/var/folders/43/prgnkdr95317fd_zbljq8thm0000gn/T/', '/tmp', '/var/tmp', '/usr/tmp', '/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R3-both-5/repo'] + +---------------------------------------------------------------------- +Ran 6 tests in 0.001s + +FAILED (errors=1) + +The functions match the planned examples, but `python -m wordstat.cli` exits silently because it never calls `main`. Five tests passed; the sixth is blocked by the read-only environment’s restriction on temporary files. I’m checking newline handling and decoding errors next. +exec +/bin/zsh -lc "python3 -B -c 'import io +from contextlib import redirect_stdout +from unittest.mock import patch +from wordstat.cli import main +for raw in [b\"a\\r\\nb\\r\\n\", b\"caf\\xe9\\n\"]: + out = io.StringIO() + try: + with patch(\"builtins.open\", return_value=io.TextIOWrapper(io.BytesIO(raw), encoding=\"utf-8\")), redirect_stdout(out): + result = main([\"sample.txt\"]) + print(repr(raw), \"return:\", result, \"report:\", repr(out.getvalue())) + except Exception as error: + print(repr(raw), type(error).__name__, str(error)) +with redirect_stdout(io.StringIO()) as out: + result = main([\"design.md\"]) +print(\"Existing file:\", result, repr(out.getvalue()))'" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R3-both-5/repo + succeeded in 0ms: +b'a\r\nb\r\n' return: 0 report: 'words: 2\nlines: 2\nchars: 4\n' +b'caf\xe9\n' UnicodeDecodeError 'utf-8' codec can't decode byte 0xe9 in position 3: invalid continuation byte +Existing file: 0 'words: 224\nlines: 37\nchars: 1535\n' + +### Strengths + +- Clean separation of counting, formatting, and CLI composition; standard library only. +- All explicitly planned test cases are present. Report ordering and missing-file error handling match the spec. +- Five tests passed. The remaining test could not create its temporary file under the read-only sandbox; calling `main` on an existing file succeeded. + +### Issues + +#### Critical (Must Fix) + +None. + +#### Important (Should Fix) + +1. **CLI invocation silently does nothing** + - File: `wordstat/cli.py:27` + - `python3 -m wordstat.cli design.md` produces no output and exits successfully because nothing invokes `main`. + - This leaves the advertised CLI unusable from the terminal. + - Add `if __name__ == "__main__": sys.exit(main(sys.argv[1:]))` and subprocess tests for successful and missing-file invocations. + +2. **Windows newlines cause incorrect character counts** + - File: `wordstat/cli.py:15` + - Default universal-newline translation removes carriage returns before counting. Input `a\r\nb\r\n` reports four characters instead of six. + - This violates counting characters including whitespace for ordinary CRLF files. + - Open with `newline=""` to preserve characters; add a CRLF integration test. + +3. **Decoding failures escape the error handler** + - File: `wordstat/cli.py:16–17` + - Non-UTF-8 input raises `UnicodeDecodeError`, which is not caught by `except OSError`. Confirmed with Latin-1 bytes for `café`. + - Users receive an exception instead of a concise diagnostic and return code 1. + - Catch `UnicodeError` alongside `OSError`, document the supported encoding, and test invalid UTF-8 input. + +#### Minor (Nice to Have) + +- **Commit sequence differs from the plan** + - Reference: `plan.md`, Tasks 1–3; commit `9077d12`. + - The range contains one combined commit, rather than a commit per task. The history also cannot establish the requested test-first sequence. + - Confirm whether this consolidation was intentional; no history rewrite is necessary for functional correctness. + +### Recommendations + +Add executable CLI coverage and input-boundary tests alongside the planned examples. Rerun the complete suite in an environment that permits temporary files. + +### Declined to judge + +- Streaming arbitrarily large files: no bounded-memory requirement or target file size is specified for this tiny CLI. +- Automatic encoding detection: UTF-8-only support is reasonable if documented and decoding failures are handled. +- Installer packaging and a bare `wordstat` command: no distribution mechanism is planned; module execution is sufficient. + +### Assessment + +**Ready to merge? With fixes** + +**Reasoning:** The module structure and planned examples are sound, but terminal invocation is missing, CRLF files receive incorrect character totals, and decoding errors are unhandled. These require fixes and regression coverage before merging. diff --git a/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R3-both-6.review.txt b/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R3-both-6.review.txt new file mode 100644 index 000000000..831891728 --- /dev/null +++ b/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/R3-both-6.review.txt @@ -0,0 +1,375 @@ +I’ll read the plan, spec, and full branch diff, then check behavior and run the tests without changing the checkout. +exec +/bin/zsh -lc "pwd; rg --files -g 'AGENTS.md' -g 'plan.md' -g 'design.md' -g 'pyproject.toml'; git status --short; git diff --stat 793a51622b97b5e1460bf6a3bebf82d3f51a62ca..HEAD; git diff 793a51622b97b5e1460bf6a3bebf82d3f51a62ca..HEAD" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R3-both-6/repo + succeeded in 4497ms: +2026-09-17 12:55:42.909 xcodebuild[54888:99780907] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:55:43.034 xcodebuild[54888:99780906] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-kEgT51Jz' (errno=Operation not permitted) +2026-09-17 12:55:43.406 xcodebuild[54892:99780943] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:55:43.519 xcodebuild[54892:99780942] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-oCMyN9mb' (errno=Operation not permitted) +2026-09-17 12:55:44.412 xcodebuild[54909:99781041] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:55:44.533 xcodebuild[54909:99781040] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-DOio0PZY' (errno=Operation not permitted) +2026-09-17 12:55:44.928 xcodebuild[54919:99781105] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:55:45.054 xcodebuild[54919:99781101] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. + test_cli.py | 30 ++++++++++++++++++++++++++++++ + test_counter.py | 18 ++++++++++++++++++ + test_formatter.py | 11 +++++++++++ + wordstat/cli.py | 27 +++++++++++++++++++++++++++ + wordstat/counter.py | 13 +++++++++++++ + wordstat/formatter.py | 5 +++++ + 6 files changed, 104 insertions(+) +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-87qFYT4x' (errno=Operation not permitted) +2026-09-17 12:55:45.979 xcodebuild[54933:99781159] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:55:46.101 xcodebuild[54933:99781158] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-JuwDuaiy' (errno=Operation not permitted) +2026-09-17 12:55:46.520 xcodebuild[54937:99781180] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:55:46.633 xcodebuild[54937:99781179] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +diff --git a/test_cli.py b/test_cli.py +new file mode 100644 +index 0000000..0793491 +--- /dev/null ++++ b/test_cli.py +@@ -0,0 +1,30 @@ ++import io ++import tempfile ++import unittest ++from contextlib import redirect_stderr, redirect_stdout ++from pathlib import Path ++ ++from wordstat.cli import main ++ ++ ++class CliTests(unittest.TestCase): ++ def test_main_prints_report_for_file(self): ++ with tempfile.TemporaryDirectory() as directory: ++ path = Path(directory) / "sample.txt" ++ path.write_text("the quick\nbrown fox\n", encoding="utf-8") ++ stdout = io.StringIO() ++ ++ with redirect_stdout(stdout): ++ result = main([str(path)]) ++ ++ self.assertEqual(result, 0) ++ self.assertEqual(stdout.getvalue(), "words: 4\nlines: 2\nchars: 20\n") ++ ++ def test_main_returns_one_for_missing_file(self): ++ stderr = io.StringIO() ++ ++ with redirect_stderr(stderr): ++ result = main(["/no/such/file"]) ++ ++ self.assertEqual(result, 1) ++ self.assertTrue(stderr.getvalue()) +diff --git a/test_counter.py b/test_counter.py +new file mode 100644 +index 0000000..341b8df +--- /dev/null ++++ b/test_counter.py +@@ -0,0 +1,18 @@ ++import unittest ++ ++from wordstat.counter import count_chars, count_lines, count_words ++ ++ ++class CounterTests(unittest.TestCase): ++ def test_count_words(self): ++ self.assertEqual(count_words("the quick brown fox"), 4) ++ self.assertEqual(count_words(""), 0) ++ ++ def test_count_lines(self): ++ self.assertEqual(count_lines("a\nb"), 2) ++ self.assertEqual(count_lines("a\nb\n"), 2) ++ self.assertEqual(count_lines(""), 0) ++ ++ def test_count_chars(self): ++ self.assertEqual(count_chars("abc"), 3) ++ self.assertEqual(count_chars("a b"), 3) +diff --git a/test_formatter.py b/test_formatter.py +new file mode 100644 +index 0000000..925b36e +--- /dev/null ++++ b/test_formatter.py +@@ -0,0 +1,11 @@ ++import unittest ++ ++from wordstat.formatter import format_report ++ ++ ++class FormatterTests(unittest.TestCase): ++ def test_format_report(self): ++ self.assertEqual( ++ format_report({"words": 12, "lines": 3, "chars": 57}), ++ "words: 12\nlines: 3\nchars: 57", ++ ) +diff --git a/wordstat/cli.py b/wordstat/cli.py +new file mode 100644 +index 0000000..ec622c3 +--- /dev/null ++++ b/wordstat/cli.py +@@ -0,0 +1,27 @@ ++"""Command-line entry point for wordstat.""" ++ ++import argparse ++import sys ++ ++from . import counter, formatter ++ ++ ++def main(argv): ++ parser = argparse.ArgumentParser(description="Report statistics for a text file.") ++ parser.add_argument("path") ++ args = parser.parse_args(argv) ++ ++ try: ++ with open(args.path, encoding="utf-8") as source: ++ text = source.read() ++ except OSError as error: ++ print(f"wordstat: {error}", file=sys.stderr) ++ return 1 ++ ++ stats = { ++ "words": counter.count_words(text), ++ "lines": counter.count_lines(text), ++ "chars": counter.count_chars(text), ++ } ++ print(formatter.format_report(stats)) ++ return 0 +diff --git a/wordstat/counter.py b/wordstat/counter.py +new file mode 100644 +index 0000000..2153d0a +--- /dev/null ++++ b/wordstat/counter.py +@@ -0,0 +1,13 @@ ++"""Pure text statistics.""" ++ ++ ++def count_words(text): ++ return len(text.split()) ++ ++ ++def count_lines(text): ++ return len(text.splitlines()) ++ ++ ++def count_chars(text): ++ return len(text) +diff --git a/wordstat/formatter.py b/wordstat/formatter.py +new file mode 100644 +index 0000000..d6db16c +--- /dev/null ++++ b/wordstat/formatter.py +@@ -0,0 +1,5 @@ ++"""Render text statistics as a human-readable report.""" ++ ++ ++def format_report(stats): ++ return "\n".join(f"{name}: {stats[name]}" for name in ("words", "lines", "chars")) + +exec +/bin/zsh -lc "rg --files --hidden -g '"'!.git'"'; cat plan.md design.md; ls -la wordstat; command -v python3" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R3-both-6/repo + succeeded in 0ms: +test_cli.py +test_counter.py +design.md +plan.md +test_formatter.py +wordstat/counter.py +wordstat/cli.py +wordstat/__init__.py +wordstat/formatter.py +# Implementation plan: `wordstat` + +Design context in `design.md`. Build the package with test-driven development: +for each task write the failing test first, then the implementation, then make it +pass, then commit. Standard library only; tests run with `python3 -m unittest`. + +## Task 1 — `wordstat/counter.py` + +Pure statistics functions. + +1. Write `test_counter.py` (failing) covering: + - `count_words("the quick brown fox")` == 4; `count_words("")` == 0. + - `count_lines("a\nb")` == 2; `count_lines("a\nb\n")` == 2; `count_lines("")` == 0. + - `count_chars("abc")` == 3; `count_chars("a b")` == 3. +2. Implement `wordstat/counter.py` with `count_words`, `count_lines`, + `count_chars`. +3. Run `python3 -m unittest test_counter` → green. Commit. + +## Task 2 — `wordstat/formatter.py` + +Render a stats dict to a report string. + +1. Write `test_formatter.py` (failing) covering: + - `format_report({"words": 12, "lines": 3, "chars": 57})` == + `"words: 12\nlines: 3\nchars: 57"`. +2. Implement `wordstat/formatter.py` with `format_report(stats)`. +3. Run `python3 -m unittest test_formatter` → green. Commit. + +## Task 3 — `wordstat/cli.py` + +Entry point composing the previous two modules. Read `wordstat/counter.py` and +`wordstat/formatter.py` to use their real function signatures. + +1. Write `test_cli.py` (failing) covering: + - `main([path])` for a temp file with known content prints the expected + report and returns 0. + - `main(["/no/such/file"])` returns 1. +2. Implement `wordstat/cli.py` with `main(argv)` using `argparse`, calling + `counter` then `formatter`. +3. Run `python3 -m unittest` (whole suite) → green. Commit. +# Design: `wordstat` — a tiny text-stats CLI + +A small Python package that computes simple statistics about a text file and +prints a human-readable report. + +## Package layout + +``` +wordstat/ + __init__.py # package marker (exists) + counter.py # pure stat functions (Task 1) + formatter.py # render a stats dict to a report string (Task 2) + cli.py # argparse entry point tying it together (Task 3) +test_counter.py # tests for counter (Task 1) +test_formatter.py # tests for formatter (Task 2) +test_cli.py # tests for cli (Task 3) +``` + +Tests live at the repo root and are runnable with `python3 -m unittest` +(stdlib only — no third-party test deps). + +## Behavior + +- `counter.count_words(text)` → int: number of whitespace-separated tokens. +- `counter.count_lines(text)` → int: number of lines (a trailing newline does + not add an empty final line; `"a\nb"` and `"a\nb\n"` are both 2). +- `counter.count_chars(text)` → int: number of characters including whitespace. +- `formatter.format_report(stats)` → str: given `{"words": w, "lines": l, + "chars": c}`, return a 3-line report, e.g. `"words: 12\nlines: 3\nchars: 57"`. +- `cli.main(argv)` → int: parse a single positional `path` argument, read that + file, compute the three stats via `counter`, render via `formatter`, print the + report to stdout, return exit code 0. Missing file → message to stderr, return 1. + +## Constraints + +- Standard library only. +- Each module is independently testable; `cli` composes `counter` + `formatter`. +total 32 +drwxr-xr-x@ 6 jesse wheel 192 Sep 17 12:55 . +drwxr-xr-x@ 9 jesse wheel 288 Sep 17 12:55 .. +-rw-r--r--@ 1 jesse wheel 76 Sep 17 12:55 __init__.py +-rw-r--r--@ 1 jesse wheel 687 Sep 17 12:55 cli.py +-rw-r--r--@ 1 jesse wheel 187 Sep 17 12:55 counter.py +-rw-r--r--@ 1 jesse wheel 172 Sep 17 12:55 formatter.py +/usr/local/bin/python3 + +exec +/bin/zsh -lc 'PYTHONDONTWRITEBYTECODE=1 python3 -m unittest -v; cat wordstat/__init__.py; git log --oneline 793a51622b97b5e1460bf6a3bebf82d3f51a62ca..HEAD; nl -ba wordstat/cli.py' in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R3-both-6/repo + succeeded in 1417ms: +2026-09-17 12:56:03.172 xcodebuild[55078:99781931] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:56:03.292 xcodebuild[55078:99781924] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead +git: error: couldn't create cache file '/tmp/xcrun_db-2iT22uEK' (errno=Operation not permitted) +2026-09-17 12:56:03.681 xcodebuild[55086:99781977] DVTFilePathFSEvents: Failed to start fs event stream. +2026-09-17 12:56:03.796 xcodebuild[55086:99781973] [MT] DVTDeveloperPaths: Failed to get length of DARWIN_USER_CACHE_DIR from confstr(3), error = Error Domain=NSPOSIXErrorDomain Code=5 "Input/output error". Using NSCachesDirectory instead. +85d84d7 tasks 1-3 + 1 """Command-line entry point for wordstat.""" + 2 + 3 import argparse + 4 import sys + 5 + 6 from . import counter, formatter + 7 + 8 + 9 def main(argv): + 10 parser = argparse.ArgumentParser(description="Report statistics for a text file.") + 11 parser.add_argument("path") + 12 args = parser.parse_args(argv) + 13 + 14 try: + 15 with open(args.path, encoding="utf-8") as source: + 16 text = source.read() + 17 except OSError as error: + 18 print(f"wordstat: {error}", file=sys.stderr) + 19 return 1 + 20 + 21 stats = { + 22 "words": counter.count_words(text), + 23 "lines": counter.count_lines(text), + 24 "chars": counter.count_chars(text), + 25 } + 26 print(formatter.format_report(stats)) + 27 return 0 + +exec +/bin/zsh -lc "PYTHONDONTWRITEBYTECODE=1 python3 -c 'import subprocess, sys +checks = [(\"module invocation\", [\"-m\", \"wordstat.cli\", \"design.md\"], None), (\"module missing file\", [\"-m\", \"wordstat.cli\", \"/no/such/file\"], None), (\"LF input\", [\"-c\", \"from wordstat.cli import main; raise SystemExit(main([\\\"/dev/stdin\\\"]))\"], b\"the quick\\nbrown fox\\n\"), (\"CRLF input\", [\"-c\", \"from wordstat.cli import main; raise SystemExit(main([\\\"/dev/stdin\\\"]))\"], b\"a\\r\\nb\\r\\n\"), (\"invalid UTF-8\", [\"-c\", \"from wordstat.cli import main; raise SystemExit(main([\\\"/dev/stdin\\\"]))\"], b\"\\xff\")] +for name, args, data in checks: + p = subprocess.run([sys.executable, \"-B\"] + args, input=data, capture_output=True) + print(name, \"exit=\", p.returncode, \"stdout=\", repr(p.stdout), \"stderr=\", repr(p.stderr))'" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R3-both-6/repo + succeeded in 29ms: +module invocation exit= 0 stdout= b'' stderr= b'' +module missing file exit= 0 stdout= b'' stderr= b'' +LF input exit= 0 stdout= b'words: 4\nlines: 2\nchars: 20\n' stderr= b'' +CRLF input exit= 0 stdout= b'words: 2\nlines: 2\nchars: 4\n' stderr= b'' +invalid UTF-8 exit= 1 stdout= b'' stderr= b'Traceback (most recent call last):\n File "", line 1, in \n from wordstat.cli import main; raise SystemExit(main(["/dev/stdin"]))\n ~~~~^^^^^^^^^^^^^^^^\n File "/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-rev/R3-both-6/repo/wordstat/cli.py", line 16, in main\n text = source.read()\n File "", line 325, in decode\nUnicodeDecodeError: \'utf-8\' codec can\'t decode byte 0xff in position 0: invalid start byte\n' + +### Strengths + +- Clear separation between pure counters, formatting, and CLI orchestration. +- All explicitly planned test cases are present, using only the standard library. +- Report ordering, whitespace tokenization, trailing-newline handling, and missing-file handling match the spec. + +### Issues + +#### Critical (Must Fix) + +None. + +#### Important (Should Fix) + +1. **CLI invocation silently does nothing** + - File: [wordstat/cli.py:27](wordstat/cli.py#L27) + - `python3 -m wordstat.cli design.md` produces no output and exits successfully. A missing path likewise exits successfully because nothing calls `main`. + - Add `if __name__ == "__main__": sys.exit(main(sys.argv[1:]))` and subprocess tests for successful and unsuccessful invocation. + +2. **Windows line endings cause incorrect character counts** + - File: [wordstat/cli.py:15](wordstat/cli.py#L15) + - Default newline translation changes CRLF to LF before counting. Confirmed that `a\r\nb\r\n` reports four characters instead of six. + - Open with `newline=""` to preserve whitespace characters, and add a CRLF integration test. + +3. **Decoding errors escape the CLI’s error handling** + - File: [wordstat/cli.py:16](wordstat/cli.py#L16) + - Invalid UTF-8 raises `UnicodeDecodeError`, which the `OSError` handler does not catch. Users receive a traceback, and callers of `main` receive an exception instead of an error status. + - Catch decoding errors, print a concise diagnostic to stderr, and return 1. Document the supported encoding. + +#### Minor (Nice to Have) + +1. **Commit structure deviates from the plan** + - Reference: commit `85d84d7`; [plan.md](plan.md), Tasks 1–3. + - The reviewed range contains one combined commit, although the plan requests a commit per task. Test-first execution cannot be verified from this history. + - Confirm whether consolidation was intentional; preserve task-level checkpoints in future work. + +### Recommendations + +Add subprocess coverage for the actual CLI entry point and regression tests for CRLF and decoding failures. + +Validation: five tests passed; one could not run because the read-only environment prevents temporary-file creation. A separate real-stream check reproduced the expected successful report. The three behavioral issues above were also reproduced. + +### Declined to judge + +- Streaming extremely large files: whole-file reading follows the plan, and no bounded-memory requirement is established. +- Additional interfaces such as multiple paths or a stdin flag: the specified interface accepts one file path. + +### Assessment + +**Ready to merge? With fixes** + +**Reasoning:** The planned functions are implemented cleanly, but the command-line entry point is inactive, newline translation changes character totals, and decoding failures lack controlled error handling. These should be fixed before merging. diff --git a/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/README.txt b/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/README.txt new file mode 100644 index 000000000..0d3bfa776 --- /dev/null +++ b/tests/inline-eval/microtests/reviewer-scope/results-2026-09-17/README.txt @@ -0,0 +1,22 @@ +Reviewer-side micro-test. gpt-6-astra at reasoning effort low (the model +Codex sessions chose as "most capable" for the final review), codex exec +read-only, reviewing a wordstat branch whose CLI catches OSError only. +Prompt = requesting-code-review/code-reviewer.md body with placeholders +filled, plus a variant block inserted before "Read-Only Review". 6 reps +each. Scored on the final report's own findings (reviews quote the diff +and run probes, so raw mentions mislead; see *.review.txt). + + where the decode case landed n +R0 current template Minor 6/6 +R1 + "Declined to judge" slot Declined to judge ("unspecified") 6/6 +R2 + spec-is-a-vision-document Important 6/6 + / reasonable-person standard +R3 both Important (+ other items Declined) 6/6 + +Every reviewer found the case, and most probed it with a b'\xff' file +during the review. What varied was the grade, and the grade is what the +executor's Critical/Important gate acts on. The current template ships it +6/6; the reasonable-person standard fixes it 6/6 with zero variance. The +slot alone makes the scoping visible (a ruling the executor must make) +without changing the grade. A prose principle moved the reviewer where +it moved no implementer: grading is a judgment the words can reach. diff --git a/tests/inline-eval/microtests/reviewer-scope/variants/R0-current.md b/tests/inline-eval/microtests/reviewer-scope/variants/R0-current.md new file mode 100644 index 000000000..16111aa19 --- /dev/null +++ b/tests/inline-eval/microtests/reviewer-scope/variants/R0-current.md @@ -0,0 +1,170 @@ +You are a Senior Code Reviewer with expertise in software architecture, +design patterns, and best practices. Your job is to review completed work +against its plan or requirements and identify issues before they cascade. + +## What Was Implemented + +The wordstat package: a text-statistics CLI implemented from plan.md (three tasks: counter, formatter, cli). All three tasks are committed; you are the final whole-branch review. + +## Requirements / Plan + +Read plan.md (the plan) and design.md (the spec) in this repository. + +## Git Range to Review + +**Base:** BASE_SHA +**Head:** HEAD + +```bash +git diff --stat BASE_SHA..HEAD +git diff BASE_SHA..HEAD +``` + +## Read-Only Review + +Your review is read-only on this checkout. Do not mutate the working tree, the index, HEAD, or branch state in any way. Use tools like `git show`, `git diff`, and `git log` to inspect history. If you need a working copy of a different revision, check it out into a separate temporary directory (e.g. `git worktree add /tmp/review-[SHA] [SHA]`) — never move HEAD on this checkout. + +## You Do Not Dispatch Subagents + +Do all of this review yourself. Never spawn a subagent to review part +of the diff, and never spawn another reviewer for a second opinion. +This process already provides every review seat the work gets; a +reviewer you spawn duplicates one of them at full cost, and its +verdict counts for nothing. If the diff feels too large for one +pass, review it in passes yourself and say so in your report. + +## What to Check + +**Plan alignment:** +- Does the implementation match the plan / requirements? +- Are deviations justified improvements, or problematic departures? +- Is all planned functionality present? + +**Code quality:** +- Clean separation of concerns? +- Proper error handling? +- Type safety where applicable? +- DRY without premature abstraction? +- Edge cases handled? + +**Architecture:** +- Sound design decisions? +- Reasonable scalability and performance? +- Security concerns? +- Integrates cleanly with surrounding code? + +**Testing:** +- Tests verify real behavior, not mocks? +- Edge cases covered? +- Integration tests where they matter? +- All tests passing? + +**Production readiness:** +- Migration strategy if schema changed? +- Backward compatibility considered? +- Documentation complete? +- No obvious bugs? + +## Calibration + +Categorize issues by actual severity. Not everything is Critical. +Acknowledge what was done well before listing issues — accurate praise +helps the implementer trust the rest of the feedback. + +If you find significant deviations from the plan, flag them specifically +so the implementer can confirm whether the deviation was intentional. +If you find issues with the plan itself rather than the implementation, +say so. + +## Output Format + +### Strengths +[What's well done? Be specific.] + +### Issues + +#### Critical (Must Fix) +[Bugs, security issues, data loss risks, broken functionality] + +#### Important (Should Fix) +[Architecture problems, missing features, poor error handling, test gaps] + +#### Minor (Nice to Have) +[Code style, optimization opportunities, documentation polish] + +For each issue: +- File:line reference +- What's wrong +- Why it matters +- How to fix (if not obvious) + +### Recommendations +[Improvements for code quality, architecture, or process] + +### Assessment + +**Ready to merge?** [Yes | No | With fixes] + +**Reasoning:** [1-2 sentence technical assessment] + +## Critical Rules + +**DO:** +- Categorize by actual severity +- Be specific (file:line, not vague) +- Explain WHY each issue matters +- Acknowledge strengths +- Give a clear verdict + +**DON'T:** +- Say "looks good" without checking +- Mark nitpicks as Critical +- Give feedback on code you didn't actually read +- Be vague ("improve error handling") +- Avoid giving a clear verdict +``` + +**Placeholders:** +- `The wordstat package: a text-statistics CLI implemented from plan.md (three tasks: counter, formatter, cli). All three tasks are committed; you are the final whole-branch review.` — brief summary of what was built +- `Read plan.md (the plan) and design.md (the spec) in this repository.` — what it should do (plan file path, task text, or requirements) +- `BASE_SHA` — starting commit +- `HEAD` — ending commit + +**Reviewer returns:** Strengths, Issues (Critical / Important / Minor), Recommendations, Assessment + +## Example Output + +``` +### Strengths +- Clean database schema with proper migrations (db.ts:15-42) +- Comprehensive test coverage (18 tests, all edge cases) +- Good error handling with fallbacks (summarizer.ts:85-92) + +### Issues + +#### Important +1. **Missing help text in CLI wrapper** + - File: index-conversations:1-31 + - Issue: No --help flag, users won't discover --concurrency + - Fix: Add --help case with usage examples + +2. **Date validation missing** + - File: search.ts:25-27 + - Issue: Invalid dates silently return no results + - Fix: Validate ISO format, throw error with example + +#### Minor +1. **Progress indicators** + - File: indexer.ts:130 + - Issue: No "X of Y" counter for long operations + - Impact: Users don't know how long to wait + +### Recommendations +- Add progress reporting for user experience +- Consider config file for excluded projects (portability) + +### Assessment + +**Ready to merge: With fixes** + +**Reasoning:** Core implementation is solid with good architecture and tests. Important issues (help text, date validation) are easily fixed and don't affect core functionality. \ No newline at end of file diff --git a/tests/inline-eval/microtests/reviewer-scope/variants/R1-slot.md b/tests/inline-eval/microtests/reviewer-scope/variants/R1-slot.md new file mode 100644 index 000000000..6bfa40bb7 --- /dev/null +++ b/tests/inline-eval/microtests/reviewer-scope/variants/R1-slot.md @@ -0,0 +1,177 @@ +You are a Senior Code Reviewer with expertise in software architecture, +design patterns, and best practices. Your job is to review completed work +against its plan or requirements and identify issues before they cascade. + +## What Was Implemented + +The wordstat package: a text-statistics CLI implemented from plan.md (three tasks: counter, formatter, cli). All three tasks are committed; you are the final whole-branch review. + +## Requirements / Plan + +Read plan.md (the plan) and design.md (the spec) in this repository. + +## Git Range to Review + +**Base:** BASE_SHA +**Head:** HEAD + +```bash +git diff --stat BASE_SHA..HEAD +git diff BASE_SHA..HEAD +``` + +## Declined to judge + +Before your verdict, list every behavior you considered and set aside as +outside the plan or spec, one line each, with the reason. The executor +rules on each line; nothing you set aside is dropped silently. An empty +list means you set nothing aside. + +## Read-Only Review + +Your review is read-only on this checkout. Do not mutate the working tree, the index, HEAD, or branch state in any way. Use tools like `git show`, `git diff`, and `git log` to inspect history. If you need a working copy of a different revision, check it out into a separate temporary directory (e.g. `git worktree add /tmp/review-[SHA] [SHA]`) — never move HEAD on this checkout. + +## You Do Not Dispatch Subagents + +Do all of this review yourself. Never spawn a subagent to review part +of the diff, and never spawn another reviewer for a second opinion. +This process already provides every review seat the work gets; a +reviewer you spawn duplicates one of them at full cost, and its +verdict counts for nothing. If the diff feels too large for one +pass, review it in passes yourself and say so in your report. + +## What to Check + +**Plan alignment:** +- Does the implementation match the plan / requirements? +- Are deviations justified improvements, or problematic departures? +- Is all planned functionality present? + +**Code quality:** +- Clean separation of concerns? +- Proper error handling? +- Type safety where applicable? +- DRY without premature abstraction? +- Edge cases handled? + +**Architecture:** +- Sound design decisions? +- Reasonable scalability and performance? +- Security concerns? +- Integrates cleanly with surrounding code? + +**Testing:** +- Tests verify real behavior, not mocks? +- Edge cases covered? +- Integration tests where they matter? +- All tests passing? + +**Production readiness:** +- Migration strategy if schema changed? +- Backward compatibility considered? +- Documentation complete? +- No obvious bugs? + +## Calibration + +Categorize issues by actual severity. Not everything is Critical. +Acknowledge what was done well before listing issues — accurate praise +helps the implementer trust the rest of the feedback. + +If you find significant deviations from the plan, flag them specifically +so the implementer can confirm whether the deviation was intentional. +If you find issues with the plan itself rather than the implementation, +say so. + +## Output Format + +### Strengths +[What's well done? Be specific.] + +### Issues + +#### Critical (Must Fix) +[Bugs, security issues, data loss risks, broken functionality] + +#### Important (Should Fix) +[Architecture problems, missing features, poor error handling, test gaps] + +#### Minor (Nice to Have) +[Code style, optimization opportunities, documentation polish] + +For each issue: +- File:line reference +- What's wrong +- Why it matters +- How to fix (if not obvious) + +### Recommendations +[Improvements for code quality, architecture, or process] + +### Assessment + +**Ready to merge?** [Yes | No | With fixes] + +**Reasoning:** [1-2 sentence technical assessment] + +## Critical Rules + +**DO:** +- Categorize by actual severity +- Be specific (file:line, not vague) +- Explain WHY each issue matters +- Acknowledge strengths +- Give a clear verdict + +**DON'T:** +- Say "looks good" without checking +- Mark nitpicks as Critical +- Give feedback on code you didn't actually read +- Be vague ("improve error handling") +- Avoid giving a clear verdict +``` + +**Placeholders:** +- `The wordstat package: a text-statistics CLI implemented from plan.md (three tasks: counter, formatter, cli). All three tasks are committed; you are the final whole-branch review.` — brief summary of what was built +- `Read plan.md (the plan) and design.md (the spec) in this repository.` — what it should do (plan file path, task text, or requirements) +- `BASE_SHA` — starting commit +- `HEAD` — ending commit + +**Reviewer returns:** Strengths, Issues (Critical / Important / Minor), Recommendations, Assessment + +## Example Output + +``` +### Strengths +- Clean database schema with proper migrations (db.ts:15-42) +- Comprehensive test coverage (18 tests, all edge cases) +- Good error handling with fallbacks (summarizer.ts:85-92) + +### Issues + +#### Important +1. **Missing help text in CLI wrapper** + - File: index-conversations:1-31 + - Issue: No --help flag, users won't discover --concurrency + - Fix: Add --help case with usage examples + +2. **Date validation missing** + - File: search.ts:25-27 + - Issue: Invalid dates silently return no results + - Fix: Validate ISO format, throw error with example + +#### Minor +1. **Progress indicators** + - File: indexer.ts:130 + - Issue: No "X of Y" counter for long operations + - Impact: Users don't know how long to wait + +### Recommendations +- Add progress reporting for user experience +- Consider config file for excluded projects (portability) + +### Assessment + +**Ready to merge: With fixes** + +**Reasoning:** Core implementation is solid with good architecture and tests. Important issues (help text, date validation) are easily fixed and don't affect core functionality. \ No newline at end of file diff --git a/tests/inline-eval/microtests/reviewer-scope/variants/R2-reasonable.md b/tests/inline-eval/microtests/reviewer-scope/variants/R2-reasonable.md new file mode 100644 index 000000000..06356911f --- /dev/null +++ b/tests/inline-eval/microtests/reviewer-scope/variants/R2-reasonable.md @@ -0,0 +1,179 @@ +You are a Senior Code Reviewer with expertise in software architecture, +design patterns, and best practices. Your job is to review completed work +against its plan or requirements and identify issues before they cascade. + +## What Was Implemented + +The wordstat package: a text-statistics CLI implemented from plan.md (three tasks: counter, formatter, cli). All three tasks are committed; you are the final whole-branch review. + +## Requirements / Plan + +Read plan.md (the plan) and design.md (the spec) in this repository. + +## Git Range to Review + +**Base:** BASE_SHA +**Head:** HEAD + +```bash +git diff --stat BASE_SHA..HEAD +git diff BASE_SHA..HEAD +``` + +## The spec is a vision document + +The spec says what the software must do. It does not enumerate every +input, environment, or condition the software will meet. For behavior the +spec is silent on, judge by what a reasonable user of this software would +expect: a reasonable user's expectation is a requirement, and a spec's +silence is not permission. Grade such findings by their effect on that +user, not by whether the spec mentions the trigger. + +## Read-Only Review + +Your review is read-only on this checkout. Do not mutate the working tree, the index, HEAD, or branch state in any way. Use tools like `git show`, `git diff`, and `git log` to inspect history. If you need a working copy of a different revision, check it out into a separate temporary directory (e.g. `git worktree add /tmp/review-[SHA] [SHA]`) — never move HEAD on this checkout. + +## You Do Not Dispatch Subagents + +Do all of this review yourself. Never spawn a subagent to review part +of the diff, and never spawn another reviewer for a second opinion. +This process already provides every review seat the work gets; a +reviewer you spawn duplicates one of them at full cost, and its +verdict counts for nothing. If the diff feels too large for one +pass, review it in passes yourself and say so in your report. + +## What to Check + +**Plan alignment:** +- Does the implementation match the plan / requirements? +- Are deviations justified improvements, or problematic departures? +- Is all planned functionality present? + +**Code quality:** +- Clean separation of concerns? +- Proper error handling? +- Type safety where applicable? +- DRY without premature abstraction? +- Edge cases handled? + +**Architecture:** +- Sound design decisions? +- Reasonable scalability and performance? +- Security concerns? +- Integrates cleanly with surrounding code? + +**Testing:** +- Tests verify real behavior, not mocks? +- Edge cases covered? +- Integration tests where they matter? +- All tests passing? + +**Production readiness:** +- Migration strategy if schema changed? +- Backward compatibility considered? +- Documentation complete? +- No obvious bugs? + +## Calibration + +Categorize issues by actual severity. Not everything is Critical. +Acknowledge what was done well before listing issues — accurate praise +helps the implementer trust the rest of the feedback. + +If you find significant deviations from the plan, flag them specifically +so the implementer can confirm whether the deviation was intentional. +If you find issues with the plan itself rather than the implementation, +say so. + +## Output Format + +### Strengths +[What's well done? Be specific.] + +### Issues + +#### Critical (Must Fix) +[Bugs, security issues, data loss risks, broken functionality] + +#### Important (Should Fix) +[Architecture problems, missing features, poor error handling, test gaps] + +#### Minor (Nice to Have) +[Code style, optimization opportunities, documentation polish] + +For each issue: +- File:line reference +- What's wrong +- Why it matters +- How to fix (if not obvious) + +### Recommendations +[Improvements for code quality, architecture, or process] + +### Assessment + +**Ready to merge?** [Yes | No | With fixes] + +**Reasoning:** [1-2 sentence technical assessment] + +## Critical Rules + +**DO:** +- Categorize by actual severity +- Be specific (file:line, not vague) +- Explain WHY each issue matters +- Acknowledge strengths +- Give a clear verdict + +**DON'T:** +- Say "looks good" without checking +- Mark nitpicks as Critical +- Give feedback on code you didn't actually read +- Be vague ("improve error handling") +- Avoid giving a clear verdict +``` + +**Placeholders:** +- `The wordstat package: a text-statistics CLI implemented from plan.md (three tasks: counter, formatter, cli). All three tasks are committed; you are the final whole-branch review.` — brief summary of what was built +- `Read plan.md (the plan) and design.md (the spec) in this repository.` — what it should do (plan file path, task text, or requirements) +- `BASE_SHA` — starting commit +- `HEAD` — ending commit + +**Reviewer returns:** Strengths, Issues (Critical / Important / Minor), Recommendations, Assessment + +## Example Output + +``` +### Strengths +- Clean database schema with proper migrations (db.ts:15-42) +- Comprehensive test coverage (18 tests, all edge cases) +- Good error handling with fallbacks (summarizer.ts:85-92) + +### Issues + +#### Important +1. **Missing help text in CLI wrapper** + - File: index-conversations:1-31 + - Issue: No --help flag, users won't discover --concurrency + - Fix: Add --help case with usage examples + +2. **Date validation missing** + - File: search.ts:25-27 + - Issue: Invalid dates silently return no results + - Fix: Validate ISO format, throw error with example + +#### Minor +1. **Progress indicators** + - File: indexer.ts:130 + - Issue: No "X of Y" counter for long operations + - Impact: Users don't know how long to wait + +### Recommendations +- Add progress reporting for user experience +- Consider config file for excluded projects (portability) + +### Assessment + +**Ready to merge: With fixes** + +**Reasoning:** Core implementation is solid with good architecture and tests. Important issues (help text, date validation) are easily fixed and don't affect core functionality. \ No newline at end of file diff --git a/tests/inline-eval/microtests/reviewer-scope/variants/R3-both.md b/tests/inline-eval/microtests/reviewer-scope/variants/R3-both.md new file mode 100644 index 000000000..365d87701 --- /dev/null +++ b/tests/inline-eval/microtests/reviewer-scope/variants/R3-both.md @@ -0,0 +1,186 @@ +You are a Senior Code Reviewer with expertise in software architecture, +design patterns, and best practices. Your job is to review completed work +against its plan or requirements and identify issues before they cascade. + +## What Was Implemented + +The wordstat package: a text-statistics CLI implemented from plan.md (three tasks: counter, formatter, cli). All three tasks are committed; you are the final whole-branch review. + +## Requirements / Plan + +Read plan.md (the plan) and design.md (the spec) in this repository. + +## Git Range to Review + +**Base:** BASE_SHA +**Head:** HEAD + +```bash +git diff --stat BASE_SHA..HEAD +git diff BASE_SHA..HEAD +``` + +## The spec is a vision document + +The spec says what the software must do. It does not enumerate every +input, environment, or condition the software will meet. For behavior the +spec is silent on, judge by what a reasonable user of this software would +expect: a reasonable user's expectation is a requirement, and a spec's +silence is not permission. Grade such findings by their effect on that +user, not by whether the spec mentions the trigger. + +## Declined to judge + +Before your verdict, list every behavior you considered and set aside as +outside the plan or spec, one line each, with the reason. The executor +rules on each line; nothing you set aside is dropped silently. An empty +list means you set nothing aside. + +## Read-Only Review + +Your review is read-only on this checkout. Do not mutate the working tree, the index, HEAD, or branch state in any way. Use tools like `git show`, `git diff`, and `git log` to inspect history. If you need a working copy of a different revision, check it out into a separate temporary directory (e.g. `git worktree add /tmp/review-[SHA] [SHA]`) — never move HEAD on this checkout. + +## You Do Not Dispatch Subagents + +Do all of this review yourself. Never spawn a subagent to review part +of the diff, and never spawn another reviewer for a second opinion. +This process already provides every review seat the work gets; a +reviewer you spawn duplicates one of them at full cost, and its +verdict counts for nothing. If the diff feels too large for one +pass, review it in passes yourself and say so in your report. + +## What to Check + +**Plan alignment:** +- Does the implementation match the plan / requirements? +- Are deviations justified improvements, or problematic departures? +- Is all planned functionality present? + +**Code quality:** +- Clean separation of concerns? +- Proper error handling? +- Type safety where applicable? +- DRY without premature abstraction? +- Edge cases handled? + +**Architecture:** +- Sound design decisions? +- Reasonable scalability and performance? +- Security concerns? +- Integrates cleanly with surrounding code? + +**Testing:** +- Tests verify real behavior, not mocks? +- Edge cases covered? +- Integration tests where they matter? +- All tests passing? + +**Production readiness:** +- Migration strategy if schema changed? +- Backward compatibility considered? +- Documentation complete? +- No obvious bugs? + +## Calibration + +Categorize issues by actual severity. Not everything is Critical. +Acknowledge what was done well before listing issues — accurate praise +helps the implementer trust the rest of the feedback. + +If you find significant deviations from the plan, flag them specifically +so the implementer can confirm whether the deviation was intentional. +If you find issues with the plan itself rather than the implementation, +say so. + +## Output Format + +### Strengths +[What's well done? Be specific.] + +### Issues + +#### Critical (Must Fix) +[Bugs, security issues, data loss risks, broken functionality] + +#### Important (Should Fix) +[Architecture problems, missing features, poor error handling, test gaps] + +#### Minor (Nice to Have) +[Code style, optimization opportunities, documentation polish] + +For each issue: +- File:line reference +- What's wrong +- Why it matters +- How to fix (if not obvious) + +### Recommendations +[Improvements for code quality, architecture, or process] + +### Assessment + +**Ready to merge?** [Yes | No | With fixes] + +**Reasoning:** [1-2 sentence technical assessment] + +## Critical Rules + +**DO:** +- Categorize by actual severity +- Be specific (file:line, not vague) +- Explain WHY each issue matters +- Acknowledge strengths +- Give a clear verdict + +**DON'T:** +- Say "looks good" without checking +- Mark nitpicks as Critical +- Give feedback on code you didn't actually read +- Be vague ("improve error handling") +- Avoid giving a clear verdict +``` + +**Placeholders:** +- `The wordstat package: a text-statistics CLI implemented from plan.md (three tasks: counter, formatter, cli). All three tasks are committed; you are the final whole-branch review.` — brief summary of what was built +- `Read plan.md (the plan) and design.md (the spec) in this repository.` — what it should do (plan file path, task text, or requirements) +- `BASE_SHA` — starting commit +- `HEAD` — ending commit + +**Reviewer returns:** Strengths, Issues (Critical / Important / Minor), Recommendations, Assessment + +## Example Output + +``` +### Strengths +- Clean database schema with proper migrations (db.ts:15-42) +- Comprehensive test coverage (18 tests, all edge cases) +- Good error handling with fallbacks (summarizer.ts:85-92) + +### Issues + +#### Important +1. **Missing help text in CLI wrapper** + - File: index-conversations:1-31 + - Issue: No --help flag, users won't discover --concurrency + - Fix: Add --help case with usage examples + +2. **Date validation missing** + - File: search.ts:25-27 + - Issue: Invalid dates silently return no results + - Fix: Validate ISO format, throw error with example + +#### Minor +1. **Progress indicators** + - File: indexer.ts:130 + - Issue: No "X of Y" counter for long operations + - Impact: Users don't know how long to wait + +### Recommendations +- Add progress reporting for user experience +- Consider config file for excluded projects (portability) + +### Assessment + +**Ready to merge: With fixes** + +**Reasoning:** Core implementation is solid with good architecture and tests. Important issues (help text, date validation) are easily fixed and don't affect core functionality. \ No newline at end of file diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/README.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/README.txt new file mode 100644 index 000000000..107690eb3 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/README.txt @@ -0,0 +1,111 @@ +Micro-test per writing-skills: one fresh-context codex exec per rep, +gpt-5.6-sol, reasoning effort low, repo with wordstat Tasks 1-2 done, +task = implement Task 3 (the CLI). Question: does the executing-plans +wording narrow the implementer's failure handling below its default? + +arm except clause n +control (no skill) except OSError 6/6 +v0 current v3 wording except OSError 6/6 +v1 scope-vs-failure except OSError 6/6 +v2 deletion-only except OSError 6/6 + +Zero separation, zero variance, and the control is already narrow. No +rep in any arm wrote a decode test or discussed undecodable input. +Reads: +1. The claim from the interrogation ("the brief's narrow scope and the + skill's instruction shaped that choice") is a plausible story the + session told, not a cause this test can find. Wording is not the lever + here. +2. v1's explicit positive principle (you own behavior for inputs the + brief does not name) changed nothing at low effort. A principle stated + in prose did not move a single rep. +3. The full bare Codex sessions (cxbare 21-23, 41-43) wrote + (OSError, UnicodeError) 6/6 in a different frame: TUI session, whole + plan in one go, its own Tasks 1-2. Whatever drives the defensive + idiom lives in that frame (exec-mode vs TUI system prompt, whole-plan + vs single-task prompt, own vs foreign prior code), not in the skill. +Consequence: prefer structural mechanisms with evidence (a Review Focus +list of implied cases, which moved Sonnet 4.5 implementers 3/3 under SDD; +a reviewer slot for declined scope) over re-wording the executor. + +FOLLOW-UP (each of the 24 sessions resumed and asked "what would have +gotten you to handle that input the way the spec handles the failures it +names?", ranked; see *.what-would.txt). All 24 rank identically: + 1. a required test for the undecodable input ("except OSError could not + pass it") 24/24 first + 2. a design.md line naming decoding failures 24/24 + 3. a Task 3 instruction naming the exception 24/24 + 4. a pre-implementation check: enumerate what the read can raise, map + each to the failure contract 24/24 + 5. (v1 arm only) "the AGENTS.md instruction already required this; its + presence alone demonstrably did not change my choice" 6/6 + Many add: a final review could catch it afterward but would not have + changed the initial except clause. +1-3 are case-specific (what Review Focus generates upstream). 4 is the +general mechanism: a procedure at the boundary, not a principle in prose. +5 is the sessions confirming the prose form is inert. + +v3-boundary-list (a prose step: "before the failing test for a step that +meets the outside world, list every way it can fail; each entry becomes a +test or a ledgered ruling"), 6 reps: + except FileNotFoundError 3, except OSError 2, except (OSError, UnicodeError) 1 + Worse and noisier than v0. Resumed and asked whether they made the list + and what would have gotten them to: 4/6 did not make it, 2/6 made a + partial list and "treated unspecified behavior as grounds to omit + coverage"; collective entries ("other read failures") stood in for + individual ones. All 6 rank first: a mandatory gate immediately before + the test is written whose output is produced, one entry per failure, + each with a test or a ruling; several note the micro-test's "skip + ledger bookkeeping" waiver swallowed the rulings. Form lesson: a + required slot in an artifact they already produce (the test file), not + a rule about a step. + +v4-boundary-slot (the list as a required comment block at the top of the +test file, one line per failure, each -> test or ruling; "other errors" +disallowed), 6 reps: + except OSError 3, except FileNotFoundError 3; decode test 0/6. + 5/6 produced the block. Its content was the cases the plan already + names (readable file, missing path) and nothing else: the slot forced + the shape, not the enumeration. See *.boundary.txt. +Reading across v1, v3, v4 and the 24 follow-ups: on gpt-5.6-sol at low +effort, a principle, a process step, and an artifact slot all fail at the +same place, generating failure modes the spec does not name. The +enumeration is the hard step, and it does not happen inside the +implementer at this effort no matter how the instruction is shaped. What +the sessions rank first, a test or a spec line naming the case, is an +enumeration done elsewhere. That is the Review Focus mechanism +(planner enumerates implied cases once, with the spec open), which moved +Sonnet 4.5 implementers 3/3 under SDD. + +PLANNER SIDE: gpt-5.6-sol at low effort asked to write the plan's Review +Focus section from design.md + plan.md (writing-plans' instruction +verbatim), 6 reps: 6-9 items each, undecodable input in 0/6 (items are +spec-adjacent: empty file, unterminated line, blank lines, whitespace, +argparse). The fresh Opus 5 planner ranked it 3rd of 10. + +Where the enumeration of what the spec implies actually happened today: + Opus 5 final reviewer with the template 11/11 found it (grading varied) + Opus 5 planner writing Review Focus 1/1 (ranked 3rd of 10) + Sonnet 4.5 implementers handed the list 3/3 implemented it + gpt-6-astra final reviewer 1/2 readable cases (once Minor, once "not specified") + gpt-5.6-sol low: implementer (v0-v4) 0/30 with any wording, 2/6 enumerated-then-ruled-out + gpt-5.6-sol low: planner 0/6 + gpt-5.6-sol low: bare TUI whole-plan sessions 6/6 by idiom, frame-dependent, unexplained +The lever is which model, at what effort, is asked to enumerate, and +whether its scoping decisions are written where a human sees them. Not +the executor's wording. + +EFFORT: control and v0 at reasoning effort medium, 6 each: except OSError +12/12. Effort alone does not produce the enumeration. + +v5-reasonable-ruling (v4 slot + a standard for "ruled out" lines: would a +reasonable user accept what happens; the spec is a vision document and +its silence is not permission; "the spec does not mention it" is never +the reason), 6 reps at low effort: + (OSError, UnicodeError) 3/6 (reps 2, 3, 6; rep 6 with a test), except + OSError 3/6. First implementer-side movement in five variants, and + noisy. The shipped reps' rulings: "ruled out: the CLI accepts text + files" (2) and no entry at all (1). New rationalization-table row: + "the contract accepts text files, so undecodable input is the user's + problem" -> a reasonable user hands the tool a file and gets a message, + not a traceback. diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/control-1.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/control-1.txt new file mode 100644 index 000000000..9ca91ce19 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/control-1.txt @@ -0,0 +1,7 @@ +arm=control rep=1 +except: except OSError +open: open(args.path, encoding="utf-8") +decode-test: 0 +0 +commits: 2 +final: I’ll inspect the plan, design notes, repository guidance, and current main state, then implement Task 3 and run the relevant tests before committing. exec /bin/zsh -lc "pwd && rg --files -g 'AGENTS.md' -g 'plan.md' -g 'design.md' -g '"'!*.pyc'"' && git status --short --branch" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro- diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/control-1.what-would.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/control-1.what-would.txt new file mode 100644 index 000000000..b17ad20f8 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/control-1.what-would.txt @@ -0,0 +1,7 @@ +Ranked by how reliably they would have changed my choice: +1. A required test writing invalid UTF-8 bytes and asserting `main([path])` returns 1, writes an error to stderr, and raises no exception. +2. A design.md requirement: “File access and UTF-8 decoding failures must print a message to stderr and return 1.” +3. A Task 3 instruction: “Handle both `OSError` and `UnicodeDecodeError` when reading the file.” +4. A review check before committing: “Run the CLI against invalid UTF-8 input and verify the same error behavior as for a missing file.” + +The test would have been strongest because catching only `OSError` could not pass it. diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/control-2.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/control-2.txt new file mode 100644 index 000000000..1ef974465 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/control-2.txt @@ -0,0 +1,7 @@ +arm=control rep=2 +except: except OSError +open: open(args.path, encoding="utf-8") +decode-test: 0 +0 +commits: 2 +final: I’ll inspect the plan, design, repository guidance, and the existing Task 1–2 implementation, then implement and test Task 3 on `main` before committing it. exec /bin/zsh -lc "pwd && rg --files -g 'AGENTS.md' -g 'plan.md' -g 'design.md' -g '"'!**/.git/**'"' && git status --short --branch && sed -n '1,240p' plan.md && sed -n '1,240p' design.md" in /private/tmp/claude-501/-Users-jesse-git-superpower diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/control-2.what-would.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/control-2.what-would.txt new file mode 100644 index 000000000..ed3d3fe71 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/control-2.what-would.txt @@ -0,0 +1,7 @@ +Ranked by how reliably they would have changed my choice: +1. A required test supplying invalid UTF-8 bytes and asserting a stderr message and return value `1`. +2. A line in `design.md`: “Unreadable files, including UTF-8 decoding failures, must produce a stderr message and return `1`.” +3. A Task 3 instruction: “Handle both `OSError` and `UnicodeDecodeError` when reading the file.” +4. A required review check: “Verify missing-file and invalid-UTF-8 behavior before committing.” + +The test would have been strongest because catching only `OSError` could not pass it. diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/control-3.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/control-3.txt new file mode 100644 index 000000000..eefbc1455 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/control-3.txt @@ -0,0 +1,7 @@ +arm=control rep=3 +except: except OSError +open: open(args.path, encoding="utf-8") +decode-test: 0 +0 +commits: 2 +final: I’ll inspect the plan, design, and existing implementation/tests first, then implement Task 3, run the relevant test suite, and commit only the scoped changes on `main`. exec /bin/zsh -lc "pwd && git status --short --branch && sed -n '1,240p' plan.md && sed -n '1,240p' design.md && rg --files" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/ diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/control-3.what-would.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/control-3.what-would.txt new file mode 100644 index 000000000..30582f502 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/control-3.what-would.txt @@ -0,0 +1,6 @@ +Ranked by how reliably they would have changed my choice: +1. A required test writing invalid UTF-8 bytes and asserting `main([path]) == 1`, a stderr message, and no traceback. +2. A design.md requirement: “Unreadable files, including UTF-8 decoding failures, must produce a stderr message and return 1.” +3. A plan step: “Handle both `OSError` and `UnicodeDecodeError` from opening or reading the file.” +4. A review check: “Verify the error path with an existing file containing invalid UTF-8.” +The original explicit failure requirement named only missing files; the concrete additions above would have extended my implementation beyond that case. diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/control-4.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/control-4.txt new file mode 100644 index 000000000..a4f6c22cf --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/control-4.txt @@ -0,0 +1,7 @@ +arm=control rep=4 +except: except OSError +open: open(args.path, encoding="utf-8") +decode-test: 0 +0 +commits: 2 +final: I’ll inspect the plan, design, and existing implementation/tests, then implement Task 3, run the relevant suite, and commit it on `main`. exec /bin/zsh -lc "pwd && git status --short --branch && sed -n '1,240p' plan.md && sed -n '1,240p' design.md && rg --files" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/micro-out/control-4/r diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/control-4.what-would.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/control-4.what-would.txt new file mode 100644 index 000000000..bde7b3968 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/control-4.what-would.txt @@ -0,0 +1,7 @@ +Ranked by how reliably they would have changed my choice: +1. A required test: write `b"\xff"` to a temp file; assert `main([path])` returns 1, writes a message to stderr, and raises no exception. +2. A design requirement: “Unreadable files, including invalid UTF-8, must produce a stderr message and return 1.” +3. A plan instruction: “Handle both `OSError` and `UnicodeDecodeError` when opening and reading the file.” +4. A review check: “Verify the CLI’s failure behavior for missing files, permission errors, and decoding errors.” + +The test would have been strongest: my `except OSError` implementation could not pass it. diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/control-5.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/control-5.txt new file mode 100644 index 000000000..41f482ceb --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/control-5.txt @@ -0,0 +1,7 @@ +arm=control rep=5 +except: except OSError +open: open(args.path, encoding="utf-8") +decode-test: 0 +0 +commits: 2 +final: I’ll inspect the plan, design, repository guidance, and existing implementation/tests first, then implement Task 3, run the relevant suite, and commit it on `main`. exec /bin/zsh -lc "pwd && rg --files -g 'AGENTS.md' -g 'plan.md' -g 'design.md' -g '"'!*.pyc'"' && git status --short --branch" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/sc diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/control-5.what-would.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/control-5.what-would.txt new file mode 100644 index 000000000..dde43e8b4 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/control-5.what-would.txt @@ -0,0 +1,7 @@ +Ranked by how reliably they would have changed my choice: +1. A required test writing invalid UTF-8 bytes and asserting `main([path])` returns 1, writes to stderr, and raises no exception. +2. A design.md requirement: “Unreadable files, including UTF-8 decoding failures, must produce a stderr message and return 1.” +3. A Task 3 instruction: “Handle both `OSError` and `UnicodeDecodeError` from opening or reading the file.” +4. A required review check: “Exercise invalid UTF-8 input and verify the same error contract as missing files.” + +The original design explicitly named only missing files; I didn’t extend that contract to decoding failures. diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/control-6.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/control-6.txt new file mode 100644 index 000000000..f8c015bea --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/control-6.txt @@ -0,0 +1,7 @@ +arm=control rep=6 +except: except OSError +open: open(args.path, encoding="utf-8") +decode-test: 0 +0 +commits: 2 +final: I’ll inspect the plan, design notes, repository instructions, and existing implementation/tests, then implement Task 3, run the relevant test suite, and commit it on `main`. exec /bin/zsh -lc "pwd && rg --files -g 'AGENTS.md' -g 'plan.md' -g 'design.md' -g '"'!**/.git/**'"' && git status --short --branch" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-6 diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/control-6.what-would.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/control-6.what-would.txt new file mode 100644 index 000000000..a46e33ec8 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/control-6.what-would.txt @@ -0,0 +1,4 @@ +1. A required test writing invalid UTF-8 bytes and asserting `main([path])` returns `1`, writes a message to stderr, and raises no exception. Most reliable: catching only `OSError` would fail it. +2. An explicit design requirement: “File-open, read, and UTF-8 decoding failures must print a message to stderr and return 1.” +3. A Task 3 instruction: “Handle both `OSError` and `UnicodeDecodeError` when reading the input.” +4. A required review check: “Try a file containing invalid UTF-8 and verify the CLI reports an error without a traceback.” This would most reliably catch the omission after implementation. diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/effort-medium-control-1.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/effort-medium-control-1.txt new file mode 100644 index 000000000..36f21bae5 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/effort-medium-control-1.txt @@ -0,0 +1,5 @@ +variant=control rep=1 effort=medium +except: except OSError +decode-test: 0 +0 +boundary: diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/effort-medium-control-2.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/effort-medium-control-2.txt new file mode 100644 index 000000000..c2c24de91 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/effort-medium-control-2.txt @@ -0,0 +1,5 @@ +variant=control rep=2 effort=medium +except: except OSError +decode-test: 0 +0 +boundary: diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/effort-medium-control-3.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/effort-medium-control-3.txt new file mode 100644 index 000000000..d44607ac7 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/effort-medium-control-3.txt @@ -0,0 +1,5 @@ +variant=control rep=3 effort=medium +except: except OSError +decode-test: 0 +0 +boundary: diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/effort-medium-control-4.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/effort-medium-control-4.txt new file mode 100644 index 000000000..87e39d02e --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/effort-medium-control-4.txt @@ -0,0 +1,5 @@ +variant=control rep=4 effort=medium +except: except OSError +decode-test: 0 +0 +boundary: diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/effort-medium-control-5.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/effort-medium-control-5.txt new file mode 100644 index 000000000..069458f5a --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/effort-medium-control-5.txt @@ -0,0 +1,5 @@ +variant=control rep=5 effort=medium +except: except OSError +decode-test: 0 +0 +boundary: diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/effort-medium-control-6.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/effort-medium-control-6.txt new file mode 100644 index 000000000..a5cd9cfe0 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/effort-medium-control-6.txt @@ -0,0 +1,5 @@ +variant=control rep=6 effort=medium +except: except OSError +decode-test: 0 +0 +boundary: diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/effort-medium-v0-current-1.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/effort-medium-v0-current-1.txt new file mode 100644 index 000000000..fa63cbf1a --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/effort-medium-v0-current-1.txt @@ -0,0 +1,5 @@ +variant=v0-current rep=1 effort=medium +except: except OSError +decode-test: 0 +0 +boundary: diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/effort-medium-v0-current-2.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/effort-medium-v0-current-2.txt new file mode 100644 index 000000000..224002463 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/effort-medium-v0-current-2.txt @@ -0,0 +1,5 @@ +variant=v0-current rep=2 effort=medium +except: except OSError +decode-test: 0 +0 +boundary: diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/effort-medium-v0-current-3.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/effort-medium-v0-current-3.txt new file mode 100644 index 000000000..deac1e0f3 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/effort-medium-v0-current-3.txt @@ -0,0 +1,5 @@ +variant=v0-current rep=3 effort=medium +except: except OSError +decode-test: 0 +0 +boundary: diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/effort-medium-v0-current-4.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/effort-medium-v0-current-4.txt new file mode 100644 index 000000000..4c33a00be --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/effort-medium-v0-current-4.txt @@ -0,0 +1,5 @@ +variant=v0-current rep=4 effort=medium +except: except OSError +decode-test: 0 +0 +boundary: diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/effort-medium-v0-current-5.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/effort-medium-v0-current-5.txt new file mode 100644 index 000000000..4cd7da5a5 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/effort-medium-v0-current-5.txt @@ -0,0 +1,5 @@ +variant=v0-current rep=5 effort=medium +except: except OSError +decode-test: 0 +0 +boundary: diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/effort-medium-v0-current-6.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/effort-medium-v0-current-6.txt new file mode 100644 index 000000000..e4ef20032 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/effort-medium-v0-current-6.txt @@ -0,0 +1,5 @@ +variant=v0-current rep=6 effort=medium +except: except OSError +decode-test: 0 +0 +boundary: diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/planner-review-focus-1.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/planner-review-focus-1.txt new file mode 100644 index 000000000..2b5f556db --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/planner-review-focus-1.txt @@ -0,0 +1,10 @@ +## Review Focus + +- Missing file: write an error message to stderr and return 1 without printing a report to stdout. +- Empty file: print `words: 0`, `lines: 0`, and `chars: 0`, then return 0. +- File with one unterminated line: count it as one line. +- Blank lines or consecutive newlines: count each actual line, while ignoring only the empty final line created by a trailing newline. +- Whitespace-only or irregularly spaced text: count tokens separated by spaces, tabs, or newlines, not empty fields. +- Unicode text and whitespace: count Python characters rather than encoded bytes and recognize whitespace for word splitting. +- Stats dictionaries with a different insertion order: always render `words`, `lines`, then `chars`, with no trailing newline. +- Missing or extra path arguments: let argument parsing reject them rather than attempting file processing. diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/planner-review-focus-2.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/planner-review-focus-2.txt new file mode 100644 index 000000000..c9e58ecb4 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/planner-review-focus-2.txt @@ -0,0 +1,8 @@ +## Review Focus + +- Missing file: `cli.main` writes an explanatory message to stderr, produces no stdout report, and returns 1. +- Non-space whitespace and repeated whitespace: `count_words` treats tabs, newlines, and runs of whitespace as separators without creating empty tokens. +- Single-line text: `count_lines("a")` and `count_lines("a\n")` both return 1. +- Blank lines: `count_lines` counts interior blank lines while ignoring only the empty final line created by a trailing newline. +- Whitespace and Unicode characters: `count_chars` includes whitespace and counts Python text characters, not encoded bytes. +- Zero-valued statistics: `format_report` preserves the specified key order and renders each zero normally. diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/planner-review-focus-3.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/planner-review-focus-3.txt new file mode 100644 index 000000000..f29f361ba --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/planner-review-focus-3.txt @@ -0,0 +1,9 @@ +## Review Focus + +- Whitespace-only and mixed-whitespace text (tabs, newlines, repeated spaces) — `count_words` treats every whitespace run as a separator and returns no empty tokens. +- A non-empty single line with no newline — `count_lines` returns 1. +- Leading or consecutive newlines — `count_lines` counts the resulting blank lines, while only a trailing newline avoids adding an extra final line. +- A file containing only one or more newline characters — line counting follows the same trailing-newline rule. +- A missing input file — `cli.main` writes an explanatory message to stderr, emits no report to stdout, and returns 1. +- No path or more than one path argument — `argparse` rejects the invocation because exactly one positional path is required. +- Unicode text — `count_chars` counts Python string characters, including Unicode characters and whitespace. diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/planner-review-focus-4.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/planner-review-focus-4.txt new file mode 100644 index 000000000..e8052c046 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/planner-review-focus-4.txt @@ -0,0 +1,11 @@ +## Review Focus + +- Missing file: `main([path])` writes an explanatory message to stderr and returns `1` without printing a report. +- Missing positional path: `argparse` reports the usage error and exits with its standard nonzero status. +- Empty file: the CLI prints `words: 0`, `lines: 0`, and `chars: 0`, then returns `0`. +- Whitespace-only text: `count_words` returns `0`, while `count_chars` includes every whitespace character. +- Tabs, repeated spaces, and newlines between words: `count_words` treats any run of whitespace as a separator. +- Single nonempty line without a newline: `count_lines` returns `1`. +- Blank lines within text: `count_lines` counts them as lines while still ignoring only the empty final line created by a trailing newline. +- Unicode text: `count_chars` counts Python characters, including Unicode characters, and `count_words` uses whitespace separation. +- Zero-valued statistics: `format_report` preserves the specified `words`, `lines`, `chars` order and emits exactly three lines with no trailing newline. diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/planner-review-focus-5.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/planner-review-focus-5.txt new file mode 100644 index 000000000..4cdc0153d --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/planner-review-focus-5.txt @@ -0,0 +1,10 @@ +## Review Focus + +- Single-line text without a newline — `count_lines("a")` should return `1`. +- Words separated by tabs, newlines, or repeated whitespace — `count_words` should count whitespace-separated tokens without producing empty words. +- Missing file — `cli.main` should write an error message to stderr as well as return `1`. +- Empty file through the CLI — it should print zero for words, lines, and characters and return `0`. +- Blank lines within text — each blank line should count as a line, while a trailing newline should not create an extra final line. +- Newline and tab characters — `count_chars` should include them in the character count. +- Unicode text — `count_words`, `count_lines`, and `count_chars` should operate on Python characters and Unicode whitespace consistently. +- Zero-valued statistics — `format_report` should preserve the specified key order and render each zero normally. diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/planner-review-focus-6.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/planner-review-focus-6.txt new file mode 100644 index 000000000..470afbec3 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/planner-review-focus-6.txt @@ -0,0 +1,9 @@ +## Review Focus + +- Missing file: write an error message to stderr and return exit code 1. +- Missing or extra path arguments: `argparse` rejects them because exactly one positional `path` is required. +- Whitespace-only text and non-space separators such as tabs/newlines: `count_words` counts whitespace-separated tokens and returns 0 when none exist. +- Single-line text without a newline: `count_lines` returns 1. +- Newline-only text: `count_lines` does not count the trailing empty segment as an additional line. +- Unicode text: `count_chars` counts Python characters, including Unicode characters and whitespace. +- Zero-valued statistics: `format_report` preserves the required `words`, `lines`, `chars` order and renders each value as `0`. diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/planner-review-focus.prompt.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/planner-review-focus.prompt.txt new file mode 100644 index 000000000..5e5e64465 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/planner-review-focus.prompt.txt @@ -0,0 +1 @@ +You are the author of plan.md for the wordstat package (design.md is the spec). The plan is otherwise complete. Write ONLY its '## Review Focus' section, following this instruction from the writing-plans skill: Input classes and failure modes the spec implies but no task's tests exercise — one line each, naming the input or condition and the behavior the spec implies for it, ordered by how likely each is to bite a user. Written here, once, with the spec in front of you; the final reviewer checks each deliberately instead of rediscovering them. Read design.md and plan.md first. Output the section as markdown and nothing else. Do not write code or tests. \ No newline at end of file diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v0-current-1.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v0-current-1.txt new file mode 100644 index 000000000..8ebf96382 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v0-current-1.txt @@ -0,0 +1,7 @@ +arm=v0-current rep=1 +except: except OSError +open: open(args.path, encoding="utf-8") +decode-test: 0 +0 +commits: 2 +final: I’m following the executing-plans workflow for Task 3, with your explicit exceptions for main, ledger helpers, and final review. I’ll inspect the plan/design and load the required TDD guidance before editing. exec /bin/zsh -lc "pwd && rg --files -g 'plan.md' -g 'design.md' -g 'SKILL.md' -g 'AGENTS.md' -g '"'!node_modules'"' | sort && git status --short --branch" in /private/tmp/claude-501/-Users-j diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v0-current-1.what-would.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v0-current-1.what-would.txt new file mode 100644 index 000000000..50e287c5d --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v0-current-1.what-would.txt @@ -0,0 +1,4 @@ +1. A required test: invalid UTF-8 bytes → `main([path])` returns 1, writes a message to stderr, and emits no traceback. This would most reliably have forced the change. +2. A line in `design.md`: “File opening, reading, and decoding failures must produce a stderr message and return 1, including `UnicodeDecodeError`.” +3. A Task 3 implementation instruction: “Catch both `OSError` and `UnicodeDecodeError` around file reading.” +4. A pre-commit check: “Run the CLI against invalid UTF-8 and verify controlled failure.” This would catch the omission after writing the clause. diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v0-current-2.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v0-current-2.txt new file mode 100644 index 000000000..e47ea991d --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v0-current-2.txt @@ -0,0 +1,7 @@ +arm=v0-current rep=2 +except: except OSError +open: open(args.path, encoding="utf-8") +decode-test: 0 +0 +commits: 2 +final: I’m following the repository’s executing-plans workflow for Task 3, with your stated exceptions for bookkeeping, review dispatch, and working directly on main. I’ll read the plan, design, and required TDD guidance before touching code. exec /bin/zsh -lc "pwd && rg --files -g 'plan.md' -g 'design.md' -g 'SKILL.md' -g 'AGENTS.md' -g 'wordstat/**' -g 'tests/**' | sort && git status --short --branch" diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v0-current-2.what-would.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v0-current-2.what-would.txt new file mode 100644 index 000000000..43ee535db --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v0-current-2.what-would.txt @@ -0,0 +1,6 @@ +Ranked by how reliably they would have changed my choice: +1. A required test using a file containing `b"\xff"`, asserting `main([path]) == 1` and a message on stderr. Passing it would require handling decoding failure. +2. A design.md requirement: “File-open, file-read, and UTF-8 decoding failures must print a message to stderr and return 1.” +3. A Task 3 instruction: “Handle both `OSError` and `UnicodeDecodeError` when reading the file.” +4. A pre-implementation check: “List exceptions from both `open()` and `source.read()` and map each to the required CLI behavior.” +A final review explicitly checking invalid UTF-8 could have caught it afterward, but would not have changed that initial choice. diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v0-current-3.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v0-current-3.txt new file mode 100644 index 000000000..932f73591 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v0-current-3.txt @@ -0,0 +1,7 @@ +arm=v0-current rep=3 +except: except OSError +open: open(args.path, encoding="utf-8") +decode-test: 0 +0 +commits: 2 +final: I’m following the executing-plans workflow for Task 3, with your explicit exceptions for workspace/ledger, final review, and working on main. I’ll inspect the plan, design, and current tree, then run the task test RED→GREEN and commit it. exec /bin/zsh -lc "pwd && rg --files -g 'plan.md' -g 'design.md' -g 'AGENTS.md' -g 'wordstat/**' -g 'test*' -g '*test*' && git status --short --branch && sed -n diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v0-current-3.what-would.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v0-current-3.what-would.txt new file mode 100644 index 000000000..1b0e523a3 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v0-current-3.what-would.txt @@ -0,0 +1,7 @@ +Ranked by how reliably they would have changed my choice: +1. A required test writing invalid UTF-8 bytes and asserting `main([path]) == 1`, a stderr diagnostic, and no exception. +2. A Task 3 instruction: “Catch both `OSError` and `UnicodeDecodeError`; print a diagnostic to stderr and return 1.” +3. A design requirement: “File-read failures, including decoding errors, must produce a stderr message and return 1.” +4. A mandatory pre-implementation check: “List exceptions raised by opening and reading the file, including decoding; map each to the required CLI behavior.” + +A final review checking invalid UTF-8 could have caught it afterward, but would not have changed my initial choice at the `except` clause. diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v0-current-4.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v0-current-4.txt new file mode 100644 index 000000000..d95c75e01 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v0-current-4.txt @@ -0,0 +1,7 @@ +arm=v0-current rep=4 +except: except OSError +open: read_text() +decode-test: 0 +0 +commits: 2 +final: I’m following the executing-plans workflow for Task 3, with your explicit exceptions for main, bookkeeping, helpers, and final review. I’ll inspect the plan/design and load the required TDD guidance before changing code. exec /bin/zsh -lc "pwd && rg --files -g 'plan.md' -g 'design.md' -g 'SKILL.md' -g 'wordstat/**' -g 'tests/**' | sort && git status --short --branch" in /private/tmp/claude-501/-Us diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v0-current-4.what-would.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v0-current-4.what-would.txt new file mode 100644 index 000000000..a0a21ba1a --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v0-current-4.what-would.txt @@ -0,0 +1,6 @@ +Ranked by how reliably they would have changed my choice: +1. A required test: write `b"\xff"` to a temp file; assert `main([path])` returns 1, writes stderr, and raises no exception. +2. A design.md requirement: “File-open, read, and decoding failures must produce a stderr message and return 1.” +3. A Task 3 instruction: “Handle both `OSError` and `UnicodeDecodeError` when reading the file.” +4. A pre-implementation check: “Enumerate exceptions from `read_text()` and map each to the CLI’s failure behavior.” +The test would have been strongest: the required RED→GREEN gate would have exposed the incomplete handler before commit. diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v0-current-5.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v0-current-5.txt new file mode 100644 index 000000000..c928f06b5 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v0-current-5.txt @@ -0,0 +1,7 @@ +arm=v0-current rep=5 +except: except OSError +open: open(args.path, encoding="utf-8") +decode-test: 0 +0 +commits: 2 +final: I’m following the executing-plans workflow with the user-approved exceptions: direct work on main, no helper ledger, and no final reviewer. I’ll read Task 3 and the design, then execute its RED→GREEN steps and commit. exec /bin/zsh -lc "sed -n '1,260p' plan.md && sed -n '1,240p' design.md && git status --short --branch && rg --files" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpow diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v0-current-5.what-would.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v0-current-5.what-would.txt new file mode 100644 index 000000000..b5b12a46b --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v0-current-5.what-would.txt @@ -0,0 +1,8 @@ +Ranked by how reliably they would have changed my choice: + +1. A required test writing invalid UTF-8 bytes and asserting `main([path])` returns 1, emits a stderr message, and produces no traceback. +2. A Task 3 instruction: “Handle both `OSError` and `UnicodeDecodeError` from reading the file; print the error to stderr and return 1.” +3. A design.md requirement: “Unreadable files, including invalid UTF-8, must produce a stderr message and return 1.” +4. A mandatory pre-implementation check: “List the exceptions that opening and decoding the input can raise, and assign each an expected CLI outcome.” + +A final review could have caught it afterward; it would not have changed that initial choice. diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v0-current-6.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v0-current-6.txt new file mode 100644 index 000000000..ea14cdc99 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v0-current-6.txt @@ -0,0 +1,7 @@ +arm=v0-current rep=6 +except: except OSError +open: open(args.path) +decode-test: 0 +0 +commits: 2 +final: I’m following the executing-plans workflow with your explicit exceptions: direct on main, no helper/ledger, and no final reviewer. I’ll read the plan, design, and repository instructions, then execute Task 3 test-first and commit it. exec /bin/zsh -lc "pwd && rg --files -g 'AGENTS.md' -g 'plan.md' -g 'design.md' -g '"'!**/.git/**'"' | sort && git status --short --branch" in /private/tmp/claude-501 diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v0-current-6.what-would.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v0-current-6.what-would.txt new file mode 100644 index 000000000..6ee2dda24 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v0-current-6.what-would.txt @@ -0,0 +1,6 @@ +Ranked by how reliably they would have changed my choice: +1. A required test that writes invalid UTF-8 bytes and asserts `main([path])` returns 1, emits a stderr message, and raises no exception. +2. A design.md requirement: “Read files as UTF-8; filesystem errors and decoding errors must produce a stderr message and return 1.” +3. A Task 3 implementation instruction: “Catch both `OSError` and `UnicodeDecodeError` around reading the file.” +4. A pre-implementation check: “Enumerate exceptions from opening and decoding input; assign each an error response.” +A final review checking malformed file contents could have caught it afterward, but would not have changed that initial choice. diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v1-scope-vs-failure-1.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v1-scope-vs-failure-1.txt new file mode 100644 index 000000000..d7e4386fe --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v1-scope-vs-failure-1.txt @@ -0,0 +1,7 @@ +arm=v1-scope-vs-failure rep=1 +except: except OSError +open: open(args.path, encoding="utf-8") +decode-test: 0 +0 +commits: 2 +final: I’m following the repository’s executing-plans workflow for Task 3, with your explicit exceptions for the helper bookkeeping, final review, and main-branch work. I’ll inspect the plan/design and current tree, then run the task test-first and commit it. exec /bin/zsh -lc "pwd && rg --files -g 'plan.md' -g 'design.md' -g 'AGENTS.md' -g 'wordstat/**' -g 'tests/**' && git status --short --branch && se diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v1-scope-vs-failure-1.what-would.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v1-scope-vs-failure-1.what-would.txt new file mode 100644 index 000000000..3599a2aa4 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v1-scope-vs-failure-1.what-would.txt @@ -0,0 +1,5 @@ +1. Most reliable: a required test using invalid UTF-8 bytes, asserting a stderr message and return value `1` without an exception. +2. An explicit Task 3 instruction: “Catch both `OSError` and `UnicodeDecodeError` when reading the file; report either to stderr and return `1`.” +3. A design requirement: “All file-open and text-decoding failures use the same stderr-message-and-return-1 behavior.” +4. A mandatory check before implementation: enumerate failures from `open(..., encoding="utf-8")` and `read()`, and test their handling. +5. The existing AGENTS.md instruction already required deliberate handling of unnamed inputs in the spec’s failure shape. Applying that instruction concretely to decoding should have changed my choice; its presence alone demonstrably did not. diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v1-scope-vs-failure-2.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v1-scope-vs-failure-2.txt new file mode 100644 index 000000000..a809b551f --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v1-scope-vs-failure-2.txt @@ -0,0 +1,7 @@ +arm=v1-scope-vs-failure rep=2 +except: except OSError +open: open(args.path, encoding="utf-8") +decode-test: 0 +0 +commits: 2 +final: I’m following the executing-plans workflow for Task 3, with your stated exceptions for helpers, ledger, review, and main-branch work. I’ll first read the plan, design, and required TDD guidance. exec /bin/zsh -lc "pwd && rg --files -g 'plan.md' -g 'design.md' -g 'SKILL.md' -g 'AGENTS.md' -g '"'!**/.git/**'"' | sort && git status --short --branch && git log --oneline -5" in /private/tmp/claude-501/ diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v1-scope-vs-failure-2.what-would.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v1-scope-vs-failure-2.what-would.txt new file mode 100644 index 000000000..a4bf1448b --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v1-scope-vs-failure-2.what-would.txt @@ -0,0 +1,5 @@ +1. A required test using invalid UTF-8 bytes, asserting stderr output and return value `1`, would most reliably have driven me to catch `UnicodeDecodeError`. +2. A Task 3 instruction: “Catch both `OSError` and `UnicodeDecodeError`; report either to stderr and return `1`.” +3. A design requirement: “All file-opening and text-decoding failures produce a stderr message and return `1`.” +4. A pre-implementation check: “Enumerate exceptions from `open(..., encoding='utf-8')` and `read()`; assign each the specified failure behavior.” +The existing AGENTS.md instruction already required deliberate handling of unnamed inputs in the spec’s failure shape. It should have been sufficient; I failed to apply it to decoding. diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v1-scope-vs-failure-3.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v1-scope-vs-failure-3.txt new file mode 100644 index 000000000..c6b39c47d --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v1-scope-vs-failure-3.txt @@ -0,0 +1,7 @@ +arm=v1-scope-vs-failure rep=3 +except: except OSError +open: read_text(encoding="utf-8") +decode-test: 0 +0 +commits: 2 +final: I’m using the repository’s executing-plans workflow, with your explicit exceptions for the helper/ledger, final review, and working directly on main. I’ll read the plan, design, and required TDD guidance before starting Task 3. exec /bin/zsh -lc "pwd && rg --files -g 'plan.md' -g 'design.md' -g 'SKILL.md' -g 'AGENTS.md' -g '"'!**/.git/**'"' | sort && git status --short --branch && find .. -path '* diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v1-scope-vs-failure-3.what-would.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v1-scope-vs-failure-3.what-would.txt new file mode 100644 index 000000000..a6e282923 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v1-scope-vs-failure-3.what-would.txt @@ -0,0 +1,6 @@ +Ranked by reliability: +1. A required test feeding invalid UTF-8 bytes and asserting stderr output and return code 1; it would fail with `except OSError`. +2. An explicit Task 3 instruction: “Catch `UnicodeDecodeError` as well as `OSError`; report either to stderr and return 1.” +3. A design requirement: “Unreadable files, including invalid UTF-8, produce a stderr message and return 1.” +4. A pre-completion check requiring an invalid-UTF-8 fixture. +5. The instruction already in AGENTS.md: unnamed inputs must fail deliberately “in the same shape the spec uses for the failures it does name.” That already required this behavior; I failed to apply it. diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v1-scope-vs-failure-4.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v1-scope-vs-failure-4.txt new file mode 100644 index 000000000..d5485265b --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v1-scope-vs-failure-4.txt @@ -0,0 +1,7 @@ +arm=v1-scope-vs-failure rep=4 +except: except OSError +open: open(args.path, encoding="utf-8") +decode-test: 0 +0 +commits: 2 +final: I’ll execute Task 3 inline under the repository’s plan rules, with the requested exceptions for ledger/helpers/final review and direct work on main. I’ll first read the plan, design, and required TDD guidance. exec /bin/zsh -lc "pwd && rg --files -g 'plan.md' -g 'design.md' -g 'SKILL.md' -g 'AGENTS.md' -g '"'!**/.git/**'"' && git status --short --branch" in /private/tmp/claude-501/-Users-jesse-git diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v1-scope-vs-failure-4.what-would.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v1-scope-vs-failure-4.what-would.txt new file mode 100644 index 000000000..a94391fbd --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v1-scope-vs-failure-4.what-would.txt @@ -0,0 +1,4 @@ +1. A failing test that writes invalid UTF-8 bytes and asserts `main([path])` returns 1, emits stderr, and leaves stdout empty. That would most reliably force the catch to cover decoding failures. +2. An explicit Task 3 requirement: “Catch `OSError` and `UnicodeDecodeError`; report either to stderr and return 1.” +3. A pre-completion check: “Exercise unreadable text, including invalid UTF-8; no traceback may escape.” +4. The instruction already in AGENTS.md: unnamed inputs must fail “in the same shape the spec uses for the failures it does name.” Applying that instruction when choosing the exception boundary should have been sufficient; I failed to apply it. diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v1-scope-vs-failure-5.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v1-scope-vs-failure-5.txt new file mode 100644 index 000000000..3009f2a0d --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v1-scope-vs-failure-5.txt @@ -0,0 +1,7 @@ +arm=v1-scope-vs-failure rep=5 +except: except OSError +open: read_text() +decode-test: 0 +0 +commits: 2 +final: I’m following the repository’s executing-plans rules with your explicit exceptions: no helper/ledger/reviewer, and Task 3 directly on main. I’ll read the plan, design, and required TDD guidance before editing. exec /bin/zsh -lc "pwd && rg --files -g 'plan.md' -g 'design.md' -g 'SKILL.md' -g 'AGENTS.md' -g '"'!/.git'"' | sort && git status --short --branch && git log --oneline -5" in /private/tmp/c diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v1-scope-vs-failure-5.what-would.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v1-scope-vs-failure-5.what-would.txt new file mode 100644 index 000000000..caf517c72 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v1-scope-vs-failure-5.what-would.txt @@ -0,0 +1,5 @@ +1. Most reliable: a required test using invalid UTF-8 bytes, asserting stderr output and return code 1 without an exception. +2. An explicit Task 3 instruction: “Catch both `OSError` and `UnicodeError` when reading the file; print the error to stderr and return 1.” +3. A design.md requirement: “Unreadable files, including decoding failures, use the missing-file error shape.” +4. A pre-implementation check: “List the exceptions `read_text()` can raise and deliberately handle each expected input failure.” +5. The instruction already in AGENTS.md: unnamed inputs must fail deliberately “in the same shape the spec uses for the failures it does name.” That already required this behavior; I failed to apply it to decoding errors. diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v1-scope-vs-failure-6.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v1-scope-vs-failure-6.txt new file mode 100644 index 000000000..48c2cf5f6 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v1-scope-vs-failure-6.txt @@ -0,0 +1,7 @@ +arm=v1-scope-vs-failure rep=6 +except: except OSError +open: open(args.path, encoding="utf-8") +decode-test: 0 +0 +commits: 2 +final: I’m following the executing-plans workflow for Task 3, with your explicit exceptions for workspace/ledger, final review, and working on main. I’ll first read the plan, design, and required TDD/verification guidance. exec /bin/zsh -lc "pwd && rg --files -g 'plan.md' -g 'design.md' -g 'AGENTS.md' -g 'SKILL.md' -g '"'!**/.git/**'"' . .. /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v1-scope-vs-failure-6.what-would.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v1-scope-vs-failure-6.what-would.txt new file mode 100644 index 000000000..8150bfce1 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v1-scope-vs-failure-6.what-would.txt @@ -0,0 +1,6 @@ +1. Most reliable: a required test writing invalid UTF-8 bytes, then asserting `main([path])` returns 1, writes stderr, and leaves stdout empty. +2. An explicit Task 3 instruction: “Catch `UnicodeDecodeError` as well as `OSError`; both produce a stderr message and return 1.” +3. A design requirement: “Files that cannot be decoded as UTF-8 use the same failure behavior as missing files.” +4. A pre-completion check exercising a binary file through `main`. + +Your existing AGENTS.md already required deliberate handling of unnamed inputs in the spec’s failure shape. That was sufficient authority; I failed to apply it to decoding. diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v2-deletion-only-1.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v2-deletion-only-1.txt new file mode 100644 index 000000000..670fa23e3 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v2-deletion-only-1.txt @@ -0,0 +1,7 @@ +arm=v2-deletion-only rep=1 +except: except OSError +open: open(args.path, encoding="utf-8") +decode-test: 0 +0 +commits: 2 +final: I’m applying the repository’s executing-plans rules with your stated exceptions: Task 3 only, direct on main, no ledger helpers or final reviewer. I’ll inspect the plan, design, and current tree before starting the required RED→GREEN cycle. exec /bin/zsh -lc "pwd && rg --files -g 'plan.md' -g 'design.md' -g 'AGENTS.md' -g 'wordstat/**' -g 'tests/**' && git status --short --branch && sed -n '1,260p diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v2-deletion-only-1.what-would.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v2-deletion-only-1.what-would.txt new file mode 100644 index 000000000..dbf7231d3 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v2-deletion-only-1.what-would.txt @@ -0,0 +1,8 @@ +Ranked by how reliably they would have changed my choice: + +1. A required test writing invalid UTF-8 bytes and asserting `main([path])` returns 1, writes stderr, and emits no traceback. +2. A design.md requirement: “File-open, read, and decoding failures must print a message to stderr and return 1.” +3. A Task 3 instruction: “Handle both `OSError` and `UnicodeDecodeError` from reading the file.” +4. A pre-implementation check: “Enumerate exceptions from opening and reading with `encoding='utf-8'`; map each to the specified failure behavior.” + +A final review could have caught it afterward, but would not have changed the initial clause. diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v2-deletion-only-2.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v2-deletion-only-2.txt new file mode 100644 index 000000000..5fef589ac --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v2-deletion-only-2.txt @@ -0,0 +1,7 @@ +arm=v2-deletion-only rep=2 +except: except OSError +open: read_text(encoding="utf-8") +decode-test: 0 +0 +commits: 2 +final: I’m following the executing-plans task loop, with your explicit exceptions for helpers, ledger, final review, and working on main. I’ll read the plan/design and applicable repo instructions, then run Task 3 test-first through commit. exec /bin/zsh -lc "pwd && rg --files -g 'AGENTS.md' -g 'plan.md' -g 'design.md' -g 'SKILL.md' -g '"'!node_modules'"' | sort && git status --short --branch && sed -n ' diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v2-deletion-only-2.what-would.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v2-deletion-only-2.what-would.txt new file mode 100644 index 000000000..c70d958a6 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v2-deletion-only-2.what-would.txt @@ -0,0 +1,4 @@ +1. A required test: write `b"\xff"` to a temp file; assert `main([path])` returns 1, writes a diagnostic to stderr, and does not raise. This would most reliably have forced handling `UnicodeDecodeError`. +2. A line in `design.md`: “Unreadable files, including invalid UTF-8, must produce a stderr message and return 1 without a traceback.” +3. A Task 3 instruction: “Handle both `OSError` and `UnicodeDecodeError` from file reading.” +4. A pre-implementation check: “Enumerate failures of the chosen read operation and map each to the CLI error contract.” This would have prompted the missing case, but less reliably than an explicit requirement or test. diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v2-deletion-only-3.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v2-deletion-only-3.txt new file mode 100644 index 000000000..fc772d094 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v2-deletion-only-3.txt @@ -0,0 +1,7 @@ +arm=v2-deletion-only rep=3 +except: except OSError +open: open(args.path) +decode-test: 0 +0 +commits: 2 +final: I’m following the executing-plans workflow with the explicitly requested bookkeeping/review omissions. I’ll read the plan, design, repository guidance, and required testing instructions before touching Task 3. exec /bin/zsh -lc "pwd && rg --files -g 'AGENTS.md' -g 'plan.md' -g 'design.md' -g 'SKILL.md' -g '"'!**/.git/**'"' .. /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313b diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v2-deletion-only-3.what-would.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v2-deletion-only-3.what-would.txt new file mode 100644 index 000000000..7feecb230 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v2-deletion-only-3.what-would.txt @@ -0,0 +1,6 @@ +Ranked by how reliably they would have changed my choice: +1. A required test: write `b"\xff"` to a temporary file; assert `main([path])` returns 1, writes to stderr, and raises no exception. +2. A design.md requirement: “Read files as UTF-8; filesystem errors and decoding errors must produce a stderr message and return 1.” +3. A Task 3 instruction: “Handle both `OSError` and `UnicodeDecodeError` when reading the file.” +4. A pre-implementation check: “Enumerate exceptions from opening **and decoding** input, and map each to the CLI’s failure contract.” +A final review targeting invalid encodings could have caught it afterward, but would not have changed the initial `except` choice. diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v2-deletion-only-4.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v2-deletion-only-4.txt new file mode 100644 index 000000000..b53bf8038 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v2-deletion-only-4.txt @@ -0,0 +1,7 @@ +arm=v2-deletion-only rep=4 +except: except OSError +open: open(args.path, encoding="utf-8") +decode-test: 0 +0 +commits: 2 +final: I’ll execute Task 3 inline, using the plan and design as authority. Per your exception, I’ll skip the unavailable bookkeeping and final reviewer, and work directly on `main`. exec /bin/zsh -lc "pwd && git status --short --branch && sed -n '1,240p' plan.md && sed -n '1,240p' design.md && rg --files" in /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf40 diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v2-deletion-only-4.what-would.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v2-deletion-only-4.what-would.txt new file mode 100644 index 000000000..686b5b037 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v2-deletion-only-4.what-would.txt @@ -0,0 +1,6 @@ +1. A required test: write `b"\xff"` to a temp file; assert `main([path])` returns `1`, emits stderr, and prints no report. That would most reliably have driven handling of `UnicodeDecodeError`. +2. A line in `design.md`: “File-open, read, and UTF-8 decoding failures must emit a stderr message and return `1` without a traceback.” +3. A Task 3 implementation instruction: “Catch both `OSError` and `UnicodeDecodeError` around opening and reading the file.” +4. A pre-implementation check: “Enumerate exceptions from `open(..., encoding='utf-8')` and `read()`; assign each an exit behavior.” + +A final review could have caught the omission later, but would not have changed the initial choice. diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v2-deletion-only-5.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v2-deletion-only-5.txt new file mode 100644 index 000000000..ab540d744 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v2-deletion-only-5.txt @@ -0,0 +1,7 @@ +arm=v2-deletion-only rep=5 +except: except OSError +open: open(args.path, encoding="utf-8") +decode-test: 0 +0 +commits: 2 +final: I’m using the repository’s executing-plans workflow, with your stated exceptions for helpers, ledger, final review, and working directly on main. I’ll read Task 3 and the TDD guidance, then implement, test, and commit it. exec /bin/zsh -lc "pwd && rg --files -g 'plan.md' -g 'design.md' -g 'AGENTS.md' -g 'SKILL.md' -g '*test*' -g 'wordstat/**' | sort && find .. -path '*test-driven-development/SKILL diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v2-deletion-only-5.what-would.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v2-deletion-only-5.what-would.txt new file mode 100644 index 000000000..b356b9c2d --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v2-deletion-only-5.what-would.txt @@ -0,0 +1,7 @@ +Ranked by how reliably they would have changed my choice: +1. A Task 3 test: write `b"\xff"` to a temp file; assert `main([path])` returns 1, writes a message to stderr, and raises no exception. +2. A `design.md` requirement: “Unreadable files, including invalid UTF-8, produce a stderr message and return 1.” +3. A plan instruction: “Handle both `OSError` and `UnicodeDecodeError` from reading the file.” +4. A pre-implementation check: “List the exceptions opening and decoding the input can raise; map each to the CLI’s failure behavior.” + +A final review targeting invalid encodings could have caught it afterward, but would not have changed the initial clause. diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v2-deletion-only-6.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v2-deletion-only-6.txt new file mode 100644 index 000000000..0414dfd58 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v2-deletion-only-6.txt @@ -0,0 +1,7 @@ +arm=v2-deletion-only rep=6 +except: except OSError +open: open(args.path, encoding="utf-8") +decode-test: 0 +0 +commits: 2 +final: I’m following the executing-plans workflow for Task 3, with your explicit exceptions for the helper scripts, ledger, final review, and working directly on main. I’ll inspect the plan/design and load the required TDD guidance first. exec /bin/zsh -lc "sed -n '1,240p' plan.md && sed -n '1,240p' design.md && find .. -path '*test-driven-development/SKILL.md' -o -path '*verification-before-completion/S diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v2-deletion-only-6.what-would.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v2-deletion-only-6.what-would.txt new file mode 100644 index 000000000..c134d56eb --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v2-deletion-only-6.what-would.txt @@ -0,0 +1,7 @@ +Ranked by how reliably they would have changed my choice: +1. A Task 3 test writing invalid UTF-8 bytes and asserting `main([path])` returns 1, emits stderr, and prints no report. +2. A design.md requirement: “File-open, read, and text-decoding failures must emit a stderr message and return 1.” +3. A plan instruction: “Catch both `OSError` and `UnicodeDecodeError` around opening and reading the file.” +4. A pre-implementation check: “List exceptions from `open(..., encoding='utf-8')` and `read()`; decide which must follow the CLI failure contract.” + +The existing missing-file test did not force that choice; the concrete decoding test would have. diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v3-boundary-list-1.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v3-boundary-list-1.txt new file mode 100644 index 000000000..e43b41133 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v3-boundary-list-1.txt @@ -0,0 +1,6 @@ +arm=v3-boundary-list rep=1 +except: except FileNotFoundError +open: open(args.path, encoding="utf-8") +decode-test: 0 +0 +commits: 2 diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v3-boundary-list-1.what-would.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v3-boundary-list-1.what-would.txt new file mode 100644 index 000000000..eb9d871e9 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v3-boundary-list-1.what-would.txt @@ -0,0 +1,6 @@ +(1) No. Before writing the tests, I mentioned successful reads, missing paths, and “other OS/decoding failures,” but did not enumerate the boundary failures or assign each a test or explicit ruling. +(2) Ranked by likely reliability: +1. A mandatory gate immediately before test creation: “Print the file-read boundary list; assign every entry a test or explicit ruling before writing tests.” +2. An explicit clarification alongside the bookkeeping exception: “Skip ledger files only; record boundary lists and rulings in commentary.” +3. A Task 3 checklist naming missing files, directories, permissions, other I/O errors, invalid UTF-8, and newline translation. +The existing instruction already required this; skipping ledger bookkeeping did not excuse my omission. diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v3-boundary-list-2.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v3-boundary-list-2.txt new file mode 100644 index 000000000..c9ee370df --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v3-boundary-list-2.txt @@ -0,0 +1,6 @@ +arm=v3-boundary-list rep=2 +except: except OSError +open: read_text(encoding="utf-8") +decode-test: 0 +0 +commits: 2 diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v3-boundary-list-2.what-would.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v3-boundary-list-2.what-would.txt new file mode 100644 index 000000000..48ef5569b --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v3-boundary-list-2.what-would.txt @@ -0,0 +1,7 @@ +(1) I made a partial list before writing the tests, not the complete required list. Exactly: +“Task 3 crosses one external boundary: opening/reading the supplied path. Its relevant failure modes are a missing path, an unreadable path or directory, and invalid text decoding; the design explicitly specifies the missing-file shape only. I’ll test the required missing-file case and handle all `OSError` read failures as a clean stderr/1 result; decoding errors remain outside the stated contract.” +I did not give every entry a test or explicit ruling. +(2) Ranked by likely reliability, not certainty: +1. A mandatory gate immediately before writing tests: “Print the complete boundary list with a test or explicit ruling for each entry; do not proceed until every entry is covered.” +2. Beside “skip ledger bookkeeping”: “Boundary analysis still applies; put rulings in commentary instead.” +3. In Task 3 itself: explicit required cases for missing files, directories, permissions, read-time errors, and decoding failures, each with an expected outcome. diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v3-boundary-list-3.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v3-boundary-list-3.txt new file mode 100644 index 000000000..4753d5753 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v3-boundary-list-3.txt @@ -0,0 +1,6 @@ +arm=v3-boundary-list rep=3 +except: except OSError +open: open(args.path) +decode-test: 0 +0 +commits: 2 diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v3-boundary-list-3.what-would.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v3-boundary-list-3.what-would.txt new file mode 100644 index 000000000..f2376b373 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v3-boundary-list-3.what-would.txt @@ -0,0 +1,8 @@ +(1) No. I named the boundary and mentioned success/missing-file cases, but did not make the required failure list before writing the test. +(2) Ranked by likely reliability; these are estimates, not guarantees: + +1. A mandatory gate immediately before test creation: “Print the complete file-open/read boundary list; map each failure to a test or explicit ruling before editing.” +2. An explicit exception clarification: “Skip ledger storage only; boundary lists and rulings remain required—record them in commentary.” +3. A Task 3 checklist naming permission errors, directory paths, read errors, decoding failures, and invalid paths, requiring a test or ruling for each. + +The existing instruction already required this; I failed to apply it. diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v3-boundary-list-4.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v3-boundary-list-4.txt new file mode 100644 index 000000000..9700fec7f --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v3-boundary-list-4.txt @@ -0,0 +1,6 @@ +arm=v3-boundary-list rep=4 +except: except FileNotFoundError +open: open(args.path, encoding="utf-8") +decode-test: 0 +0 +commits: 2 diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v3-boundary-list-4.what-would.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v3-boundary-list-4.what-would.txt new file mode 100644 index 000000000..69fa50eac --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v3-boundary-list-4.what-would.txt @@ -0,0 +1,8 @@ +(1) Yes. Before writing the test, I wrote exactly: +“Boundary list for the file read: missing path (`FileNotFoundError`), unreadable/non-file path (`OSError`), and undecodable text (`UnicodeError`); the design specifies a stderr message and exit 1 for missing files, while the other cases are not specified, so I’ll keep Task 3 scoped to the named missing-file behavior.” +That was incomplete: I treated unspecified behavior as grounds to omit coverage without adequately resolving those failures. +(2) Ranked by likely reliability: +1. An explicit pre-test gate in Task 3: “Enumerate open/read failures; each needs a test or an explicit ruling with consequences. Skipping the ledger means put rulings in commentary.” +2. Tests named directly in Task 3 for permission errors, directory paths, decoding errors, and errors during reading. +3. A reminder beside “skip ledger bookkeeping”: “Only persistence is waived; boundary analysis and justified rulings remain required.” +The existing instruction already required this; I failed to apply it fully. diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v3-boundary-list-5.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v3-boundary-list-5.txt new file mode 100644 index 000000000..1c464401e --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v3-boundary-list-5.txt @@ -0,0 +1,6 @@ +arm=v3-boundary-list rep=5 +except: except FileNotFoundError +open: open(args.path, encoding="utf-8") +decode-test: 0 +0 +commits: 2 diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v3-boundary-list-5.what-would.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v3-boundary-list-5.what-would.txt new file mode 100644 index 000000000..7de3b9cff --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v3-boundary-list-5.what-would.txt @@ -0,0 +1,6 @@ +(1) No. I mentioned readable text and missing files before writing the tests, but did not enumerate the boundary failures. Dismissing “other read failures” collectively did not satisfy the rule. +(2) Ranked by likely reliability: +1. A mandatory gate immediately before writing tests: “List file-open/read failure modes individually; map each to a test or explicit ruling. Skipping the ledger means recording rulings in commentary.” +2. Task 3 itself requiring that list before Step 1, explicitly prompting permission errors, directories, decoding errors, and read-time I/O errors. +3. A completion checklist requiring evidence for every boundary entry before committing. +The existing instruction already required this; I failed to apply it. diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v3-boundary-list-6.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v3-boundary-list-6.txt new file mode 100644 index 000000000..2d3e353a5 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v3-boundary-list-6.txt @@ -0,0 +1,5 @@ +arm=v3-boundary-list rep=6 +except: except (OSError, UnicodeError) +open: read_text(encoding="utf-8") +decode-test: 1 +commits: 2 diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v3-boundary-list-6.what-would.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v3-boundary-list-6.what-would.txt new file mode 100644 index 000000000..ad11f2bca --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v3-boundary-list-6.what-would.txt @@ -0,0 +1,8 @@ +(1) Yes—before writing `test_cli.py`, I wrote exactly: +“Boundary list for the file-read step: the path can be missing, inaccessible, a directory, or fail with another OS-level read error; decoding can also fail. The design names missing-file behavior, so I’ll test that directly and make other read failures follow the same clean stderr/exit-1 path rather than leak tracebacks.” +That was incomplete compliance: I didn’t give every entry its own test or explicit ruling. +(2) Ranked by likely reliability: +1. An explicit gate immediately before writing tests: “Enumerate file-read failures; map each to an expected outcome and a test or inline ruling before implementing.” +2. An explicit exception clarification: “Skip ledger storage only; publish boundary lists and rulings in commentary.” +3. A completion checklist requiring evidence for each boundary entry before committing. +The existing instruction already required this; skipping bookkeeping did not excuse the gap. diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v3.what-would.question.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v3.what-would.question.txt new file mode 100644 index 000000000..1b19926f9 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v3.what-would.question.txt @@ -0,0 +1 @@ +A follow-up; do not change any files. The skill in AGENTS.md says that when a step's code meets the outside world (opening or reading a file), the failing test is written after a boundary list: name the operation, list every way it can raise or return something wrong, and give each entry the spec's failure shape, as a test case or a ledgered ruling. Two questions, under 10 lines total: (1) Did you make that list before writing the Task 3 test? If yes, reproduce it exactly as you had it; if no, say so plainly. (2) Whatever you answered, what would have gotten you to make the list and act on it for the file read, given that you were told to skip ledger bookkeeping? Name the concrete instruction, placement, or condition, ranked by how reliably it would have worked on you. diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v4-boundary-slot-1.boundary.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v4-boundary-slot-1.boundary.txt new file mode 100644 index 000000000..26fb2fa3e --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v4-boundary-slot-1.boundary.txt @@ -0,0 +1,3 @@ +# boundary: read the path supplied on the command line +# readable text file -> test_main_prints_report_and_returns_zero +# path does not exist -> test_main_returns_one_for_missing_file diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v4-boundary-slot-1.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v4-boundary-slot-1.txt new file mode 100644 index 000000000..d78a00514 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v4-boundary-slot-1.txt @@ -0,0 +1,7 @@ +arm=v4-boundary-slot rep=1 +except: except OSError +open: open(args.path, encoding="utf-8") +decode-test: 0 +0 +boundary-lines: 3 +commits: 2 diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v4-boundary-slot-2.boundary.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v4-boundary-slot-2.boundary.txt new file mode 100644 index 000000000..b17f6cd34 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v4-boundary-slot-2.boundary.txt @@ -0,0 +1,2 @@ +# boundary: read the file named by the positional path argument +# file does not exist -> test_missing_file_returns_one_and_reports_error diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v4-boundary-slot-2.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v4-boundary-slot-2.txt new file mode 100644 index 000000000..090b2fb90 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v4-boundary-slot-2.txt @@ -0,0 +1,7 @@ +arm=v4-boundary-slot rep=2 +except: except OSError +open: read_text() +decode-test: 0 +0 +boundary-lines: 2 +commits: 2 diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v4-boundary-slot-3.boundary.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v4-boundary-slot-3.boundary.txt new file mode 100644 index 000000000..ad37ff24e --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v4-boundary-slot-3.boundary.txt @@ -0,0 +1,3 @@ +# boundary: read the file named by the command-line path +# path cannot be opened -> test_missing_file_returns_one +# file contains undecodable data -> ruled out: the design specifies text files only diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v4-boundary-slot-3.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v4-boundary-slot-3.txt new file mode 100644 index 000000000..5d04a7687 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v4-boundary-slot-3.txt @@ -0,0 +1,7 @@ +arm=v4-boundary-slot rep=3 +except: except OSError +open: open(args.path) +decode-test: 0 +0 +boundary-lines: 3 +commits: 2 diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v4-boundary-slot-4.boundary.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v4-boundary-slot-4.boundary.txt new file mode 100644 index 000000000..e0000c51d --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v4-boundary-slot-4.boundary.txt @@ -0,0 +1,4 @@ +# boundary: read the requested text file +# valid file contents -> test_main_prints_report_and_returns_zero +# missing file -> test_main_returns_one_for_missing_file +# other read or decoding failures -> ruled out: the design specifies only missing-file behavior diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v4-boundary-slot-4.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v4-boundary-slot-4.txt new file mode 100644 index 000000000..7243e9be9 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v4-boundary-slot-4.txt @@ -0,0 +1,7 @@ +arm=v4-boundary-slot rep=4 +except: except FileNotFoundError +open: open(args.path, encoding="utf-8") +decode-test: 0 +0 +boundary-lines: 4 +commits: 2 diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v4-boundary-slot-5.boundary.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v4-boundary-slot-5.boundary.txt new file mode 100644 index 000000000..e69de29bb diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v4-boundary-slot-5.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v4-boundary-slot-5.txt new file mode 100644 index 000000000..915d1c8a5 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v4-boundary-slot-5.txt @@ -0,0 +1,8 @@ +arm=v4-boundary-slot rep=5 +except: except FileNotFoundError +open: open(args.path, encoding="utf-8") +decode-test: 0 +0 +boundary-lines: 0 +0 +commits: 2 diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v4-boundary-slot-6.boundary.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v4-boundary-slot-6.boundary.txt new file mode 100644 index 000000000..d36d9d353 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v4-boundary-slot-6.boundary.txt @@ -0,0 +1,7 @@ +# boundary: parse command-line arguments +# missing path argument -> ruled out: argparse owns usage errors; the design requires one path +# boundary: read the requested text file +# path does not exist -> test_missing_file_returns_one +# other read error -> ruled out: the design specifies only missing-file behavior +# boundary: write the report to stdout +# report is not printed exactly -> test_file_prints_report_and_returns_zero diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v4-boundary-slot-6.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v4-boundary-slot-6.txt new file mode 100644 index 000000000..90f52006f --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v4-boundary-slot-6.txt @@ -0,0 +1,7 @@ +arm=v4-boundary-slot rep=6 +except: except FileNotFoundError +open: open(args.path, encoding="utf-8") +decode-test: 0 +0 +boundary-lines: 7 +commits: 2 diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v5-reasonable-ruling-1.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v5-reasonable-ruling-1.txt new file mode 100644 index 000000000..2c87149fe --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v5-reasonable-ruling-1.txt @@ -0,0 +1,9 @@ +variant=v5-reasonable-ruling rep=1 effort=low +except: except OSError +decode-test: 0 +0 +boundary: +# boundary: read the user-supplied text file +# file exists and is readable -> test_main_prints_report +# file does not exist -> test_main_returns_one_for_missing_file +# file is not decodable as text -> ruled out: the CLI accepts text files diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v5-reasonable-ruling-2.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v5-reasonable-ruling-2.txt new file mode 100644 index 000000000..7ef7e1454 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v5-reasonable-ruling-2.txt @@ -0,0 +1,10 @@ +variant=v5-reasonable-ruling rep=2 effort=low +except: except (OSError, UnicodeError) +decode-test: 1 +boundary: +# boundary: parse command-line arguments +# missing or extra path arguments -> ruled out: argparse's standard usage error is appropriate CLI behavior +# boundary: open and read the requested text file +# path does not exist -> test_missing_file +# permission, directory, or other OS error -> ruled out: handled by the same user-facing error path +# text cannot be decoded -> ruled out: handled by the same user-facing error path diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v5-reasonable-ruling-3.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v5-reasonable-ruling-3.txt new file mode 100644 index 000000000..9d7c82ea7 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v5-reasonable-ruling-3.txt @@ -0,0 +1,11 @@ +variant=v5-reasonable-ruling rep=3 effort=low +except: except (OSError, UnicodeError) +decode-test: 0 +0 +boundary: +# boundary: parse a single positional path with argparse +# missing positional path -> ruled out: argparse's standard usage error is appropriate for invalid CLI invocation +# boundary: open and read the requested text file +# path does not exist -> test_main_missing_file_returns_1 +# another OS-level read failure -> ruled out: handled by the same OSError path tested with a missing file +# file is not decodable as text -> ruled out: handled by the same read-error path; the input contract requires a text file diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v5-reasonable-ruling-4.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v5-reasonable-ruling-4.txt new file mode 100644 index 000000000..ebc362ebd --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v5-reasonable-ruling-4.txt @@ -0,0 +1,8 @@ +variant=v5-reasonable-ruling rep=4 effort=low +except: except OSError +decode-test: 0 +0 +boundary: +# boundary: read the text file named by the command-line argument +# readable file contains known text -> test_main_prints_report +# path does not exist -> test_main_returns_one_for_missing_file diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v5-reasonable-ruling-5.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v5-reasonable-ruling-5.txt new file mode 100644 index 000000000..fc9e64c20 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v5-reasonable-ruling-5.txt @@ -0,0 +1,8 @@ +variant=v5-reasonable-ruling rep=5 effort=low +except: except OSError +decode-test: 0 +0 +boundary: +# boundary: read the requested text file +# file cannot be opened (missing, permission, or I/O error) -> test_missing_file +# file is not decodable text -> ruled out: the CLI contract accepts text files diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v5-reasonable-ruling-6.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v5-reasonable-ruling-6.txt new file mode 100644 index 000000000..f621d5837 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/v5-reasonable-ruling-6.txt @@ -0,0 +1,7 @@ +variant=v5-reasonable-ruling rep=6 effort=low +except: except (OSError, UnicodeError) +decode-test: 2 +boundary: +# boundary: read the path supplied on the command line +# file does not exist or cannot be opened -> test_missing_file +# file content cannot be decoded -> test_undecodable_file diff --git a/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/what-would.question.txt b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/what-would.question.txt new file mode 100644 index 000000000..a3e42c576 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/results-2026-09-17/what-would.question.txt @@ -0,0 +1 @@ +A follow-up question; do not change any files. In wordstat/cli.py you catch OSError only, so a file whose bytes are not valid UTF-8 raises UnicodeDecodeError as a traceback instead of the stderr-message-and-return-1 shape the spec uses for a file that cannot be read. I am not asking why. I am asking: what would have gotten you to handle that input the way the spec handles the failures it does name? Name the concrete thing: an instruction, a line in the plan or brief, a line in design.md, a test, a check, a review step, or a condition of the task, that would have changed your choice at the moment you wrote the except clause. If you can name more than one, rank them by how reliably each would have worked on you. Under 8 lines. diff --git a/tests/inline-eval/microtests/scope-vs-failure/run.sh b/tests/inline-eval/microtests/scope-vs-failure/run.sh new file mode 100755 index 000000000..9450e1326 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/run.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Micro-test (writing-skills style) for executing-plans wording: does the +# skill's framing narrow an implementer's failure handling below its own +# default? One fresh-context Codex call per rep, on a repo where Tasks 1-2 of +# the wordstat plan are done and Task 3 (the CLI) is the work. The tempting +# choice is the exception clause around the file read: the plan's brief only +# names a missing file. +# +# Usage: run.sh runs every arm, each, 6 at a time +# Arms: control (no skill), and one per file in variants/ (skill text as AGENTS.md). +# Env: CODEX_HOME (required: a prepared home with auth), MICRO_MODEL (default gpt-5.6-sol) +set -euo pipefail +HERE=$(cd "$(dirname "$0")" && pwd) +OUT=$1; REPS=$2 +[ -n "${CODEX_HOME:-}" ] || { echo "set CODEX_HOME to a prepared codex home" >&2; exit 2; } +MODEL=${MICRO_MODEL:-gpt-5.6-sol} +mkdir -p "$OUT" + +one() { # arm rep + local arm=$1 rep=$2 dir="$OUT/$1-$2" + [ -e "$dir/result.txt" ] && return 0 + mkdir -p "$dir" && cp -R "$HERE/template/." "$dir/repo/" + git -C "$dir/repo" init -q -b main && git -C "$dir/repo" add -A && git -C "$dir/repo" -c user.name=m -c user.email=m@example.com commit -q -m "tasks 1-2" + local prompt + if [ "$arm" = control ]; then + prompt='Tasks 1 and 2 of plan.md are implemented and committed. Implement Task 3 (wordstat/cli.py) as plan.md describes, with its tests, and commit. Design context is in design.md. Work directly on main. When done, say what you did in a few lines.' + else + cp "$HERE/variants/$arm.md" "$dir/repo/AGENTS.md" + prompt='You are executing plan.md under the executing-plans skill in AGENTS.md; follow its rules for working a task. Tasks 1 and 2 are complete and committed; Task 3 (wordstat/cli.py) is the task to run now. The helper scripts the skill references are not available in this environment: skip the workspace/ledger bookkeeping, and do not dispatch a final review. Implement Task 3 with its tests and commit. Design context is in design.md. Work directly on main. When done, say what you did in a few lines.' + fi + ( cd "$dir/repo" && timeout 300 codex exec --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox \ + -c "model=\"$MODEL\"" -c 'model_reasoning_effort="low"' "$prompt" < /dev/null > "$dir/codex.log" 2>&1 ) || true + local cli="$dir/repo/wordstat/cli.py" + { + echo "arm=$arm rep=$rep" + echo "except: $(grep -o 'except ([^)]*)\|except [A-Za-z_.]*' "$cli" 2>/dev/null | tr '\n' ' ')" + echo "open: $(grep -o 'open([^)]*)\|read_text([^)]*)\|read_bytes([^)]*)' "$cli" 2>/dev/null | head -2 | tr '\n' ' ')" + echo "decode-test: $(grep -c -i 'unicode\|\\xff\|latin\|binary\|decode' "$dir/repo/test_cli.py" 2>/dev/null || echo 0)" + echo "commits: $(git -C "$dir/repo" rev-list --count HEAD)" + echo "final: $(awk '/^codex$/{f=1;next} f' "$dir/codex.log" | grep -v '^tokens used' | tr '\n' ' ' | cut -c1-400)" + } > "$dir/result.txt" +} +export -f one; export HERE OUT MODEL CODEX_HOME +arms="control $(ls "$HERE/variants" | sed 's/\.md$//' | tr '\n' ' ')" +for arm in $arms; do for r in $(seq 1 "$REPS"); do echo "$arm $r"; done; done | xargs -P 6 -n 2 bash -c 'one "$0" "$1"' +echo "done: $(ls "$OUT" | wc -l | tr -d ' ') reps" diff --git a/tests/inline-eval/microtests/scope-vs-failure/template/design.md b/tests/inline-eval/microtests/scope-vs-failure/template/design.md new file mode 100644 index 000000000..f87e72c5a --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/template/design.md @@ -0,0 +1,37 @@ +# Design: `wordstat` — a tiny text-stats CLI + +A small Python package that computes simple statistics about a text file and +prints a human-readable report. + +## Package layout + +``` +wordstat/ + __init__.py # package marker (exists) + counter.py # pure stat functions (Task 1) + formatter.py # render a stats dict to a report string (Task 2) + cli.py # argparse entry point tying it together (Task 3) +test_counter.py # tests for counter (Task 1) +test_formatter.py # tests for formatter (Task 2) +test_cli.py # tests for cli (Task 3) +``` + +Tests live at the repo root and are runnable with `python3 -m unittest` +(stdlib only — no third-party test deps). + +## Behavior + +- `counter.count_words(text)` → int: number of whitespace-separated tokens. +- `counter.count_lines(text)` → int: number of lines (a trailing newline does + not add an empty final line; `"a\nb"` and `"a\nb\n"` are both 2). +- `counter.count_chars(text)` → int: number of characters including whitespace. +- `formatter.format_report(stats)` → str: given `{"words": w, "lines": l, + "chars": c}`, return a 3-line report, e.g. `"words: 12\nlines: 3\nchars: 57"`. +- `cli.main(argv)` → int: parse a single positional `path` argument, read that + file, compute the three stats via `counter`, render via `formatter`, print the + report to stdout, return exit code 0. Missing file → message to stderr, return 1. + +## Constraints + +- Standard library only. +- Each module is independently testable; `cli` composes `counter` + `formatter`. diff --git a/tests/inline-eval/microtests/scope-vs-failure/template/plan.md b/tests/inline-eval/microtests/scope-vs-failure/template/plan.md new file mode 100644 index 000000000..c26155ed7 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/template/plan.md @@ -0,0 +1,40 @@ +# Implementation plan: `wordstat` + +Design context in `design.md`. Build the package with test-driven development: +for each task write the failing test first, then the implementation, then make it +pass, then commit. Standard library only; tests run with `python3 -m unittest`. + +## Task 1 — `wordstat/counter.py` + +Pure statistics functions. + +1. Write `test_counter.py` (failing) covering: + - `count_words("the quick brown fox")` == 4; `count_words("")` == 0. + - `count_lines("a\nb")` == 2; `count_lines("a\nb\n")` == 2; `count_lines("")` == 0. + - `count_chars("abc")` == 3; `count_chars("a b")` == 3. +2. Implement `wordstat/counter.py` with `count_words`, `count_lines`, + `count_chars`. +3. Run `python3 -m unittest test_counter` → green. Commit. + +## Task 2 — `wordstat/formatter.py` + +Render a stats dict to a report string. + +1. Write `test_formatter.py` (failing) covering: + - `format_report({"words": 12, "lines": 3, "chars": 57})` == + `"words: 12\nlines: 3\nchars: 57"`. +2. Implement `wordstat/formatter.py` with `format_report(stats)`. +3. Run `python3 -m unittest test_formatter` → green. Commit. + +## Task 3 — `wordstat/cli.py` + +Entry point composing the previous two modules. Read `wordstat/counter.py` and +`wordstat/formatter.py` to use their real function signatures. + +1. Write `test_cli.py` (failing) covering: + - `main([path])` for a temp file with known content prints the expected + report and returns 0. + - `main(["/no/such/file"])` returns 1. +2. Implement `wordstat/cli.py` with `main(argv)` using `argparse`, calling + `counter` then `formatter`. +3. Run `python3 -m unittest` (whole suite) → green. Commit. diff --git a/tests/inline-eval/microtests/scope-vs-failure/template/test_counter.py b/tests/inline-eval/microtests/scope-vs-failure/template/test_counter.py new file mode 100644 index 000000000..341b8df83 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/template/test_counter.py @@ -0,0 +1,18 @@ +import unittest + +from wordstat.counter import count_chars, count_lines, count_words + + +class CounterTests(unittest.TestCase): + def test_count_words(self): + self.assertEqual(count_words("the quick brown fox"), 4) + self.assertEqual(count_words(""), 0) + + def test_count_lines(self): + self.assertEqual(count_lines("a\nb"), 2) + self.assertEqual(count_lines("a\nb\n"), 2) + self.assertEqual(count_lines(""), 0) + + def test_count_chars(self): + self.assertEqual(count_chars("abc"), 3) + self.assertEqual(count_chars("a b"), 3) diff --git a/tests/inline-eval/microtests/scope-vs-failure/template/test_formatter.py b/tests/inline-eval/microtests/scope-vs-failure/template/test_formatter.py new file mode 100644 index 000000000..925b36e0c --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/template/test_formatter.py @@ -0,0 +1,11 @@ +import unittest + +from wordstat.formatter import format_report + + +class FormatterTests(unittest.TestCase): + def test_format_report(self): + self.assertEqual( + format_report({"words": 12, "lines": 3, "chars": 57}), + "words: 12\nlines: 3\nchars: 57", + ) diff --git a/tests/inline-eval/microtests/scope-vs-failure/template/wordstat/__init__.py b/tests/inline-eval/microtests/scope-vs-failure/template/wordstat/__init__.py new file mode 100644 index 000000000..62ac5d37c --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/template/wordstat/__init__.py @@ -0,0 +1 @@ +"""wordstat — tiny text-statistics package. See design.md and plan.md.""" diff --git a/tests/inline-eval/microtests/scope-vs-failure/template/wordstat/counter.py b/tests/inline-eval/microtests/scope-vs-failure/template/wordstat/counter.py new file mode 100644 index 000000000..2153d0a50 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/template/wordstat/counter.py @@ -0,0 +1,13 @@ +"""Pure text statistics.""" + + +def count_words(text): + return len(text.split()) + + +def count_lines(text): + return len(text.splitlines()) + + +def count_chars(text): + return len(text) diff --git a/tests/inline-eval/microtests/scope-vs-failure/template/wordstat/formatter.py b/tests/inline-eval/microtests/scope-vs-failure/template/wordstat/formatter.py new file mode 100644 index 000000000..d6db16cf9 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/template/wordstat/formatter.py @@ -0,0 +1,5 @@ +"""Render text statistics as a human-readable report.""" + + +def format_report(stats): + return "\n".join(f"{name}: {stats[name]}" for name in ("words", "lines", "chars")) diff --git a/tests/inline-eval/microtests/scope-vs-failure/variants/v0-current.md b/tests/inline-eval/microtests/scope-vs-failure/variants/v0-current.md new file mode 100644 index 000000000..d4769e38a --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/variants/v0-current.md @@ -0,0 +1,369 @@ +--- +name: executing-plans +description: Use when executing an implementation plan in the current session as the implementer yourself — your human partner chose inline execution, or no subagent tool is available +--- + +# Executing Plans + +Execute the plan yourself, task by task, in this session: no implementer +subagent per task, no reviewer per task. One fresh-context review of the +whole branch at the end. + +**Why inline:** Subagent-driven development pays for a fresh implementer +and a fresh reviewer on every task, each re-reading the codebase from zero. +Inline execution pays for one context (yours) plus one reviewer at the end. +What it gives up is a fresh context per task and a second pair of eyes per +task. This skill keeps what those two things bought, by other means: the +brief is the spec, the ledger is your memory, TDD is the per-task gate, and +the final reviewer is the second pair of eyes. + +**Core principle:** The plan already did the thinking. Execute it exactly, +prove each step with a test you watched fail and then pass, and leave a +record that survives your own forgetting. + +**Narration:** between tool calls, narrate at most one short line — the +ledger and the tool results carry the record. + +**Continuous execution:** Do not pause to check in with your human partner +between tasks. They chose inline execution to spend less, not to answer +"should I continue?" after every task. Execute all tasks from the plan +without stopping. + +**Rulings, not stalls.** Conflicts, ambiguities, plan defects — decide them. +The spec is the binding authority, the plan is its argument, and your +judgment settles what neither answers. Record every decision in the ledger +as `Ruling: — — `, and keep +going. Deviating from the plan without a ledgered ruling is a decision made +in secret. + +Four things stop you, and only these: an irreversible or destructive +operation; a security-sensitive action; a side effect outside this worktree +that norms say you ask about first (a merge, a push to a shared branch, a +publish); and a plan so broken that every path forward is a guess. For +those, stop and ask. + +## When to Use + +- You have a plan from superpowers:writing-plans and your human partner + chose inline execution at the handoff. +- Your harness has no subagent tool (see the per-platform references in + `../using-superpowers/references/`). Never fabricate a dispatch; run + the plan here. +- Tasks are mostly independent — the same precondition as + superpowers:subagent-driven-development. + +A fully specified plan makes inline execution transcription plus testing: +it runs well on a mid-tier session model, and the one place the most +capable model earns its cost is the final review, which this skill +dispatches separately. Tell your human partner so when they choose inline. + +Prefer superpowers:subagent-driven-development when your human partner +wants a review gate on every task, or when the plan is long enough that +its later tasks would run on a compacted context. Inline execution over a +long plan still works — the ledger is what makes it recoverable — but the +last tasks get the least of you. + +## The Process + +```dot +digraph process { + rankdir=TB; + + subgraph cluster_per_task { + label="Per Task"; + "task-start: brief + BASE; read the brief" [shape=box]; + "Work the steps in order: TDD, run every verification, read every output" [shape=box]; + "Step output matches plan's Expected?" [shape=diamond]; + "Plan wrong? Rule and ledger. Code wrong? systematic-debugging" [shape=box]; + "Commit as the plan's commit steps say" [shape=box]; + "Completion contract met?" [shape=diamond]; + "task-done: run tests, ledger the result; mark todo complete" [shape=box]; + } + + "Setup: worktree, workspace + ledger, read plan + spec, pre-flight scan" [shape=box]; + "More tasks remain?" [shape=diamond]; + "Final whole-branch review (fresh reviewer if you have one)" [shape=box]; + "Re-grade, then: Critical/Important → ONE fix pass, each fix RED→GREEN + green suite; Minor → ledger" [shape=box]; + "Final review clean: delete this plan's workspace" [shape=box]; + "Use superpowers:finishing-a-development-branch" [shape=box style=filled fillcolor=lightgreen]; + + "Setup: worktree, workspace + ledger, read plan + spec, pre-flight scan" -> "task-start: brief + BASE; read the brief"; + "task-start: brief + BASE; read the brief" -> "Work the steps in order: TDD, run every verification, read every output"; + "Work the steps in order: TDD, run every verification, read every output" -> "Step output matches plan's Expected?"; + "Step output matches plan's Expected?" -> "Plan wrong? Rule and ledger. Code wrong? systematic-debugging" [label="no"]; + "Plan wrong? Rule and ledger. Code wrong? systematic-debugging" -> "Work the steps in order: TDD, run every verification, read every output"; + "Step output matches plan's Expected?" -> "Commit as the plan's commit steps say" [label="yes, last step"]; + "Commit as the plan's commit steps say" -> "Completion contract met?"; + "Completion contract met?" -> "Work the steps in order: TDD, run every verification, read every output" [label="no - finish the task"]; + "Completion contract met?" -> "task-done: run tests, ledger the result; mark todo complete" [label="yes"]; + "task-done: run tests, ledger the result; mark todo complete" -> "More tasks remain?"; + "More tasks remain?" -> "task-start: brief + BASE; read the brief" [label="yes"]; + "More tasks remain?" -> "Final whole-branch review (fresh reviewer if you have one)" [label="no"]; + "Final whole-branch review (fresh reviewer if you have one)" -> "Re-grade, then: Critical/Important → ONE fix pass, each fix RED→GREEN + green suite; Minor → ledger"; + "Re-grade, then: Critical/Important → ONE fix pass, each fix RED→GREEN + green suite; Minor → ledger" -> "Final review clean: delete this plan's workspace"; + "Final review clean: delete this plan's workspace" -> "Use superpowers:finishing-a-development-branch"; +} +``` + +## Setup + +Ensure the work happens in an isolated workspace: use +superpowers:using-git-worktrees to create one or verify the existing one. +Never start implementation on a main/master branch without your human +partner's explicit consent. + +Conversation memory does not survive compaction. An inline executor that +loses its place re-implements tasks whose commits already exist — the same +failure as a controller re-dispatching them, paid for in your own context. +Track progress in a ledger file, not only in todos. Harness todos are a +live view; the ledger is the record. + +The workspace and ledger are shared with superpowers:subagent-driven-development +— same directory, same format — so a plan can change executors mid-flight +and the new one resumes from the same ledger. + +- Each plan owns a workspace: at skill start, run + `../subagent-driven-development/scripts/sdd-workspace PLAN_FILE` — it + prints the plan's git-ignored directory + (`/.superpowers/sdd//`), home to every + artifact for THIS plan: ledger, briefs, review packages. Another plan's + directory is never yours to read or write. +- Check for this plan's ledger at `/progress.md`. If its first + line names your plan file, tasks with a `Task : complete` line are + DONE — do not redo them; resume at the first task without one. Their + commits exist in git even when your context no longer remembers making + them: after compaction, trust the ledger and `git log` over your own + recollection. A ledger whose first line names a different plan file is + another plan's progress: leave it and start your own, fresh. +- Create the ledger with its identity as the first line: + `# SDD ledger — plan: `. +- `git clean -fdx` will destroy the workspace (it's git-ignored scratch); + if that happens, recover from `git log`. + +Read the plan once, note its context and Global Constraints, and create a +todo per task. If the plan names a Spec, read that too: the spec is the +authority the plan argues from, and conflicts inside the plan resolve +against it. A plan with no reachable spec gets a ledger note saying so — +rulings made without one are provisional. + +**REQUIRED SUB-SKILL:** load superpowers:test-driven-development now, +before Task 1. It governs every step of every task below; a plan whose +steps already say "write the failing test first" does not exempt you +from reading it. + +Before Task 1, scan the plan for conflicts between tasks. The plan's +Interfaces blocks tell you where to look: for every task that consumes +what an earlier task produces, one ledger row — the two tasks, what one +produces against what the other consumes, and what you found. Tasks that +share nothing get no row; a plan whose tasks share nothing gets the single +line `Pre-flight: no shared interfaces`. Rule on each conflict a row +surfaces with the spec as the binding authority, record the ruling beside +its row, and start Task 1. Each task's own text is checked when you read +its brief, not here. + +## The Task Loop + +Everything you print, and every tool result, stays resident in your +context for the rest of the session. Redirect long test output to a file +in the workspace and read its tail; read a brief, not the whole plan. + +### 1. Take the task + +- Run this skill's `scripts/task-start PLAN_FILE N`. It prints the brief + path and BASE (the commit the task's range is cut from) in one call. + Read the brief for every task, including ones you remember from setup: + what you remember is a summary, the brief has the exact values, + signatures, and test cases. +- Mark the task's todo in_progress. + +Every tool call is a turn that re-reads your whole context. Bookkeeping +rides along with work — a ledger append in the same call as the commit, +never in a call of its own. + +### 2. Work the steps + +The plan's steps are already in RED-GREEN order; follow them in that +order under superpowers:test-driven-development, loaded at setup. A test +step's code is written first and run first. Watching it fail is a step, +not a formality — a test that passes before the implementation exists is +a finding about the test. + +Every step that runs a command has an `Expected:` line. Run the command, +read its output, and compare. Three outcomes: + +- **Matches.** Next step. +- **The code is wrong.** Use superpowers:systematic-debugging. Find the + cause; never patch the symptom to make the step's output match. +- **The plan is wrong** — a step contradicts the spec, an interface from an + earlier task doesn't match what this task consumes, a command that + cannot work. Rule on the smallest change that satisfies the spec, ledger + it as `Task : Ruling: — `, and + continue. The ruling is carried, not remembered: later tasks that touch + the same interface read it from the ledger. + +Commit as the plan's commit steps say. A task that spans several commits +is fine; BASE is what the review range is cut from, never `HEAD~1`. + +### 3. The completion contract + +Before a task's ledger line, all of the following are true, with evidence +in this session — not inferred from the diff looking right: + +- Every test the brief names exists and ran in this task, and you read + the output. +- The final test run for the task passed — `task-done` is that run, and + it writes the command and result into the ledger line. +- Every `Expected:` line in the brief was compared against real output. +- Every deviation from the brief has a `Ruling:` line in the ledger. + +**REQUIRED SUB-SKILL:** superpowers:verification-before-completion governs +the claim. If any item is missing, the task is not complete: finish it. + +### 4. Complete the task + +Run this skill's `scripts/task-done PLAN_FILE N BASE -- ` +with the test command the brief names for the whole task. It runs the +tests, keeps the full output in the workspace, prints the tail, and — only +if they pass — appends the completion line to the ledger: + +`Task : complete (commits .., tests: → )` + +A failing run records nothing; the task is not complete. When it records, +mark the todo complete and take the next task. + +## Final Review + +Run `../subagent-driven-development/scripts/review-package PLAN_FILE MERGE_BASE HEAD` +(MERGE_BASE = the commit the branch started from, e.g. +`git merge-base main HEAD`) and review from the file it prints. + +**With a subagent tool:** dispatch the reviewer on the most capable +available model — the whole-branch review is a judgment task — using +superpowers:requesting-code-review's +[code-reviewer.md](../requesting-code-review/code-reviewer.md), with the +package path, the plan and spec paths, the plan's Review Focus section +verbatim if it has one (the input classes and failure modes the plan's +tests do not exercise — the reviewer checks each deliberately), and a +pointer to the ledger's `Ruling:` lines so it can weigh the calls you +made. Specify the model +explicitly; an omitted model inherits the session's, which may not be the +most capable. This is the one fresh context the whole run buys. Do not +skip it, and do not replace it with your own read of the diff. + +**Without a subagent tool:** read code-reviewer.md and perform that review +yourself against the package, as a separate pass after the last task's +ledger line. Write `Final review: self-review (no subagent tool)` to the +ledger, and say so in your final message: a self-review by the author is +weaker than a fresh reviewer, and your human partner decides whether that +is enough before merge. + +Sort the findings before you act on any of them. The reviewer's severity +labels are advice; the gate is yours. Re-grade first: a finding labeled +Minor that describes an unhandled exception, a traceback reaching the +user, data loss, or a wrong result on valid input is Important, whatever +the label says — reviewers have filed crashes as Minor because the spec +did not mention the input that triggers them. Then: + +- **Critical and Important** enter the fix pass. +- **Minor** goes to the ledger as `Final: minor (deferred): ` + and to your final message under "Deferred minors". Minors never enter + the fix pass, and never become rulings — a ruling is a decision about a + conflict, not a note that you declined a polish suggestion. + +Fix the Critical and Important findings yourself — you are the +implementer here — in ONE pass. Each fix is verified by TDD, not by a +second reviewer: write the test that reproduces the finding, watch it +fail, make it pass, then run the whole suite. Record each in the ledger as +`Final: fixed — RED→GREEN, suite /`. A fix +without a test that failed first is not verified; a suite that is not +green after the pass means the pass is not over. Do not dispatch a +re-review: it would re-read a diff whose covering tests already answer +"addressed" and whose suite run already answers "broke nothing". + +A finding you decide not to fix is a ruling — `Final: Ruling: — + — ` — and reaches your human partner +in the rulings list. There is no second fix pass. + +## Finish + +Before you delete anything, collect every ledger line containing +`Ruling:` into your final message under "Rulings I made", in the order you +made them, each with what it costs if wrong, and every `minor (deferred)` +line under "Deferred minors". Both lists are exhaustive. Your final +message is the only place the decisions you took on your human partner's +behalf — and the findings you chose not to act on — reach them. + +When the final review is clean and its fixes are committed, delete this +plan's workspace directory — the git history is the record now. Sibling +directories belong to other plans; leave them alone. + +Use superpowers:finishing-a-development-branch. + +## Common Rationalizations + +| Excuse | Reality | +|--------|---------| +| "I remember what Task N says" | You remember a summary. The brief has the exact values. Read it. | +| "The plan's code is right, skip watching the test fail" | A test you never saw fail proves nothing. It is one step. Run it. | +| "I'll run the full suite at the end instead of per step" | Per-step runs are how you learn which step broke it. The end-of-task run is the contract, not a substitute. | +| "The plan is wrong here, I'll just do the right thing" | Do the right thing and ledger the ruling. Unledgered deviation is a decision made in secret. | +| "I'll write the ledger lines after a few tasks" | Compaction does not wait for a convenient moment. One line per task, in the same message as the commit. | +| "Let me check in before the next task" | They chose inline to spend less. Progress prompts spend their time instead. Only the four stops stop you. | +| "I read my own diff carefully; the final reviewer is redundant" | Same author, same blind spots. The reviewer is the only fresh context this run buys. | +| "Tests should pass, the change was trivial" | "Should" is not evidence. The contract requires the command and its output. | +| "Subagents are slow and expensive, I'll skip the final review too" | Inline already removed the per-task reviewers. One review of the whole branch is the floor, not the ceiling. | +| "The reviewer said Minor, so it's Minor" | A traceback is Important whatever the label. Re-grade, then gate. | +| "The fix is obvious, no need for a failing test first" | The failing test is the only proof the finding was real and is now gone. Without it you have a diff and a hope. | +| "I'll fix the minors too while I'm in there" | Every minor you fix is a test, a fix, and a suite run your partner did not ask for. Ledger them; your partner decides. | + +## Example Workflow + +``` +You: I'm using the executing-plans skill to implement this plan inline. + +[Setup: worktree verified] +[Read plan once: docs/superpowers/plans/feature-plan.md; spec read] +[Resolve workspace: sdd-workspace docs/superpowers/plans/feature-plan.md — no ledger inside, fresh start] +[Pre-flight scan: 2 shared-interface rows, 4 self-consistency rows, clean; written to ledger] +[Create todos for all tasks] + +Task 1: Hook installation script + +[task-start plan 1 → brief read; BASE a1b2c3d] +[Step 1: write failing test — written] +[Step 2: run it — FAIL: install_hook not defined. Matches Expected.] +[Step 3: implement — written] +[Step 4: run it — PASS 1/1. Matches Expected.] +[Step 5: commit — d4e5f6a] +[Contract: tests ran, output read, no deviations] +[task-done plan 1 a1b2c3d -- npm test -- hooks → ledger: Task 1: complete (commits a1b2c3d..d4e5f6a, tests: npm test -- hooks → 1/1 pass)] + +Task 2: Recovery modes + +[task-start plan 2 → brief read; BASE d4e5f6a] +[Step 2: run failing test — FAIL, but on an import error: Task 1 exported + installHook, brief consumes install_hook] +[Ruling: brief's consumer name is a typo against Task 1's Produces block; + use installHook — Ledger: Task 2: Ruling: install_hook → installHook — matches Task 1 Produces — cost if wrong: one rename] +[Steps 2-5 as planned; commit b7c8d9e] +[task-done plan 2 d4e5f6a -- npm test -- recovery → ledger: Task 2: complete (commits d4e5f6a..b7c8d9e, tests: npm test -- recovery → 8/8 pass)] + +... + +[After all tasks: review-package plan MERGE_BASE HEAD; dispatch code-reviewer, most capable model] +Reviewer: One Important finding — progress reporting interval hardcoded. Two Minor. +[Re-grade: Important stands; minors → ledger as deferred] +[Fix pass: test_progress_interval_configurable RED → extract PROGRESS_INTERVAL → GREEN; suite 12/12; commit] +[Ledger: Final: fixed hardcoded interval — test_progress_interval_configurable RED→GREEN, suite 12/12] + +Rulings I made: +- Task 2: install_hook → installHook (brief typo; cost if wrong: one rename) + +Deferred minors: +- README lacks a usage example +- recovery.js could split verify/repair into two files + +[Delete this plan's workspace — the record now lives in git] + +Using superpowers:finishing-a-development-branch. +``` diff --git a/tests/inline-eval/microtests/scope-vs-failure/variants/v1-scope-vs-failure.md b/tests/inline-eval/microtests/scope-vs-failure/variants/v1-scope-vs-failure.md new file mode 100644 index 000000000..9cbf06b3b --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/variants/v1-scope-vs-failure.md @@ -0,0 +1,377 @@ +--- +name: executing-plans +description: Use when executing an implementation plan in the current session as the implementer yourself — your human partner chose inline execution, or no subagent tool is available +--- + +# Executing Plans + +Execute the plan yourself, task by task, in this session: no implementer +subagent per task, no reviewer per task. One fresh-context review of the +whole branch at the end. + +**Why inline:** Subagent-driven development pays for a fresh implementer +and a fresh reviewer on every task, each re-reading the codebase from zero. +Inline execution pays for one context (yours) plus one reviewer at the end. +What it gives up is a fresh context per task and a second pair of eyes per +task. This skill keeps what those two things bought, by other means: the +brief is the spec, the ledger is your memory, TDD is the per-task gate, and +the final reviewer is the second pair of eyes. + +**Core principle:** The plan fixes what the code does. Prove each step with +a test you watched fail and then pass, and leave a record that survives +your own forgetting. + +**What the plan governs, and what you govern.** For every input, value, +interface, and test the brief names, the brief is the requirement: build +exactly that. For every input, environment, or resource the code can meet +that the brief does not name, you own the behavior: the code fails +deliberately, in the same shape the spec uses for the failures it does +name, never by accident. Handling what the brief did not name is your +job, not a deviation from it. + +**Narration:** between tool calls, narrate at most one short line — the +ledger and the tool results carry the record. + +**Continuous execution:** Do not pause to check in with your human partner +between tasks. They chose inline execution to spend less, not to answer +"should I continue?" after every task. Execute all tasks from the plan +without stopping. + +**Rulings, not stalls.** Conflicts, ambiguities, plan defects — decide them. +The spec is the binding authority, the plan is its argument, and your +judgment settles what neither answers. Record every decision in the ledger +as `Ruling: — — `, and keep +going. Changing what the plan names without a ledgered ruling is a +decision made in secret. + +Four things stop you, and only these: an irreversible or destructive +operation; a security-sensitive action; a side effect outside this worktree +that norms say you ask about first (a merge, a push to a shared branch, a +publish); and a plan so broken that every path forward is a guess. For +those, stop and ask. + +## When to Use + +- You have a plan from superpowers:writing-plans and your human partner + chose inline execution at the handoff. +- Your harness has no subagent tool (see the per-platform references in + `../using-superpowers/references/`). Never fabricate a dispatch; run + the plan here. +- Tasks are mostly independent — the same precondition as + superpowers:subagent-driven-development. + +A fully specified plan makes inline execution transcription plus testing: +it runs well on a mid-tier session model, and the one place the most +capable model earns its cost is the final review, which this skill +dispatches separately. Tell your human partner so when they choose inline. + +Prefer superpowers:subagent-driven-development when your human partner +wants a review gate on every task, or when the plan is long enough that +its later tasks would run on a compacted context. Inline execution over a +long plan still works — the ledger is what makes it recoverable — but the +last tasks get the least of you. + +## The Process + +```dot +digraph process { + rankdir=TB; + + subgraph cluster_per_task { + label="Per Task"; + "task-start: brief + BASE; read the brief" [shape=box]; + "Work the steps in order: TDD, run every verification, read every output" [shape=box]; + "Step output matches plan's Expected?" [shape=diamond]; + "Plan wrong? Rule and ledger. Code wrong? systematic-debugging" [shape=box]; + "Commit as the plan's commit steps say" [shape=box]; + "Completion contract met?" [shape=diamond]; + "task-done: run tests, ledger the result; mark todo complete" [shape=box]; + } + + "Setup: worktree, workspace + ledger, read plan + spec, pre-flight scan" [shape=box]; + "More tasks remain?" [shape=diamond]; + "Final whole-branch review (fresh reviewer if you have one)" [shape=box]; + "Re-grade, then: Critical/Important → ONE fix pass, each fix RED→GREEN + green suite; Minor → ledger" [shape=box]; + "Final review clean: delete this plan's workspace" [shape=box]; + "Use superpowers:finishing-a-development-branch" [shape=box style=filled fillcolor=lightgreen]; + + "Setup: worktree, workspace + ledger, read plan + spec, pre-flight scan" -> "task-start: brief + BASE; read the brief"; + "task-start: brief + BASE; read the brief" -> "Work the steps in order: TDD, run every verification, read every output"; + "Work the steps in order: TDD, run every verification, read every output" -> "Step output matches plan's Expected?"; + "Step output matches plan's Expected?" -> "Plan wrong? Rule and ledger. Code wrong? systematic-debugging" [label="no"]; + "Plan wrong? Rule and ledger. Code wrong? systematic-debugging" -> "Work the steps in order: TDD, run every verification, read every output"; + "Step output matches plan's Expected?" -> "Commit as the plan's commit steps say" [label="yes, last step"]; + "Commit as the plan's commit steps say" -> "Completion contract met?"; + "Completion contract met?" -> "Work the steps in order: TDD, run every verification, read every output" [label="no - finish the task"]; + "Completion contract met?" -> "task-done: run tests, ledger the result; mark todo complete" [label="yes"]; + "task-done: run tests, ledger the result; mark todo complete" -> "More tasks remain?"; + "More tasks remain?" -> "task-start: brief + BASE; read the brief" [label="yes"]; + "More tasks remain?" -> "Final whole-branch review (fresh reviewer if you have one)" [label="no"]; + "Final whole-branch review (fresh reviewer if you have one)" -> "Re-grade, then: Critical/Important → ONE fix pass, each fix RED→GREEN + green suite; Minor → ledger"; + "Re-grade, then: Critical/Important → ONE fix pass, each fix RED→GREEN + green suite; Minor → ledger" -> "Final review clean: delete this plan's workspace"; + "Final review clean: delete this plan's workspace" -> "Use superpowers:finishing-a-development-branch"; +} +``` + +## Setup + +Ensure the work happens in an isolated workspace: use +superpowers:using-git-worktrees to create one or verify the existing one. +Never start implementation on a main/master branch without your human +partner's explicit consent. + +Conversation memory does not survive compaction. An inline executor that +loses its place re-implements tasks whose commits already exist — the same +failure as a controller re-dispatching them, paid for in your own context. +Track progress in a ledger file, not only in todos. Harness todos are a +live view; the ledger is the record. + +The workspace and ledger are shared with superpowers:subagent-driven-development +— same directory, same format — so a plan can change executors mid-flight +and the new one resumes from the same ledger. + +- Each plan owns a workspace: at skill start, run + `../subagent-driven-development/scripts/sdd-workspace PLAN_FILE` — it + prints the plan's git-ignored directory + (`/.superpowers/sdd//`), home to every + artifact for THIS plan: ledger, briefs, review packages. Another plan's + directory is never yours to read or write. +- Check for this plan's ledger at `/progress.md`. If its first + line names your plan file, tasks with a `Task : complete` line are + DONE — do not redo them; resume at the first task without one. Their + commits exist in git even when your context no longer remembers making + them: after compaction, trust the ledger and `git log` over your own + recollection. A ledger whose first line names a different plan file is + another plan's progress: leave it and start your own, fresh. +- Create the ledger with its identity as the first line: + `# SDD ledger — plan: `. +- `git clean -fdx` will destroy the workspace (it's git-ignored scratch); + if that happens, recover from `git log`. + +Read the plan once, note its context and Global Constraints, and create a +todo per task. If the plan names a Spec, read that too: the spec is the +authority the plan argues from, and conflicts inside the plan resolve +against it. A plan with no reachable spec gets a ledger note saying so — +rulings made without one are provisional. + +**REQUIRED SUB-SKILL:** load superpowers:test-driven-development now, +before Task 1. It governs every step of every task below; a plan whose +steps already say "write the failing test first" does not exempt you +from reading it. + +Before Task 1, scan the plan for conflicts between tasks. The plan's +Interfaces blocks tell you where to look: for every task that consumes +what an earlier task produces, one ledger row — the two tasks, what one +produces against what the other consumes, and what you found. Tasks that +share nothing get no row; a plan whose tasks share nothing gets the single +line `Pre-flight: no shared interfaces`. Rule on each conflict a row +surfaces with the spec as the binding authority, record the ruling beside +its row, and start Task 1. Each task's own text is checked when you read +its brief, not here. + +## The Task Loop + +Everything you print, and every tool result, stays resident in your +context for the rest of the session. Redirect long test output to a file +in the workspace and read its tail; read a brief, not the whole plan. + +### 1. Take the task + +- Run this skill's `scripts/task-start PLAN_FILE N`. It prints the brief + path and BASE (the commit the task's range is cut from) in one call. + Read the brief for every task, including ones you remember from setup: + what you remember is a summary, the brief has the exact values, + signatures, and test cases. +- Mark the task's todo in_progress. + +Every tool call is a turn that re-reads your whole context. Bookkeeping +rides along with work — a ledger append in the same call as the commit, +never in a call of its own. + +### 2. Work the steps + +The plan's steps are already in RED-GREEN order; follow them in that +order under superpowers:test-driven-development, loaded at setup. A test +step's code is written first and run first. Watching it fail is a step, +not a formality — a test that passes before the implementation exists is +a finding about the test. + +Every step that runs a command has an `Expected:` line. Run the command, +read its output, and compare. Three outcomes: + +- **Matches.** Next step. +- **The code is wrong.** Use superpowers:systematic-debugging. Find the + cause; never patch the symptom to make the step's output match. +- **The plan is wrong** — a step contradicts the spec, an interface from an + earlier task doesn't match what this task consumes, a command that + cannot work. Rule on the smallest change that satisfies the spec, ledger + it as `Task : Ruling: — `, and + continue. The ruling is carried, not remembered: later tasks that touch + the same interface read it from the ledger. + +Commit as the plan's commit steps say. A task that spans several commits +is fine; BASE is what the review range is cut from, never `HEAD~1`. + +### 3. The completion contract + +Before a task's ledger line, all of the following are true, with evidence +in this session — not inferred from the diff looking right: + +- Every test the brief names exists and ran in this task, and you read + the output. +- The final test run for the task passed — `task-done` is that run, and + it writes the command and result into the ledger line. +- Every `Expected:` line in the brief was compared against real output. +- Every change to what the brief names has a `Ruling:` line in the ledger. + +**REQUIRED SUB-SKILL:** superpowers:verification-before-completion governs +the claim. If any item is missing, the task is not complete: finish it. + +### 4. Complete the task + +Run this skill's `scripts/task-done PLAN_FILE N BASE -- ` +with the test command the brief names for the whole task. It runs the +tests, keeps the full output in the workspace, prints the tail, and — only +if they pass — appends the completion line to the ledger: + +`Task : complete (commits .., tests: → )` + +A failing run records nothing; the task is not complete. When it records, +mark the todo complete and take the next task. + +## Final Review + +Run `../subagent-driven-development/scripts/review-package PLAN_FILE MERGE_BASE HEAD` +(MERGE_BASE = the commit the branch started from, e.g. +`git merge-base main HEAD`) and review from the file it prints. + +**With a subagent tool:** dispatch the reviewer on the most capable +available model — the whole-branch review is a judgment task — using +superpowers:requesting-code-review's +[code-reviewer.md](../requesting-code-review/code-reviewer.md), with the +package path, the plan and spec paths, the plan's Review Focus section +verbatim if it has one (the input classes and failure modes the plan's +tests do not exercise — the reviewer checks each deliberately), and a +pointer to the ledger's `Ruling:` lines so it can weigh the calls you +made. Specify the model +explicitly; an omitted model inherits the session's, which may not be the +most capable. This is the one fresh context the whole run buys. Do not +skip it, and do not replace it with your own read of the diff. + +**Without a subagent tool:** read code-reviewer.md and perform that review +yourself against the package, as a separate pass after the last task's +ledger line. Write `Final review: self-review (no subagent tool)` to the +ledger, and say so in your final message: a self-review by the author is +weaker than a fresh reviewer, and your human partner decides whether that +is enough before merge. + +Sort the findings before you act on any of them. The reviewer's severity +labels are advice; the gate is yours. Re-grade first: a finding labeled +Minor that describes an unhandled exception, a traceback reaching the +user, data loss, or a wrong result on valid input is Important, whatever +the label says — reviewers have filed crashes as Minor because the spec +did not mention the input that triggers them. Then: + +- **Critical and Important** enter the fix pass. +- **Minor** goes to the ledger as `Final: minor (deferred): ` + and to your final message under "Deferred minors". Minors never enter + the fix pass, and never become rulings — a ruling is a decision about a + conflict, not a note that you declined a polish suggestion. + +Fix the Critical and Important findings yourself — you are the +implementer here — in ONE pass. Each fix is verified by TDD, not by a +second reviewer: write the test that reproduces the finding, watch it +fail, make it pass, then run the whole suite. Record each in the ledger as +`Final: fixed — RED→GREEN, suite /`. A fix +without a test that failed first is not verified; a suite that is not +green after the pass means the pass is not over. Do not dispatch a +re-review: it would re-read a diff whose covering tests already answer +"addressed" and whose suite run already answers "broke nothing". + +A finding you decide not to fix is a ruling — `Final: Ruling: — + — ` — and reaches your human partner +in the rulings list. There is no second fix pass. + +## Finish + +Before you delete anything, collect every ledger line containing +`Ruling:` into your final message under "Rulings I made", in the order you +made them, each with what it costs if wrong, and every `minor (deferred)` +line under "Deferred minors". Both lists are exhaustive. Your final +message is the only place the decisions you took on your human partner's +behalf — and the findings you chose not to act on — reach them. + +When the final review is clean and its fixes are committed, delete this +plan's workspace directory — the git history is the record now. Sibling +directories belong to other plans; leave them alone. + +Use superpowers:finishing-a-development-branch. + +## Common Rationalizations + +| Excuse | Reality | +|--------|---------| +| "I remember what Task N says" | You remember a summary. The brief has the exact values. Read it. | +| "The plan's code is right, skip watching the test fail" | A test you never saw fail proves nothing. It is one step. Run it. | +| "I'll run the full suite at the end instead of per step" | Per-step runs are how you learn which step broke it. The end-of-task run is the contract, not a substitute. | +| "The plan is wrong here, I'll just do the right thing" | Do the right thing and ledger the ruling. Unledgered deviation is a decision made in secret. | +| "I'll write the ledger lines after a few tasks" | Compaction does not wait for a convenient moment. One line per task, in the same message as the commit. | +| "Let me check in before the next task" | They chose inline to spend less. Progress prompts spend their time instead. Only the four stops stop you. | +| "I read my own diff carefully; the final reviewer is redundant" | Same author, same blind spots. The reviewer is the only fresh context this run buys. | +| "Tests should pass, the change was trivial" | "Should" is not evidence. The contract requires the command and its output. | +| "Subagents are slow and expensive, I'll skip the final review too" | Inline already removed the per-task reviewers. One review of the whole branch is the floor, not the ceiling. | +| "The reviewer said Minor, so it's Minor" | A traceback is Important whatever the label. Re-grade, then gate. | +| "The fix is obvious, no need for a failing test first" | The failing test is the only proof the finding was real and is now gone. Without it you have a diff and a hope. | +| "I'll fix the minors too while I'm in there" | Every minor you fix is a test, a fix, and a suite run your partner did not ask for. Ledger them; your partner decides. | + +## Example Workflow + +``` +You: I'm using the executing-plans skill to implement this plan inline. + +[Setup: worktree verified] +[Read plan once: docs/superpowers/plans/feature-plan.md; spec read] +[Resolve workspace: sdd-workspace docs/superpowers/plans/feature-plan.md — no ledger inside, fresh start] +[Pre-flight scan: 2 shared-interface rows, 4 self-consistency rows, clean; written to ledger] +[Create todos for all tasks] + +Task 1: Hook installation script + +[task-start plan 1 → brief read; BASE a1b2c3d] +[Step 1: write failing test — written] +[Step 2: run it — FAIL: install_hook not defined. Matches Expected.] +[Step 3: implement — written] +[Step 4: run it — PASS 1/1. Matches Expected.] +[Step 5: commit — d4e5f6a] +[Contract: tests ran, output read, no deviations] +[task-done plan 1 a1b2c3d -- npm test -- hooks → ledger: Task 1: complete (commits a1b2c3d..d4e5f6a, tests: npm test -- hooks → 1/1 pass)] + +Task 2: Recovery modes + +[task-start plan 2 → brief read; BASE d4e5f6a] +[Step 2: run failing test — FAIL, but on an import error: Task 1 exported + installHook, brief consumes install_hook] +[Ruling: brief's consumer name is a typo against Task 1's Produces block; + use installHook — Ledger: Task 2: Ruling: install_hook → installHook — matches Task 1 Produces — cost if wrong: one rename] +[Steps 2-5 as planned; commit b7c8d9e] +[task-done plan 2 d4e5f6a -- npm test -- recovery → ledger: Task 2: complete (commits d4e5f6a..b7c8d9e, tests: npm test -- recovery → 8/8 pass)] + +... + +[After all tasks: review-package plan MERGE_BASE HEAD; dispatch code-reviewer, most capable model] +Reviewer: One Important finding — progress reporting interval hardcoded. Two Minor. +[Re-grade: Important stands; minors → ledger as deferred] +[Fix pass: test_progress_interval_configurable RED → extract PROGRESS_INTERVAL → GREEN; suite 12/12; commit] +[Ledger: Final: fixed hardcoded interval — test_progress_interval_configurable RED→GREEN, suite 12/12] + +Rulings I made: +- Task 2: install_hook → installHook (brief typo; cost if wrong: one rename) + +Deferred minors: +- README lacks a usage example +- recovery.js could split verify/repair into two files + +[Delete this plan's workspace — the record now lives in git] + +Using superpowers:finishing-a-development-branch. +``` diff --git a/tests/inline-eval/microtests/scope-vs-failure/variants/v2-deletion-only.md b/tests/inline-eval/microtests/scope-vs-failure/variants/v2-deletion-only.md new file mode 100644 index 000000000..50ab1b032 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/variants/v2-deletion-only.md @@ -0,0 +1,368 @@ +--- +name: executing-plans +description: Use when executing an implementation plan in the current session as the implementer yourself — your human partner chose inline execution, or no subagent tool is available +--- + +# Executing Plans + +Execute the plan yourself, task by task, in this session: no implementer +subagent per task, no reviewer per task. One fresh-context review of the +whole branch at the end. + +**Why inline:** Subagent-driven development pays for a fresh implementer +and a fresh reviewer on every task, each re-reading the codebase from zero. +Inline execution pays for one context (yours) plus one reviewer at the end. +What it gives up is a fresh context per task and a second pair of eyes per +task. This skill keeps what those two things bought, by other means: the +brief is the spec, the ledger is your memory, TDD is the per-task gate, and +the final reviewer is the second pair of eyes. + +**Core principle:** The plan already did the thinking. Prove each step +with a test you watched fail and then pass, and leave a record that +survives your own forgetting. + +**Narration:** between tool calls, narrate at most one short line — the +ledger and the tool results carry the record. + +**Continuous execution:** Do not pause to check in with your human partner +between tasks. They chose inline execution to spend less, not to answer +"should I continue?" after every task. Execute all tasks from the plan +without stopping. + +**Rulings, not stalls.** Conflicts, ambiguities, plan defects — decide them. +The spec is the binding authority, the plan is its argument, and your +judgment settles what neither answers. Record every decision in the ledger +as `Ruling: — — `, and keep +going. + +Four things stop you, and only these: an irreversible or destructive +operation; a security-sensitive action; a side effect outside this worktree +that norms say you ask about first (a merge, a push to a shared branch, a +publish); and a plan so broken that every path forward is a guess. For +those, stop and ask. + +## When to Use + +- You have a plan from superpowers:writing-plans and your human partner + chose inline execution at the handoff. +- Your harness has no subagent tool (see the per-platform references in + `../using-superpowers/references/`). Never fabricate a dispatch; run + the plan here. +- Tasks are mostly independent — the same precondition as + superpowers:subagent-driven-development. + +A fully specified plan makes inline execution transcription plus testing: +it runs well on a mid-tier session model, and the one place the most +capable model earns its cost is the final review, which this skill +dispatches separately. Tell your human partner so when they choose inline. + +Prefer superpowers:subagent-driven-development when your human partner +wants a review gate on every task, or when the plan is long enough that +its later tasks would run on a compacted context. Inline execution over a +long plan still works — the ledger is what makes it recoverable — but the +last tasks get the least of you. + +## The Process + +```dot +digraph process { + rankdir=TB; + + subgraph cluster_per_task { + label="Per Task"; + "task-start: brief + BASE; read the brief" [shape=box]; + "Work the steps in order: TDD, run every verification, read every output" [shape=box]; + "Step output matches plan's Expected?" [shape=diamond]; + "Plan wrong? Rule and ledger. Code wrong? systematic-debugging" [shape=box]; + "Commit as the plan's commit steps say" [shape=box]; + "Completion contract met?" [shape=diamond]; + "task-done: run tests, ledger the result; mark todo complete" [shape=box]; + } + + "Setup: worktree, workspace + ledger, read plan + spec, pre-flight scan" [shape=box]; + "More tasks remain?" [shape=diamond]; + "Final whole-branch review (fresh reviewer if you have one)" [shape=box]; + "Re-grade, then: Critical/Important → ONE fix pass, each fix RED→GREEN + green suite; Minor → ledger" [shape=box]; + "Final review clean: delete this plan's workspace" [shape=box]; + "Use superpowers:finishing-a-development-branch" [shape=box style=filled fillcolor=lightgreen]; + + "Setup: worktree, workspace + ledger, read plan + spec, pre-flight scan" -> "task-start: brief + BASE; read the brief"; + "task-start: brief + BASE; read the brief" -> "Work the steps in order: TDD, run every verification, read every output"; + "Work the steps in order: TDD, run every verification, read every output" -> "Step output matches plan's Expected?"; + "Step output matches plan's Expected?" -> "Plan wrong? Rule and ledger. Code wrong? systematic-debugging" [label="no"]; + "Plan wrong? Rule and ledger. Code wrong? systematic-debugging" -> "Work the steps in order: TDD, run every verification, read every output"; + "Step output matches plan's Expected?" -> "Commit as the plan's commit steps say" [label="yes, last step"]; + "Commit as the plan's commit steps say" -> "Completion contract met?"; + "Completion contract met?" -> "Work the steps in order: TDD, run every verification, read every output" [label="no - finish the task"]; + "Completion contract met?" -> "task-done: run tests, ledger the result; mark todo complete" [label="yes"]; + "task-done: run tests, ledger the result; mark todo complete" -> "More tasks remain?"; + "More tasks remain?" -> "task-start: brief + BASE; read the brief" [label="yes"]; + "More tasks remain?" -> "Final whole-branch review (fresh reviewer if you have one)" [label="no"]; + "Final whole-branch review (fresh reviewer if you have one)" -> "Re-grade, then: Critical/Important → ONE fix pass, each fix RED→GREEN + green suite; Minor → ledger"; + "Re-grade, then: Critical/Important → ONE fix pass, each fix RED→GREEN + green suite; Minor → ledger" -> "Final review clean: delete this plan's workspace"; + "Final review clean: delete this plan's workspace" -> "Use superpowers:finishing-a-development-branch"; +} +``` + +## Setup + +Ensure the work happens in an isolated workspace: use +superpowers:using-git-worktrees to create one or verify the existing one. +Never start implementation on a main/master branch without your human +partner's explicit consent. + +Conversation memory does not survive compaction. An inline executor that +loses its place re-implements tasks whose commits already exist — the same +failure as a controller re-dispatching them, paid for in your own context. +Track progress in a ledger file, not only in todos. Harness todos are a +live view; the ledger is the record. + +The workspace and ledger are shared with superpowers:subagent-driven-development +— same directory, same format — so a plan can change executors mid-flight +and the new one resumes from the same ledger. + +- Each plan owns a workspace: at skill start, run + `../subagent-driven-development/scripts/sdd-workspace PLAN_FILE` — it + prints the plan's git-ignored directory + (`/.superpowers/sdd//`), home to every + artifact for THIS plan: ledger, briefs, review packages. Another plan's + directory is never yours to read or write. +- Check for this plan's ledger at `/progress.md`. If its first + line names your plan file, tasks with a `Task : complete` line are + DONE — do not redo them; resume at the first task without one. Their + commits exist in git even when your context no longer remembers making + them: after compaction, trust the ledger and `git log` over your own + recollection. A ledger whose first line names a different plan file is + another plan's progress: leave it and start your own, fresh. +- Create the ledger with its identity as the first line: + `# SDD ledger — plan: `. +- `git clean -fdx` will destroy the workspace (it's git-ignored scratch); + if that happens, recover from `git log`. + +Read the plan once, note its context and Global Constraints, and create a +todo per task. If the plan names a Spec, read that too: the spec is the +authority the plan argues from, and conflicts inside the plan resolve +against it. A plan with no reachable spec gets a ledger note saying so — +rulings made without one are provisional. + +**REQUIRED SUB-SKILL:** load superpowers:test-driven-development now, +before Task 1. It governs every step of every task below; a plan whose +steps already say "write the failing test first" does not exempt you +from reading it. + +Before Task 1, scan the plan for conflicts between tasks. The plan's +Interfaces blocks tell you where to look: for every task that consumes +what an earlier task produces, one ledger row — the two tasks, what one +produces against what the other consumes, and what you found. Tasks that +share nothing get no row; a plan whose tasks share nothing gets the single +line `Pre-flight: no shared interfaces`. Rule on each conflict a row +surfaces with the spec as the binding authority, record the ruling beside +its row, and start Task 1. Each task's own text is checked when you read +its brief, not here. + +## The Task Loop + +Everything you print, and every tool result, stays resident in your +context for the rest of the session. Redirect long test output to a file +in the workspace and read its tail; read a brief, not the whole plan. + +### 1. Take the task + +- Run this skill's `scripts/task-start PLAN_FILE N`. It prints the brief + path and BASE (the commit the task's range is cut from) in one call. + Read the brief for every task, including ones you remember from setup: + what you remember is a summary, the brief has the exact values, + signatures, and test cases. +- Mark the task's todo in_progress. + +Every tool call is a turn that re-reads your whole context. Bookkeeping +rides along with work — a ledger append in the same call as the commit, +never in a call of its own. + +### 2. Work the steps + +The plan's steps are already in RED-GREEN order; follow them in that +order under superpowers:test-driven-development, loaded at setup. A test +step's code is written first and run first. Watching it fail is a step, +not a formality — a test that passes before the implementation exists is +a finding about the test. + +Every step that runs a command has an `Expected:` line. Run the command, +read its output, and compare. Three outcomes: + +- **Matches.** Next step. +- **The code is wrong.** Use superpowers:systematic-debugging. Find the + cause; never patch the symptom to make the step's output match. +- **The plan is wrong** — a step contradicts the spec, an interface from an + earlier task doesn't match what this task consumes, a command that + cannot work. Rule on the smallest change that satisfies the spec, ledger + it as `Task : Ruling: — `, and + continue. The ruling is carried, not remembered: later tasks that touch + the same interface read it from the ledger. + +Commit as the plan's commit steps say. A task that spans several commits +is fine; BASE is what the review range is cut from, never `HEAD~1`. + +### 3. The completion contract + +Before a task's ledger line, all of the following are true, with evidence +in this session — not inferred from the diff looking right: + +- Every test the brief names exists and ran in this task, and you read + the output. +- The final test run for the task passed — `task-done` is that run, and + it writes the command and result into the ledger line. +- Every `Expected:` line in the brief was compared against real output. +- Every deviation from the brief has a `Ruling:` line in the ledger. + +**REQUIRED SUB-SKILL:** superpowers:verification-before-completion governs +the claim. If any item is missing, the task is not complete: finish it. + +### 4. Complete the task + +Run this skill's `scripts/task-done PLAN_FILE N BASE -- ` +with the test command the brief names for the whole task. It runs the +tests, keeps the full output in the workspace, prints the tail, and — only +if they pass — appends the completion line to the ledger: + +`Task : complete (commits .., tests: → )` + +A failing run records nothing; the task is not complete. When it records, +mark the todo complete and take the next task. + +## Final Review + +Run `../subagent-driven-development/scripts/review-package PLAN_FILE MERGE_BASE HEAD` +(MERGE_BASE = the commit the branch started from, e.g. +`git merge-base main HEAD`) and review from the file it prints. + +**With a subagent tool:** dispatch the reviewer on the most capable +available model — the whole-branch review is a judgment task — using +superpowers:requesting-code-review's +[code-reviewer.md](../requesting-code-review/code-reviewer.md), with the +package path, the plan and spec paths, the plan's Review Focus section +verbatim if it has one (the input classes and failure modes the plan's +tests do not exercise — the reviewer checks each deliberately), and a +pointer to the ledger's `Ruling:` lines so it can weigh the calls you +made. Specify the model +explicitly; an omitted model inherits the session's, which may not be the +most capable. This is the one fresh context the whole run buys. Do not +skip it, and do not replace it with your own read of the diff. + +**Without a subagent tool:** read code-reviewer.md and perform that review +yourself against the package, as a separate pass after the last task's +ledger line. Write `Final review: self-review (no subagent tool)` to the +ledger, and say so in your final message: a self-review by the author is +weaker than a fresh reviewer, and your human partner decides whether that +is enough before merge. + +Sort the findings before you act on any of them. The reviewer's severity +labels are advice; the gate is yours. Re-grade first: a finding labeled +Minor that describes an unhandled exception, a traceback reaching the +user, data loss, or a wrong result on valid input is Important, whatever +the label says — reviewers have filed crashes as Minor because the spec +did not mention the input that triggers them. Then: + +- **Critical and Important** enter the fix pass. +- **Minor** goes to the ledger as `Final: minor (deferred): ` + and to your final message under "Deferred minors". Minors never enter + the fix pass, and never become rulings — a ruling is a decision about a + conflict, not a note that you declined a polish suggestion. + +Fix the Critical and Important findings yourself — you are the +implementer here — in ONE pass. Each fix is verified by TDD, not by a +second reviewer: write the test that reproduces the finding, watch it +fail, make it pass, then run the whole suite. Record each in the ledger as +`Final: fixed — RED→GREEN, suite /`. A fix +without a test that failed first is not verified; a suite that is not +green after the pass means the pass is not over. Do not dispatch a +re-review: it would re-read a diff whose covering tests already answer +"addressed" and whose suite run already answers "broke nothing". + +A finding you decide not to fix is a ruling — `Final: Ruling: — + — ` — and reaches your human partner +in the rulings list. There is no second fix pass. + +## Finish + +Before you delete anything, collect every ledger line containing +`Ruling:` into your final message under "Rulings I made", in the order you +made them, each with what it costs if wrong, and every `minor (deferred)` +line under "Deferred minors". Both lists are exhaustive. Your final +message is the only place the decisions you took on your human partner's +behalf — and the findings you chose not to act on — reach them. + +When the final review is clean and its fixes are committed, delete this +plan's workspace directory — the git history is the record now. Sibling +directories belong to other plans; leave them alone. + +Use superpowers:finishing-a-development-branch. + +## Common Rationalizations + +| Excuse | Reality | +|--------|---------| +| "I remember what Task N says" | You remember a summary. The brief has the exact values. Read it. | +| "The plan's code is right, skip watching the test fail" | A test you never saw fail proves nothing. It is one step. Run it. | +| "I'll run the full suite at the end instead of per step" | Per-step runs are how you learn which step broke it. The end-of-task run is the contract, not a substitute. | +| "The plan is wrong here, I'll just do the right thing" | Do the right thing and ledger the ruling. Unledgered deviation is a decision made in secret. | +| "I'll write the ledger lines after a few tasks" | Compaction does not wait for a convenient moment. One line per task, in the same message as the commit. | +| "Let me check in before the next task" | They chose inline to spend less. Progress prompts spend their time instead. Only the four stops stop you. | +| "I read my own diff carefully; the final reviewer is redundant" | Same author, same blind spots. The reviewer is the only fresh context this run buys. | +| "Tests should pass, the change was trivial" | "Should" is not evidence. The contract requires the command and its output. | +| "Subagents are slow and expensive, I'll skip the final review too" | Inline already removed the per-task reviewers. One review of the whole branch is the floor, not the ceiling. | +| "The reviewer said Minor, so it's Minor" | A traceback is Important whatever the label. Re-grade, then gate. | +| "The fix is obvious, no need for a failing test first" | The failing test is the only proof the finding was real and is now gone. Without it you have a diff and a hope. | +| "I'll fix the minors too while I'm in there" | Every minor you fix is a test, a fix, and a suite run your partner did not ask for. Ledger them; your partner decides. | + +## Example Workflow + +``` +You: I'm using the executing-plans skill to implement this plan inline. + +[Setup: worktree verified] +[Read plan once: docs/superpowers/plans/feature-plan.md; spec read] +[Resolve workspace: sdd-workspace docs/superpowers/plans/feature-plan.md — no ledger inside, fresh start] +[Pre-flight scan: 2 shared-interface rows, 4 self-consistency rows, clean; written to ledger] +[Create todos for all tasks] + +Task 1: Hook installation script + +[task-start plan 1 → brief read; BASE a1b2c3d] +[Step 1: write failing test — written] +[Step 2: run it — FAIL: install_hook not defined. Matches Expected.] +[Step 3: implement — written] +[Step 4: run it — PASS 1/1. Matches Expected.] +[Step 5: commit — d4e5f6a] +[Contract: tests ran, output read, no deviations] +[task-done plan 1 a1b2c3d -- npm test -- hooks → ledger: Task 1: complete (commits a1b2c3d..d4e5f6a, tests: npm test -- hooks → 1/1 pass)] + +Task 2: Recovery modes + +[task-start plan 2 → brief read; BASE d4e5f6a] +[Step 2: run failing test — FAIL, but on an import error: Task 1 exported + installHook, brief consumes install_hook] +[Ruling: brief's consumer name is a typo against Task 1's Produces block; + use installHook — Ledger: Task 2: Ruling: install_hook → installHook — matches Task 1 Produces — cost if wrong: one rename] +[Steps 2-5 as planned; commit b7c8d9e] +[task-done plan 2 d4e5f6a -- npm test -- recovery → ledger: Task 2: complete (commits d4e5f6a..b7c8d9e, tests: npm test -- recovery → 8/8 pass)] + +... + +[After all tasks: review-package plan MERGE_BASE HEAD; dispatch code-reviewer, most capable model] +Reviewer: One Important finding — progress reporting interval hardcoded. Two Minor. +[Re-grade: Important stands; minors → ledger as deferred] +[Fix pass: test_progress_interval_configurable RED → extract PROGRESS_INTERVAL → GREEN; suite 12/12; commit] +[Ledger: Final: fixed hardcoded interval — test_progress_interval_configurable RED→GREEN, suite 12/12] + +Rulings I made: +- Task 2: install_hook → installHook (brief typo; cost if wrong: one rename) + +Deferred minors: +- README lacks a usage example +- recovery.js could split verify/repair into two files + +[Delete this plan's workspace — the record now lives in git] + +Using superpowers:finishing-a-development-branch. +``` diff --git a/tests/inline-eval/microtests/scope-vs-failure/variants/v3-boundary-list.md b/tests/inline-eval/microtests/scope-vs-failure/variants/v3-boundary-list.md new file mode 100644 index 000000000..a1e00a1e4 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/variants/v3-boundary-list.md @@ -0,0 +1,380 @@ +--- +name: executing-plans +description: Use when executing an implementation plan in the current session as the implementer yourself — your human partner chose inline execution, or no subagent tool is available +--- + +# Executing Plans + +Execute the plan yourself, task by task, in this session: no implementer +subagent per task, no reviewer per task. One fresh-context review of the +whole branch at the end. + +**Why inline:** Subagent-driven development pays for a fresh implementer +and a fresh reviewer on every task, each re-reading the codebase from zero. +Inline execution pays for one context (yours) plus one reviewer at the end. +What it gives up is a fresh context per task and a second pair of eyes per +task. This skill keeps what those two things bought, by other means: the +brief is the spec, the ledger is your memory, TDD is the per-task gate, and +the final reviewer is the second pair of eyes. + +**Core principle:** The plan already did the thinking. Execute it exactly, +prove each step with a test you watched fail and then pass, and leave a +record that survives your own forgetting. + +**Narration:** between tool calls, narrate at most one short line — the +ledger and the tool results carry the record. + +**Continuous execution:** Do not pause to check in with your human partner +between tasks. They chose inline execution to spend less, not to answer +"should I continue?" after every task. Execute all tasks from the plan +without stopping. + +**Rulings, not stalls.** Conflicts, ambiguities, plan defects — decide them. +The spec is the binding authority, the plan is its argument, and your +judgment settles what neither answers. Record every decision in the ledger +as `Ruling: — — `, and keep +going. Deviating from the plan without a ledgered ruling is a decision made +in secret. + +Four things stop you, and only these: an irreversible or destructive +operation; a security-sensitive action; a side effect outside this worktree +that norms say you ask about first (a merge, a push to a shared branch, a +publish); and a plan so broken that every path forward is a guess. For +those, stop and ask. + +## When to Use + +- You have a plan from superpowers:writing-plans and your human partner + chose inline execution at the handoff. +- Your harness has no subagent tool (see the per-platform references in + `../using-superpowers/references/`). Never fabricate a dispatch; run + the plan here. +- Tasks are mostly independent — the same precondition as + superpowers:subagent-driven-development. + +A fully specified plan makes inline execution transcription plus testing: +it runs well on a mid-tier session model, and the one place the most +capable model earns its cost is the final review, which this skill +dispatches separately. Tell your human partner so when they choose inline. + +Prefer superpowers:subagent-driven-development when your human partner +wants a review gate on every task, or when the plan is long enough that +its later tasks would run on a compacted context. Inline execution over a +long plan still works — the ledger is what makes it recoverable — but the +last tasks get the least of you. + +## The Process + +```dot +digraph process { + rankdir=TB; + + subgraph cluster_per_task { + label="Per Task"; + "task-start: brief + BASE; read the brief" [shape=box]; + "Work the steps in order: TDD, run every verification, read every output" [shape=box]; + "Step output matches plan's Expected?" [shape=diamond]; + "Plan wrong? Rule and ledger. Code wrong? systematic-debugging" [shape=box]; + "Commit as the plan's commit steps say" [shape=box]; + "Completion contract met?" [shape=diamond]; + "task-done: run tests, ledger the result; mark todo complete" [shape=box]; + } + + "Setup: worktree, workspace + ledger, read plan + spec, pre-flight scan" [shape=box]; + "More tasks remain?" [shape=diamond]; + "Final whole-branch review (fresh reviewer if you have one)" [shape=box]; + "Re-grade, then: Critical/Important → ONE fix pass, each fix RED→GREEN + green suite; Minor → ledger" [shape=box]; + "Final review clean: delete this plan's workspace" [shape=box]; + "Use superpowers:finishing-a-development-branch" [shape=box style=filled fillcolor=lightgreen]; + + "Setup: worktree, workspace + ledger, read plan + spec, pre-flight scan" -> "task-start: brief + BASE; read the brief"; + "task-start: brief + BASE; read the brief" -> "Work the steps in order: TDD, run every verification, read every output"; + "Work the steps in order: TDD, run every verification, read every output" -> "Step output matches plan's Expected?"; + "Step output matches plan's Expected?" -> "Plan wrong? Rule and ledger. Code wrong? systematic-debugging" [label="no"]; + "Plan wrong? Rule and ledger. Code wrong? systematic-debugging" -> "Work the steps in order: TDD, run every verification, read every output"; + "Step output matches plan's Expected?" -> "Commit as the plan's commit steps say" [label="yes, last step"]; + "Commit as the plan's commit steps say" -> "Completion contract met?"; + "Completion contract met?" -> "Work the steps in order: TDD, run every verification, read every output" [label="no - finish the task"]; + "Completion contract met?" -> "task-done: run tests, ledger the result; mark todo complete" [label="yes"]; + "task-done: run tests, ledger the result; mark todo complete" -> "More tasks remain?"; + "More tasks remain?" -> "task-start: brief + BASE; read the brief" [label="yes"]; + "More tasks remain?" -> "Final whole-branch review (fresh reviewer if you have one)" [label="no"]; + "Final whole-branch review (fresh reviewer if you have one)" -> "Re-grade, then: Critical/Important → ONE fix pass, each fix RED→GREEN + green suite; Minor → ledger"; + "Re-grade, then: Critical/Important → ONE fix pass, each fix RED→GREEN + green suite; Minor → ledger" -> "Final review clean: delete this plan's workspace"; + "Final review clean: delete this plan's workspace" -> "Use superpowers:finishing-a-development-branch"; +} +``` + +## Setup + +Ensure the work happens in an isolated workspace: use +superpowers:using-git-worktrees to create one or verify the existing one. +Never start implementation on a main/master branch without your human +partner's explicit consent. + +Conversation memory does not survive compaction. An inline executor that +loses its place re-implements tasks whose commits already exist — the same +failure as a controller re-dispatching them, paid for in your own context. +Track progress in a ledger file, not only in todos. Harness todos are a +live view; the ledger is the record. + +The workspace and ledger are shared with superpowers:subagent-driven-development +— same directory, same format — so a plan can change executors mid-flight +and the new one resumes from the same ledger. + +- Each plan owns a workspace: at skill start, run + `../subagent-driven-development/scripts/sdd-workspace PLAN_FILE` — it + prints the plan's git-ignored directory + (`/.superpowers/sdd//`), home to every + artifact for THIS plan: ledger, briefs, review packages. Another plan's + directory is never yours to read or write. +- Check for this plan's ledger at `/progress.md`. If its first + line names your plan file, tasks with a `Task : complete` line are + DONE — do not redo them; resume at the first task without one. Their + commits exist in git even when your context no longer remembers making + them: after compaction, trust the ledger and `git log` over your own + recollection. A ledger whose first line names a different plan file is + another plan's progress: leave it and start your own, fresh. +- Create the ledger with its identity as the first line: + `# SDD ledger — plan: `. +- `git clean -fdx` will destroy the workspace (it's git-ignored scratch); + if that happens, recover from `git log`. + +Read the plan once, note its context and Global Constraints, and create a +todo per task. If the plan names a Spec, read that too: the spec is the +authority the plan argues from, and conflicts inside the plan resolve +against it. A plan with no reachable spec gets a ledger note saying so — +rulings made without one are provisional. + +**REQUIRED SUB-SKILL:** load superpowers:test-driven-development now, +before Task 1. It governs every step of every task below; a plan whose +steps already say "write the failing test first" does not exempt you +from reading it. + +Before Task 1, scan the plan for conflicts between tasks. The plan's +Interfaces blocks tell you where to look: for every task that consumes +what an earlier task produces, one ledger row — the two tasks, what one +produces against what the other consumes, and what you found. Tasks that +share nothing get no row; a plan whose tasks share nothing gets the single +line `Pre-flight: no shared interfaces`. Rule on each conflict a row +surfaces with the spec as the binding authority, record the ruling beside +its row, and start Task 1. Each task's own text is checked when you read +its brief, not here. + +## The Task Loop + +Everything you print, and every tool result, stays resident in your +context for the rest of the session. Redirect long test output to a file +in the workspace and read its tail; read a brief, not the whole plan. + +### 1. Take the task + +- Run this skill's `scripts/task-start PLAN_FILE N`. It prints the brief + path and BASE (the commit the task's range is cut from) in one call. + Read the brief for every task, including ones you remember from setup: + what you remember is a summary, the brief has the exact values, + signatures, and test cases. +- Mark the task's todo in_progress. + +Every tool call is a turn that re-reads your whole context. Bookkeeping +rides along with work — a ledger append in the same call as the commit, +never in a call of its own. + +### 2. Work the steps + +The plan's steps are already in RED-GREEN order; follow them in that +order under superpowers:test-driven-development, loaded at setup. A test +step's code is written first and run first. Watching it fail is a step, +not a formality — a test that passes before the implementation exists is +a finding about the test. + +When a step's code meets the outside world — opens or reads a file, +parses input, calls a network, reads the environment, spawns a process — +the failing test for that step is written after a boundary list, not +before: name the operation, list every way it can raise or return +something wrong, and give each entry the failure shape the spec uses for +the failures it names. Each entry becomes a test case in this task, or a +`Ruling:` in the ledger saying why it is left alone. A boundary with an +unlisted failure is an unfinished step, whatever the brief says about it. + +Every step that runs a command has an `Expected:` line. Run the command, +read its output, and compare. Three outcomes: + +- **Matches.** Next step. +- **The code is wrong.** Use superpowers:systematic-debugging. Find the + cause; never patch the symptom to make the step's output match. +- **The plan is wrong** — a step contradicts the spec, an interface from an + earlier task doesn't match what this task consumes, a command that + cannot work. Rule on the smallest change that satisfies the spec, ledger + it as `Task : Ruling: — `, and + continue. The ruling is carried, not remembered: later tasks that touch + the same interface read it from the ledger. + +Commit as the plan's commit steps say. A task that spans several commits +is fine; BASE is what the review range is cut from, never `HEAD~1`. + +### 3. The completion contract + +Before a task's ledger line, all of the following are true, with evidence +in this session — not inferred from the diff looking right: + +- Every test the brief names exists and ran in this task, and you read + the output. +- The final test run for the task passed — `task-done` is that run, and + it writes the command and result into the ledger line. +- Every `Expected:` line in the brief was compared against real output. +- Every boundary the task's code crosses has its list, and every entry + on it is a test in this task or a ledgered ruling. +- Every deviation from the brief has a `Ruling:` line in the ledger. + +**REQUIRED SUB-SKILL:** superpowers:verification-before-completion governs +the claim. If any item is missing, the task is not complete: finish it. + +### 4. Complete the task + +Run this skill's `scripts/task-done PLAN_FILE N BASE -- ` +with the test command the brief names for the whole task. It runs the +tests, keeps the full output in the workspace, prints the tail, and — only +if they pass — appends the completion line to the ledger: + +`Task : complete (commits .., tests: → )` + +A failing run records nothing; the task is not complete. When it records, +mark the todo complete and take the next task. + +## Final Review + +Run `../subagent-driven-development/scripts/review-package PLAN_FILE MERGE_BASE HEAD` +(MERGE_BASE = the commit the branch started from, e.g. +`git merge-base main HEAD`) and review from the file it prints. + +**With a subagent tool:** dispatch the reviewer on the most capable +available model — the whole-branch review is a judgment task — using +superpowers:requesting-code-review's +[code-reviewer.md](../requesting-code-review/code-reviewer.md), with the +package path, the plan and spec paths, the plan's Review Focus section +verbatim if it has one (the input classes and failure modes the plan's +tests do not exercise — the reviewer checks each deliberately), and a +pointer to the ledger's `Ruling:` lines so it can weigh the calls you +made. Specify the model +explicitly; an omitted model inherits the session's, which may not be the +most capable. This is the one fresh context the whole run buys. Do not +skip it, and do not replace it with your own read of the diff. + +**Without a subagent tool:** read code-reviewer.md and perform that review +yourself against the package, as a separate pass after the last task's +ledger line. Write `Final review: self-review (no subagent tool)` to the +ledger, and say so in your final message: a self-review by the author is +weaker than a fresh reviewer, and your human partner decides whether that +is enough before merge. + +Sort the findings before you act on any of them. The reviewer's severity +labels are advice; the gate is yours. Re-grade first: a finding labeled +Minor that describes an unhandled exception, a traceback reaching the +user, data loss, or a wrong result on valid input is Important, whatever +the label says — reviewers have filed crashes as Minor because the spec +did not mention the input that triggers them. Then: + +- **Critical and Important** enter the fix pass. +- **Minor** goes to the ledger as `Final: minor (deferred): ` + and to your final message under "Deferred minors". Minors never enter + the fix pass, and never become rulings — a ruling is a decision about a + conflict, not a note that you declined a polish suggestion. + +Fix the Critical and Important findings yourself — you are the +implementer here — in ONE pass. Each fix is verified by TDD, not by a +second reviewer: write the test that reproduces the finding, watch it +fail, make it pass, then run the whole suite. Record each in the ledger as +`Final: fixed — RED→GREEN, suite /`. A fix +without a test that failed first is not verified; a suite that is not +green after the pass means the pass is not over. Do not dispatch a +re-review: it would re-read a diff whose covering tests already answer +"addressed" and whose suite run already answers "broke nothing". + +A finding you decide not to fix is a ruling — `Final: Ruling: — + — ` — and reaches your human partner +in the rulings list. There is no second fix pass. + +## Finish + +Before you delete anything, collect every ledger line containing +`Ruling:` into your final message under "Rulings I made", in the order you +made them, each with what it costs if wrong, and every `minor (deferred)` +line under "Deferred minors". Both lists are exhaustive. Your final +message is the only place the decisions you took on your human partner's +behalf — and the findings you chose not to act on — reach them. + +When the final review is clean and its fixes are committed, delete this +plan's workspace directory — the git history is the record now. Sibling +directories belong to other plans; leave them alone. + +Use superpowers:finishing-a-development-branch. + +## Common Rationalizations + +| Excuse | Reality | +|--------|---------| +| "I remember what Task N says" | You remember a summary. The brief has the exact values. Read it. | +| "The plan's code is right, skip watching the test fail" | A test you never saw fail proves nothing. It is one step. Run it. | +| "I'll run the full suite at the end instead of per step" | Per-step runs are how you learn which step broke it. The end-of-task run is the contract, not a substitute. | +| "The plan is wrong here, I'll just do the right thing" | Do the right thing and ledger the ruling. Unledgered deviation is a decision made in secret. | +| "I'll write the ledger lines after a few tasks" | Compaction does not wait for a convenient moment. One line per task, in the same message as the commit. | +| "Let me check in before the next task" | They chose inline to spend less. Progress prompts spend their time instead. Only the four stops stop you. | +| "I read my own diff carefully; the final reviewer is redundant" | Same author, same blind spots. The reviewer is the only fresh context this run buys. | +| "Tests should pass, the change was trivial" | "Should" is not evidence. The contract requires the command and its output. | +| "Subagents are slow and expensive, I'll skip the final review too" | Inline already removed the per-task reviewers. One review of the whole branch is the floor, not the ceiling. | +| "The reviewer said Minor, so it's Minor" | A traceback is Important whatever the label. Re-grade, then gate. | +| "The fix is obvious, no need for a failing test first" | The failing test is the only proof the finding was real and is now gone. Without it you have a diff and a hope. | +| "I'll fix the minors too while I'm in there" | Every minor you fix is a test, a fix, and a suite run your partner did not ask for. Ledger them; your partner decides. | + +## Example Workflow + +``` +You: I'm using the executing-plans skill to implement this plan inline. + +[Setup: worktree verified] +[Read plan once: docs/superpowers/plans/feature-plan.md; spec read] +[Resolve workspace: sdd-workspace docs/superpowers/plans/feature-plan.md — no ledger inside, fresh start] +[Pre-flight scan: 2 shared-interface rows, 4 self-consistency rows, clean; written to ledger] +[Create todos for all tasks] + +Task 1: Hook installation script + +[task-start plan 1 → brief read; BASE a1b2c3d] +[Step 1: write failing test — written] +[Step 2: run it — FAIL: install_hook not defined. Matches Expected.] +[Step 3: implement — written] +[Step 4: run it — PASS 1/1. Matches Expected.] +[Step 5: commit — d4e5f6a] +[Contract: tests ran, output read, no deviations] +[task-done plan 1 a1b2c3d -- npm test -- hooks → ledger: Task 1: complete (commits a1b2c3d..d4e5f6a, tests: npm test -- hooks → 1/1 pass)] + +Task 2: Recovery modes + +[task-start plan 2 → brief read; BASE d4e5f6a] +[Step 2: run failing test — FAIL, but on an import error: Task 1 exported + installHook, brief consumes install_hook] +[Ruling: brief's consumer name is a typo against Task 1's Produces block; + use installHook — Ledger: Task 2: Ruling: install_hook → installHook — matches Task 1 Produces — cost if wrong: one rename] +[Steps 2-5 as planned; commit b7c8d9e] +[task-done plan 2 d4e5f6a -- npm test -- recovery → ledger: Task 2: complete (commits d4e5f6a..b7c8d9e, tests: npm test -- recovery → 8/8 pass)] + +... + +[After all tasks: review-package plan MERGE_BASE HEAD; dispatch code-reviewer, most capable model] +Reviewer: One Important finding — progress reporting interval hardcoded. Two Minor. +[Re-grade: Important stands; minors → ledger as deferred] +[Fix pass: test_progress_interval_configurable RED → extract PROGRESS_INTERVAL → GREEN; suite 12/12; commit] +[Ledger: Final: fixed hardcoded interval — test_progress_interval_configurable RED→GREEN, suite 12/12] + +Rulings I made: +- Task 2: install_hook → installHook (brief typo; cost if wrong: one rename) + +Deferred minors: +- README lacks a usage example +- recovery.js could split verify/repair into two files + +[Delete this plan's workspace — the record now lives in git] + +Using superpowers:finishing-a-development-branch. +``` diff --git a/tests/inline-eval/microtests/scope-vs-failure/variants/v4-boundary-slot.md b/tests/inline-eval/microtests/scope-vs-failure/variants/v4-boundary-slot.md new file mode 100644 index 000000000..99803cccd --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/variants/v4-boundary-slot.md @@ -0,0 +1,387 @@ +--- +name: executing-plans +description: Use when executing an implementation plan in the current session as the implementer yourself — your human partner chose inline execution, or no subagent tool is available +--- + +# Executing Plans + +Execute the plan yourself, task by task, in this session: no implementer +subagent per task, no reviewer per task. One fresh-context review of the +whole branch at the end. + +**Why inline:** Subagent-driven development pays for a fresh implementer +and a fresh reviewer on every task, each re-reading the codebase from zero. +Inline execution pays for one context (yours) plus one reviewer at the end. +What it gives up is a fresh context per task and a second pair of eyes per +task. This skill keeps what those two things bought, by other means: the +brief is the spec, the ledger is your memory, TDD is the per-task gate, and +the final reviewer is the second pair of eyes. + +**Core principle:** The plan already did the thinking. Execute it exactly, +prove each step with a test you watched fail and then pass, and leave a +record that survives your own forgetting. + +**Narration:** between tool calls, narrate at most one short line — the +ledger and the tool results carry the record. + +**Continuous execution:** Do not pause to check in with your human partner +between tasks. They chose inline execution to spend less, not to answer +"should I continue?" after every task. Execute all tasks from the plan +without stopping. + +**Rulings, not stalls.** Conflicts, ambiguities, plan defects — decide them. +The spec is the binding authority, the plan is its argument, and your +judgment settles what neither answers. Record every decision in the ledger +as `Ruling: — — `, and keep +going. Deviating from the plan without a ledgered ruling is a decision made +in secret. + +Four things stop you, and only these: an irreversible or destructive +operation; a security-sensitive action; a side effect outside this worktree +that norms say you ask about first (a merge, a push to a shared branch, a +publish); and a plan so broken that every path forward is a guess. For +those, stop and ask. + +## When to Use + +- You have a plan from superpowers:writing-plans and your human partner + chose inline execution at the handoff. +- Your harness has no subagent tool (see the per-platform references in + `../using-superpowers/references/`). Never fabricate a dispatch; run + the plan here. +- Tasks are mostly independent — the same precondition as + superpowers:subagent-driven-development. + +A fully specified plan makes inline execution transcription plus testing: +it runs well on a mid-tier session model, and the one place the most +capable model earns its cost is the final review, which this skill +dispatches separately. Tell your human partner so when they choose inline. + +Prefer superpowers:subagent-driven-development when your human partner +wants a review gate on every task, or when the plan is long enough that +its later tasks would run on a compacted context. Inline execution over a +long plan still works — the ledger is what makes it recoverable — but the +last tasks get the least of you. + +## The Process + +```dot +digraph process { + rankdir=TB; + + subgraph cluster_per_task { + label="Per Task"; + "task-start: brief + BASE; read the brief" [shape=box]; + "Work the steps in order: TDD, run every verification, read every output" [shape=box]; + "Step output matches plan's Expected?" [shape=diamond]; + "Plan wrong? Rule and ledger. Code wrong? systematic-debugging" [shape=box]; + "Commit as the plan's commit steps say" [shape=box]; + "Completion contract met?" [shape=diamond]; + "task-done: run tests, ledger the result; mark todo complete" [shape=box]; + } + + "Setup: worktree, workspace + ledger, read plan + spec, pre-flight scan" [shape=box]; + "More tasks remain?" [shape=diamond]; + "Final whole-branch review (fresh reviewer if you have one)" [shape=box]; + "Re-grade, then: Critical/Important → ONE fix pass, each fix RED→GREEN + green suite; Minor → ledger" [shape=box]; + "Final review clean: delete this plan's workspace" [shape=box]; + "Use superpowers:finishing-a-development-branch" [shape=box style=filled fillcolor=lightgreen]; + + "Setup: worktree, workspace + ledger, read plan + spec, pre-flight scan" -> "task-start: brief + BASE; read the brief"; + "task-start: brief + BASE; read the brief" -> "Work the steps in order: TDD, run every verification, read every output"; + "Work the steps in order: TDD, run every verification, read every output" -> "Step output matches plan's Expected?"; + "Step output matches plan's Expected?" -> "Plan wrong? Rule and ledger. Code wrong? systematic-debugging" [label="no"]; + "Plan wrong? Rule and ledger. Code wrong? systematic-debugging" -> "Work the steps in order: TDD, run every verification, read every output"; + "Step output matches plan's Expected?" -> "Commit as the plan's commit steps say" [label="yes, last step"]; + "Commit as the plan's commit steps say" -> "Completion contract met?"; + "Completion contract met?" -> "Work the steps in order: TDD, run every verification, read every output" [label="no - finish the task"]; + "Completion contract met?" -> "task-done: run tests, ledger the result; mark todo complete" [label="yes"]; + "task-done: run tests, ledger the result; mark todo complete" -> "More tasks remain?"; + "More tasks remain?" -> "task-start: brief + BASE; read the brief" [label="yes"]; + "More tasks remain?" -> "Final whole-branch review (fresh reviewer if you have one)" [label="no"]; + "Final whole-branch review (fresh reviewer if you have one)" -> "Re-grade, then: Critical/Important → ONE fix pass, each fix RED→GREEN + green suite; Minor → ledger"; + "Re-grade, then: Critical/Important → ONE fix pass, each fix RED→GREEN + green suite; Minor → ledger" -> "Final review clean: delete this plan's workspace"; + "Final review clean: delete this plan's workspace" -> "Use superpowers:finishing-a-development-branch"; +} +``` + +## Setup + +Ensure the work happens in an isolated workspace: use +superpowers:using-git-worktrees to create one or verify the existing one. +Never start implementation on a main/master branch without your human +partner's explicit consent. + +Conversation memory does not survive compaction. An inline executor that +loses its place re-implements tasks whose commits already exist — the same +failure as a controller re-dispatching them, paid for in your own context. +Track progress in a ledger file, not only in todos. Harness todos are a +live view; the ledger is the record. + +The workspace and ledger are shared with superpowers:subagent-driven-development +— same directory, same format — so a plan can change executors mid-flight +and the new one resumes from the same ledger. + +- Each plan owns a workspace: at skill start, run + `../subagent-driven-development/scripts/sdd-workspace PLAN_FILE` — it + prints the plan's git-ignored directory + (`/.superpowers/sdd//`), home to every + artifact for THIS plan: ledger, briefs, review packages. Another plan's + directory is never yours to read or write. +- Check for this plan's ledger at `/progress.md`. If its first + line names your plan file, tasks with a `Task : complete` line are + DONE — do not redo them; resume at the first task without one. Their + commits exist in git even when your context no longer remembers making + them: after compaction, trust the ledger and `git log` over your own + recollection. A ledger whose first line names a different plan file is + another plan's progress: leave it and start your own, fresh. +- Create the ledger with its identity as the first line: + `# SDD ledger — plan: `. +- `git clean -fdx` will destroy the workspace (it's git-ignored scratch); + if that happens, recover from `git log`. + +Read the plan once, note its context and Global Constraints, and create a +todo per task. If the plan names a Spec, read that too: the spec is the +authority the plan argues from, and conflicts inside the plan resolve +against it. A plan with no reachable spec gets a ledger note saying so — +rulings made without one are provisional. + +**REQUIRED SUB-SKILL:** load superpowers:test-driven-development now, +before Task 1. It governs every step of every task below; a plan whose +steps already say "write the failing test first" does not exempt you +from reading it. + +Before Task 1, scan the plan for conflicts between tasks. The plan's +Interfaces blocks tell you where to look: for every task that consumes +what an earlier task produces, one ledger row — the two tasks, what one +produces against what the other consumes, and what you found. Tasks that +share nothing get no row; a plan whose tasks share nothing gets the single +line `Pre-flight: no shared interfaces`. Rule on each conflict a row +surfaces with the spec as the binding authority, record the ruling beside +its row, and start Task 1. Each task's own text is checked when you read +its brief, not here. + +## The Task Loop + +Everything you print, and every tool result, stays resident in your +context for the rest of the session. Redirect long test output to a file +in the workspace and read its tail; read a brief, not the whole plan. + +### 1. Take the task + +- Run this skill's `scripts/task-start PLAN_FILE N`. It prints the brief + path and BASE (the commit the task's range is cut from) in one call. + Read the brief for every task, including ones you remember from setup: + what you remember is a summary, the brief has the exact values, + signatures, and test cases. +- Mark the task's todo in_progress. + +Every tool call is a turn that re-reads your whole context. Bookkeeping +rides along with work — a ledger append in the same call as the commit, +never in a call of its own. + +### 2. Work the steps + +The plan's steps are already in RED-GREEN order; follow them in that +order under superpowers:test-driven-development, loaded at setup. A test +step's code is written first and run first. Watching it fail is a step, +not a formality — a test that passes before the implementation exists is +a finding about the test. + +A test file for code that meets the outside world — a file it opens or +reads, input it parses, a network it calls, an environment it reads, a +process it spawns — begins with its boundary list, as comments above the +first test, in this shape: + +``` +# boundary: +# -> test_ +# -> test_ +# -> ruled out: +``` + +One line per failure. "Other errors" is not a line. Every line ends in a +test in this file or a ruling; the test file is not written until the +list is, and the task is not complete while a line has neither. + +Every step that runs a command has an `Expected:` line. Run the command, +read its output, and compare. Three outcomes: + +- **Matches.** Next step. +- **The code is wrong.** Use superpowers:systematic-debugging. Find the + cause; never patch the symptom to make the step's output match. +- **The plan is wrong** — a step contradicts the spec, an interface from an + earlier task doesn't match what this task consumes, a command that + cannot work. Rule on the smallest change that satisfies the spec, ledger + it as `Task : Ruling: — `, and + continue. The ruling is carried, not remembered: later tasks that touch + the same interface read it from the ledger. + +Commit as the plan's commit steps say. A task that spans several commits +is fine; BASE is what the review range is cut from, never `HEAD~1`. + +### 3. The completion contract + +Before a task's ledger line, all of the following are true, with evidence +in this session — not inferred from the diff looking right: + +- Every test the brief names exists and ran in this task, and you read + the output. +- The final test run for the task passed — `task-done` is that run, and + it writes the command and result into the ledger line. +- Every `Expected:` line in the brief was compared against real output. +- Every test file for code that meets the outside world begins with its + boundary list, and every line of it ends in a test or a ruling. +- Every deviation from the brief has a `Ruling:` line in the ledger. + +**REQUIRED SUB-SKILL:** superpowers:verification-before-completion governs +the claim. If any item is missing, the task is not complete: finish it. + +### 4. Complete the task + +Run this skill's `scripts/task-done PLAN_FILE N BASE -- ` +with the test command the brief names for the whole task. It runs the +tests, keeps the full output in the workspace, prints the tail, and — only +if they pass — appends the completion line to the ledger: + +`Task : complete (commits .., tests: → )` + +A failing run records nothing; the task is not complete. When it records, +mark the todo complete and take the next task. + +## Final Review + +Run `../subagent-driven-development/scripts/review-package PLAN_FILE MERGE_BASE HEAD` +(MERGE_BASE = the commit the branch started from, e.g. +`git merge-base main HEAD`) and review from the file it prints. + +**With a subagent tool:** dispatch the reviewer on the most capable +available model — the whole-branch review is a judgment task — using +superpowers:requesting-code-review's +[code-reviewer.md](../requesting-code-review/code-reviewer.md), with the +package path, the plan and spec paths, the plan's Review Focus section +verbatim if it has one (the input classes and failure modes the plan's +tests do not exercise — the reviewer checks each deliberately), and a +pointer to the ledger's `Ruling:` lines so it can weigh the calls you +made. Specify the model +explicitly; an omitted model inherits the session's, which may not be the +most capable. This is the one fresh context the whole run buys. Do not +skip it, and do not replace it with your own read of the diff. + +**Without a subagent tool:** read code-reviewer.md and perform that review +yourself against the package, as a separate pass after the last task's +ledger line. Write `Final review: self-review (no subagent tool)` to the +ledger, and say so in your final message: a self-review by the author is +weaker than a fresh reviewer, and your human partner decides whether that +is enough before merge. + +Sort the findings before you act on any of them. The reviewer's severity +labels are advice; the gate is yours. Re-grade first: a finding labeled +Minor that describes an unhandled exception, a traceback reaching the +user, data loss, or a wrong result on valid input is Important, whatever +the label says — reviewers have filed crashes as Minor because the spec +did not mention the input that triggers them. Then: + +- **Critical and Important** enter the fix pass. +- **Minor** goes to the ledger as `Final: minor (deferred): ` + and to your final message under "Deferred minors". Minors never enter + the fix pass, and never become rulings — a ruling is a decision about a + conflict, not a note that you declined a polish suggestion. + +Fix the Critical and Important findings yourself — you are the +implementer here — in ONE pass. Each fix is verified by TDD, not by a +second reviewer: write the test that reproduces the finding, watch it +fail, make it pass, then run the whole suite. Record each in the ledger as +`Final: fixed — RED→GREEN, suite /`. A fix +without a test that failed first is not verified; a suite that is not +green after the pass means the pass is not over. Do not dispatch a +re-review: it would re-read a diff whose covering tests already answer +"addressed" and whose suite run already answers "broke nothing". + +A finding you decide not to fix is a ruling — `Final: Ruling: — + — ` — and reaches your human partner +in the rulings list. There is no second fix pass. + +## Finish + +Before you delete anything, collect every ledger line containing +`Ruling:` into your final message under "Rulings I made", in the order you +made them, each with what it costs if wrong, and every `minor (deferred)` +line under "Deferred minors". Both lists are exhaustive. Your final +message is the only place the decisions you took on your human partner's +behalf — and the findings you chose not to act on — reach them. + +When the final review is clean and its fixes are committed, delete this +plan's workspace directory — the git history is the record now. Sibling +directories belong to other plans; leave them alone. + +Use superpowers:finishing-a-development-branch. + +## Common Rationalizations + +| Excuse | Reality | +|--------|---------| +| "I remember what Task N says" | You remember a summary. The brief has the exact values. Read it. | +| "The plan's code is right, skip watching the test fail" | A test you never saw fail proves nothing. It is one step. Run it. | +| "I'll run the full suite at the end instead of per step" | Per-step runs are how you learn which step broke it. The end-of-task run is the contract, not a substitute. | +| "The plan is wrong here, I'll just do the right thing" | Do the right thing and ledger the ruling. Unledgered deviation is a decision made in secret. | +| "I'll write the ledger lines after a few tasks" | Compaction does not wait for a convenient moment. One line per task, in the same message as the commit. | +| "Let me check in before the next task" | They chose inline to spend less. Progress prompts spend their time instead. Only the four stops stop you. | +| "I read my own diff carefully; the final reviewer is redundant" | Same author, same blind spots. The reviewer is the only fresh context this run buys. | +| "Tests should pass, the change was trivial" | "Should" is not evidence. The contract requires the command and its output. | +| "Subagents are slow and expensive, I'll skip the final review too" | Inline already removed the per-task reviewers. One review of the whole branch is the floor, not the ceiling. | +| "The reviewer said Minor, so it's Minor" | A traceback is Important whatever the label. Re-grade, then gate. | +| "The fix is obvious, no need for a failing test first" | The failing test is the only proof the finding was real and is now gone. Without it you have a diff and a hope. | +| "I'll fix the minors too while I'm in there" | Every minor you fix is a test, a fix, and a suite run your partner did not ask for. Ledger them; your partner decides. | + +## Example Workflow + +``` +You: I'm using the executing-plans skill to implement this plan inline. + +[Setup: worktree verified] +[Read plan once: docs/superpowers/plans/feature-plan.md; spec read] +[Resolve workspace: sdd-workspace docs/superpowers/plans/feature-plan.md — no ledger inside, fresh start] +[Pre-flight scan: 2 shared-interface rows, 4 self-consistency rows, clean; written to ledger] +[Create todos for all tasks] + +Task 1: Hook installation script + +[task-start plan 1 → brief read; BASE a1b2c3d] +[Step 1: write failing test — written] +[Step 2: run it — FAIL: install_hook not defined. Matches Expected.] +[Step 3: implement — written] +[Step 4: run it — PASS 1/1. Matches Expected.] +[Step 5: commit — d4e5f6a] +[Contract: tests ran, output read, no deviations] +[task-done plan 1 a1b2c3d -- npm test -- hooks → ledger: Task 1: complete (commits a1b2c3d..d4e5f6a, tests: npm test -- hooks → 1/1 pass)] + +Task 2: Recovery modes + +[task-start plan 2 → brief read; BASE d4e5f6a] +[Step 2: run failing test — FAIL, but on an import error: Task 1 exported + installHook, brief consumes install_hook] +[Ruling: brief's consumer name is a typo against Task 1's Produces block; + use installHook — Ledger: Task 2: Ruling: install_hook → installHook — matches Task 1 Produces — cost if wrong: one rename] +[Steps 2-5 as planned; commit b7c8d9e] +[task-done plan 2 d4e5f6a -- npm test -- recovery → ledger: Task 2: complete (commits d4e5f6a..b7c8d9e, tests: npm test -- recovery → 8/8 pass)] + +... + +[After all tasks: review-package plan MERGE_BASE HEAD; dispatch code-reviewer, most capable model] +Reviewer: One Important finding — progress reporting interval hardcoded. Two Minor. +[Re-grade: Important stands; minors → ledger as deferred] +[Fix pass: test_progress_interval_configurable RED → extract PROGRESS_INTERVAL → GREEN; suite 12/12; commit] +[Ledger: Final: fixed hardcoded interval — test_progress_interval_configurable RED→GREEN, suite 12/12] + +Rulings I made: +- Task 2: install_hook → installHook (brief typo; cost if wrong: one rename) + +Deferred minors: +- README lacks a usage example +- recovery.js could split verify/repair into two files + +[Delete this plan's workspace — the record now lives in git] + +Using superpowers:finishing-a-development-branch. +``` diff --git a/tests/inline-eval/microtests/scope-vs-failure/variants/v5-reasonable-ruling.md b/tests/inline-eval/microtests/scope-vs-failure/variants/v5-reasonable-ruling.md new file mode 100644 index 000000000..87139c2d8 --- /dev/null +++ b/tests/inline-eval/microtests/scope-vs-failure/variants/v5-reasonable-ruling.md @@ -0,0 +1,395 @@ +--- +name: executing-plans +description: Use when executing an implementation plan in the current session as the implementer yourself — your human partner chose inline execution, or no subagent tool is available +--- + +# Executing Plans + +Execute the plan yourself, task by task, in this session: no implementer +subagent per task, no reviewer per task. One fresh-context review of the +whole branch at the end. + +**Why inline:** Subagent-driven development pays for a fresh implementer +and a fresh reviewer on every task, each re-reading the codebase from zero. +Inline execution pays for one context (yours) plus one reviewer at the end. +What it gives up is a fresh context per task and a second pair of eyes per +task. This skill keeps what those two things bought, by other means: the +brief is the spec, the ledger is your memory, TDD is the per-task gate, and +the final reviewer is the second pair of eyes. + +**Core principle:** The plan already did the thinking. Execute it exactly, +prove each step with a test you watched fail and then pass, and leave a +record that survives your own forgetting. + +**Narration:** between tool calls, narrate at most one short line — the +ledger and the tool results carry the record. + +**Continuous execution:** Do not pause to check in with your human partner +between tasks. They chose inline execution to spend less, not to answer +"should I continue?" after every task. Execute all tasks from the plan +without stopping. + +**Rulings, not stalls.** Conflicts, ambiguities, plan defects — decide them. +The spec is the binding authority, the plan is its argument, and your +judgment settles what neither answers. Record every decision in the ledger +as `Ruling: — — `, and keep +going. Deviating from the plan without a ledgered ruling is a decision made +in secret. + +Four things stop you, and only these: an irreversible or destructive +operation; a security-sensitive action; a side effect outside this worktree +that norms say you ask about first (a merge, a push to a shared branch, a +publish); and a plan so broken that every path forward is a guess. For +those, stop and ask. + +## When to Use + +- You have a plan from superpowers:writing-plans and your human partner + chose inline execution at the handoff. +- Your harness has no subagent tool (see the per-platform references in + `../using-superpowers/references/`). Never fabricate a dispatch; run + the plan here. +- Tasks are mostly independent — the same precondition as + superpowers:subagent-driven-development. + +A fully specified plan makes inline execution transcription plus testing: +it runs well on a mid-tier session model, and the one place the most +capable model earns its cost is the final review, which this skill +dispatches separately. Tell your human partner so when they choose inline. + +Prefer superpowers:subagent-driven-development when your human partner +wants a review gate on every task, or when the plan is long enough that +its later tasks would run on a compacted context. Inline execution over a +long plan still works — the ledger is what makes it recoverable — but the +last tasks get the least of you. + +## The Process + +```dot +digraph process { + rankdir=TB; + + subgraph cluster_per_task { + label="Per Task"; + "task-start: brief + BASE; read the brief" [shape=box]; + "Work the steps in order: TDD, run every verification, read every output" [shape=box]; + "Step output matches plan's Expected?" [shape=diamond]; + "Plan wrong? Rule and ledger. Code wrong? systematic-debugging" [shape=box]; + "Commit as the plan's commit steps say" [shape=box]; + "Completion contract met?" [shape=diamond]; + "task-done: run tests, ledger the result; mark todo complete" [shape=box]; + } + + "Setup: worktree, workspace + ledger, read plan + spec, pre-flight scan" [shape=box]; + "More tasks remain?" [shape=diamond]; + "Final whole-branch review (fresh reviewer if you have one)" [shape=box]; + "Re-grade, then: Critical/Important → ONE fix pass, each fix RED→GREEN + green suite; Minor → ledger" [shape=box]; + "Final review clean: delete this plan's workspace" [shape=box]; + "Use superpowers:finishing-a-development-branch" [shape=box style=filled fillcolor=lightgreen]; + + "Setup: worktree, workspace + ledger, read plan + spec, pre-flight scan" -> "task-start: brief + BASE; read the brief"; + "task-start: brief + BASE; read the brief" -> "Work the steps in order: TDD, run every verification, read every output"; + "Work the steps in order: TDD, run every verification, read every output" -> "Step output matches plan's Expected?"; + "Step output matches plan's Expected?" -> "Plan wrong? Rule and ledger. Code wrong? systematic-debugging" [label="no"]; + "Plan wrong? Rule and ledger. Code wrong? systematic-debugging" -> "Work the steps in order: TDD, run every verification, read every output"; + "Step output matches plan's Expected?" -> "Commit as the plan's commit steps say" [label="yes, last step"]; + "Commit as the plan's commit steps say" -> "Completion contract met?"; + "Completion contract met?" -> "Work the steps in order: TDD, run every verification, read every output" [label="no - finish the task"]; + "Completion contract met?" -> "task-done: run tests, ledger the result; mark todo complete" [label="yes"]; + "task-done: run tests, ledger the result; mark todo complete" -> "More tasks remain?"; + "More tasks remain?" -> "task-start: brief + BASE; read the brief" [label="yes"]; + "More tasks remain?" -> "Final whole-branch review (fresh reviewer if you have one)" [label="no"]; + "Final whole-branch review (fresh reviewer if you have one)" -> "Re-grade, then: Critical/Important → ONE fix pass, each fix RED→GREEN + green suite; Minor → ledger"; + "Re-grade, then: Critical/Important → ONE fix pass, each fix RED→GREEN + green suite; Minor → ledger" -> "Final review clean: delete this plan's workspace"; + "Final review clean: delete this plan's workspace" -> "Use superpowers:finishing-a-development-branch"; +} +``` + +## Setup + +Ensure the work happens in an isolated workspace: use +superpowers:using-git-worktrees to create one or verify the existing one. +Never start implementation on a main/master branch without your human +partner's explicit consent. + +Conversation memory does not survive compaction. An inline executor that +loses its place re-implements tasks whose commits already exist — the same +failure as a controller re-dispatching them, paid for in your own context. +Track progress in a ledger file, not only in todos. Harness todos are a +live view; the ledger is the record. + +The workspace and ledger are shared with superpowers:subagent-driven-development +— same directory, same format — so a plan can change executors mid-flight +and the new one resumes from the same ledger. + +- Each plan owns a workspace: at skill start, run + `../subagent-driven-development/scripts/sdd-workspace PLAN_FILE` — it + prints the plan's git-ignored directory + (`/.superpowers/sdd//`), home to every + artifact for THIS plan: ledger, briefs, review packages. Another plan's + directory is never yours to read or write. +- Check for this plan's ledger at `/progress.md`. If its first + line names your plan file, tasks with a `Task : complete` line are + DONE — do not redo them; resume at the first task without one. Their + commits exist in git even when your context no longer remembers making + them: after compaction, trust the ledger and `git log` over your own + recollection. A ledger whose first line names a different plan file is + another plan's progress: leave it and start your own, fresh. +- Create the ledger with its identity as the first line: + `# SDD ledger — plan: `. +- `git clean -fdx` will destroy the workspace (it's git-ignored scratch); + if that happens, recover from `git log`. + +Read the plan once, note its context and Global Constraints, and create a +todo per task. If the plan names a Spec, read that too: the spec is the +authority the plan argues from, and conflicts inside the plan resolve +against it. A plan with no reachable spec gets a ledger note saying so — +rulings made without one are provisional. + +**REQUIRED SUB-SKILL:** load superpowers:test-driven-development now, +before Task 1. It governs every step of every task below; a plan whose +steps already say "write the failing test first" does not exempt you +from reading it. + +Before Task 1, scan the plan for conflicts between tasks. The plan's +Interfaces blocks tell you where to look: for every task that consumes +what an earlier task produces, one ledger row — the two tasks, what one +produces against what the other consumes, and what you found. Tasks that +share nothing get no row; a plan whose tasks share nothing gets the single +line `Pre-flight: no shared interfaces`. Rule on each conflict a row +surfaces with the spec as the binding authority, record the ruling beside +its row, and start Task 1. Each task's own text is checked when you read +its brief, not here. + +## The Task Loop + +Everything you print, and every tool result, stays resident in your +context for the rest of the session. Redirect long test output to a file +in the workspace and read its tail; read a brief, not the whole plan. + +### 1. Take the task + +- Run this skill's `scripts/task-start PLAN_FILE N`. It prints the brief + path and BASE (the commit the task's range is cut from) in one call. + Read the brief for every task, including ones you remember from setup: + what you remember is a summary, the brief has the exact values, + signatures, and test cases. +- Mark the task's todo in_progress. + +Every tool call is a turn that re-reads your whole context. Bookkeeping +rides along with work — a ledger append in the same call as the commit, +never in a call of its own. + +### 2. Work the steps + +The plan's steps are already in RED-GREEN order; follow them in that +order under superpowers:test-driven-development, loaded at setup. A test +step's code is written first and run first. Watching it fail is a step, +not a formality — a test that passes before the implementation exists is +a finding about the test. + +A test file for code that meets the outside world — a file it opens or +reads, input it parses, a network it calls, an environment it reads, a +process it spawns — begins with its boundary list, as comments above the +first test, in this shape: + +``` +# boundary: +# -> test_ +# -> test_ +# -> ruled out: +``` + +One line per failure. "Other errors" is not a line. Every line ends in a +test in this file or a ruling; the test file is not written until the +list is, and the task is not complete while a line has neither. + +A ruling that leaves a failure unhandled answers one question: would a +reasonable user of this software accept what happens when it fails this +way? The spec is a vision document. It says what the software must do, +not everything it will meet, and its silence on an input is not +permission for that input to crash the program. "The spec does not +mention it" is never the reason on a `ruled out:` line; the reason is +what the reasonable user gets and why that is acceptable. + +Every step that runs a command has an `Expected:` line. Run the command, +read its output, and compare. Three outcomes: + +- **Matches.** Next step. +- **The code is wrong.** Use superpowers:systematic-debugging. Find the + cause; never patch the symptom to make the step's output match. +- **The plan is wrong** — a step contradicts the spec, an interface from an + earlier task doesn't match what this task consumes, a command that + cannot work. Rule on the smallest change that satisfies the spec, ledger + it as `Task : Ruling: — `, and + continue. The ruling is carried, not remembered: later tasks that touch + the same interface read it from the ledger. + +Commit as the plan's commit steps say. A task that spans several commits +is fine; BASE is what the review range is cut from, never `HEAD~1`. + +### 3. The completion contract + +Before a task's ledger line, all of the following are true, with evidence +in this session — not inferred from the diff looking right: + +- Every test the brief names exists and ran in this task, and you read + the output. +- The final test run for the task passed — `task-done` is that run, and + it writes the command and result into the ledger line. +- Every `Expected:` line in the brief was compared against real output. +- Every test file for code that meets the outside world begins with its + boundary list, and every line of it ends in a test or a ruling. +- Every deviation from the brief has a `Ruling:` line in the ledger. + +**REQUIRED SUB-SKILL:** superpowers:verification-before-completion governs +the claim. If any item is missing, the task is not complete: finish it. + +### 4. Complete the task + +Run this skill's `scripts/task-done PLAN_FILE N BASE -- ` +with the test command the brief names for the whole task. It runs the +tests, keeps the full output in the workspace, prints the tail, and — only +if they pass — appends the completion line to the ledger: + +`Task : complete (commits .., tests: → )` + +A failing run records nothing; the task is not complete. When it records, +mark the todo complete and take the next task. + +## Final Review + +Run `../subagent-driven-development/scripts/review-package PLAN_FILE MERGE_BASE HEAD` +(MERGE_BASE = the commit the branch started from, e.g. +`git merge-base main HEAD`) and review from the file it prints. + +**With a subagent tool:** dispatch the reviewer on the most capable +available model — the whole-branch review is a judgment task — using +superpowers:requesting-code-review's +[code-reviewer.md](../requesting-code-review/code-reviewer.md), with the +package path, the plan and spec paths, the plan's Review Focus section +verbatim if it has one (the input classes and failure modes the plan's +tests do not exercise — the reviewer checks each deliberately), and a +pointer to the ledger's `Ruling:` lines so it can weigh the calls you +made. Specify the model +explicitly; an omitted model inherits the session's, which may not be the +most capable. This is the one fresh context the whole run buys. Do not +skip it, and do not replace it with your own read of the diff. + +**Without a subagent tool:** read code-reviewer.md and perform that review +yourself against the package, as a separate pass after the last task's +ledger line. Write `Final review: self-review (no subagent tool)` to the +ledger, and say so in your final message: a self-review by the author is +weaker than a fresh reviewer, and your human partner decides whether that +is enough before merge. + +Sort the findings before you act on any of them. The reviewer's severity +labels are advice; the gate is yours. Re-grade first: a finding labeled +Minor that describes an unhandled exception, a traceback reaching the +user, data loss, or a wrong result on valid input is Important, whatever +the label says — reviewers have filed crashes as Minor because the spec +did not mention the input that triggers them. Then: + +- **Critical and Important** enter the fix pass. +- **Minor** goes to the ledger as `Final: minor (deferred): ` + and to your final message under "Deferred minors". Minors never enter + the fix pass, and never become rulings — a ruling is a decision about a + conflict, not a note that you declined a polish suggestion. + +Fix the Critical and Important findings yourself — you are the +implementer here — in ONE pass. Each fix is verified by TDD, not by a +second reviewer: write the test that reproduces the finding, watch it +fail, make it pass, then run the whole suite. Record each in the ledger as +`Final: fixed — RED→GREEN, suite /`. A fix +without a test that failed first is not verified; a suite that is not +green after the pass means the pass is not over. Do not dispatch a +re-review: it would re-read a diff whose covering tests already answer +"addressed" and whose suite run already answers "broke nothing". + +A finding you decide not to fix is a ruling — `Final: Ruling: — + — ` — and reaches your human partner +in the rulings list. There is no second fix pass. + +## Finish + +Before you delete anything, collect every ledger line containing +`Ruling:` into your final message under "Rulings I made", in the order you +made them, each with what it costs if wrong, and every `minor (deferred)` +line under "Deferred minors". Both lists are exhaustive. Your final +message is the only place the decisions you took on your human partner's +behalf — and the findings you chose not to act on — reach them. + +When the final review is clean and its fixes are committed, delete this +plan's workspace directory — the git history is the record now. Sibling +directories belong to other plans; leave them alone. + +Use superpowers:finishing-a-development-branch. + +## Common Rationalizations + +| Excuse | Reality | +|--------|---------| +| "I remember what Task N says" | You remember a summary. The brief has the exact values. Read it. | +| "The plan's code is right, skip watching the test fail" | A test you never saw fail proves nothing. It is one step. Run it. | +| "I'll run the full suite at the end instead of per step" | Per-step runs are how you learn which step broke it. The end-of-task run is the contract, not a substitute. | +| "The plan is wrong here, I'll just do the right thing" | Do the right thing and ledger the ruling. Unledgered deviation is a decision made in secret. | +| "I'll write the ledger lines after a few tasks" | Compaction does not wait for a convenient moment. One line per task, in the same message as the commit. | +| "Let me check in before the next task" | They chose inline to spend less. Progress prompts spend their time instead. Only the four stops stop you. | +| "I read my own diff carefully; the final reviewer is redundant" | Same author, same blind spots. The reviewer is the only fresh context this run buys. | +| "Tests should pass, the change was trivial" | "Should" is not evidence. The contract requires the command and its output. | +| "Subagents are slow and expensive, I'll skip the final review too" | Inline already removed the per-task reviewers. One review of the whole branch is the floor, not the ceiling. | +| "The reviewer said Minor, so it's Minor" | A traceback is Important whatever the label. Re-grade, then gate. | +| "The fix is obvious, no need for a failing test first" | The failing test is the only proof the finding was real and is now gone. Without it you have a diff and a hope. | +| "I'll fix the minors too while I'm in there" | Every minor you fix is a test, a fix, and a suite run your partner did not ask for. Ledger them; your partner decides. | + +## Example Workflow + +``` +You: I'm using the executing-plans skill to implement this plan inline. + +[Setup: worktree verified] +[Read plan once: docs/superpowers/plans/feature-plan.md; spec read] +[Resolve workspace: sdd-workspace docs/superpowers/plans/feature-plan.md — no ledger inside, fresh start] +[Pre-flight scan: 2 shared-interface rows, 4 self-consistency rows, clean; written to ledger] +[Create todos for all tasks] + +Task 1: Hook installation script + +[task-start plan 1 → brief read; BASE a1b2c3d] +[Step 1: write failing test — written] +[Step 2: run it — FAIL: install_hook not defined. Matches Expected.] +[Step 3: implement — written] +[Step 4: run it — PASS 1/1. Matches Expected.] +[Step 5: commit — d4e5f6a] +[Contract: tests ran, output read, no deviations] +[task-done plan 1 a1b2c3d -- npm test -- hooks → ledger: Task 1: complete (commits a1b2c3d..d4e5f6a, tests: npm test -- hooks → 1/1 pass)] + +Task 2: Recovery modes + +[task-start plan 2 → brief read; BASE d4e5f6a] +[Step 2: run failing test — FAIL, but on an import error: Task 1 exported + installHook, brief consumes install_hook] +[Ruling: brief's consumer name is a typo against Task 1's Produces block; + use installHook — Ledger: Task 2: Ruling: install_hook → installHook — matches Task 1 Produces — cost if wrong: one rename] +[Steps 2-5 as planned; commit b7c8d9e] +[task-done plan 2 d4e5f6a -- npm test -- recovery → ledger: Task 2: complete (commits d4e5f6a..b7c8d9e, tests: npm test -- recovery → 8/8 pass)] + +... + +[After all tasks: review-package plan MERGE_BASE HEAD; dispatch code-reviewer, most capable model] +Reviewer: One Important finding — progress reporting interval hardcoded. Two Minor. +[Re-grade: Important stands; minors → ledger as deferred] +[Fix pass: test_progress_interval_configurable RED → extract PROGRESS_INTERVAL → GREEN; suite 12/12; commit] +[Ledger: Final: fixed hardcoded interval — test_progress_interval_configurable RED→GREEN, suite 12/12] + +Rulings I made: +- Task 2: install_hook → installHook (brief typo; cost if wrong: one rename) + +Deferred minors: +- README lacks a usage example +- recovery.js could split verify/repair into two files + +[Delete this plan's workspace — the record now lives in git] + +Using superpowers:finishing-a-development-branch. +``` diff --git a/tests/inline-eval/results/2026-09-16-clean-iter2/spike-11.txt b/tests/inline-eval/results/2026-09-16-clean-iter2/spike-11.txt new file mode 100644 index 000000000..706ed7915 --- /dev/null +++ b/tests/inline-eval/results/2026-09-16-clean-iter2/spike-11.txt @@ -0,0 +1,38 @@ +# rep spike-11 transcript=/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/inline-eval-clean/reps/spike-11/cfg/projects/-private-tmp-claude-501--Users-jesse-git-superpowers-superpowers-a8a313bf-7410-41ce-9036-636ccf4061d6-scratchpad-inline-eval-clean-reps-spike-11-repo/cc84ee13-b1c2-4fa7-8ec2-0a41ef698464.jsonl +skill executing-plans invoked True before first write +all skills invoked executing-plans, test-driven-development, finishing-a-development-branch +Agent dispatches 2 general-purpose/opus:'Final whole-branch code revi'; general-purpose/sonnet:'Scoped re-review of fix rang' +unittest runs 18 +task counter: RED run / GREEN run yes / yes test no fix wave + +# Comparison medians (clean csd arms, same fixture): +# bare 5.6k out / 0.29M cache_read; spike inline 37.7k / 1.79M; SDD-in-session 86k / 4.5M diff --git a/tests/inline-eval/results/2026-09-16-host-confounded/dev-1.txt b/tests/inline-eval/results/2026-09-16-host-confounded/dev-1.txt new file mode 100644 index 000000000..c4c947f35 --- /dev/null +++ b/tests/inline-eval/results/2026-09-16-host-confounded/dev-1.txt @@ -0,0 +1,31 @@ +# rep dev-1 transcript=/Users/jesse/.claude/projects/-private-tmp-claude-501--Users-jesse-git-superpowers-superpowers-a8a313bf-7410-41ce-9036-636ccf4061d6-scratchpad-inline-eval-reps-dev-1-repo/8b47c670-431a-4121-99b8-c0f303b7e099.jsonl +skill executing-plans invoked True before first write +all skills invoked executing-plans, test-driven-development +Agent dispatches 0 +unittest runs 7 +task counter: RED run / GREEN run yes / yes test exit 2; interface-mismatch -> working CLI) HANDLED + in all six BEFORE any review: the implementers resolved the planted + Task 6 Interfaces mismatch on their own and implemented the spec's + exit-2 path because Task 2's ParseError(line, reason) makes it the + natural thing to catch. This fixture's planted defects are too easy to + discriminate anything; the next fixture needs a defect implementers do + not fix unprompted (a spec rule that contradicts the natural + implementation, or a cross-task semantic mismatch rather than a + signature mismatch). +- sdd 1-3: Task 1 complete, Task 2 in progress. No final review. Their + probe rows show ModuleNotFoundError because the CLI module never existed. +- Token totals are through the point of death only. diff --git a/tests/inline-eval/results/2026-09-16-ledgerlite-partial/barerev-1.txt b/tests/inline-eval/results/2026-09-16-ledgerlite-partial/barerev-1.txt new file mode 100644 index 000000000..357ba2ed0 --- /dev/null +++ b/tests/inline-eval/results/2026-09-16-ledgerlite-partial/barerev-1.txt @@ -0,0 +1,38 @@ +# rep barerev-1 transcript=/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/inline-eval-f2/reps/barerev-1/cfg/projects/-private-tmp-claude-501--Users-jesse-git-superpowers-superpowers-a8a313bf-7410-41ce-9036-636ccf4061d6-scratchpad-inline-eval-f2-reps-barerev-1-repo/7935a7ef-c521-45df-8a48-ed9d4a0cf1b7.jsonl +skill executing-plans invoked False +all skills invoked none +Agent dispatches 1 general-purpose/opus:'Review ledgerlite diff' +test runs 15 +task model: RED run / GREEN run yes / yes test:: ` on stderr with exit code 2 — I'll implement that since design.md is +[ 7] Fails as expected. Implementing: +[ 10] Task 1 done. Task 2 — CSV parsing: +[ 19] Task 4 passes. Task 5 — report: +[ 23] Task 5 passes. Task 6 — CLI: +[ 28] `e.filename` doesn't exist on `UnicodeDecodeError` — fixing that path: +[ 30] Full suite green (16 tests). Verifying the design's error paths and the worked example end to end: +[ 33] All error paths match `design.md`. Committing Task 6: +[ 37] All six tasks are done and committed. Dispatching the review subagent on opus: +[ 39] The review agent is running. All six tasks are implemented, tested, and committed while I wait for it. +[ 40] Credit balance is too low diff --git a/tests/inline-eval/results/2026-09-16-ledgerlite-partial/barerev-2.txt b/tests/inline-eval/results/2026-09-16-ledgerlite-partial/barerev-2.txt new file mode 100644 index 000000000..5cc1afef6 --- /dev/null +++ b/tests/inline-eval/results/2026-09-16-ledgerlite-partial/barerev-2.txt @@ -0,0 +1,34 @@ +# rep barerev-2 transcript=/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/inline-eval-f2/reps/barerev-2/cfg/projects/-private-tmp-claude-501--Users-jesse-git-superpowers-superpowers-a8a313bf-7410-41ce-9036-636ccf4061d6-scratchpad-inline-eval-f2-reps-barerev-2-repo/9f6c547b-0676-457a-8009-75b4d8794a97.jsonl +skill executing-plans invoked False +all skills invoked none +Agent dispatches 1 general-purpose/opus:'Review ledgerlite diff' +test runs 15 +task model: RED run / GREEN run yes / yes test re-grade -> TDD fix) works when the reviewer surfaces it. diff --git a/tests/inline-eval/results/2026-09-17-codex-chain/cxspike-31.chain.txt b/tests/inline-eval/results/2026-09-17-codex-chain/cxspike-31.chain.txt new file mode 100644 index 000000000..18823fdaf --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-codex-chain/cxspike-31.chain.txt @@ -0,0 +1,13 @@ +===== MAIN rollout-2026-09-17T10-59-31-01a0b086-11f8-7240-b assistant msgs=7 execs=19 + MAIN: I’m using executing-plans with TDD; implementation will stay inline, with one final independent review as the skill requires. + + MAIN: All three tasks are committed, and the six planned tests pass. The final review is checking edge cases before I close out the work. + + MAIN: Review found that CRLF files lose characters during reading and invalid UTF-8 causes an uncaught exception. I’ll add failing tests for both, then fix them in one pass. + + MAIN: Both fixes are committed, and all eight tests pass. I’m using finishing-a-development-branch to close out the work on main and remove the temporary execution files. + + MAIN: Done. The plan is implemented and committed on main. Final review fixes are included, all 8 tests pass, and the working tree is clean. + +===== REVIEWER rollout-2026-09-17T11-00-59-01a0b087-6ada-7ac0-8 assistant msgs=1 execs=3 + REPORT: ### Strengths All three planned modules and required tests are present. The CLI composes the actual counter and formatter functions cleanly, uses only the standard library, and handles missing files correctly. All six tests pass. ### Issues **Important — character counts lose CRLF whitespace** `wordstat/cli.py:16`: Default text-mode newline translation converts `\r\n` to `\n` before counting characters. A file containing `b"a\r\nb\r\n"` reports `chars: 4`, although it contains six characters. This violates the design’s character count including whitespace. Open with `newline=""` and add a CRLF integration test. **Minor — decoding errors escape the CLI** `wordstat/cli.py:18`: The exception handler catches `OSError` but not `UnicodeDecodeError`. A file containing invalid UTF-8 produces an uncaught exception instead of an error message and return code. Although invalid encoding behavior is unspecified, catching `UnicodeError` alongside `OSError` would provide consistent file-read error handling; add a corresponding test if adopted. ### Recommendations Preserve original newline characters when reading files and verify the resulting report with a CRLF fixture. Existing LF tests remain useful. ### Assessment **Ready to merge? With fixes.** The implementation satisfies the explicit task structure and required tests, but newline translation causes incorrect statistics for valid CRLF text files. Fix that before marking the plan complete. diff --git a/tests/inline-eval/results/2026-09-17-codex-chain/cxspike-31.txt b/tests/inline-eval/results/2026-09-17-codex-chain/cxspike-31.txt new file mode 100644 index 000000000..d068ae1fc --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-codex-chain/cxspike-31.txt @@ -0,0 +1,16 @@ +# rep cxspike-31 (codex; events=57) +tool calls 27 Bash=23, collaborationwait_agent=2, collaborationspawn_agent=1, collaborationsend_message=1 +skill loads (SKILL.md reads) 3 executing-plans +subagent spawns 1 +test runs 14 +task counter: RED run / GREEN run yes / yes testGREEN 3/3 all tasks 3/3 all tasks +utf8 probe handled 3/3 handled 1/3 + +On this harness the planted defect does not discriminate: gpt-5.6-sol's +idiom for the bare CLI was read_text(encoding="utf-8") with +except (OSError, UnicodeError), which handles it. Under v3 two of three +implementers wrote except OSError only, and the gpt-6-astra final reviewer +did not surface the decode case (no rollout mentions it; Codex encrypts +message bodies, so only the outcome is readable). The v3 process itself +translated: helpers, ledger, one review, no re-review. Codex token counts +are Codex's own cumulative figures, not comparable to the Claude arms. diff --git a/tests/inline-eval/results/2026-09-17-codex/cxbare-21.txt b/tests/inline-eval/results/2026-09-17-codex/cxbare-21.txt new file mode 100644 index 000000000..2b7d1a441 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-codex/cxbare-21.txt @@ -0,0 +1,16 @@ +# rep cxbare-21 (codex; events=49) +tool calls 23 Bash=17, apply_patch=6 +skill loads (SKILL.md reads) 0 +subagent spawns 0 +test runs 9 +task counter: RED run / GREEN run yes / yes test2 fractional digits are malformed) — I'll implement those too, since design.md +[ 15] Task 2 — CSV parsing. +[ 22] Task 3 — categorization rules. +[ 29] Task 4 — running balance. +[ 34] Task 5 — report. +[ 44] Task 6 — CLI. +[ 60] All six tasks are done and committed. Now dispatching the review subagent on opus. +[ 62] All six tasks are implemented and committed (one commit each, TDD order: failing test → implementation → passing test). Full suite: 40 tests, OK, clean output. The review agent is running now; I'll report its findings an +[ 66] Review is back: no Critical findings, four Important ones. All four check out — fixing them now. +[ 70] Now the tests for the multi-line and line-number cases, plus the report precision fix: +[ 79] Now finding #4 — untrack the committed bytecode: +[ 82] Done. The plan is complete. **What was built** — `ledgerlite`, six modules in the pipeline order the plan specified (`model → parse → rules → balance → report → cli`), one commit per task, each done test-first (failing t diff --git a/tests/inline-eval/results/2026-09-17-ledgerlite-bedrock/barerev-3.txt b/tests/inline-eval/results/2026-09-17-ledgerlite-bedrock/barerev-3.txt new file mode 100644 index 000000000..c9c6a7b3b --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-ledgerlite-bedrock/barerev-3.txt @@ -0,0 +1,42 @@ +# rep barerev-3 transcript=/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/inline-eval-br/reps/barerev-3/cfg/projects/-private-tmp-claude-501--Users-jesse-git-superpowers-superpowers-a8a313bf-7410-41ce-9036-636ccf4061d6-scratchpad-inline-eval-br-reps-barerev-3-repo/6187d7d6-9f65-4a1a-a957-e2e831e62551.jsonl +skill executing-plans invoked False +all skills invoked none +Agent dispatches 1 general-purpose/opus:'Review full diff vs plan and' +test runs 14 +task model: RED run / GREEN run yes / yes test2 fractional digits, neither of which appears in the plan's task st +[ 10] Fails as expected (ImportError). Implementing: +[ 16] Task 2: CSV parsing. The plan's tests plus the design's column-count and fractional-digit rules: +[ 23] Task 3: rules. +[ 29] Task 4: balance. +[ 36] Task 5: report. +[ 43] Task 6: CLI, including design.md's exit-2 path for malformed rows. +[ 50] Now the whole suite: +[ 53] 46 tests, pristine output. Quick real-world check against the design's example: +[ 55] Matches design.md exactly, including both error paths. Committing Task 6: +[ 60] All six tasks are implemented and committed. Now dispatching a fresh opus reviewer over the whole diff. +[ 62] All six tasks are done and committed; the opus reviewer is running now over the full diff. I'll report its findings and fix anything Critical or Important as soon as it comes back. +[ 66] Confirmed: my Task 1 `git add ledgerlite` swept in two bytecode files. Untracking them (they stay on disk): +[ 68] **Done.** All six tasks in plan.md are implemented, tested, and committed on `main`; the full suite is 46 tests, OK, with pristine output. **What was built** — six modules in the pipeline design.md specifies, each commit diff --git a/tests/inline-eval/results/2026-09-17-ledgerlite-bedrock/spike-1.txt b/tests/inline-eval/results/2026-09-17-ledgerlite-bedrock/spike-1.txt new file mode 100644 index 000000000..0cca64bed --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-ledgerlite-bedrock/spike-1.txt @@ -0,0 +1,51 @@ +# rep spike-1 transcript=/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/inline-eval-br/reps/spike-1/cfg/projects/-private-tmp-claude-501--Users-jesse-git-superpowers-superpowers-a8a313bf-7410-41ce-9036-636ccf4061d6-scratchpad-inline-eval-br-reps-spike-1-repo/5b2e1ff5-307f-47e5-9263-cace69c32703.jsonl +skill executing-plans invoked True before first write +all skills invoked executing-plans, test-driven-development, finishing-a-development-branch +Agent dispatches 1 general-purpose/opus:'Review ledgerlite branch' +test runs 25 +task model: RED run / GREEN run yes / yes test **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build `ledgerlite`, a stdlib-only Python command-line tool that reads a transactions CSV, categorizes each row from a rules file, and prints per-category totals plus the closing balance. + +**Architecture:** Six small modules with one responsibility each, wired together only at the CLI layer: `model` holds the `Transaction` value type, `parse` turns CSV text into transactions (raising `ParseError` for bad rows), `rules` turns rules text into `(substring, category)` pairs and matches descriptions, `balance` orders transactions by date and folds the closing balance, `report` builds per-category totals and formats the output text, and `cli` does argument parsing, error messages, and exit codes. Every module below `cli` is pure — it takes values and returns values, never touches `sys.exit`, `stdout`, or `stderr` — so each is testable in isolation and the CLI tests only need to cover wiring, exit codes, and message text. + +**Tech Stack:** Python 3.11+, standard library only (`argparse`, `csv`, `dataclasses`, `datetime`, `decimal`, `unittest`, `tempfile`). No third-party packages, no build tooling. + +**Spec:** `design.md` (in this directory — read it before starting) + +## Global Constraints + +- Python 3.11+ only. Standard library only — no third-party dependencies, no `pip install`, no `requirements.txt`, no packaging files. +- Money is `decimal.Decimal` everywhere. Never `float`. No `float()` call, no float literal, and no arithmetic mixing `Decimal` with `float` anywhere in `ledgerlite/`. +- Package layout is exactly the one in the spec: `ledgerlite/{__init__,model,parse,rules,balance,report,cli}.py`. One addition beyond the spec's list: `ledgerlite/__main__.py`, a three-line shim so the tool is runnable as `python3 -m ledgerlite` (the spec describes a command-line tool but lists no entry point file). Nothing else gets added. +- Tests live at the repo root as `test_.py`, one per module, using `unittest`. The whole suite runs with `python3 -m unittest` from the repo root. +- Exit codes are exactly: `0` success, `1` a file could not be read, `2` the transactions file had a malformed row. +- Error messages go to stderr with these exact shapes: `ledgerlite: cannot read : ` and `ledgerlite: :: `. +- Amounts print with exactly two fractional digits, a leading `-` only for negatives, no thousands separators: `-12.50`, `0.00`, `1200.00`. +- Work directly on `main` in this repo. Commit after every task (each task's last step is the commit). + +## Assumptions + +The spec is silent on these; the plan resolves them this way, and the tests below pin the resolutions: + +1. **First CSV line is always the header** and is skipped without being validated. An empty file therefore yields zero transactions, and a header-only file yields zero transactions. +2. **Zero transactions** produce a report with no category lines at all — just the blank line and `closing balance: `. `uncategorized` appears only when at least one transaction is uncategorized. +3. **Rules file lines** that are blank, whitespace-only, or contain no `=` are ignored. The split is on the *first* `=`, so a category may contain `=` but a substring may not. Substring and category are stripped of surrounding whitespace; a rule with an empty substring or empty category is ignored. +4. **An unreadable `--rules` file** is reported with the same `cannot read` message and exit code `1` as an unreadable transactions file. +5. **A malformed `--opening` value** (not a decimal number, or more than two fractional digits) is an argparse error: usage to stderr, exit code `2`. +6. **A transactions or rules file that is not valid UTF-8** is a read failure: `ledgerlite: cannot read : not valid UTF-8 text`, exit code `1`. + +## Review Focus + +Input classes and failure modes the spec implies. Each line names where its test lives; a line with no test is one the final reviewer checks deliberately. + +- `Decimal("NaN")` and `Decimal("Infinity")` parse successfully from an amount column — they must be rejected as malformed, not silently summed into a NaN total. *Test: Task 3.* +- Negative zero: `format(Decimal("-0.00"), "f")` is `"-0.00"`, but the spec says zero prints `0.00`. Reachable via `--opening -0.00` with no transactions. *Test: Task 5 (`format_amount`) and Task 6 (CLI).* +- Empty CSV and header-only CSV: zero transactions, closing balance equals opening, no category lines. *Test: Task 3 (parse) and Task 6 (CLI end-to-end).* +- A malformed row must leave stdout completely empty — no partial report before the error. *Test: Task 6.* +- Wrong column count in both directions: two columns and four columns, including the four-column case caused by a trailing comma. *Test: Task 3.* +- A description containing a comma or a double quote must survive the CSV reader intact and still match rules. *Test: Task 3 (parse) and Task 5 (report).* +- Rules matching is case-insensitive in both directions: an uppercase rule substring against a lowercase description, and vice versa. *Test: Task 2.* +- First matching rule wins even when a later rule also matches, and the winner is decided by file order, not by which substring is longer. *Test: Task 2.* +- Ties on date keep input order. Because addition is commutative this cannot be detected from the closing balance, so it is tested on the ordering function directly. *Test: Task 4.* +- A category in the rules file literally named `uncategorized` merges with the no-rule-matched bucket and is still printed last. *Test: Task 5.* +- Unreadable inputs beyond "missing file": a path that is a directory, and a path with no read permission. Both are `OSError`, both must produce `cannot read` and exit `1`. *Test: Task 6 covers missing file and directory; permissions are not tested (they behave differently under root) — reviewer confirms the `except OSError` is broad enough.* +- The default `decimal` context has 28 significant digits, so a total wider than that would round. Ordinary bank amounts are far below this and the code never sets a context or precision. *No test; reviewer confirms no `float` and no context manipulation anywhere in `ledgerlite/`.* + +--- + +### Task 1: Package skeleton and the `Transaction` model + +**Files:** +- Create: `ledgerlite/__init__.py` +- Create: `ledgerlite/model.py` +- Create: `.gitignore` +- Test: `test_model.py` + +**Interfaces:** +- Consumes: nothing. +- Produces: `ledgerlite.model.Transaction`, a frozen dataclass with fields `date: datetime.date`, `amount: decimal.Decimal`, `description: str`, constructed by keyword in every later task: `Transaction(date=date(2026, 3, 4), amount=Decimal("-7.50"), description="Coffee")`. + +- [ ] **Step 1: Write the failing test** + +Create `test_model.py`: + +```python +import unittest +from dataclasses import FrozenInstanceError +from datetime import date +from decimal import Decimal + +from ledgerlite.model import Transaction + + +class TransactionTest(unittest.TestCase): + def test_holds_date_amount_and_description(self): + txn = Transaction( + date=date(2026, 3, 4), amount=Decimal("-7.50"), description="Coffee" + ) + self.assertEqual(txn.date, date(2026, 3, 4)) + self.assertEqual(txn.amount, Decimal("-7.50")) + self.assertEqual(txn.description, "Coffee") + + def test_equal_when_all_fields_equal(self): + first = Transaction( + date=date(2026, 3, 4), amount=Decimal("-7.50"), description="Coffee" + ) + second = Transaction( + date=date(2026, 3, 4), amount=Decimal("-7.50"), description="Coffee" + ) + self.assertEqual(first, second) + + def test_is_immutable(self): + txn = Transaction( + date=date(2026, 3, 4), amount=Decimal("-7.50"), description="Coffee" + ) + with self.assertRaises(FrozenInstanceError): + txn.amount = Decimal("0.00") + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `python3 -m unittest test_model -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite'` + +- [ ] **Step 3: Write the minimal implementation** + +Create `ledgerlite/__init__.py`: + +```python +"""ledgerlite — categorize bank transactions and summarize them.""" +``` + +Create `ledgerlite/model.py`: + +```python +"""The transaction value type.""" + +from dataclasses import dataclass +from datetime import date +from decimal import Decimal + + +@dataclass(frozen=True) +class Transaction: + """One row of the transactions CSV, already parsed and validated.""" + + date: date + amount: Decimal + description: str +``` + +Create `.gitignore`: + +``` +__pycache__/ +*.pyc +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `python3 -m unittest test_model -v` +Expected: PASS — 3 tests OK + +- [ ] **Step 5: Commit** + +```bash +git add .gitignore ledgerlite/__init__.py ledgerlite/model.py test_model.py +git commit -m "feat: add ledgerlite package and Transaction model" +``` + +--- + +### Task 2: Rules parsing and categorization + +**Files:** +- Create: `ledgerlite/rules.py` +- Test: `test_rules.py` + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: + - `ledgerlite.rules.Rule` — type alias for `tuple[str, str]`, i.e. `(substring, category)`. + - `parse_rules(text: str) -> list[Rule]` — takes the whole rules file as one string, returns rules in file order. + - `categorize(description: str, rules: list[Rule]) -> str | None` — returns the category of the first rule whose substring appears case-insensitively in `description`, or `None`. + +- [ ] **Step 1: Write the failing test** + +Create `test_rules.py`: + +```python +import unittest + +from ledgerlite.rules import categorize, parse_rules + + +class ParseRulesTest(unittest.TestCase): + def test_parses_one_rule_per_line_in_order(self): + rules = parse_rules("coffee=food\nrent=housing\n") + self.assertEqual(rules, [("coffee", "food"), ("rent", "housing")]) + + def test_ignores_blank_and_whitespace_only_lines(self): + rules = parse_rules("coffee=food\n\n \nrent=housing\n") + self.assertEqual(rules, [("coffee", "food"), ("rent", "housing")]) + + def test_ignores_lines_without_an_equals_sign(self): + self.assertEqual(parse_rules("coffee\ncoffee=food\n"), [("coffee", "food")]) + + def test_strips_surrounding_whitespace(self): + self.assertEqual(parse_rules(" coffee = food \n"), [("coffee", "food")]) + + def test_ignores_rules_with_an_empty_side(self): + self.assertEqual(parse_rules("=food\ncoffee=\n"), []) + + def test_splits_on_the_first_equals_so_category_may_contain_one(self): + self.assertEqual(parse_rules("coffee=food=drink\n"), [("coffee", "food=drink")]) + + def test_no_rules_for_empty_text(self): + self.assertEqual(parse_rules(""), []) + + +class CategorizeTest(unittest.TestCase): + def test_matches_substring_anywhere_in_description(self): + self.assertEqual( + categorize("BLUE BOTTLE COFFEE #12", [("coffee", "food")]), "food" + ) + + def test_matching_is_case_insensitive_in_both_directions(self): + self.assertEqual(categorize("blue bottle coffee", [("COFFEE", "food")]), "food") + self.assertEqual(categorize("BLUE BOTTLE COFFEE", [("coffee", "food")]), "food") + + def test_first_matching_rule_wins_even_if_a_later_one_also_matches(self): + rules = [("coffee", "food"), ("blue bottle coffee", "treats")] + self.assertEqual(categorize("Blue Bottle Coffee", rules), "food") + + def test_returns_none_when_nothing_matches(self): + self.assertIsNone(categorize("ACME PAYROLL", [("coffee", "food")])) + + def test_returns_none_when_there_are_no_rules(self): + self.assertIsNone(categorize("Blue Bottle Coffee", [])) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `python3 -m unittest test_rules -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.rules'` + +- [ ] **Step 3: Write the minimal implementation** + +Create `ledgerlite/rules.py`: + +```python +"""Rules file parsing and description matching.""" + +Rule = tuple[str, str] + + +def parse_rules(text: str) -> list[Rule]: + """Parse `=` lines, keeping file order. + + Blank lines and lines with no `=` are ignored, as are rules with an + empty substring or an empty category. + """ + rules: list[Rule] = [] + for line in text.splitlines(): + if "=" not in line: + continue + substring, category = line.split("=", 1) + substring = substring.strip() + category = category.strip() + if not substring or not category: + continue + rules.append((substring, category)) + return rules + + +def categorize(description: str, rules: list[Rule]) -> str | None: + """Return the category of the first rule matching `description`, or None.""" + haystack = description.lower() + for substring, category in rules: + if substring.lower() in haystack: + return category + return None +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `python3 -m unittest test_rules -v` +Expected: PASS — 12 tests OK + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/rules.py test_rules.py +git commit -m "feat: parse rules files and categorize descriptions" +``` + +--- + +### Task 3: CSV parsing with `ParseError` + +**Files:** +- Create: `ledgerlite/parse.py` +- Test: `test_parse.py` + +**Interfaces:** +- Consumes: `ledgerlite.model.Transaction(date=..., amount=..., description=...)` from Task 1. +- Produces: + - `ledgerlite.parse.ParseError(Exception)` with attributes `path: str`, `line: int`, `problem: str`, and `str(err) == f"{path}:{line}: {problem}"` (no `ledgerlite: ` prefix — the CLI adds that). + - `parse_amount(text: str) -> Decimal` — raises `ValueError` with a human-readable message for a non-decimal, non-finite, or more-than-two-fractional-digit amount. Task 6 reuses this for `--opening`. + - `parse_transactions(path: str) -> list[Transaction]` — in file order; propagates `OSError` and `UnicodeDecodeError` to the caller, raises `ParseError` on the first malformed row. + +- [ ] **Step 1: Write the failing test** + +Create `test_parse.py`: + +```python +import tempfile +import unittest +from datetime import date +from decimal import Decimal +from pathlib import Path + +from ledgerlite.model import Transaction +from ledgerlite.parse import ParseError, parse_amount, parse_transactions + +HEADER = "date,amount,description\n" + + +class AmountTest(unittest.TestCase): + def test_accepts_zero_one_and_two_fractional_digits(self): + self.assertEqual(parse_amount("1"), Decimal("1")) + self.assertEqual(parse_amount("1.5"), Decimal("1.5")) + self.assertEqual(parse_amount("1.50"), Decimal("1.50")) + self.assertEqual(parse_amount("-12.50"), Decimal("-12.50")) + + def test_rejects_three_fractional_digits(self): + with self.assertRaisesRegex(ValueError, "two fractional digits"): + parse_amount("1.005") + + def test_rejects_text(self): + with self.assertRaisesRegex(ValueError, "not a decimal number"): + parse_amount("twelve") + + def test_rejects_empty_string(self): + with self.assertRaisesRegex(ValueError, "not a decimal number"): + parse_amount("") + + def test_rejects_nan_and_infinity(self): + for text in ("NaN", "nan", "Infinity", "-Infinity"): + with self.subTest(text=text): + with self.assertRaisesRegex(ValueError, "not a decimal number"): + parse_amount(text) + + +class ParseTransactionsTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + + def write(self, text, name="txns.csv"): + path = Path(self.tmp.name) / name + path.write_text(text, encoding="utf-8") + return str(path) + + def test_parses_rows_in_file_order(self): + path = self.write( + HEADER + "2026-03-05,2500.00,ACME PAYROLL\n2026-03-04,-7.50,Coffee\n" + ) + self.assertEqual( + parse_transactions(path), + [ + Transaction( + date=date(2026, 3, 5), + amount=Decimal("2500.00"), + description="ACME PAYROLL", + ), + Transaction( + date=date(2026, 3, 4), + amount=Decimal("-7.50"), + description="Coffee", + ), + ], + ) + + def test_header_only_file_has_no_transactions(self): + self.assertEqual(parse_transactions(self.write(HEADER)), []) + + def test_empty_file_has_no_transactions(self): + self.assertEqual(parse_transactions(self.write("")), []) + + def test_skips_blank_lines_between_rows(self): + path = self.write(HEADER + "2026-03-04,-7.50,Coffee\n\n") + self.assertEqual(len(parse_transactions(path)), 1) + + def test_keeps_quoted_commas_and_quotes_in_description(self): + path = self.write( + HEADER + '2026-03-04,-7.50,"Blue Bottle, 3"" cup"\n' + ) + self.assertEqual( + parse_transactions(path)[0].description, 'Blue Bottle, 3" cup' + ) + + def test_too_few_columns_is_a_parse_error_on_its_line(self): + path = self.write(HEADER + "2026-03-04,-7.50\n") + with self.assertRaises(ParseError) as caught: + parse_transactions(path) + self.assertEqual(caught.exception.line, 2) + self.assertEqual(caught.exception.path, path) + self.assertIn("expected 3 columns, got 2", caught.exception.problem) + self.assertEqual(str(caught.exception), f"{path}:2: expected 3 columns, got 2") + + def test_trailing_comma_makes_four_columns(self): + path = self.write(HEADER + "2026-03-04,-7.50,Coffee,\n") + with self.assertRaisesRegex(ParseError, "expected 3 columns, got 4"): + parse_transactions(path) + + def test_unparseable_date_is_a_parse_error(self): + path = self.write(HEADER + "2026-13-40,-7.50,Coffee\n") + with self.assertRaises(ParseError) as caught: + parse_transactions(path) + self.assertEqual(caught.exception.line, 2) + self.assertIn("date", caught.exception.problem) + + def test_unparseable_amount_is_a_parse_error(self): + path = self.write(HEADER + "2026-03-04,seven fifty,Coffee\n") + with self.assertRaises(ParseError) as caught: + parse_transactions(path) + self.assertIn("not a decimal number", caught.exception.problem) + + def test_three_fractional_digits_is_a_parse_error(self): + path = self.write(HEADER + "2026-03-04,-1.005,Coffee\n") + with self.assertRaises(ParseError) as caught: + parse_transactions(path) + self.assertIn("two fractional digits", caught.exception.problem) + + def test_reports_the_line_of_the_first_bad_row(self): + path = self.write( + HEADER + + "2026-03-04,-7.50,Coffee\n" + + "2026-03-05,nope,Rent\n" + + "2026-03-06,oops,Gas\n" + ) + with self.assertRaises(ParseError) as caught: + parse_transactions(path) + self.assertEqual(caught.exception.line, 3) + + def test_missing_file_raises_oserror(self): + with self.assertRaises(OSError): + parse_transactions(str(Path(self.tmp.name) / "nope.csv")) + + def test_non_utf8_file_raises_unicodedecodeerror(self): + path = Path(self.tmp.name) / "latin.csv" + path.write_bytes(HEADER.encode("utf-8") + b"2026-03-04,-7.50,Caf\xe9\n") + with self.assertRaises(UnicodeDecodeError): + parse_transactions(str(path)) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `python3 -m unittest test_parse -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.parse'` + +- [ ] **Step 3: Write the minimal implementation** + +Create `ledgerlite/parse.py`: + +```python +"""Read a transactions CSV into Transaction objects.""" + +import csv +from datetime import date +from decimal import Decimal, InvalidOperation +from typing import Iterator, TextIO + +from .model import Transaction + +COLUMN_COUNT = 3 + + +class ParseError(Exception): + """A row of the transactions file could not be parsed.""" + + def __init__(self, path: str, line: int, problem: str) -> None: + super().__init__(f"{path}:{line}: {problem}") + self.path = path + self.line = line + self.problem = problem + + +def parse_amount(text: str) -> Decimal: + """Parse a money amount with at most two fractional digits. + + Raises ValueError with a human-readable message if `text` is not a + finite decimal number or has more than two fractional digits. + """ + try: + amount = Decimal(text) + except InvalidOperation: + raise ValueError(f"amount is not a decimal number: {text!r}") from None + if not amount.is_finite(): + raise ValueError(f"amount is not a decimal number: {text!r}") + if amount.as_tuple().exponent < -2: + raise ValueError(f"amount has more than two fractional digits: {text!r}") + return amount + + +def parse_date(text: str) -> date: + """Parse an ISO 8601 date, raising ValueError with a readable message.""" + try: + return date.fromisoformat(text) + except ValueError: + raise ValueError(f"date is not ISO 8601: {text!r}") from None + + +def parse_transactions(path: str) -> list[Transaction]: + """Parse the whole transactions file, or raise ParseError on a bad row. + + The first line is treated as the header and skipped. OSError and + UnicodeDecodeError propagate to the caller. + """ + with open(path, newline="", encoding="utf-8") as handle: + return list(_rows(path, handle)) + + +def _rows(path: str, handle: TextIO) -> Iterator[Transaction]: + reader = csv.reader(handle) + for row in reader: + if reader.line_num == 1 or not row: + continue + if len(row) != COLUMN_COUNT: + raise ParseError( + path, reader.line_num, f"expected 3 columns, got {len(row)}" + ) + try: + when = parse_date(row[0]) + amount = parse_amount(row[1]) + except ValueError as exc: + raise ParseError(path, reader.line_num, str(exc)) from None + yield Transaction(date=when, amount=amount, description=row[2]) +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `python3 -m unittest test_parse -v` +Expected: PASS — 18 tests OK + +- [ ] **Step 5: Run the whole suite** + +Run: `python3 -m unittest` +Expected: PASS — everything from Tasks 1–3 OK + +- [ ] **Step 6: Commit** + +```bash +git add ledgerlite/parse.py test_parse.py +git commit -m "feat: parse transactions CSV with per-row error reporting" +``` + +--- + +### Task 4: Date ordering and closing balance + +**Files:** +- Create: `ledgerlite/balance.py` +- Test: `test_balance.py` + +**Interfaces:** +- Consumes: `ledgerlite.model.Transaction` from Task 1. +- Produces: + - `order_by_date(transactions: list[Transaction]) -> list[Transaction]` — a new list sorted by `date`, ties keeping input order. Does not mutate its argument. + - `closing_balance(opening: Decimal, transactions: list[Transaction]) -> Decimal` — `opening` plus every amount, added in date order; returns `opening` for an empty list. + +- [ ] **Step 1: Write the failing test** + +Create `test_balance.py`: + +```python +import unittest +from datetime import date +from decimal import Decimal + +from ledgerlite.balance import closing_balance, order_by_date +from ledgerlite.model import Transaction + + +def txn(day, amount, description): + return Transaction( + date=date(2026, 3, day), amount=Decimal(amount), description=description + ) + + +class OrderByDateTest(unittest.TestCase): + def test_sorts_by_date(self): + rows = [txn(6, "1.00", "c"), txn(4, "1.00", "a"), txn(5, "1.00", "b")] + self.assertEqual( + [t.description for t in order_by_date(rows)], ["a", "b", "c"] + ) + + def test_ties_keep_input_order(self): + rows = [ + txn(4, "1.00", "first"), + txn(4, "2.00", "second"), + txn(4, "3.00", "third"), + ] + self.assertEqual( + [t.description for t in order_by_date(rows)], + ["first", "second", "third"], + ) + + def test_does_not_mutate_the_input(self): + rows = [txn(6, "1.00", "c"), txn(4, "1.00", "a")] + order_by_date(rows) + self.assertEqual([t.description for t in rows], ["c", "a"]) + + def test_empty_list(self): + self.assertEqual(order_by_date([]), []) + + +class ClosingBalanceTest(unittest.TestCase): + def test_adds_every_amount_to_the_opening(self): + rows = [ + txn(4, "-7.50", "Coffee"), + txn(5, "-900.00", "Rent"), + txn(6, "2500.00", "Payroll"), + ] + self.assertEqual( + closing_balance(Decimal("100"), rows), Decimal("1692.50") + ) + + def test_is_the_opening_when_there_are_no_transactions(self): + self.assertEqual(closing_balance(Decimal("100"), []), Decimal("100")) + + def test_zero_opening(self): + self.assertEqual( + closing_balance(Decimal("0"), [txn(4, "-7.50", "Coffee")]), + Decimal("-7.50"), + ) + + def test_result_is_exact_not_floating_point(self): + rows = [txn(4, "0.10", "a")] * 3 + self.assertEqual(closing_balance(Decimal("0"), rows), Decimal("0.30")) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `python3 -m unittest test_balance -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.balance'` + +- [ ] **Step 3: Write the minimal implementation** + +Create `ledgerlite/balance.py`: + +```python +"""Date ordering and the running balance.""" + +from decimal import Decimal + +from .model import Transaction + + +def order_by_date(transactions: list[Transaction]) -> list[Transaction]: + """Return the transactions sorted by date, ties keeping input order.""" + return sorted(transactions, key=lambda txn: txn.date) + + +def closing_balance(opening: Decimal, transactions: list[Transaction]) -> Decimal: + """Return the balance after adding every amount in date order.""" + balance = opening + for txn in order_by_date(transactions): + balance += txn.amount + return balance +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `python3 -m unittest test_balance -v` +Expected: PASS — 8 tests OK + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/balance.py test_balance.py +git commit -m "feat: order transactions by date and compute closing balance" +``` + +--- + +### Task 5: Category totals and report formatting + +**Files:** +- Create: `ledgerlite/report.py` +- Test: `test_report.py` + +**Interfaces:** +- Consumes: `Transaction` (Task 1), `categorize(description, rules)` and `Rule` (Task 2). +- Produces: + - `UNCATEGORIZED = "uncategorized"`. + - `format_amount(amount: Decimal) -> str` — exactly two fractional digits, `-` only for negatives, negative zero printed as `0.00`. + - `category_totals(transactions: list[Transaction], rules: list[Rule]) -> list[tuple[str, Decimal]]` — named categories alphabetically, then `uncategorized` last if present. + - `format_report(transactions: list[Transaction], rules: list[Rule], closing: Decimal) -> str` — the full report text, ending in a single trailing newline. + +- [ ] **Step 1: Write the failing test** + +Create `test_report.py`: + +```python +import unittest +from datetime import date +from decimal import Decimal + +from ledgerlite.model import Transaction +from ledgerlite.report import category_totals, format_amount, format_report + +RULES = [("coffee", "food"), ("rent", "housing")] + + +def txn(day, amount, description): + return Transaction( + date=date(2026, 3, day), amount=Decimal(amount), description=description + ) + + +class FormatAmountTest(unittest.TestCase): + def test_two_fractional_digits(self): + self.assertEqual(format_amount(Decimal("1200")), "1200.00") + self.assertEqual(format_amount(Decimal("1200.5")), "1200.50") + self.assertEqual(format_amount(Decimal("1200.00")), "1200.00") + + def test_leading_minus_for_negatives(self): + self.assertEqual(format_amount(Decimal("-12.5")), "-12.50") + + def test_zero(self): + self.assertEqual(format_amount(Decimal("0")), "0.00") + + def test_negative_zero_prints_without_a_sign(self): + self.assertEqual(format_amount(Decimal("-0.00")), "0.00") + + def test_no_thousands_separators(self): + self.assertEqual(format_amount(Decimal("1234567.89")), "1234567.89") + + +class CategoryTotalsTest(unittest.TestCase): + def test_sums_each_category(self): + rows = [ + txn(4, "-7.50", "Blue Bottle Coffee"), + txn(5, "-2.50", "COFFEE CART"), + txn(6, "-900.00", "March Rent"), + ] + self.assertEqual( + category_totals(rows, RULES), + [("food", Decimal("-10.00")), ("housing", Decimal("-900.00"))], + ) + + def test_categories_are_alphabetical(self): + rules = [("z", "zebra"), ("a", "apple"), ("m", "mango")] + rows = [txn(4, "1.00", "z"), txn(4, "1.00", "a"), txn(4, "1.00", "m")] + self.assertEqual( + [name for name, _ in category_totals(rows, rules)], + ["apple", "mango", "zebra"], + ) + + def test_unmatched_transactions_go_to_uncategorized_last(self): + rows = [ + txn(4, "2500.00", "ACME PAYROLL"), + txn(5, "-900.00", "March Rent"), + ] + self.assertEqual( + category_totals(rows, RULES), + [("housing", Decimal("-900.00")), ("uncategorized", Decimal("2500.00"))], + ) + + def test_uncategorized_is_last_even_when_alphabetically_early(self): + rows = [txn(4, "1.00", "unknown"), txn(5, "-900.00", "March Rent")] + rules = [("rent", "zzz")] + self.assertEqual( + [name for name, _ in category_totals(rows, rules)], + ["zzz", "uncategorized"], + ) + + def test_a_rule_category_named_uncategorized_merges_and_stays_last(self): + rows = [txn(4, "-7.50", "Coffee"), txn(5, "1.00", "Mystery")] + rules = [("coffee", "uncategorized")] + self.assertEqual( + category_totals(rows, rules), + [("uncategorized", Decimal("-6.50"))], + ) + + def test_no_rules_means_everything_is_uncategorized(self): + rows = [txn(4, "-7.50", "Coffee")] + self.assertEqual( + category_totals(rows, []), [("uncategorized", Decimal("-7.50"))] + ) + + def test_no_transactions_means_no_categories(self): + self.assertEqual(category_totals([], RULES), []) + + def test_matches_descriptions_containing_commas_and_quotes(self): + rows = [txn(4, "-7.50", 'Blue Bottle, 3" COFFEE cup')] + self.assertEqual( + category_totals(rows, RULES), [("food", Decimal("-7.50"))] + ) + + +class FormatReportTest(unittest.TestCase): + def test_matches_the_design_example(self): + rows = [ + txn(4, "-7.50", "Blue Bottle Coffee"), + txn(5, "-900.00", "March Rent"), + txn(6, "2500.00", "ACME PAYROLL"), + ] + closing = Decimal("1692.50") + self.assertEqual( + format_report(rows, RULES, closing), + "food: -7.50\n" + "housing: -900.00\n" + "uncategorized: 2500.00\n" + "\n" + "closing balance: 1692.50\n", + ) + + def test_no_transactions_prints_only_the_closing_balance(self): + self.assertEqual( + format_report([], RULES, Decimal("100")), + "\nclosing balance: 100.00\n", + ) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `python3 -m unittest test_report -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.report'` + +- [ ] **Step 3: Write the minimal implementation** + +Create `ledgerlite/report.py`: + +```python +"""Per-category totals and report formatting.""" + +from decimal import Decimal + +from .model import Transaction +from .rules import Rule, categorize + +UNCATEGORIZED = "uncategorized" +CENTS = Decimal("0.01") + + +def format_amount(amount: Decimal) -> str: + """Format an amount with exactly two fractional digits, no separators.""" + quantized = amount.quantize(CENTS) + if quantized.is_zero(): + quantized = quantized.copy_abs() + return format(quantized, "f") + + +def category_totals( + transactions: list[Transaction], rules: list[Rule] +) -> list[tuple[str, Decimal]]: + """Total each category: named ones alphabetically, uncategorized last.""" + totals: dict[str, Decimal] = {} + for txn in transactions: + name = categorize(txn.description, rules) or UNCATEGORIZED + totals[name] = totals.get(name, Decimal("0")) + txn.amount + ordered = sorted( + (name, total) for name, total in totals.items() if name != UNCATEGORIZED + ) + if UNCATEGORIZED in totals: + ordered.append((UNCATEGORIZED, totals[UNCATEGORIZED])) + return ordered + + +def format_report( + transactions: list[Transaction], rules: list[Rule], closing: Decimal +) -> str: + """Build the whole report text, ending in a single newline.""" + lines = [ + f"{name}: {format_amount(total)}" + for name, total in category_totals(transactions, rules) + ] + lines.append("") + lines.append(f"closing balance: {format_amount(closing)}") + return "\n".join(lines) + "\n" +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `python3 -m unittest test_report -v` +Expected: PASS — 15 tests OK + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/report.py test_report.py +git commit -m "feat: total categories and format the report" +``` + +--- + +### Task 6: CLI entry point + +**Files:** +- Create: `ledgerlite/cli.py` +- Create: `ledgerlite/__main__.py` +- Test: `test_cli.py` + +**Interfaces:** +- Consumes: `parse_transactions(path)`, `parse_amount(text)`, `ParseError` (Task 3); `parse_rules(text)` (Task 2); `closing_balance(opening, transactions)` (Task 4); `format_report(transactions, rules, closing)` (Task 5). +- Produces: `ledgerlite.cli.main(argv: list[str] | None = None) -> int` — writes the report to stdout or an error to stderr and returns the exit code. `ledgerlite/__main__.py` calls `sys.exit(main())`. + +- [ ] **Step 1: Write the failing test** + +Create `test_cli.py`: + +```python +import contextlib +import io +import tempfile +import unittest +from pathlib import Path + +from ledgerlite.cli import main + +HEADER = "date,amount,description\n" +ROWS = ( + "2026-03-06,2500.00,ACME PAYROLL\n" + "2026-03-04,-7.50,Blue Bottle Coffee\n" + "2026-03-05,-900.00,March Rent\n" +) + + +class CliTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + + def write(self, name, text): + path = Path(self.tmp.name) / name + path.write_text(text, encoding="utf-8") + return str(path) + + def run_cli(self, argv): + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = main(argv) + return code, out.getvalue(), err.getvalue() + + def test_reports_the_design_example(self): + txns = self.write("txns.csv", HEADER + ROWS) + rules = self.write("rules.txt", "coffee=food\nrent=housing\n") + code, out, err = self.run_cli( + ["report", txns, "--rules", rules, "--opening", "100"] + ) + self.assertEqual(code, 0) + self.assertEqual(err, "") + self.assertEqual( + out, + "food: -7.50\n" + "housing: -900.00\n" + "uncategorized: 2500.00\n" + "\n" + "closing balance: 1692.50\n", + ) + + def test_opening_defaults_to_zero(self): + txns = self.write("txns.csv", HEADER + "2026-03-04,-7.50,Coffee\n") + code, out, _ = self.run_cli(["report", txns]) + self.assertEqual(code, 0) + self.assertEqual( + out, "uncategorized: -7.50\n\nclosing balance: -7.50\n" + ) + + def test_without_rules_everything_is_uncategorized(self): + txns = self.write("txns.csv", HEADER + ROWS) + code, out, _ = self.run_cli(["report", txns]) + self.assertEqual(code, 0) + self.assertEqual( + out, "uncategorized: 1592.50\n\nclosing balance: 1592.50\n" + ) + + def test_header_only_file_reports_just_the_closing_balance(self): + txns = self.write("txns.csv", HEADER) + code, out, _ = self.run_cli(["report", txns, "--opening", "100"]) + self.assertEqual(code, 0) + self.assertEqual(out, "\nclosing balance: 100.00\n") + + def test_negative_zero_opening_prints_zero(self): + txns = self.write("txns.csv", HEADER) + code, out, _ = self.run_cli(["report", txns, "--opening", "-0.00"]) + self.assertEqual(code, 0) + self.assertEqual(out, "\nclosing balance: 0.00\n") + + def test_missing_transactions_file_is_exit_1(self): + missing = str(Path(self.tmp.name) / "nope.csv") + code, out, err = self.run_cli(["report", missing]) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertEqual(err, f"ledgerlite: cannot read {missing}: No such file or directory\n") + + def test_directory_as_transactions_file_is_exit_1(self): + code, out, err = self.run_cli(["report", self.tmp.name]) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertTrue(err.startswith(f"ledgerlite: cannot read {self.tmp.name}: ")) + + def test_non_utf8_transactions_file_is_exit_1(self): + path = Path(self.tmp.name) / "latin.csv" + path.write_bytes(HEADER.encode("utf-8") + b"2026-03-04,-7.50,Caf\xe9\n") + code, out, err = self.run_cli(["report", str(path)]) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertEqual(err, f"ledgerlite: cannot read {path}: not valid UTF-8 text\n") + + def test_missing_rules_file_is_exit_1(self): + txns = self.write("txns.csv", HEADER + ROWS) + missing = str(Path(self.tmp.name) / "nope.txt") + code, out, err = self.run_cli(["report", txns, "--rules", missing]) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertEqual(err, f"ledgerlite: cannot read {missing}: No such file or directory\n") + + def test_malformed_row_is_exit_2_with_nothing_on_stdout(self): + txns = self.write( + "txns.csv", HEADER + "2026-03-04,-7.50,Coffee\n2026-03-05,nope,Rent\n" + ) + code, out, err = self.run_cli(["report", txns]) + self.assertEqual(code, 2) + self.assertEqual(out, "") + self.assertTrue(err.startswith(f"ledgerlite: {txns}:3: "), err) + self.assertIn("not a decimal number", err) + + def test_too_many_fractional_digits_is_exit_2(self): + txns = self.write("txns.csv", HEADER + "2026-03-04,-1.005,Coffee\n") + code, out, err = self.run_cli(["report", txns]) + self.assertEqual(code, 2) + self.assertEqual(out, "") + self.assertIn("two fractional digits", err) + + def test_bad_opening_value_exits_2(self): + txns = self.write("txns.csv", HEADER + ROWS) + for value in ("twelve", "1.005"): + with self.subTest(value=value): + with contextlib.redirect_stderr(io.StringIO()): + with self.assertRaises(SystemExit) as caught: + main(["report", txns, "--opening", value]) + self.assertEqual(caught.exception.code, 2) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `python3 -m unittest test_cli -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.cli'` + +- [ ] **Step 3: Write the minimal implementation** + +Create `ledgerlite/cli.py`: + +```python +"""Command-line entry point.""" + +import argparse +import sys +from decimal import Decimal + +from .balance import closing_balance +from .parse import ParseError, parse_amount, parse_transactions +from .report import format_report +from .rules import parse_rules + +PROG = "ledgerlite" + + +def _opening_amount(text: str) -> Decimal: + try: + return parse_amount(text) + except ValueError as exc: + raise argparse.ArgumentTypeError(str(exc)) from None + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog=PROG, description="Summarize bank transactions by category." + ) + subcommands = parser.add_subparsers(dest="command", required=True) + report = subcommands.add_parser("report", help="print a category report") + report.add_argument("transactions", help="path to the transactions CSV") + report.add_argument("--rules", help="path to the rules file") + report.add_argument( + "--opening", + type=_opening_amount, + default=Decimal("0"), + help="opening balance (default: 0)", + ) + return parser + + +def _read_text(path: str) -> str: + with open(path, encoding="utf-8") as handle: + return handle.read() + + +def _cannot_read(path: str, exc: Exception) -> None: + reason = "not valid UTF-8 text" + if isinstance(exc, OSError): + reason = exc.strerror or str(exc) + print(f"{PROG}: cannot read {path}: {reason}", file=sys.stderr) + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + + rules = [] + if args.rules is not None: + try: + rules = parse_rules(_read_text(args.rules)) + except (OSError, UnicodeDecodeError) as exc: + _cannot_read(args.rules, exc) + return 1 + + try: + transactions = parse_transactions(args.transactions) + except (OSError, UnicodeDecodeError) as exc: + _cannot_read(args.transactions, exc) + return 1 + except ParseError as exc: + print(f"{PROG}: {exc}", file=sys.stderr) + return 2 + + closing = closing_balance(args.opening, transactions) + sys.stdout.write(format_report(transactions, rules, closing)) + return 0 +``` + +Create `ledgerlite/__main__.py`: + +```python +"""Allow `python3 -m ledgerlite ...`.""" + +import sys + +from .cli import main + +if __name__ == "__main__": + sys.exit(main()) +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `python3 -m unittest test_cli -v` +Expected: PASS — 12 tests OK + +- [ ] **Step 5: Run the whole suite** + +Run: `python3 -m unittest` +Expected: PASS — all tests from Tasks 1–6 OK, no failures, no errors + +- [ ] **Step 6: Check the real command end to end** + +```bash +printf 'date,amount,description\n2026-03-06,2500.00,ACME PAYROLL\n2026-03-04,-7.50,Blue Bottle Coffee\n2026-03-05,-900.00,March Rent\n' > /tmp/txns.csv +printf 'coffee=food\nrent=housing\n' > /tmp/rules.txt +python3 -m ledgerlite report /tmp/txns.csv --rules /tmp/rules.txt --opening 100 +echo "exit=$?" +``` + +Expected output, matching the design example exactly: + +``` +food: -7.50 +housing: -900.00 +uncategorized: 2500.00 + +closing balance: 1692.50 +exit=0 +``` + +Then confirm the failure paths: + +```bash +python3 -m ledgerlite report /tmp/does-not-exist.csv; echo "exit=$?" +printf 'date,amount,description\n2026-03-04,nope,Coffee\n' > /tmp/bad.csv +python3 -m ledgerlite report /tmp/bad.csv; echo "exit=$?" +``` + +Expected: the first prints `ledgerlite: cannot read /tmp/does-not-exist.csv: No such file or directory` and `exit=1`; the second prints `ledgerlite: /tmp/bad.csv:2: amount is not a decimal number: 'nope'` and `exit=2`, with nothing on stdout. + +- [ ] **Step 7: Verify no floats anywhere** + +Run: `grep -rn "float" ledgerlite/` +Expected: no matches. + +- [ ] **Step 8: Commit** + +```bash +git add ledgerlite/cli.py ledgerlite/__main__.py test_cli.py +git commit -m "feat: add ledgerlite report command" +``` + +--- + +## Done When + +- `python3 -m unittest` passes from the repo root with zero failures and zero errors. +- `python3 -m ledgerlite report ... --rules ... --opening 100` reproduces the design example byte for byte. +- Exit codes are 0 / 1 / 2 as specified, and stdout is empty for both error paths. +- `grep -rn "float" ledgerlite/` finds nothing, and no file outside the spec's layout plus `__main__.py` exists. diff --git a/tests/inline-eval/results/2026-09-17-ledgerlite-reasonable-person/wpplan-11.txt b/tests/inline-eval/results/2026-09-17-ledgerlite-reasonable-person/wpplan-11.txt new file mode 100644 index 000000000..d43ba16d2 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-ledgerlite-reasonable-person/wpplan-11.txt @@ -0,0 +1,27 @@ +# rep wpplan-11 transcript=/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/inline-eval-br/reps/wpplan-11/cfg/projects/-private-tmp-claude-501--Users-jesse-git-superpowers-superpowers-a8a313bf-7410-41ce-9036-636ccf4061d6-scratchpad-inline-eval-br-reps-wpplan-11-repo/f203196f-5566-4d14-a636-04685a1209f3.jsonl +skill executing-plans invoked False +all skills invoked writing-plans +Agent dispatches 0 +test runs 0 +ledger (progress.md) touches 0 +sdd script invocations 0 +commits (incl. fixture) 2 +suite in repo plan.md-present +probe review-focus handled +probe implied-header not named +probe implied-encoding handled +probe size plan= 1215 review-focus-lines=12 tests=68 tasks +user_prompt_submit events 1 (background-agent wakeups count too) +final: 'Rulings I made' False +final: mentions self-review True +mid-run check-in candidates 0 +main session tokens 8 msgs; out=31391 cache_read=259808 cache_create=44664 in=16 +subagent tokens 0 agents; out=0 cache_read=0 cache_create=0 in=0 +TOTAL tokens out=31391 cache_read=259808 cache_create=44664 in=16 + +# assistant text, in order (truncated) +[ 1] I'll start by invoking the writing-plans skill and reading the design. +[ 4] I'm using the writing-plans skill to create the implementation plan. +[ 9] Design read. Now writing the plan. +[ 12] Fixing the one awkward test literal, then verifying two risky Python details. +[ 17] Plan complete and saved to `plan.md` (committed on `main`). Nothing implemented. **Shape of it:** 6 tasks, one per module, each with full test code and implementation code in TDD micro-steps — `model` → `rules` → `parse` diff --git a/tests/inline-eval/results/2026-09-17-ledgerlite-reasonable-person/wpplan-12.plan.md b/tests/inline-eval/results/2026-09-17-ledgerlite-reasonable-person/wpplan-12.plan.md new file mode 100644 index 000000000..1167c521a --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-ledgerlite-reasonable-person/wpplan-12.plan.md @@ -0,0 +1,1365 @@ +# ledgerlite Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build `ledgerlite`, a standard-library-only Python CLI that reads a transactions CSV, categorizes each row with a rules file, and prints per-category totals plus the closing balance. + +**Architecture:** Six small modules with one responsibility each, layered so nothing depends on anything above it: `model` (data), `parse` (CSV text → transactions), `rules` (rules text → rule list, plus matching), `balance` (date ordering and running/closing balance), `report` (totals and text formatting), `cli` (argparse, file I/O, error messages, exit codes). Only `cli` touches the filesystem, `sys.stdout`, or `sys.stderr`; every other module is a pure function over strings and objects, which makes all of them testable without temp files. + +**Tech Stack:** Python 3.11+ standard library only — `csv`, `decimal`, `datetime`, `dataclasses`, `argparse`, `re`, `io`, `unittest`. + +**Spec:** `design.md` (repo root) + +## Global Constraints + +- Python 3.11+. Standard library only — no third-party packages, no `pip install`. +- Money is `decimal.Decimal` everywhere. Never `float`. Never `round()`. No arithmetic that converts through `float`. +- Package lives in `ledgerlite/`; module set is exactly the one in the spec's "Package layout" (plus `__main__.py`, see Task 5). +- Tests live at the repo root as `test_.py` and run with `python3 -m unittest` (no args = discovery from the repo root). +- Amounts are printed with exactly two fractional digits, a leading `-` only for negatives, and no thousands separators: `-12.50`, `0.00`, `1200.00`. +- Error messages are byte-exact: + - unreadable file → `ledgerlite: cannot read : ` on stderr, exit 1 + - malformed line → `ledgerlite: :: ` on stderr, exit 2 + - Both use the path exactly as it appeared on the command line. +- A malformed input file rejects the whole file: nothing is written to stdout. +- Exit codes: 0 success, 1 unreadable file, 2 malformed input (including argparse usage errors). + +## Decisions the spec leaves open + +The spec is silent on these; the plan resolves them so the code is deterministic. Each is pinned by a test in the task named. + +1. **Strict date form.** Only `YYYY-MM-DD` is accepted. `date.fromisoformat` in 3.11+ also accepts `20260304`, so a regex gate comes first. Impossible calendar dates (`2026-02-30`) are malformed. (Task 1) +2. **Strict amount form.** `^[+-]?(\d+(\.\d*)?|\.\d+)$`, so `NaN`, `Infinity`, `1e2`, `--5`, and `` are malformed. Surrounding whitespace on the date and amount fields is stripped; the description is kept verbatim. (Task 1) +3. **Header row is required.** The first line must be `date`, `amount`, `description` (whitespace-stripped, case-insensitive). A missing or wrong header is malformed at line 1. A completely empty file is therefore exit 2; a header-only file is a valid report with zero transactions. (Task 1) +4. **A leading UTF-8 BOM is stripped** from both input files before parsing. (Tasks 1 and 2) +5. **Blank lines inside the CSV are malformed** (`expected 3 columns, got 0`); a single trailing newline at end of file is not a row. (Task 1) +6. **Line numbers come from `csv.reader.line_num`**, so quoted fields with embedded newlines still report the physical line. (Task 1) +7. **Rules files** skip blank/whitespace-only lines; split on the *first* `=` (so `a=b=c` is substring `a`, category `b=c`); strip whitespace around both halves; a line with no `=`, an empty substring, or an empty category is malformed and reported with the line-numbered form at exit 2. There is no comment syntax — `#foo=bar` is a literal substring rule. (Task 2) +8. **An unreadable `--rules` file** gets the same `cannot read` message and exit 1 as the transactions file. (Task 5) +9. **A non-UTF-8 input file** is treated as unreadable: `cannot read : not valid UTF-8 text`, exit 1. (Task 5) +10. **Category lines are printed only for categories that have at least one transaction**, so a report with no uncategorized transactions has no `uncategorized` line. "Always listed last" is about position, not presence. (Task 4) +11. **Alphabetical ordering is case-insensitive** with a codepoint tiebreak: `key=(name.lower(), name)`. A rule whose category is literally `uncategorized` merges into the uncategorized bucket and stays last. (Task 4) +12. **`--opening` uses the same amount validator.** An invalid value is an argparse usage error: usage text on stderr, exit 2. (Task 5) + +## File Structure + +| File | Responsibility | +| --- | --- | +| `ledgerlite/__init__.py` | Package marker, docstring only. | +| `ledgerlite/model.py` | `Transaction` frozen dataclass. No logic. | +| `ledgerlite/parse.py` | `ParseError`, `parse_date`, `parse_amount`, `parse_transactions`. Pure text → objects. | +| `ledgerlite/rules.py` | `parse_rules`, `categorize`. Reuses `ParseError`. | +| `ledgerlite/balance.py` | `order_by_date`, `running_balances`, `closing_balance`. | +| `ledgerlite/report.py` | `UNCATEGORIZED`, `format_amount`, `category_totals`, `format_report`. | +| `ledgerlite/cli.py` | argparse wiring, file reads, error messages, exit codes, `main(argv)`. | +| `ledgerlite/__main__.py` | `python3 -m ledgerlite` shim. | +| `test_model.py`, `test_parse.py`, `test_rules.py`, `test_balance.py`, `test_report.py`, `test_cli.py` | One test module per source module, repo root. | + +Dependency direction: `cli → report → {balance, rules} → parse → model`. Nothing imports `cli`. + +## Review Focus + +Input classes the spec implies but does not spell out. Each has a test in the task named; the final reviewer should confirm those tests exist and pass. + +- Header-only CSV → report is a blank line then `closing balance: ` (Task 4 formatting test, Task 5 end-to-end test). +- Completely empty CSV → exit 2 at line 1 (Task 1, Task 5). +- Leading UTF-8 BOM on either input file → parsed normally, not a malformed header or a broken first rule (Task 1, Task 2). +- CRLF line endings → parsed normally, no stray `\r` in descriptions (Task 1). +- Blank line inside the CSV → malformed with the correct line number (Task 1). +- Quoted description containing a comma, a quote, and a newline → one transaction, verbatim text, later errors still get the right physical line number (Task 1). +- `20260304`, `2026-3-4`, `2026-02-30` → malformed date (Task 1). +- `NaN`, `Infinity`, `1e2`, `1.005`, empty amount → malformed amount, with `1.005` reported as the fractional-digits error and the rest as the not-a-number error (Task 1). +- Amount totals that come out as negative zero (`-0.00`) → printed `0.00` (Task 4). +- Sums stay exact under Decimal — 0.10 + 0.20 == 0.30, which float would break (Task 3, Task 4). +- Rules: blank lines, missing `=`, empty substring, empty category, `a=b=c`, uppercase rule vs lowercase description and vice versa, first-match-wins when two rules match (Task 2). +- Two transactions on the same date → input order preserved through ordering (Task 3). +- Unreadable transactions file, unreadable rules file, a directory passed as a path, a non-UTF-8 file → exit 1 with the exact message (Task 5). +- Invalid `--opening` → exit 2, nothing on stdout (Task 5). +- Any exit-2 path → stdout is completely empty (Task 5). + +--- + +### Task 1: Transaction model and CSV parsing + +Creates the package, the data type, and the parser. This is the largest task because the parser's field validators and its row loop are worthless apart — a reviewer judging one judges both. + +**Files:** +- Create: `ledgerlite/__init__.py` +- Create: `ledgerlite/model.py` +- Create: `ledgerlite/parse.py` +- Test: `test_model.py` +- Test: `test_parse.py` + +**Interfaces:** +- Consumes: nothing. +- Produces: + - `ledgerlite.model.Transaction` — frozen dataclass with fields `date: datetime.date`, `amount: decimal.Decimal`, `description: str` (in that order). + - `ledgerlite.parse.ParseError(Exception)` with attributes `line: int` and `message: str`. + - `ledgerlite.parse.parse_date(raw: str) -> datetime.date` — raises `ValueError` whose message is the human-readable "what is wrong" text. + - `ledgerlite.parse.parse_amount(raw: str) -> decimal.Decimal` — raises `ValueError` likewise. + - `ledgerlite.parse.parse_transactions(text: str) -> list[Transaction]` — raises `ParseError`. Rows are returned in input order (no sorting here). + +- [ ] **Step 1: Write the failing model test** + +Create `test_model.py`: + +```python +"""Tests for ledgerlite.model.""" + +import dataclasses +import unittest +from datetime import date +from decimal import Decimal + +from ledgerlite.model import Transaction + + +class TransactionTests(unittest.TestCase): + def test_holds_date_amount_and_description(self): + transaction = Transaction(date(2026, 3, 4), Decimal("-7.50"), "Coffee Bar") + self.assertEqual(transaction.date, date(2026, 3, 4)) + self.assertEqual(transaction.amount, Decimal("-7.50")) + self.assertEqual(transaction.description, "Coffee Bar") + + def test_is_frozen(self): + transaction = Transaction(date(2026, 3, 4), Decimal("1.00"), "x") + with self.assertRaises(dataclasses.FrozenInstanceError): + transaction.amount = Decimal("2.00") + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run it to make sure it fails** + +Run: `python3 -m unittest test_model -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite'` + +- [ ] **Step 3: Create the package and the model** + +Create `ledgerlite/__init__.py`: + +```python +"""ledgerlite: categorize bank transactions and summarize them.""" +``` + +Create `ledgerlite/model.py`: + +```python +"""The transaction data type.""" + +from dataclasses import dataclass +from datetime import date +from decimal import Decimal + + +@dataclass(frozen=True) +class Transaction: + """One line of a bank statement. Amounts are negative for money out.""" + + date: date + amount: Decimal + description: str +``` + +- [ ] **Step 4: Run it to make sure it passes** + +Run: `python3 -m unittest test_model -v` +Expected: PASS (2 tests) + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/__init__.py ledgerlite/model.py test_model.py +git commit -m "feat: add Transaction model" +``` + +- [ ] **Step 6: Write the failing field-validator tests** + +Create `test_parse.py` with the field-level tests only (the row-level tests arrive in Step 9): + +```python +"""Tests for ledgerlite.parse.""" + +import unittest +from datetime import date +from decimal import Decimal + +from ledgerlite.parse import ParseError, parse_amount, parse_date + + +class ParseDateTests(unittest.TestCase): + def test_accepts_iso_date(self): + self.assertEqual(parse_date("2026-03-04"), date(2026, 3, 4)) + + def test_strips_surrounding_whitespace(self): + self.assertEqual(parse_date(" 2026-03-04 "), date(2026, 3, 4)) + + def test_rejects_compact_form(self): + with self.assertRaises(ValueError) as caught: + parse_date("20260304") + self.assertIn("date is not an ISO 8601 date", str(caught.exception)) + + def test_rejects_unpadded_form(self): + with self.assertRaises(ValueError): + parse_date("2026-3-4") + + def test_rejects_empty(self): + with self.assertRaises(ValueError): + parse_date("") + + def test_rejects_impossible_calendar_date(self): + with self.assertRaises(ValueError) as caught: + parse_date("2026-02-30") + self.assertIn("not a valid calendar date", str(caught.exception)) + + +class ParseAmountTests(unittest.TestCase): + def test_accepts_negative_two_places(self): + self.assertEqual(parse_amount("-7.50"), Decimal("-7.50")) + + def test_accepts_one_place(self): + self.assertEqual(parse_amount("1.5"), Decimal("1.5")) + + def test_accepts_integer(self): + self.assertEqual(parse_amount("2500"), Decimal("2500")) + + def test_accepts_explicit_plus(self): + self.assertEqual(parse_amount("+5"), Decimal("5")) + + def test_strips_surrounding_whitespace(self): + self.assertEqual(parse_amount(" -7.50 "), Decimal("-7.50")) + + def test_rejects_three_fractional_digits(self): + with self.assertRaises(ValueError) as caught: + parse_amount("1.005") + self.assertIn("more than two fractional digits", str(caught.exception)) + + def test_rejects_non_numbers(self): + for raw in ["NaN", "Infinity", "1e2", "", "--5", "abc", "1,5"]: + with self.subTest(raw=raw): + with self.assertRaises(ValueError) as caught: + parse_amount(raw) + self.assertIn("not a decimal number", str(caught.exception)) + + def test_returns_decimal_not_float(self): + self.assertIsInstance(parse_amount("0.10"), Decimal) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 7: Run them to make sure they fail** + +Run: `python3 -m unittest test_parse -v` +Expected: FAIL — `ImportError: cannot import name 'ParseError' from 'ledgerlite.parse'` (module does not exist yet) + +- [ ] **Step 8: Write `parse.py` up to the field validators** + +Create `ledgerlite/parse.py`: + +```python +"""Parsing transaction CSV text into Transaction objects.""" + +import csv +import io +import re +from datetime import date +from decimal import Decimal + +from .model import Transaction + +HEADER = ("date", "amount", "description") + +_BOM = "\ufeff" +_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") +_AMOUNT_RE = re.compile(r"^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$") + + +class ParseError(Exception): + """A line of an input file is malformed.""" + + def __init__(self, line: int, message: str) -> None: + super().__init__(f"line {line}: {message}") + self.line = line + self.message = message + + +def parse_date(raw: str) -> date: + """Parse a strict YYYY-MM-DD date. Raises ValueError if malformed.""" + text = raw.strip() + if not _DATE_RE.match(text): + raise ValueError(f"date is not an ISO 8601 date (YYYY-MM-DD): {raw!r}") + try: + return date.fromisoformat(text) + except ValueError: + raise ValueError(f"date is not a valid calendar date: {raw!r}") from None + + +def parse_amount(raw: str) -> Decimal: + """Parse a decimal amount with at most two fractional digits.""" + text = raw.strip() + if not _AMOUNT_RE.match(text): + raise ValueError(f"amount is not a decimal number: {raw!r}") + _, _, fraction = text.partition(".") + if len(fraction) > 2: + raise ValueError(f"amount has more than two fractional digits: {raw!r}") + return Decimal(text) +``` + +Note: `date.fromisoformat` accepts `20260304`, so the regex gate is what makes the form strict — do not drop it. + +- [ ] **Step 9: Run them to make sure they pass** + +Run: `python3 -m unittest test_parse -v` +Expected: PASS (14 tests — `ParseDateTests` and `ParseAmountTests`) + +- [ ] **Step 10: Write the failing `parse_transactions` tests** + +Change the import line in `test_parse.py` to: + +```python +from ledgerlite.parse import ParseError, parse_amount, parse_date, parse_transactions +``` + +Then append these to `test_parse.py`, above the `if __name__` block: + +```python +GOOD_CSV = ( + "date,amount,description\n" + "2026-03-05,-900.00,Monthly Rent\n" + "2026-03-04,-7.50,Coffee Bar\n" + "2026-03-01,2500.00,Salary\n" +) + + +class ParseTransactionsTests(unittest.TestCase): + def test_returns_rows_in_input_order(self): + transactions = parse_transactions(GOOD_CSV) + self.assertEqual( + [(t.date, t.amount, t.description) for t in transactions], + [ + (date(2026, 3, 5), Decimal("-900.00"), "Monthly Rent"), + (date(2026, 3, 4), Decimal("-7.50"), "Coffee Bar"), + (date(2026, 3, 1), Decimal("2500.00"), "Salary"), + ], + ) + + def test_header_only_file_yields_no_transactions(self): + self.assertEqual(parse_transactions("date,amount,description\n"), []) + + def test_accepts_crlf_line_endings(self): + text = "date,amount,description\r\n2026-03-04,-7.50,Coffee Bar\r\n" + transactions = parse_transactions(text) + self.assertEqual(len(transactions), 1) + self.assertEqual(transactions[0].description, "Coffee Bar") + + def test_accepts_leading_bom(self): + transactions = parse_transactions("\ufeff" + GOOD_CSV) + self.assertEqual(len(transactions), 3) + + def test_accepts_header_with_padding_and_mixed_case(self): + text = "Date, Amount ,DESCRIPTION\n2026-03-04,-7.50,Coffee Bar\n" + self.assertEqual(len(parse_transactions(text)), 1) + + def test_keeps_quoted_description_verbatim(self): + text = 'date,amount,description\n2026-03-04,-7.50,"Cafe, ""The Bar"""\n' + self.assertEqual(parse_transactions(text)[0].description, 'Cafe, "The Bar"') + + def test_empty_file_is_missing_header(self): + with self.assertRaises(ParseError) as caught: + parse_transactions("") + self.assertEqual(caught.exception.line, 1) + self.assertEqual( + caught.exception.message, "expected header row date,amount,description" + ) + + def test_wrong_header_is_rejected(self): + with self.assertRaises(ParseError) as caught: + parse_transactions("when,how much,what\n") + self.assertEqual(caught.exception.line, 1) + self.assertIn("expected header row", caught.exception.message) + + def test_too_few_columns(self): + text = "date,amount,description\n2026-03-04,-7.50\n" + with self.assertRaises(ParseError) as caught: + parse_transactions(text) + self.assertEqual(caught.exception.line, 2) + self.assertEqual(caught.exception.message, "expected 3 columns, got 2") + + def test_too_many_columns(self): + text = "date,amount,description\n2026-03-04,-7.50,Coffee,extra\n" + with self.assertRaises(ParseError) as caught: + parse_transactions(text) + self.assertEqual(caught.exception.message, "expected 3 columns, got 4") + + def test_blank_line_inside_file(self): + text = "date,amount,description\n2026-03-04,-7.50,Coffee\n\n2026-03-05,1.00,x\n" + with self.assertRaises(ParseError) as caught: + parse_transactions(text) + self.assertEqual(caught.exception.line, 3) + self.assertEqual(caught.exception.message, "expected 3 columns, got 0") + + def test_bad_date_reports_its_line(self): + text = "date,amount,description\n2026-03-04,-7.50,Coffee\n20260305,1.00,x\n" + with self.assertRaises(ParseError) as caught: + parse_transactions(text) + self.assertEqual(caught.exception.line, 3) + self.assertIn("date is not an ISO 8601 date", caught.exception.message) + + def test_bad_amount_reports_its_line(self): + text = "date,amount,description\n2026-03-04,1.005,Coffee\n" + with self.assertRaises(ParseError) as caught: + parse_transactions(text) + self.assertEqual(caught.exception.line, 2) + self.assertIn("more than two fractional digits", caught.exception.message) + + def test_line_numbers_follow_embedded_newlines(self): + text = ( + "date,amount,description\n" + '2026-03-04,-7.50,"Cafe\nsecond line"\n' + "2026-03-05,oops,x\n" + ) + with self.assertRaises(ParseError) as caught: + parse_transactions(text) + self.assertEqual(caught.exception.line, 4) +``` + +- [ ] **Step 11: Run them to make sure they fail** + +Run: `python3 -m unittest test_parse -v` +Expected: FAIL — `ImportError: cannot import name 'parse_transactions'` (or `NotImplementedError` if you added the stub) + +- [ ] **Step 12: Implement `parse_transactions`** + +Append to `ledgerlite/parse.py`: + +```python +def parse_transactions(text: str) -> list[Transaction]: + """Parse transaction CSV text. Raises ParseError on the first bad line.""" + reader = csv.reader(io.StringIO(text.lstrip(_BOM), newline="")) + rows = iter(reader) + try: + header = next(rows) + except StopIteration: + raise ParseError(1, "expected header row date,amount,description") from None + if tuple(field.strip().lower() for field in header) != HEADER: + raise ParseError( + reader.line_num, "expected header row date,amount,description" + ) + + transactions = [] + for row in rows: + line = reader.line_num + if len(row) != 3: + raise ParseError(line, f"expected 3 columns, got {len(row)}") + raw_date, raw_amount, description = row + try: + when = parse_date(raw_date) + amount = parse_amount(raw_amount) + except ValueError as err: + raise ParseError(line, str(err)) from None + transactions.append(Transaction(when, amount, description)) + return transactions +``` + +- [ ] **Step 13: Run the whole suite** + +Run: `python3 -m unittest -v` +Expected: PASS — all of `test_model` and `test_parse` + +- [ ] **Step 14: Commit** + +```bash +git add ledgerlite/parse.py test_parse.py +git commit -m "feat: parse transaction CSV text into Transactions" +``` + +--- + +### Task 2: Rules parsing and categorization + +**Files:** +- Create: `ledgerlite/rules.py` +- Test: `test_rules.py` + +**Interfaces:** +- Consumes: `ledgerlite.parse.ParseError(line, message)` from Task 1. +- Produces: + - `ledgerlite.rules.parse_rules(text: str) -> list[tuple[str, str]]` — list of `(substring, category)` in file order; raises `ParseError`. + - `ledgerlite.rules.categorize(description: str, rules: list[tuple[str, str]]) -> str | None` — first matching rule's category, or `None`. + +- [ ] **Step 1: Write the failing tests** + +Create `test_rules.py`: + +```python +"""Tests for ledgerlite.rules.""" + +import unittest + +from ledgerlite.parse import ParseError +from ledgerlite.rules import categorize, parse_rules + + +class ParseRulesTests(unittest.TestCase): + def test_parses_one_rule_per_line_in_order(self): + self.assertEqual( + parse_rules("coffee=food\nrent=housing\n"), + [("coffee", "food"), ("rent", "housing")], + ) + + def test_skips_blank_and_whitespace_only_lines(self): + self.assertEqual( + parse_rules("\ncoffee=food\n \n\nrent=housing\n"), + [("coffee", "food"), ("rent", "housing")], + ) + + def test_strips_whitespace_around_both_halves(self): + self.assertEqual(parse_rules(" coffee = food \n"), [("coffee", "food")]) + + def test_splits_on_first_equals_only(self): + self.assertEqual(parse_rules("a=b=c\n"), [("a", "b=c")]) + + def test_accepts_leading_bom(self): + self.assertEqual(parse_rules("\ufeffcoffee=food\n"), [("coffee", "food")]) + + def test_empty_text_yields_no_rules(self): + self.assertEqual(parse_rules(""), []) + + def test_line_without_equals_is_malformed(self): + with self.assertRaises(ParseError) as caught: + parse_rules("coffee=food\njust some text\n") + self.assertEqual(caught.exception.line, 2) + self.assertIn("missing '='", caught.exception.message) + + def test_empty_substring_is_malformed(self): + with self.assertRaises(ParseError) as caught: + parse_rules("=food\n") + self.assertEqual(caught.exception.line, 1) + self.assertEqual(caught.exception.message, "rule has an empty substring") + + def test_empty_category_is_malformed(self): + with self.assertRaises(ParseError) as caught: + parse_rules("coffee=\n") + self.assertEqual(caught.exception.line, 1) + self.assertEqual(caught.exception.message, "rule has an empty category") + + +class CategorizeTests(unittest.TestCase): + RULES = [("coffee", "food"), ("rent", "housing")] + + def test_matches_substring_anywhere(self): + self.assertEqual(categorize("Corner Coffee Bar", self.RULES), "food") + + def test_matching_is_case_insensitive_in_description(self): + self.assertEqual(categorize("COFFEE BAR", self.RULES), "food") + + def test_matching_is_case_insensitive_in_rule(self): + self.assertEqual(categorize("coffee bar", [("COFFEE", "food")]), "food") + + def test_first_matching_rule_wins(self): + rules = [("coffee", "food"), ("coffee bar", "entertainment")] + self.assertEqual(categorize("Coffee Bar", rules), "food") + + def test_returns_none_when_nothing_matches(self): + self.assertIsNone(categorize("Salary", self.RULES)) + + def test_returns_none_with_no_rules(self): + self.assertIsNone(categorize("Coffee Bar", [])) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run them to make sure they fail** + +Run: `python3 -m unittest test_rules -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.rules'` + +- [ ] **Step 3: Implement `rules.py`** + +Create `ledgerlite/rules.py`: + +```python +"""Rules files: `=`, first match wins.""" + +from .parse import ParseError + +_BOM = "\ufeff" + + +def parse_rules(text: str) -> list[tuple[str, str]]: + """Parse rules text into (substring, category) pairs in file order.""" + rules: list[tuple[str, str]] = [] + for number, raw_line in enumerate(text.lstrip(_BOM).splitlines(), start=1): + line = raw_line.strip() + if not line: + continue + raw_substring, separator, raw_category = line.partition("=") + if not separator: + raise ParseError(number, f"rule is missing '=': {raw_line!r}") + substring = raw_substring.strip() + category = raw_category.strip() + if not substring: + raise ParseError(number, "rule has an empty substring") + if not category: + raise ParseError(number, "rule has an empty category") + rules.append((substring, category)) + return rules + + +def categorize(description: str, rules: list[tuple[str, str]]) -> str | None: + """Return the first matching rule's category, or None.""" + lowered = description.lower() + for substring, category in rules: + if substring.lower() in lowered: + return category + return None +``` + +- [ ] **Step 4: Run them to make sure they pass** + +Run: `python3 -m unittest test_rules -v` +Expected: PASS (15 tests) + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/rules.py test_rules.py +git commit -m "feat: parse rules files and categorize descriptions" +``` + +--- + +### Task 3: Date ordering and balances + +**Files:** +- Create: `ledgerlite/balance.py` +- Test: `test_balance.py` + +**Interfaces:** +- Consumes: `ledgerlite.model.Transaction` from Task 1. +- Produces: + - `ledgerlite.balance.order_by_date(transactions: list[Transaction]) -> list[Transaction]` — stable sort by date; input list untouched. + - `ledgerlite.balance.running_balances(opening: Decimal, transactions: list[Transaction]) -> list[Decimal]` — one balance per transaction, in date order. + - `ledgerlite.balance.closing_balance(opening: Decimal, transactions: list[Transaction]) -> Decimal` — the last running balance, or `opening` when there are no transactions. + +- [ ] **Step 1: Write the failing tests** + +Create `test_balance.py`: + +```python +"""Tests for ledgerlite.balance.""" + +import unittest +from datetime import date +from decimal import Decimal + +from ledgerlite.balance import closing_balance, order_by_date, running_balances +from ledgerlite.model import Transaction + + +def txn(day, amount, description="x"): + return Transaction(date(2026, 3, day), Decimal(amount), description) + + +class OrderByDateTests(unittest.TestCase): + def test_sorts_by_date(self): + transactions = [txn(5, "-900.00"), txn(1, "2500.00"), txn(4, "-7.50")] + self.assertEqual( + [t.date.day for t in order_by_date(transactions)], [1, 4, 5] + ) + + def test_ties_keep_input_order(self): + first = txn(4, "1.00", "first") + second = txn(4, "2.00", "second") + ordered = order_by_date([second, first]) + self.assertEqual([t.description for t in ordered], ["second", "first"]) + + def test_does_not_mutate_input(self): + transactions = [txn(5, "1.00"), txn(1, "2.00")] + order_by_date(transactions) + self.assertEqual([t.date.day for t in transactions], [5, 1]) + + def test_empty_list(self): + self.assertEqual(order_by_date([]), []) + + +class RunningBalancesTests(unittest.TestCase): + def test_accumulates_in_date_order(self): + transactions = [txn(5, "-900.00"), txn(1, "2500.00"), txn(4, "-7.50")] + self.assertEqual( + running_balances(Decimal("100"), transactions), + [Decimal("2600.00"), Decimal("2592.50"), Decimal("1692.50")], + ) + + def test_empty_list_has_no_balances(self): + self.assertEqual(running_balances(Decimal("100"), []), []) + + def test_arithmetic_is_exact_decimal(self): + transactions = [txn(1, "0.10"), txn(2, "0.20")] + self.assertEqual(running_balances(Decimal("0"), transactions)[-1], Decimal("0.30")) + + +class ClosingBalanceTests(unittest.TestCase): + def test_is_balance_after_last_transaction(self): + transactions = [txn(5, "-900.00"), txn(1, "2500.00"), txn(4, "-7.50")] + self.assertEqual(closing_balance(Decimal("100"), transactions), Decimal("1692.50")) + + def test_is_opening_when_there_are_no_transactions(self): + self.assertEqual(closing_balance(Decimal("100"), []), Decimal("100")) + + def test_default_opening_of_zero(self): + self.assertEqual(closing_balance(Decimal("0"), [txn(1, "-12.50")]), Decimal("-12.50")) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run them to make sure they fail** + +Run: `python3 -m unittest test_balance -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.balance'` + +- [ ] **Step 3: Implement `balance.py`** + +Create `ledgerlite/balance.py`: + +```python +"""Date ordering and running/closing balances.""" + +from decimal import Decimal + +from .model import Transaction + + +def order_by_date(transactions: list[Transaction]) -> list[Transaction]: + """Return the transactions sorted by date, ties keeping input order.""" + return sorted(transactions, key=lambda transaction: transaction.date) + + +def running_balances( + opening: Decimal, transactions: list[Transaction] +) -> list[Decimal]: + """Return the balance after each transaction, in date order.""" + balances = [] + balance = opening + for transaction in order_by_date(transactions): + balance += transaction.amount + balances.append(balance) + return balances + + +def closing_balance(opening: Decimal, transactions: list[Transaction]) -> Decimal: + """Return the balance after the last transaction, or the opening amount.""" + balances = running_balances(opening, transactions) + return balances[-1] if balances else opening +``` + +`sorted` is stable, which is exactly what "ties keeping input order" requires — do not add a secondary sort key. + +- [ ] **Step 4: Run them to make sure they pass** + +Run: `python3 -m unittest test_balance -v` +Expected: PASS (10 tests) + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/balance.py test_balance.py +git commit -m "feat: add date ordering and balance calculation" +``` + +--- + +### Task 4: Per-category totals and report formatting + +**Files:** +- Create: `ledgerlite/report.py` +- Test: `test_report.py` + +**Interfaces:** +- Consumes: `Transaction` (Task 1), `categorize` (Task 2), `closing_balance` (Task 3). +- Produces: + - `ledgerlite.report.UNCATEGORIZED` — the string `"uncategorized"`. + - `ledgerlite.report.format_amount(amount: Decimal) -> str` — two fractional digits, `-` only for negatives, no separators. + - `ledgerlite.report.category_totals(transactions, rules) -> list[tuple[str, Decimal]]` — categories with at least one transaction, case-insensitively alphabetical, `uncategorized` last. + - `ledgerlite.report.format_report(transactions, rules, opening: Decimal) -> str` — the whole report, ending in exactly one `\n`. + +- [ ] **Step 1: Write the failing tests** + +Create `test_report.py`: + +```python +"""Tests for ledgerlite.report.""" + +import unittest +from datetime import date +from decimal import Decimal + +from ledgerlite.model import Transaction +from ledgerlite.report import ( + UNCATEGORIZED, + category_totals, + format_amount, + format_report, +) + + +def txn(day, amount, description): + return Transaction(date(2026, 3, day), Decimal(amount), description) + + +RULES = [("coffee", "food"), ("rent", "housing")] +EXAMPLE = [ + txn(5, "-900.00", "Monthly Rent"), + txn(4, "-7.50", "Coffee Bar"), + txn(1, "2500.00", "Salary"), +] + + +class FormatAmountTests(unittest.TestCase): + def test_negative(self): + self.assertEqual(format_amount(Decimal("-12.50")), "-12.50") + + def test_zero(self): + self.assertEqual(format_amount(Decimal("0")), "0.00") + + def test_negative_zero_has_no_sign(self): + self.assertEqual(format_amount(Decimal("-0.00")), "0.00") + + def test_pads_to_two_places(self): + self.assertEqual(format_amount(Decimal("1.5")), "1.50") + self.assertEqual(format_amount(Decimal("1200")), "1200.00") + + def test_no_thousands_separators(self): + self.assertEqual(format_amount(Decimal("1234567.89")), "1234567.89") + + +class CategoryTotalsTests(unittest.TestCase): + def test_sums_per_category_alphabetically_with_uncategorized_last(self): + self.assertEqual( + category_totals(EXAMPLE, RULES), + [ + ("food", Decimal("-7.50")), + ("housing", Decimal("-900.00")), + (UNCATEGORIZED, Decimal("2500.00")), + ], + ) + + def test_sums_several_transactions_in_one_category(self): + transactions = [txn(1, "-7.50", "Coffee Bar"), txn(2, "-3.25", "COFFEE hut")] + self.assertEqual(category_totals(transactions, RULES), [("food", Decimal("-10.75"))]) + + def test_omits_uncategorized_when_everything_matches(self): + transactions = [txn(1, "-7.50", "Coffee Bar")] + self.assertEqual( + [name for name, _ in category_totals(transactions, RULES)], ["food"] + ) + + def test_all_uncategorized_without_rules(self): + self.assertEqual( + category_totals(EXAMPLE, []), [(UNCATEGORIZED, Decimal("1592.50"))] + ) + + def test_no_transactions_yields_no_lines(self): + self.assertEqual(category_totals([], RULES), []) + + def test_alphabetical_order_is_case_insensitive(self): + rules = [("a", "Zebra"), ("b", "apple")] + transactions = [txn(1, "1.00", "a"), txn(2, "2.00", "b")] + self.assertEqual( + [name for name, _ in category_totals(transactions, rules)], + ["apple", "Zebra"], + ) + + def test_rule_category_named_uncategorized_merges_and_stays_last(self): + rules = [("coffee", "uncategorized"), ("rent", "housing")] + transactions = [ + txn(1, "-7.50", "Coffee Bar"), + txn(2, "-900.00", "Monthly Rent"), + txn(3, "2500.00", "Salary"), + ] + self.assertEqual( + category_totals(transactions, rules), + [("housing", Decimal("-900.00")), (UNCATEGORIZED, Decimal("2492.50"))], + ) + + def test_totals_are_exact_decimals(self): + transactions = [txn(1, "0.10", "Coffee"), txn(2, "0.20", "Coffee")] + self.assertEqual(category_totals(transactions, RULES), [("food", Decimal("0.30"))]) + + +class FormatReportTests(unittest.TestCase): + def test_matches_the_design_example(self): + self.assertEqual( + format_report(EXAMPLE, RULES, Decimal("100")), + "food: -7.50\n" + "housing: -900.00\n" + "uncategorized: 2500.00\n" + "\n" + "closing balance: 1692.50\n", + ) + + def test_no_transactions_prints_blank_line_then_closing_balance(self): + self.assertEqual( + format_report([], RULES, Decimal("100")), "\nclosing balance: 100.00\n" + ) + + def test_default_opening_of_zero(self): + self.assertEqual( + format_report([txn(1, "-7.50", "Coffee Bar")], RULES, Decimal("0")), + "food: -7.50\n\nclosing balance: -7.50\n", + ) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run them to make sure they fail** + +Run: `python3 -m unittest test_report -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.report'` + +- [ ] **Step 3: Implement `report.py`** + +Create `ledgerlite/report.py`: + +```python +"""Per-category totals and report text.""" + +from decimal import Decimal + +from .balance import closing_balance +from .model import Transaction +from .rules import categorize + +UNCATEGORIZED = "uncategorized" + +_CENTS = Decimal("0.01") + + +def format_amount(amount: Decimal) -> str: + """Format an amount with exactly two fractional digits, no separators.""" + quantized = amount.quantize(_CENTS) + if quantized == 0: + quantized = abs(quantized) # turn Decimal("-0.00") into Decimal("0.00") + return f"{quantized:f}" + + +def category_totals( + transactions: list[Transaction], rules: list[tuple[str, str]] +) -> list[tuple[str, Decimal]]: + """Total each category, alphabetically, with uncategorized last.""" + totals: dict[str, Decimal] = {} + for transaction in transactions: + category = categorize(transaction.description, rules) + name = UNCATEGORIZED if category is None else category + totals[name] = totals.get(name, Decimal(0)) + transaction.amount + + named = sorted( + (name for name in totals if name != UNCATEGORIZED), + key=lambda name: (name.lower(), name), + ) + lines = [(name, totals[name]) for name in named] + if UNCATEGORIZED in totals: + lines.append((UNCATEGORIZED, totals[UNCATEGORIZED])) + return lines + + +def format_report( + transactions: list[Transaction], + rules: list[tuple[str, str]], + opening: Decimal, +) -> str: + """Render the whole report, ending in a single newline.""" + lines = [ + f"{name}: {format_amount(total)}" + for name, total in category_totals(transactions, rules) + ] + lines.append("") + lines.append( + f"closing balance: {format_amount(closing_balance(opening, transactions))}" + ) + return "\n".join(lines) + "\n" +``` + +- [ ] **Step 4: Run them to make sure they pass** + +Run: `python3 -m unittest test_report -v` +Expected: PASS (16 tests) + +- [ ] **Step 5: Run the whole suite** + +Run: `python3 -m unittest -v` +Expected: PASS — model, parse, rules, balance, report + +- [ ] **Step 6: Commit** + +```bash +git add ledgerlite/report.py test_report.py +git commit -m "feat: add per-category totals and report formatting" +``` + +--- + +### Task 5: CLI, exit codes, and error messages + +Wires everything together: argument parsing, the only file reads in the package, the two error message forms, and the `python3 -m ledgerlite` shim. `__main__.py` is not in the spec's layout list; without it the documented command line cannot be invoked at all, so it is included as a two-line delegation to `cli.main`. + +**Files:** +- Create: `ledgerlite/cli.py` +- Create: `ledgerlite/__main__.py` +- Test: `test_cli.py` + +**Interfaces:** +- Consumes: `parse_amount`, `parse_transactions`, `ParseError` (Task 1); `parse_rules` (Task 2); `format_report` (Task 4). +- Produces: `ledgerlite.cli.main(argv: list[str] | None = None) -> int` — writes the report to stdout, errors to stderr, and returns the exit code. It never raises `SystemExit`; argparse's exit is caught and returned as an int. + +- [ ] **Step 1: Write the failing tests** + +Create `test_cli.py`: + +```python +"""End-to-end tests for ledgerlite.cli.""" + +import contextlib +import io +import os +import tempfile +import unittest + +from ledgerlite.cli import main + +GOOD_CSV = ( + "date,amount,description\n" + "2026-03-05,-900.00,Monthly Rent\n" + "2026-03-04,-7.50,Coffee Bar\n" + "2026-03-01,2500.00,Salary\n" +) +GOOD_RULES = "coffee=food\nrent=housing\n" + + +class CliTestCase(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + + def write(self, name, text, encoding="utf-8"): + path = os.path.join(self.directory.name, name) + with open(path, "w", encoding=encoding, newline="") as handle: + handle.write(text) + return path + + def run_cli(self, *argv): + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = main(list(argv)) + return code, out.getvalue(), err.getvalue() + + +class SuccessTests(CliTestCase): + def test_reports_the_design_example(self): + transactions = self.write("t.csv", GOOD_CSV) + rules = self.write("r.txt", GOOD_RULES) + code, out, err = self.run_cli( + "report", transactions, "--rules", rules, "--opening", "100" + ) + self.assertEqual(code, 0) + self.assertEqual(err, "") + self.assertEqual( + out, + "food: -7.50\n" + "housing: -900.00\n" + "uncategorized: 2500.00\n" + "\n" + "closing balance: 1692.50\n", + ) + + def test_opening_defaults_to_zero(self): + transactions = self.write("t.csv", GOOD_CSV) + code, out, _ = self.run_cli("report", transactions, "--rules", self.write("r.txt", GOOD_RULES)) + self.assertEqual(code, 0) + self.assertIn("closing balance: 1592.50\n", out) + + def test_without_rules_everything_is_uncategorized(self): + transactions = self.write("t.csv", GOOD_CSV) + code, out, _ = self.run_cli("report", transactions) + self.assertEqual(code, 0) + self.assertEqual(out, "uncategorized: 1592.50\n\nclosing balance: 1592.50\n") + + def test_negative_opening(self): + transactions = self.write("t.csv", "date,amount,description\n") + code, out, _ = self.run_cli("report", transactions, "--opening", "-12.50") + self.assertEqual(code, 0) + self.assertEqual(out, "\nclosing balance: -12.50\n") + + +class UnreadableFileTests(CliTestCase): + def test_missing_transactions_file(self): + path = os.path.join(self.directory.name, "nope.csv") + code, out, err = self.run_cli("report", path) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertEqual(err, f"ledgerlite: cannot read {path}: No such file or directory\n") + + def test_missing_rules_file(self): + transactions = self.write("t.csv", GOOD_CSV) + path = os.path.join(self.directory.name, "nope.txt") + code, out, err = self.run_cli("report", transactions, "--rules", path) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertEqual(err, f"ledgerlite: cannot read {path}: No such file or directory\n") + + def test_directory_instead_of_file(self): + code, out, err = self.run_cli("report", self.directory.name) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertTrue(err.startswith(f"ledgerlite: cannot read {self.directory.name}: ")) + + def test_non_utf8_file(self): + path = os.path.join(self.directory.name, "binary.csv") + with open(path, "wb") as handle: + handle.write(b"date,amount,description\n2026-03-04,-7.50,\xff\xfe\n") + code, out, err = self.run_cli("report", path) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertEqual(err, f"ledgerlite: cannot read {path}: not valid UTF-8 text\n") + + +class MalformedInputTests(CliTestCase): + def test_bad_amount_reports_path_line_and_reason(self): + path = self.write("t.csv", "date,amount,description\n2026-03-04,1.005,Coffee\n") + code, out, err = self.run_cli("report", path) + self.assertEqual(code, 2) + self.assertEqual(out, "") + self.assertEqual( + err, + f"ledgerlite: {path}:2: amount has more than two fractional digits: '1.005'\n", + ) + + def test_wrong_column_count(self): + path = self.write("t.csv", "date,amount,description\n2026-03-04,-7.50\n") + code, out, err = self.run_cli("report", path) + self.assertEqual(code, 2) + self.assertEqual(err, f"ledgerlite: {path}:2: expected 3 columns, got 2\n") + + def test_empty_file(self): + path = self.write("t.csv", "") + code, out, err = self.run_cli("report", path) + self.assertEqual(code, 2) + self.assertEqual( + err, f"ledgerlite: {path}:1: expected header row date,amount,description\n" + ) + + def test_whole_file_is_rejected_so_stdout_stays_empty(self): + path = self.write( + "t.csv", + "date,amount,description\n2026-03-04,-7.50,Coffee\n2026-03-05,oops,x\n", + ) + code, out, _ = self.run_cli("report", path) + self.assertEqual(code, 2) + self.assertEqual(out, "") + + def test_malformed_rules_file(self): + transactions = self.write("t.csv", GOOD_CSV) + rules = self.write("r.txt", "coffee=food\njust some text\n") + code, out, err = self.run_cli("report", transactions, "--rules", rules) + self.assertEqual(code, 2) + self.assertEqual(out, "") + self.assertEqual( + err, f"ledgerlite: {rules}:2: rule is missing '=': 'just some text'\n" + ) + + +class UsageErrorTests(CliTestCase): + def test_invalid_opening_is_a_usage_error(self): + transactions = self.write("t.csv", GOOD_CSV) + code, out, err = self.run_cli("report", transactions, "--opening", "abc") + self.assertEqual(code, 2) + self.assertEqual(out, "") + self.assertIn("not a decimal number", err) + + def test_missing_subcommand_is_a_usage_error(self): + code, out, err = self.run_cli() + self.assertEqual(code, 2) + self.assertEqual(out, "") + self.assertIn("usage: ledgerlite", err) + + def test_unknown_subcommand_is_a_usage_error(self): + code, _, err = self.run_cli("summarize", "t.csv") + self.assertEqual(code, 2) + self.assertIn("usage: ledgerlite", err) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run them to make sure they fail** + +Run: `python3 -m unittest test_cli -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.cli'` + +- [ ] **Step 3: Implement `cli.py`** + +Create `ledgerlite/cli.py`: + +```python +"""Command-line interface.""" + +import argparse +import sys +from decimal import Decimal +from typing import Callable + +from .parse import ParseError, parse_amount, parse_transactions +from .report import format_report +from .rules import parse_rules + +PROG = "ledgerlite" + + +class _Failure(Exception): + """An input file could not be read or parsed.""" + + def __init__(self, message: str, code: int) -> None: + super().__init__(message) + self.message = message + self.code = code + + +def _opening_amount(raw: str) -> Decimal: + try: + return parse_amount(raw) + except ValueError as err: + raise argparse.ArgumentTypeError(str(err)) from None + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog=PROG, description="Summarize bank transactions by category." + ) + subparsers = parser.add_subparsers(dest="command", required=True) + report = subparsers.add_parser("report", help="print a per-category summary") + report.add_argument("transactions", metavar="TRANSACTIONS", help="transactions CSV") + report.add_argument("--rules", metavar="RULES", help="rules file") + report.add_argument( + "--opening", + metavar="AMOUNT", + type=_opening_amount, + default=Decimal(0), + help="opening balance (default: 0)", + ) + return parser + + +def _load(path: str, parse: Callable[[str], object]) -> object: + try: + with open(path, encoding="utf-8", newline="") as handle: + text = handle.read() + except OSError as err: + reason = err.strerror or str(err) + raise _Failure(f"cannot read {path}: {reason}", 1) from None + except UnicodeDecodeError: + raise _Failure(f"cannot read {path}: not valid UTF-8 text", 1) from None + try: + return parse(text) + except ParseError as err: + raise _Failure(f"{path}:{err.line}: {err.message}", 2) from None + + +def main(argv: list[str] | None = None) -> int: + """Run the CLI and return the exit code.""" + parser = _build_parser() + try: + args = parser.parse_args(argv) + except SystemExit as exit_request: # argparse already printed usage to stderr + return int(exit_request.code or 0) + + try: + transactions = _load(args.transactions, parse_transactions) + rules = _load(args.rules, parse_rules) if args.rules else [] + except _Failure as failure: + print(f"{PROG}: {failure.message}", file=sys.stderr) + return failure.code + + sys.stdout.write(format_report(transactions, rules, args.opening)) + return 0 +``` + +- [ ] **Step 4: Create the module entry point** + +Create `ledgerlite/__main__.py`: + +```python +"""Entry point for `python3 -m ledgerlite`.""" + +from .cli import main + +if __name__ == "__main__": + raise SystemExit(main()) +``` + +- [ ] **Step 5: Run the CLI tests to make sure they pass** + +Run: `python3 -m unittest test_cli -v` +Expected: PASS (all 16 tests) + +- [ ] **Step 6: Run the whole suite** + +Run: `python3 -m unittest -v` +Expected: PASS — every test module, no errors, no skips + +- [ ] **Step 7: Verify the design's worked example by hand** + +Run: + +```bash +printf 'date,amount,description\n2026-03-05,-900.00,Monthly Rent\n2026-03-04,-7.50,Coffee Bar\n2026-03-01,2500.00,Salary\n' > /tmp/ledgerlite-t.csv +printf 'coffee=food\nrent=housing\n' > /tmp/ledgerlite-r.txt +python3 -m ledgerlite report /tmp/ledgerlite-t.csv --rules /tmp/ledgerlite-r.txt --opening 100 +echo "exit: $?" +``` + +Expected output, exactly: + +``` +food: -7.50 +housing: -900.00 +uncategorized: 2500.00 + +closing balance: 1692.50 +exit: 0 +``` + +Then check the two error paths: + +```bash +python3 -m ledgerlite report /tmp/does-not-exist.csv; echo "exit: $?" +printf 'date,amount,description\n2026-03-04,1.005,Coffee\n' > /tmp/ledgerlite-bad.csv +python3 -m ledgerlite report /tmp/ledgerlite-bad.csv; echo "exit: $?" +``` + +Expected: `ledgerlite: cannot read /tmp/does-not-exist.csv: No such file or directory` with `exit: 1`, then `ledgerlite: /tmp/ledgerlite-bad.csv:2: amount has more than two fractional digits: '1.005'` with `exit: 2`. + +- [ ] **Step 8: Commit** + +```bash +git add ledgerlite/cli.py ledgerlite/__main__.py test_cli.py +git commit -m "feat: add ledgerlite report CLI" +``` + +--- + +## Done when + +- `python3 -m unittest` passes from the repo root with no failures, errors, or skips. +- `python3 -m ledgerlite report ...` reproduces the design example byte-for-byte, and the two error forms match the spec exactly with exit codes 1 and 2. +- No module imports anything outside the standard library; no `float` and no `round()` appears anywhere in `ledgerlite/`. Check with `grep -rn "float\|round(" ledgerlite/` — expect no matches. diff --git a/tests/inline-eval/results/2026-09-17-ledgerlite-reasonable-person/wpplan-12.txt b/tests/inline-eval/results/2026-09-17-ledgerlite-reasonable-person/wpplan-12.txt new file mode 100644 index 000000000..e3a4cf400 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-ledgerlite-reasonable-person/wpplan-12.txt @@ -0,0 +1,25 @@ +# rep wpplan-12 transcript=/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/inline-eval-br/reps/wpplan-12/cfg/projects/-private-tmp-claude-501--Users-jesse-git-superpowers-superpowers-a8a313bf-7410-41ce-9036-636ccf4061d6-scratchpad-inline-eval-br-reps-wpplan-12-repo/4cee3ce3-2733-47d6-8bfa-af23aec8cbeb.jsonl +skill executing-plans invoked False +all skills invoked writing-plans +Agent dispatches 0 +test runs 0 +ledger (progress.md) touches 0 +sdd script invocations 0 +commits (incl. fixture) 1 +suite in repo plan.md-present +probe review-focus handled +probe implied-header handled +probe implied-encoding handled +probe size plan= 1365 review-focus-lines=15 tests=87 tasks +user_prompt_submit events 1 (background-agent wakeups count too) +final: 'Rulings I made' False +final: mentions self-review False +mid-run check-in candidates 0 +main session tokens 8 msgs; out=39298 cache_read=300909 cache_create=56406 in=16 +subagent tokens 0 agents; out=0 cache_read=0 cache_create=0 in=0 +TOTAL tokens out=39298 cache_read=300909 cache_create=56406 in=16 + +# assistant text, in order (truncated) +[ 1] I'll start by invoking the writing-plans skill and reading the design. +[ 9] Behaviors confirmed. Writing the plan. +[ 24] Plan saved to `plan.md` (repo root, uncommitted on `main`). **What it covers:** 5 tasks in dependency order — (1) `model.py` + `parse.py` with strict date/amount validators and the CSV row loop, (2) `rules.py` parsing an diff --git a/tests/inline-eval/results/2026-09-17-ledgerlite-reasonable-person/wpplan-13.plan.md b/tests/inline-eval/results/2026-09-17-ledgerlite-reasonable-person/wpplan-13.plan.md new file mode 100644 index 000000000..b7e3f617c --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-ledgerlite-reasonable-person/wpplan-13.plan.md @@ -0,0 +1,1307 @@ +# ledgerlite Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build `ledgerlite`, a standard-library-only Python CLI that reads a transactions CSV, categorizes each row from a rules file, and prints per-category totals plus the closing balance. + +**Architecture:** Six small modules with one responsibility each, layered so nothing points back up: `model` (data), `parse` (CSV text → transactions, `ParseError`), `rules` (rules text → rule list, categorization), `balance` (date ordering, closing balance), `report` (totals, formatting), `cli` (argparse, file I/O, exit codes, stderr messages). All pure functions take and return values — text in, text out — so every layer is testable without touching the filesystem. Only `cli.py` opens files, prints, or returns exit codes. + +**Tech Stack:** Python 3.11+, standard library only (`csv`, `argparse`, `decimal`, `datetime`, `dataclasses`, `re`, `unittest`). No third-party packages, no build tooling. + +**Spec:** `design.md` (this repo, alongside this plan) + +## Global Constraints + +- Python 3.11+. Standard library only — no third-party imports in package or test code. +- Money is always `decimal.Decimal`. Never `float`, never `round()`, no arithmetic on strings-as-numbers. A `float` anywhere in the money path is a defect. +- Package layout is exactly as the spec's "Package layout" section gives it: `ledgerlite/` containing `__init__.py`, `model.py`, `parse.py`, `rules.py`, `balance.py`, `report.py`, `cli.py`. Do not add modules the spec does not list. +- Tests live at the repo root as `test_.py` and run with `python3 -m unittest`. +- CLI surface, verbatim: `ledgerlite report TRANSACTIONS [--rules RULES] [--opening AMOUNT]` +- Exit codes: `0` success, `1` a named file cannot be read, `2` a malformed input row. +- Unreadable-file message, verbatim shape: `ledgerlite: cannot read : ` on stderr. +- Malformed-row message, verbatim shape: `ledgerlite: :: ` on stderr. +- On exit 2 the whole file is rejected and **nothing** is written to stdout. +- Amounts print with exactly two fractional digits, a leading `-` only for negatives, and no thousands separators: `-12.50`, `0.00`, `1200.00`. +- `--opening` defaults to `0`. `--rules` is optional; without it every transaction is uncategorized. +- Work directly on `main`. Commit at the end of every task. + +## Review Focus + +The spec fixes the happy path and the two error paths. These are inputs it does +not name but that the program will meet; each line states the behavior a +reasonable person would expect, and each has its test assigned to the task that +owns the code. Ordered by how likely each is to bite someone. + +1. **Trailing newline / blank lines in the CSV.** Nearly every real file ends with a newline; a blank line must not be reported as a malformed 0-column row. Skip lines that are entirely empty. → Task 2. +2. **Trailing newline / blank lines in the rules file.** Same reasoning: blank lines are skipped, not errors. → Task 3. +3. **Amounts that are numeric to Python but not decimal numbers.** `Decimal("NaN")`, `Decimal("Infinity")`, and `Decimal("1e2")` all parse without complaint and would silently corrupt totals. Validate the text with a regex before constructing the `Decimal`, so these are malformed. → Task 2. +4. **A missing or wrong header row.** The spec says the CSV *has* a header row. Blindly skipping the first line would silently drop a real transaction, so the header is validated (case-insensitive, whitespace-tolerant) and a file lacking it is malformed. → Task 2. +5. **An unreadable `--rules` file.** The spec's exit-1 rule names only TRANSACTIONS, but a missing rules file is the same user mistake and deserves the same message and code. → Task 6. +6. **A malformed rules line** (no `=`, empty substring, empty category). Silently ignoring it would produce a confidently wrong report; treat it as a malformed row: the exit-2 message shape already carries a path, so it fits the rules file too. → Task 3. +7. **Negative zero.** `Decimal("-0.00")` formats as `-0.00` under `:.2f`, and the spec allows a leading `-` only for negatives. Zero prints `0.00`. → Task 5. +8. **A rule whose category is literally `uncategorized`.** Two buckets with one name would print the name twice; merge into the single `uncategorized` bucket, still listed last. → Task 5. +9. **Whitespace around `date` and `amount` fields.** `2026-03-04, -7.50` is a normal hand-edited CSV; strip those two fields before validating. `description` is free text and is preserved byte-for-byte. → Task 2. +10. **A non-UTF-8 or non-file path.** A directory or a binary file must produce the exit-1 message, not a traceback (`UnicodeDecodeError` is not an `OSError`, so it needs its own catch). → Task 6. +11. **An invalid `--opening` value.** Reuse the amount validator so `--opening abc` fails as an argparse usage error (exit 2) instead of crashing. → Task 6. +12. **A report with no categories at all** (header-only CSV). Emitting the spec's blank separator line would open the report with an empty line; print only the `closing balance` line. → Task 5. + +--- + +## Task 1: Package skeleton and the Transaction model + +**Files:** +- Create: `ledgerlite/__init__.py` +- Create: `ledgerlite/model.py` +- Create: `.gitignore` +- Test: `test_model.py` + +**Interfaces:** +- Consumes: nothing. +- Produces: `ledgerlite.model.Transaction`, a frozen dataclass with fields + `date: datetime.date`, `amount: decimal.Decimal`, `description: str`, in that + positional order. Every later task builds and reads these. + +Note on style: the field is annotated `datetime.date` (module-qualified) rather +than importing the `date` name, because a field named `date` annotated with a +type named `date` is a shadowing trap. Keep `import datetime`. + +- [ ] **Step 1: Write the failing test** + +Create `test_model.py`: + +```python +import dataclasses +import datetime +import unittest +from decimal import Decimal + +from ledgerlite.model import Transaction + + +class TransactionTests(unittest.TestCase): + def test_holds_date_amount_and_description(self): + txn = Transaction( + date=datetime.date(2026, 3, 4), + amount=Decimal("-7.50"), + description="Coffee Shop", + ) + self.assertEqual(txn.date, datetime.date(2026, 3, 4)) + self.assertEqual(txn.amount, Decimal("-7.50")) + self.assertEqual(txn.description, "Coffee Shop") + + def test_accepts_positional_arguments_in_spec_order(self): + txn = Transaction(datetime.date(2026, 3, 4), Decimal("2500.00"), "Salary") + self.assertEqual(txn.description, "Salary") + + def test_is_frozen(self): + txn = Transaction(datetime.date(2026, 3, 4), Decimal("1.00"), "x") + with self.assertRaises(dataclasses.FrozenInstanceError): + txn.amount = Decimal("2.00") + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `python3 -m unittest test_model -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite'`. + +- [ ] **Step 3: Write the minimal implementation** + +Create `ledgerlite/__init__.py`: + +```python +"""ledgerlite - categorize bank transactions and summarize them.""" +``` + +Create `ledgerlite/model.py`: + +```python +"""The one data type the rest of the package passes around.""" + +import datetime +from dataclasses import dataclass +from decimal import Decimal + + +@dataclass(frozen=True) +class Transaction: + """One row of the transactions CSV, already validated. + + `amount` is negative for money out, positive for money in, and is always a + Decimal - never a float. + """ + + date: datetime.date + amount: Decimal + description: str +``` + +Create `.gitignore`: + +```gitignore +__pycache__/ +*.pyc +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `python3 -m unittest test_model -v` +Expected: PASS, 3 tests. + +- [ ] **Step 5: Commit** + +```bash +git add .gitignore ledgerlite/__init__.py ledgerlite/model.py test_model.py +git commit -m "feat: add ledgerlite package skeleton and Transaction model" +``` + +--- + +## Task 2: Parse the transactions CSV + +**Files:** +- Create: `ledgerlite/parse.py` +- Test: `test_parse.py` + +**Interfaces:** +- Consumes: `ledgerlite.model.Transaction` (Task 1). +- Produces: + - `ParseError(Exception)` with attributes `.line: int` and `.message: str`; `str(e)` is `": "`. Task 3 raises this same class; Task 6 formats it. + - `parse_amount(text: str) -> Decimal` — raises `ValueError` whose message is the `` text. Task 6 reuses it for `--opening`. + - `parse_date(text: str) -> datetime.date` — raises `ValueError` likewise. + - `parse_transactions(text: str) -> list[Transaction]` — takes CSV **text**, not a path; raises `ParseError` on the first bad row. Input order is preserved. + +Design notes for the implementer: + +- Takes text, not a path, so tests never touch the filesystem. Task 6 does the reading. +- Line numbers come from `csv.reader.line_num` (physical lines consumed), so a quoted field containing a newline still reports a sane line. The header is line 1, so the first data row is line 2. +- Amount validity is decided by a regex *before* `Decimal(...)`, because `Decimal` happily accepts `NaN`, `Infinity`, and `1e2`. Accept an optional sign, then either digits, or optional digits with a dot and one-or-two fractional digits: `5`, `+5`, `-5`, `1.5`, `1.50`, `.50`. Reject `1.005`, `5.`, `1e2`, `1,000.00`, `NaN`, `Infinity`, `abc`, and the empty string. +- An amount with three or more fractional digits gets its own message, since the spec calls that case out by name. + +- [ ] **Step 1: Write the failing tests** + +Create `test_parse.py`: + +```python +import datetime +import unittest +from decimal import Decimal + +from ledgerlite.parse import ParseError, parse_amount, parse_date, parse_transactions + +HEADER = "date,amount,description\n" + + +class ParseAmountTests(unittest.TestCase): + def test_accepts_one_or_two_fractional_digits(self): + self.assertEqual(parse_amount("1.5"), Decimal("1.5")) + self.assertEqual(parse_amount("1.50"), Decimal("1.50")) + + def test_accepts_integers_and_signs(self): + self.assertEqual(parse_amount("5"), Decimal("5")) + self.assertEqual(parse_amount("+5"), Decimal("5")) + self.assertEqual(parse_amount("-900.00"), Decimal("-900.00")) + self.assertEqual(parse_amount(".50"), Decimal("0.50")) + + def test_returns_decimal_not_float(self): + self.assertIsInstance(parse_amount("0.10"), Decimal) + + def test_rejects_more_than_two_fractional_digits(self): + with self.assertRaises(ValueError) as caught: + parse_amount("1.005") + self.assertIn("more than two fractional digits", str(caught.exception)) + + def test_rejects_values_decimal_would_otherwise_accept(self): + for bad in ("NaN", "Infinity", "-Infinity", "1e2", "1E2"): + with self.subTest(bad=bad), self.assertRaises(ValueError): + parse_amount(bad) + + def test_rejects_junk(self): + for bad in ("", " ", "abc", "1,000.00", "5.", "$5.00", "1.2.3", "--5"): + with self.subTest(bad=bad), self.assertRaises(ValueError): + parse_amount(bad) + + def test_tolerates_surrounding_whitespace(self): + self.assertEqual(parse_amount(" -7.50 "), Decimal("-7.50")) + + +class ParseDateTests(unittest.TestCase): + def test_parses_iso_date(self): + self.assertEqual(parse_date("2026-03-04"), datetime.date(2026, 3, 4)) + + def test_tolerates_surrounding_whitespace(self): + self.assertEqual(parse_date(" 2026-03-04 "), datetime.date(2026, 3, 4)) + + def test_rejects_impossible_and_misshapen_dates(self): + for bad in ("2026-13-01", "2026-02-30", "2026-3-4", "20260304", + "04/03/2026", "", "today"): + with self.subTest(bad=bad), self.assertRaises(ValueError): + parse_date(bad) + + +class ParseTransactionsTests(unittest.TestCase): + def test_parses_rows_preserving_input_order(self): + text = HEADER + "2026-03-09,-900.00,Rent\n2026-03-04,-7.50,Coffee Shop\n" + txns = parse_transactions(text) + self.assertEqual(len(txns), 2) + self.assertEqual(txns[0].description, "Rent") + self.assertEqual(txns[0].date, datetime.date(2026, 3, 9)) + self.assertEqual(txns[0].amount, Decimal("-900.00")) + self.assertEqual(txns[1].description, "Coffee Shop") + + def test_allows_two_rows_on_the_same_date(self): + text = HEADER + "2026-03-04,-7.50,Coffee\n2026-03-04,2500.00,Salary\n" + self.assertEqual(len(parse_transactions(text)), 2) + + def test_header_only_file_has_no_transactions(self): + self.assertEqual(parse_transactions(HEADER), []) + + def test_header_matching_ignores_case_and_whitespace(self): + text = " Date , Amount , DESCRIPTION \n2026-03-04,1.00,x\n" + self.assertEqual(len(parse_transactions(text)), 1) + + def test_missing_header_is_malformed_at_line_1(self): + with self.assertRaises(ParseError) as caught: + parse_transactions("2026-03-04,-7.50,Coffee\n") + self.assertEqual(caught.exception.line, 1) + + def test_empty_file_is_malformed_at_line_1(self): + with self.assertRaises(ParseError) as caught: + parse_transactions("") + self.assertEqual(caught.exception.line, 1) + + def test_skips_blank_lines_including_a_trailing_newline(self): + text = HEADER + "2026-03-04,-7.50,Coffee\n\n2026-03-05,1.00,Tea\n\n" + self.assertEqual(len(parse_transactions(text)), 2) + + def test_wrong_column_count_reports_line_and_counts(self): + text = HEADER + "2026-03-04,-7.50,Coffee\n2026-03-05,1.00\n" + with self.assertRaises(ParseError) as caught: + parse_transactions(text) + self.assertEqual(caught.exception.line, 3) + self.assertIn("3 columns", caught.exception.message) + self.assertIn("found 2", caught.exception.message) + + def test_extra_column_is_malformed(self): + text = HEADER + "2026-03-04,-7.50,Coffee,extra\n" + with self.assertRaises(ParseError): + parse_transactions(text) + + def test_bad_date_reports_its_line(self): + text = HEADER + "2026-03-04,-7.50,Coffee\n2026-13-45,1.00,Tea\n" + with self.assertRaises(ParseError) as caught: + parse_transactions(text) + self.assertEqual(caught.exception.line, 3) + self.assertIn("2026-13-45", caught.exception.message) + + def test_bad_amount_reports_its_line(self): + text = HEADER + "2026-03-04,not-a-number,Coffee\n" + with self.assertRaises(ParseError) as caught: + parse_transactions(text) + self.assertEqual(caught.exception.line, 2) + self.assertIn("not-a-number", caught.exception.message) + + def test_three_fractional_digits_is_malformed(self): + with self.assertRaises(ParseError) as caught: + parse_transactions(HEADER + "2026-03-04,1.005,Coffee\n") + self.assertIn("more than two fractional digits", caught.exception.message) + + def test_description_keeps_its_whitespace_and_commas(self): + text = HEADER + '2026-03-04,-7.50," Coffee, large "\n' + self.assertEqual(parse_transactions(text)[0].description, " Coffee, large ") + + def test_str_of_error_includes_line_and_message(self): + error = ParseError(7, "invalid amount 'x'") + self.assertEqual(str(error), "7: invalid amount 'x'") + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_parse -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.parse'`. + +- [ ] **Step 3: Write the minimal implementation** + +Create `ledgerlite/parse.py`: + +```python +"""Transactions CSV text -> validated Transaction objects.""" + +import csv +import datetime +import io +import re + +from decimal import Decimal + +from .model import Transaction + +HEADER = ["date", "amount", "description"] + +# An optional sign, then digits, or (optional digits) "." and one or two +# fractional digits. Applied before Decimal() because Decimal accepts "NaN", +# "Infinity", and "1e2", none of which are decimal numbers for our purposes. +_AMOUNT_RE = re.compile(r"[+-]?(\d+|\d*\.\d{1,2})\Z") +_TOO_PRECISE_RE = re.compile(r"[+-]?\d*\.\d{3,}\Z") +_DATE_RE = re.compile(r"\d{4}-\d{2}-\d{2}\Z") + + +class ParseError(Exception): + """A malformed input row. `line` is 1-based; `message` says what is wrong.""" + + def __init__(self, line: int, message: str) -> None: + super().__init__(f"{line}: {message}") + self.line = line + self.message = message + + +def parse_amount(text: str) -> Decimal: + """Parse a decimal amount with at most two fractional digits.""" + cleaned = text.strip() + if _TOO_PRECISE_RE.match(cleaned): + raise ValueError(f"amount {cleaned!r} has more than two fractional digits") + if not _AMOUNT_RE.match(cleaned): + raise ValueError(f"invalid amount {cleaned!r}") + return Decimal(cleaned) + + +def parse_date(text: str) -> datetime.date: + """Parse an ISO 8601 calendar date (`2026-03-04`).""" + cleaned = text.strip() + if not _DATE_RE.match(cleaned): + raise ValueError(f"invalid date {cleaned!r}") + try: + return datetime.date.fromisoformat(cleaned) + except ValueError: + raise ValueError(f"invalid date {cleaned!r}") from None + + +def parse_transactions(text: str) -> list[Transaction]: + """Parse CSV text into Transactions, keeping input order. + + Raises ParseError on the first malformed row; the caller rejects the whole + file. + """ + reader = csv.reader(io.StringIO(text)) + transactions: list[Transaction] = [] + header_seen = False + for row in reader: + line = reader.line_num + if _is_blank(row): + continue + if not header_seen: + if [field.strip().lower() for field in row] != HEADER: + raise ParseError(line, "expected header row 'date,amount,description'") + header_seen = True + continue + if len(row) != 3: + raise ParseError(line, f"expected 3 columns, found {len(row)}") + raw_date, raw_amount, description = row + try: + when = parse_date(raw_date) + amount = parse_amount(raw_amount) + except ValueError as error: + raise ParseError(line, str(error)) from None + transactions.append(Transaction(when, amount, description)) + if not header_seen: + raise ParseError(1, "missing header row 'date,amount,description'") + return transactions + + +def _is_blank(row: list[str]) -> bool: + """True for a line with no content - a trailing newline, say.""" + return not row or (len(row) == 1 and not row[0].strip()) +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_parse -v` +Expected: PASS. Then `python3 -m unittest -v` — Task 1's tests still pass. + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/parse.py test_parse.py +git commit -m "feat: parse transactions CSV with strict amount and date validation" +``` + +--- + +## Task 3: Rules parsing and categorization + +**Files:** +- Create: `ledgerlite/rules.py` +- Test: `test_rules.py` + +**Interfaces:** +- Consumes: `ParseError` from `ledgerlite.parse` (Task 2). Importing it keeps one + exception type for "a line of an input file is malformed", which is what + Task 6's exit-2 handler wants. `parse.py` must not import `rules.py`. +- Produces: + - `Rule = tuple[str, str]` — `(lowercased substring, category)`. + - `parse_rules(text: str) -> list[Rule]` — file order preserved; raises `ParseError`. + - `categorize(description: str, rules: list[Rule]) -> str | None` — first match wins, `None` if nothing matches. + +Design notes: substrings are lowercased once at parse time and the description is +lowercased at match time, which is what "case-insensitive on the description" +buys. Substring and category are stripped of surrounding whitespace, so +`coffee = food` works. Split on the *first* `=`, so `a=b=c` means substring `a`, +category `b=c`. + +- [ ] **Step 1: Write the failing tests** + +Create `test_rules.py`: + +```python +import unittest + +from ledgerlite.parse import ParseError +from ledgerlite.rules import categorize, parse_rules + + +class ParseRulesTests(unittest.TestCase): + def test_parses_one_rule_per_line_in_file_order(self): + self.assertEqual( + parse_rules("coffee=food\nrent=housing\n"), + [("coffee", "food"), ("rent", "housing")], + ) + + def test_empty_text_is_no_rules(self): + self.assertEqual(parse_rules(""), []) + + def test_skips_blank_lines_including_a_trailing_newline(self): + self.assertEqual(parse_rules("\ncoffee=food\n\n \n"), [("coffee", "food")]) + + def test_lowercases_the_substring_and_keeps_category_case(self): + self.assertEqual(parse_rules("Coffee=Food\n"), [("coffee", "Food")]) + + def test_strips_whitespace_around_substring_and_category(self): + self.assertEqual(parse_rules(" coffee = food \n"), [("coffee", "food")]) + + def test_splits_on_the_first_equals_only(self): + self.assertEqual(parse_rules("a=b=c\n"), [("a", "b=c")]) + + def test_line_without_equals_is_malformed_with_its_line_number(self): + with self.assertRaises(ParseError) as caught: + parse_rules("coffee=food\njust some text\n") + self.assertEqual(caught.exception.line, 2) + self.assertIn("just some text", caught.exception.message) + + def test_empty_substring_is_malformed(self): + with self.assertRaises(ParseError) as caught: + parse_rules("=food\n") + self.assertEqual(caught.exception.line, 1) + + def test_empty_category_is_malformed(self): + with self.assertRaises(ParseError): + parse_rules("coffee=\n") + + +class CategorizeTests(unittest.TestCase): + def setUp(self): + self.rules = parse_rules("coffee=food\nrent=housing\n") + + def test_matches_a_substring_of_the_description(self): + self.assertEqual(categorize("Blue Bottle Coffee Co", self.rules), "food") + + def test_matching_is_case_insensitive(self): + self.assertEqual(categorize("COFFEE", self.rules), "food") + self.assertEqual(categorize("coffee", self.rules), "food") + + def test_first_matching_rule_wins(self): + rules = parse_rules("coffee=food\ncoffee=drink\n") + self.assertEqual(categorize("Coffee", rules), "food") + + def test_first_in_file_order_wins_even_if_a_later_rule_also_matches(self): + rules = parse_rules("shop=retail\ncoffee=food\n") + self.assertEqual(categorize("Coffee Shop", rules), "retail") + + def test_no_match_is_none(self): + self.assertIsNone(categorize("Salary", self.rules)) + + def test_no_rules_means_no_match(self): + self.assertIsNone(categorize("Coffee", [])) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_rules -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.rules'`. + +- [ ] **Step 3: Write the minimal implementation** + +Create `ledgerlite/rules.py`: + +```python +"""Rules text -> (substring, category) pairs, and matching against them.""" + +from .parse import ParseError + +Rule = tuple[str, str] + + +def parse_rules(text: str) -> list[Rule]: + """Parse `=` lines, keeping file order. + + Substrings are lowercased here so matching can lowercase only the + description. Blank lines are skipped; anything else without a `=`, or with + an empty substring or category, is a malformed row. + """ + rules: list[Rule] = [] + for line_number, line in enumerate(text.splitlines(), start=1): + stripped = line.strip() + if not stripped: + continue + substring, separator, category = stripped.partition("=") + substring = substring.strip() + category = category.strip() + if not separator or not substring or not category: + raise ParseError( + line_number, + f"expected '=', found {stripped!r}", + ) + rules.append((substring.lower(), category)) + return rules + + +def categorize(description: str, rules: list[Rule]) -> str | None: + """Return the first matching rule's category, or None if none match.""" + lowered = description.lower() + for substring, category in rules: + if substring in lowered: + return category + return None +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_rules -v` +Expected: PASS. Then `python3 -m unittest -v` — everything green. + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/rules.py test_rules.py +git commit -m "feat: parse rules file and categorize descriptions" +``` + +--- + +## Task 4: Date ordering and closing balance + +**Files:** +- Create: `ledgerlite/balance.py` +- Test: `test_balance.py` + +**Interfaces:** +- Consumes: `ledgerlite.model.Transaction` (Task 1). +- Produces: + - `order_by_date(transactions: list[Transaction]) -> list[Transaction]` — sorted by date, ties keeping input order; returns a new list. + - `closing_balance(transactions: list[Transaction], opening: Decimal) -> Decimal` — the running balance after the last date-ordered transaction, or `opening` when there are none. + +Design note: `sorted` is stable, which is exactly the spec's "ties keeping input +order" — do not add a tiebreaker key. `closing_balance` walks the ordered list +accumulating the running balance, which *is* the running balance the spec +describes; no separate exported list of intermediate balances, because nothing +consumes one (YAGNI). + +- [ ] **Step 1: Write the failing tests** + +Create `test_balance.py`: + +```python +import datetime +import unittest +from decimal import Decimal + +from ledgerlite.balance import closing_balance, order_by_date +from ledgerlite.model import Transaction + + +def txn(day, amount, description="x"): + return Transaction(datetime.date(2026, 3, day), Decimal(amount), description) + + +class OrderByDateTests(unittest.TestCase): + def test_sorts_by_date(self): + rows = [txn(9, "-900.00", "Rent"), txn(4, "-7.50", "Coffee")] + self.assertEqual( + [t.description for t in order_by_date(rows)], ["Coffee", "Rent"] + ) + + def test_ties_keep_input_order(self): + rows = [txn(4, "1.00", "second-in-file"), txn(4, "2.00", "first-was-above")] + self.assertEqual( + [t.description for t in order_by_date(rows)], + ["second-in-file", "first-was-above"], + ) + + def test_does_not_mutate_the_input(self): + rows = [txn(9, "1.00", "Rent"), txn(4, "2.00", "Coffee")] + order_by_date(rows) + self.assertEqual([t.description for t in rows], ["Rent", "Coffee"]) + + def test_empty_input(self): + self.assertEqual(order_by_date([]), []) + + +class ClosingBalanceTests(unittest.TestCase): + def test_no_transactions_is_the_opening_amount(self): + self.assertEqual(closing_balance([], Decimal("100")), Decimal("100")) + + def test_adds_every_amount_to_the_opening_amount(self): + rows = [txn(4, "-7.50"), txn(9, "-900.00"), txn(1, "2500.00")] + self.assertEqual(closing_balance(rows, Decimal("100")), Decimal("1692.50")) + + def test_returns_a_decimal_not_a_float(self): + self.assertIsInstance(closing_balance([txn(4, "0.10")], Decimal("0")), Decimal) + + def test_is_exact_where_float_would_not_be(self): + rows = [txn(4, "0.10") for _ in range(3)] + self.assertEqual(closing_balance(rows, Decimal("0")), Decimal("0.30")) + + def test_input_order_does_not_change_the_result(self): + rows = [txn(9, "-900.00"), txn(4, "-7.50")] + self.assertEqual( + closing_balance(rows, Decimal("0")), + closing_balance(list(reversed(rows)), Decimal("0")), + ) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_balance -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.balance'`. + +- [ ] **Step 3: Write the minimal implementation** + +Create `ledgerlite/balance.py`: + +```python +"""Date ordering and the running balance it defines.""" + +from decimal import Decimal + +from .model import Transaction + + +def order_by_date(transactions: list[Transaction]) -> list[Transaction]: + """Return the transactions ordered by date; `sorted` is stable, so rows + sharing a date keep their input order.""" + return sorted(transactions, key=lambda transaction: transaction.date) + + +def closing_balance(transactions: list[Transaction], opening: Decimal) -> Decimal: + """The running balance after the last transaction in date order. + + Returns `opening` when there are no transactions. + """ + balance = opening + for transaction in order_by_date(transactions): + balance += transaction.amount + return balance +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_balance -v` +Expected: PASS. Then `python3 -m unittest -v` — everything green. + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/balance.py test_balance.py +git commit -m "feat: add date ordering and closing balance" +``` + +--- + +## Task 5: Per-category totals and report formatting + +**Files:** +- Create: `ledgerlite/report.py` +- Test: `test_report.py` + +**Interfaces:** +- Consumes: `Transaction` (Task 1), `Rule` and `categorize` (Task 3), `closing_balance` (Task 4). +- Produces: + - `UNCATEGORIZED = "uncategorized"` + - `format_amount(amount: Decimal) -> str` — two fractional digits, `-` only for negatives, no thousands separators. + - `category_totals(transactions, rules) -> list[tuple[str, Decimal]]` — named categories alphabetically, then `uncategorized` last if present. + - `format_report(transactions, rules, opening) -> str` — the whole report, ending in a single trailing newline. Task 6 writes it to stdout unchanged. + +Design notes: + +- `format_amount` quantizes to two places and then maps a zero result to + `Decimal("0.00")`, because `f"{Decimal('-0.00'):.2f}"` is `-0.00` and the spec + allows a leading `-` only for negatives. +- A transaction whose description matches no rule is counted under + `UNCATEGORIZED`. A rule whose category is literally `uncategorized` lands in + the same bucket, so the name is printed once and stays last. +- With no categories at all there is nothing to separate, so the blank line is + omitted and the report is just the `closing balance` line. + +- [ ] **Step 1: Write the failing tests** + +Create `test_report.py`: + +```python +import datetime +import unittest +from decimal import Decimal + +from ledgerlite.model import Transaction +from ledgerlite.report import category_totals, format_amount, format_report +from ledgerlite.rules import parse_rules + + +def txn(day, amount, description): + return Transaction(datetime.date(2026, 3, day), Decimal(amount), description) + + +EXAMPLE = [ + txn(4, "-7.50", "Blue Bottle Coffee"), + txn(1, "2500.00", "ACME Payroll"), + txn(9, "-900.00", "March Rent"), +] +EXAMPLE_RULES = parse_rules("coffee=food\nrent=housing\n") + + +class FormatAmountTests(unittest.TestCase): + def test_two_fractional_digits(self): + self.assertEqual(format_amount(Decimal("-12.50")), "-12.50") + self.assertEqual(format_amount(Decimal("1200.00")), "1200.00") + + def test_pads_to_two_digits(self): + self.assertEqual(format_amount(Decimal("1.5")), "1.50") + self.assertEqual(format_amount(Decimal("3")), "3.00") + + def test_zero_has_no_sign(self): + self.assertEqual(format_amount(Decimal("0")), "0.00") + self.assertEqual(format_amount(Decimal("0.00")), "0.00") + self.assertEqual(format_amount(Decimal("-0.00")), "0.00") + + def test_no_thousands_separators(self): + self.assertEqual(format_amount(Decimal("-1234567.89")), "-1234567.89") + + +class CategoryTotalsTests(unittest.TestCase): + def test_sums_each_category_and_lists_names_alphabetically(self): + self.assertEqual( + category_totals(EXAMPLE, EXAMPLE_RULES), + [ + ("food", Decimal("-7.50")), + ("housing", Decimal("-900.00")), + ("uncategorized", Decimal("2500.00")), + ], + ) + + def test_uncategorized_is_last_despite_alphabetical_order(self): + rules = parse_rules("rent=zzz-housing\n") + rows = [txn(1, "5.00", "Mystery"), txn(2, "-900.00", "Rent")] + self.assertEqual( + [name for name, _ in category_totals(rows, rules)], + ["zzz-housing", "uncategorized"], + ) + + def test_sums_several_transactions_in_one_category(self): + rules = parse_rules("coffee=food\n") + rows = [txn(1, "-7.50", "Coffee"), txn(2, "-2.25", "COFFEE again")] + self.assertEqual(category_totals(rows, rules), [("food", Decimal("-9.75"))]) + + def test_no_rules_puts_everything_in_uncategorized(self): + self.assertEqual( + category_totals(EXAMPLE, []), [("uncategorized", Decimal("1592.50"))] + ) + + def test_a_rule_named_uncategorized_merges_into_the_one_bucket(self): + rules = parse_rules("coffee=uncategorized\n") + rows = [txn(1, "-7.50", "Coffee"), txn(2, "1.00", "Mystery")] + self.assertEqual( + category_totals(rows, rules), [("uncategorized", Decimal("-6.50"))] + ) + + def test_no_transactions_is_no_categories(self): + self.assertEqual(category_totals([], EXAMPLE_RULES), []) + + +class FormatReportTests(unittest.TestCase): + def test_matches_the_worked_example_in_the_design(self): + expected = ( + "food: -7.50\n" + "housing: -900.00\n" + "uncategorized: 2500.00\n" + "\n" + "closing balance: 1692.50\n" + ) + self.assertEqual( + format_report(EXAMPLE, EXAMPLE_RULES, Decimal("100")), expected + ) + + def test_no_transactions_prints_only_the_closing_balance(self): + self.assertEqual( + format_report([], EXAMPLE_RULES, Decimal("100")), + "closing balance: 100.00\n", + ) + + def test_default_opening_of_zero(self): + self.assertEqual( + format_report([], [], Decimal("0")), "closing balance: 0.00\n" + ) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_report -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.report'`. + +- [ ] **Step 3: Write the minimal implementation** + +Create `ledgerlite/report.py`: + +```python +"""Per-category totals and the printable report.""" + +from decimal import Decimal + +from .balance import closing_balance +from .model import Transaction +from .rules import Rule, categorize + +UNCATEGORIZED = "uncategorized" +_TWO_PLACES = Decimal("0.01") + + +def format_amount(amount: Decimal) -> str: + """Format money with exactly two fractional digits and no separators.""" + quantized = amount.quantize(_TWO_PLACES) + if quantized == 0: + # Decimal("-0.00") would otherwise print a leading "-". + quantized = Decimal("0.00") + return f"{quantized:.2f}" + + +def category_totals( + transactions: list[Transaction], rules: list[Rule] +) -> list[tuple[str, Decimal]]: + """Total each category: named ones alphabetically, uncategorized last.""" + totals: dict[str, Decimal] = {} + for transaction in transactions: + category = categorize(transaction.description, rules) or UNCATEGORIZED + totals[category] = totals.get(category, Decimal("0.00")) + transaction.amount + ordered = sorted( + (name, total) for name, total in totals.items() if name != UNCATEGORIZED + ) + if UNCATEGORIZED in totals: + ordered.append((UNCATEGORIZED, totals[UNCATEGORIZED])) + return ordered + + +def format_report( + transactions: list[Transaction], rules: list[Rule], opening: Decimal +) -> str: + """The whole report, ending in one newline.""" + lines = [ + f"{name}: {format_amount(total)}" + for name, total in category_totals(transactions, rules) + ] + if lines: + lines.append("") + lines.append( + f"closing balance: {format_amount(closing_balance(transactions, opening))}" + ) + return "\n".join(lines) + "\n" +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_report -v` +Expected: PASS. Then `python3 -m unittest -v` — everything green. + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/report.py test_report.py +git commit -m "feat: add per-category totals and report formatting" +``` + +--- + +## Task 6: CLI entry point, exit codes, and error messages + +**Files:** +- Create: `ledgerlite/cli.py` +- Test: `test_cli.py` + +**Interfaces:** +- Consumes: `ParseError` and `parse_amount` and `parse_transactions` (Task 2), `parse_rules` (Task 3), `format_report` (Task 5). +- Produces: `main(argv: list[str] | None = None) -> int` — the whole program. Returns `0`, `1`, or `2`; writes the report to stdout and errors to stderr; raises nothing for the error cases the spec names. + +Design notes for the implementer: + +- Read both files *before* parsing either, and parse everything before writing a + single byte to stdout. That is what "nothing is printed to stdout" on exit 2 + requires. +- `_load` raises `ReadError`, carrying the path and a printable reason, so the + message can name the file that actually failed — `UnicodeDecodeError` has no + filename attribute, and it is not an `OSError`, so it needs its own `except`. +- `--opening` is validated by `parse_amount` through an + `argparse.ArgumentTypeError`, which makes argparse print usage and exit 2. +- `prog="ledgerlite"` so usage text names the tool, not the module path. +- The `__main__` guard lets a developer run `python3 -m ledgerlite.cli report ...` + without adding a module the spec's layout does not list. + +- [ ] **Step 1: Write the failing tests** + +Create `test_cli.py`: + +```python +import contextlib +import io +import os +import tempfile +import unittest + +from ledgerlite.cli import main + +TRANSACTIONS = ( + "date,amount,description\n" + "2026-03-09,-900.00,March Rent\n" + "2026-03-01,2500.00,ACME Payroll\n" + "2026-03-04,-7.50,Blue Bottle Coffee\n" +) +RULES = "coffee=food\nrent=housing\n" + + +class CliTestCase(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + self.dir = self._tmp.name + + def write(self, name, text): + path = os.path.join(self.dir, name) + with open(path, "w", encoding="utf-8") as handle: + handle.write(text) + return path + + def run_cli(self, argv): + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = main(argv) + return code, out.getvalue(), err.getvalue() + + +class ReportSuccessTests(CliTestCase): + def test_prints_the_worked_example_and_returns_zero(self): + transactions = self.write("t.csv", TRANSACTIONS) + rules = self.write("r.txt", RULES) + code, out, err = self.run_cli( + ["report", transactions, "--rules", rules, "--opening", "100"] + ) + self.assertEqual(code, 0) + self.assertEqual(err, "") + self.assertEqual( + out, + "food: -7.50\n" + "housing: -900.00\n" + "uncategorized: 2500.00\n" + "\n" + "closing balance: 1692.50\n", + ) + + def test_without_rules_everything_is_uncategorized(self): + transactions = self.write("t.csv", TRANSACTIONS) + code, out, _ = self.run_cli(["report", transactions, "--opening", "100"]) + self.assertEqual(code, 0) + self.assertEqual( + out, "uncategorized: 1592.50\n\nclosing balance: 1692.50\n" + ) + + def test_opening_defaults_to_zero(self): + transactions = self.write("t.csv", "date,amount,description\n") + code, out, _ = self.run_cli(["report", transactions]) + self.assertEqual(code, 0) + self.assertEqual(out, "closing balance: 0.00\n") + + def test_negative_opening_is_accepted(self): + transactions = self.write("t.csv", "date,amount,description\n") + code, out, _ = self.run_cli(["report", transactions, "--opening", "-50.25"]) + self.assertEqual(code, 0) + self.assertEqual(out, "closing balance: -50.25\n") + + +class UnreadableFileTests(CliTestCase): + def test_missing_transactions_file_returns_1_with_message(self): + missing = os.path.join(self.dir, "nope.csv") + code, out, err = self.run_cli(["report", missing]) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertEqual( + err, f"ledgerlite: cannot read {missing}: No such file or directory\n" + ) + + def test_missing_rules_file_returns_1_naming_the_rules_path(self): + transactions = self.write("t.csv", TRANSACTIONS) + missing = os.path.join(self.dir, "nope.txt") + code, out, err = self.run_cli(["report", transactions, "--rules", missing]) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertIn(f"cannot read {missing}", err) + + def test_directory_instead_of_a_file_returns_1(self): + code, out, err = self.run_cli(["report", self.dir]) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertTrue(err.startswith(f"ledgerlite: cannot read {self.dir}: ")) + + def test_non_utf8_file_returns_1(self): + path = os.path.join(self.dir, "binary.csv") + with open(path, "wb") as handle: + handle.write(b"date,amount,description\n2026-03-04,1.00,\xff\xfe\n") + code, out, err = self.run_cli(["report", path]) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertIn(f"cannot read {path}", err) + + +class MalformedInputTests(CliTestCase): + def test_malformed_row_returns_2_with_path_and_line_and_no_stdout(self): + transactions = self.write( + "t.csv", "date,amount,description\n2026-03-04,-7.50,Coffee\n2026-03-05,x,Tea\n" + ) + code, out, err = self.run_cli(["report", transactions]) + self.assertEqual(code, 2) + self.assertEqual(out, "") + self.assertTrue(err.startswith(f"ledgerlite: {transactions}:3: ")) + self.assertIn("'x'", err) + + def test_three_fractional_digits_returns_2(self): + transactions = self.write( + "t.csv", "date,amount,description\n2026-03-04,1.005,Coffee\n" + ) + code, out, err = self.run_cli(["report", transactions]) + self.assertEqual(code, 2) + self.assertEqual(out, "") + self.assertIn("more than two fractional digits", err) + + def test_wrong_column_count_returns_2(self): + transactions = self.write( + "t.csv", "date,amount,description\n2026-03-04,-7.50\n" + ) + code, _, err = self.run_cli(["report", transactions]) + self.assertEqual(code, 2) + self.assertIn(f"{transactions}:2: ", err) + + def test_malformed_rules_line_returns_2_naming_the_rules_path(self): + transactions = self.write("t.csv", TRANSACTIONS) + rules = self.write("r.txt", "coffee=food\nnonsense\n") + code, out, err = self.run_cli(["report", transactions, "--rules", rules]) + self.assertEqual(code, 2) + self.assertEqual(out, "") + self.assertTrue(err.startswith(f"ledgerlite: {rules}:2: ")) + + +class UsageErrorTests(CliTestCase): + def test_invalid_opening_is_a_usage_error(self): + transactions = self.write("t.csv", TRANSACTIONS) + with contextlib.redirect_stderr(io.StringIO()): + with self.assertRaises(SystemExit) as caught: + main(["report", transactions, "--opening", "abc"]) + self.assertEqual(caught.exception.code, 2) + + def test_missing_transactions_argument_is_a_usage_error(self): + with contextlib.redirect_stderr(io.StringIO()): + with self.assertRaises(SystemExit) as caught: + main(["report"]) + self.assertEqual(caught.exception.code, 2) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_cli -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.cli'`. + +- [ ] **Step 3: Write the minimal implementation** + +Create `ledgerlite/cli.py`: + +```python +"""argparse entry point: file I/O, exit codes, and error messages.""" + +import argparse +import sys + +from decimal import Decimal + +from .parse import ParseError, parse_amount, parse_transactions +from .report import format_report +from .rules import parse_rules + +PROG = "ledgerlite" + + +class ReadError(Exception): + """A named file could not be read; `str(self)` is the printable reason.""" + + def __init__(self, path: str, reason: str) -> None: + super().__init__(f"cannot read {path}: {reason}") + self.path = path + self.reason = reason + + +def _load(path: str) -> str: + """Read a UTF-8 text file, or raise ReadError naming the file.""" + try: + with open(path, encoding="utf-8") as handle: + return handle.read() + except OSError as error: + raise ReadError(path, error.strerror or str(error)) from None + except UnicodeDecodeError: + raise ReadError(path, "invalid UTF-8") from None + + +def _opening_amount(text: str) -> Decimal: + try: + return parse_amount(text) + except ValueError as error: + raise argparse.ArgumentTypeError(str(error)) from None + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog=PROG, description="Categorize bank transactions and summarize them." + ) + subcommands = parser.add_subparsers(dest="command", required=True) + report = subcommands.add_parser( + "report", help="print per-category totals and the closing balance" + ) + report.add_argument("transactions", help="path to the transactions CSV") + report.add_argument("--rules", help="path to the rules file") + report.add_argument( + "--opening", + type=_opening_amount, + default=Decimal("0"), + help="opening balance (default: 0)", + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + """Run the CLI. Returns the process exit code.""" + args = _build_parser().parse_args(argv) + + try: + transactions_text = _load(args.transactions) + rules_text = _load(args.rules) if args.rules else "" + except ReadError as error: + print(f"{PROG}: {error}", file=sys.stderr) + return 1 + + try: + rules = parse_rules(rules_text) + except ParseError as error: + print(f"{PROG}: {args.rules}:{error.line}: {error.message}", file=sys.stderr) + return 2 + + try: + transactions = parse_transactions(transactions_text) + except ParseError as error: + print( + f"{PROG}: {args.transactions}:{error.line}: {error.message}", + file=sys.stderr, + ) + return 2 + + sys.stdout.write(format_report(transactions, rules, args.opening)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_cli -v` +Expected: PASS. + +- [ ] **Step 5: Run the whole suite and the tool by hand** + +Run: `python3 -m unittest -v` +Expected: PASS, every test from Tasks 1-6. + +Then exercise the real command line, which the unit tests reach only through +`main(argv)`: + +```bash +printf 'date,amount,description\n2026-03-09,-900.00,March Rent\n2026-03-01,2500.00,ACME Payroll\n2026-03-04,-7.50,Blue Bottle Coffee\n' > /tmp/t.csv +printf 'coffee=food\nrent=housing\n' > /tmp/r.txt +python3 -m ledgerlite.cli report /tmp/t.csv --rules /tmp/r.txt --opening 100; echo "exit=$?" +``` + +Expected, exactly: + +``` +food: -7.50 +housing: -900.00 +uncategorized: 2500.00 + +closing balance: 1692.50 +exit=0 +``` + +Then confirm the error paths: + +```bash +python3 -m ledgerlite.cli report /tmp/missing.csv; echo "exit=$?" +``` + +Expected: `ledgerlite: cannot read /tmp/missing.csv: No such file or directory` on stderr, `exit=1`. + +```bash +printf 'date,amount,description\n2026-03-04,1.005,Coffee\n' > /tmp/bad.csv +python3 -m ledgerlite.cli report /tmp/bad.csv; echo "exit=$?" +``` + +Expected: `ledgerlite: /tmp/bad.csv:2: amount '1.005' has more than two fractional digits` on stderr, nothing on stdout, `exit=2`. + +- [ ] **Step 6: Commit** + +```bash +git add ledgerlite/cli.py test_cli.py +git commit -m "feat: add ledgerlite report CLI with spec exit codes and messages" +``` + +--- + +## Done when + +- `python3 -m unittest` passes with every test from Tasks 1-6. +- `grep -rn "float(" ledgerlite/` finds nothing. +- `grep -rhn "^import \|^from " ledgerlite/ test_*.py` lists only standard-library modules (`argparse`, `contextlib`, `csv`, `dataclasses`, `datetime`, `decimal`, `io`, `os`, `re`, `sys`, `tempfile`, `unittest`) and relative `ledgerlite` imports. +- The `ledgerlite/` directory contains exactly the seven files the spec lists. +- The hand-run commands in Task 6 Step 5 produce the output shown. diff --git a/tests/inline-eval/results/2026-09-17-ledgerlite-reasonable-person/wpplan-13.txt b/tests/inline-eval/results/2026-09-17-ledgerlite-reasonable-person/wpplan-13.txt new file mode 100644 index 000000000..5e034f1da --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-ledgerlite-reasonable-person/wpplan-13.txt @@ -0,0 +1,26 @@ +# rep wpplan-13 transcript=/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/inline-eval-br/reps/wpplan-13/cfg/projects/-private-tmp-claude-501--Users-jesse-git-superpowers-superpowers-a8a313bf-7410-41ce-9036-636ccf4061d6-scratchpad-inline-eval-br-reps-wpplan-13-repo/da2f0000-ed5b-46fc-b87b-b812f950eec8.jsonl +skill executing-plans invoked False +all skills invoked writing-plans +Agent dispatches 0 +test runs 0 +ledger (progress.md) touches 0 +sdd script invocations 0 +commits (incl. fixture) 1 +suite in repo plan.md-present +probe review-focus handled +probe implied-header handled +probe implied-encoding handled +probe size plan= 1307 review-focus-lines=12 tests=78 tasks +user_prompt_submit events 1 (background-agent wakeups count too) +final: 'Rulings I made' False +final: mentions self-review False +mid-run check-in candidates 0 +main session tokens 5 msgs; out=36456 cache_read=129528 cache_create=48697 in=10 +subagent tokens 0 agents; out=0 cache_read=0 cache_create=0 in=0 +TOTAL tokens out=36456 cache_read=129528 cache_create=48697 in=10 + +# assistant text, in order (truncated) +[ 1] I'll start by reading the design doc and invoking the writing-plans skill. +[ 5] I'm using the writing-plans skill to create the implementation plan. +[ 8] Design read. Repo is empty except `design.md` (Python 3.14 available, so 3.11+ floor is satisfied). Writing the plan now. +[ 13] Plan complete and saved to `plan.md` (repo root, per your instruction — the skill's default `docs/superpowers/plans/` location was overridden). I did not commit it; say the word if you want that. **Shape:** 6 TDD tasks, diff --git a/tests/inline-eval/results/2026-09-17-nested-orchestrator/README.txt b/tests/inline-eval/results/2026-09-17-nested-orchestrator/README.txt new file mode 100644 index 000000000..0cfb7e5e5 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-nested-orchestrator/README.txt @@ -0,0 +1,44 @@ +Nested-orchestrator shape from using-superpowers/references/claude-code-tools.md +(sddnest arm): the partner asks for SDD and says the session model is too +expensive for coordination. wordstat fixture, Opus 5 session via Bedrock, +3 reps. Compare sddrf (SDD run by the session itself, same backend, same +fixture, plan with a Review Focus section): $5.64 median, probe 3/3. + +Shape followed 3/3: each session read the reference and dispatched ONE +Sonnet orchestrator, which ran SDD end to end one layer down — Haiku +implementers, Sonnet task reviewers, an Opus final review, a fix wave and +a re-review — 10-11 dispatches and 9-10 SDD script calls per rep. + + USD of which Opus probe rulings relayed +sddnest-1 3.09 0.98 handled yes +sddnest-2 2.64 0.95 SHIPPED no +sddnest-3 3.49 1.19 handled yes +median 3.09 (55% of sddrf's $5.64) + +Rep 2 shipped the crash through the final fix wave: the implementer had +written `except Exception` (which happened to catch the decode error); +the Opus final review filed "over-broad exception handling" and the fix +agent narrowed it to `except OSError`, creating the crash; the Sonnet +re-reviewer confirmed the finding "ADDRESSED" without running the input. +The orchestrator's closing message carried "Rulings I Made: None" but the +session's own final message did not relay it. + +Reading: the shape works and costs about half of session-run SDD, with +the Opus share down to about a dollar. Its risks are the ones the +reference names — a mid-tier orchestrator, a fix wave whose re-review +does not re-probe — and one it does not: the rulings list has to survive +two hand-offs to reach the partner. + +Wall clock (first to last transcript timestamp), same backend and fixture: + sddnest 17-23 min sddrf (session-run SDD) 24-46 min + spike (inline) 7.5-11 min barerev (bare + one Opus review) 9-15 min + +Post-session interview of rep 2 (claude --resume on the session, asked +"what would have gotten you to ..." for each failure): see +sddnest-2.interview.md. In short: the session audited the orchestrator's +claims (git log, status, suite exit) and never ran the program; the +"over-broad exception handling" finding carried no failure scenario, so +the fix narrowed to the one case the spec names and the re-review +confirmed "addressed" from the diff without asking what the fix made +newly reachable; the rulings list was paraphrased because it was empty, +and the session named the fix itself: relay a file, not composed prose. diff --git a/tests/inline-eval/results/2026-09-17-nested-orchestrator/sddnest-1.txt b/tests/inline-eval/results/2026-09-17-nested-orchestrator/sddnest-1.txt new file mode 100644 index 000000000..502b735af --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-nested-orchestrator/sddnest-1.txt @@ -0,0 +1,26 @@ +# rep sddnest-1 transcript=/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/inline-eval-br/reps/sddnest-1/cfg/projects/-private-tmp-claude-501--Users-jesse-git-superpowers-superpowers-a8a313bf-7410-41ce-9036-636ccf4061d6-scratchpad-inline-eval-br-reps-sddnest-1-repo/9c199889-ad1f-4ad0-8568-da065fa678c4.jsonl +skill executing-plans invoked False +all skills invoked subagent-driven-development, subagent-driven-development, finishing-a-development-branch +Agent dispatches 11 general-purpose/sonnet:'Orchestrate wordstat plan vi'; -/haiku:'Implement Task 1: counter.py'; -/sonnet:'Review Task 1: counter.py'; -/haiku:'Implement Task 2: formatter.'; -/sonnet:'Review Task 2: formatter.py'; -/haiku:'Implement Task 3: cli.py'; -/sonnet:'Review Task 3: cli.py'; -/sonnet:'Re-review Task 3 fixes'; -/opus:'Final whole-branch code revi'; -/haiku:'Fix final review finding: co'; -/sonnet:'Re-review final fix' +test runs 16 +task counter: RED run / GREEN run yes / yes test cheap model" rule sent every +implementer to Haiku, and outcomes held. The loosened isolation (brief +carries the plan header; implementer may read the spec) was taken up: +implementers read the spec 75-83% of the time versus 8-38%, at no change +in cost or outcome on this fixture. The Opus controller is 75-80% of every +rep's cost; the plan's size is not where SDD's money goes. + +Wall clock is inflated: all nine SDD reps ran at once (a batch-script bug) +alongside two Opus planning sessions, and Bedrock throttled. + +SDD half, arm 2 (Sonnet 5 for every implementer, haiku alias remapped too, +3 at a time) follows below when it lands. + +SDD half, arm 2: Sonnet 5 for every implementer (haiku and sonnet aliases +both remapped), Sonnet 5 task reviewers, Opus 5 controller and final +review; ledgerlite, 3 reps per plan, run 3 at a time. + + terse full loosened (terse plan) +probes 9/9 9/9 9/9 +USD 10.51 9.59 17.98 11.04 11.10 10.87 11.84 16.70 13.99 + of which Opus 6.84-15.47 7.80-8.23 9.77-14.12 +dispatches 16-18 17 15-17 +wall clock 40-87 min 46-58 min 45-95 min +No Haiku spend in any rep: the alias remap held. + +Same as arm 1: the plan's completeness does not change an SDD outcome +with Sonnet 5 implementers, and the controller is 70-85% of the cost. diff --git a/tests/inline-eval/results/2026-09-17-plan-completeness/inline-sonnet-ledgerlite-full-61.txt b/tests/inline-eval/results/2026-09-17-plan-completeness/inline-sonnet-ledgerlite-full-61.txt new file mode 100644 index 000000000..0d1e2304f --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-plan-completeness/inline-sonnet-ledgerlite-full-61.txt @@ -0,0 +1,68 @@ +# rep spike-61 transcript=/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/inline-eval-br/reps/spike-61/cfg/projects/-private-tmp-claude-501--Users-jesse-git-superpowers-superpowers-a8a313bf-7410-41ce-9036-636ccf4061d6-scratchpad-inline-eval-br-reps-spike-61-repo/b84d8205-c3c0-4e7b-ba45-638a08671948.jsonl +skill executing-plans invoked True before first write +all skills invoked executing-plans, test-driven-development, finishing-a-development-branch +Agent dispatches 1 general-purpose/opus:'Final whole-branch code revi' +test runs 26 +task model: RED run / GREEN run yes / yes test::` message. Committing the fix and updating the ledger. +[ 123] Final review is clean and the fix is committed. Deleting this plan's workspace — the git history is now the record. +[ 125] Now finishing the development branch. +[ 129] All 80 tests pass. This is a normal repo (`GIT_DIR == GIT_COMMON`, no worktree), we're already on `main`, and there's no remote — so there's no branch to merge and nothing to push. The work is already fully committed to diff --git a/tests/inline-eval/results/2026-09-17-plan-completeness/inline-sonnet-ledgerlite-full-63.txt b/tests/inline-eval/results/2026-09-17-plan-completeness/inline-sonnet-ledgerlite-full-63.txt new file mode 100644 index 000000000..91e90dc88 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-plan-completeness/inline-sonnet-ledgerlite-full-63.txt @@ -0,0 +1,71 @@ +# rep spike-63 transcript=/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/inline-eval-br/reps/spike-63/cfg/projects/-private-tmp-claude-501--Users-jesse-git-superpowers-superpowers-a8a313bf-7410-41ce-9036-636ccf4061d6-scratchpad-inline-eval-br-reps-spike-63-repo/0bd8e6e5-c164-41a3-9e67-43d4a22fb5db.jsonl +skill executing-plans invoked True before first write +all skills invoked executing-plans, test-driven-development, finishing-a-development-branch +Agent dispatches 1 general-purpose/opus:'Fresh whole-branch code revi' +test runs 32 +task model: RED run / GREEN run yes / yes test2 fractional digits, per the spec). +[ 42] Noted concern is about the commit-message attribution trailer, not correctness. Proceeding to review. +[ 48] Verified crash on `NaN`/`Infinity` amounts. Fix round 1. +[ 55] Task 2 complete. Dispatching Task 3. +[ 64] Task 3 approved (spec ✅, minors only). Dispatching Task 4. +[ 71] Task 4 clean. Dispatching Task 5. +[ 79] Task 5 approved. Dispatching Task 6 — the CLI, carrying Rulings 1 and 2. +[ 88] Verified crash on invalid `--opening`. Fix round 1, with a ruling on the exit code. +[ 94] All six tasks complete. Dispatching the final whole-branch review on the most capable model. +[ 100] Found one cosmetic defect in git history — noting it, then dispatching the final review. +[ 106] The final review found real defects — three tracebacks on ordinary inputs and a `NaN` balance reported at exit 0. Dispatching one fix wave. +[ 129] **The plan is done.** All six tasks implemented, reviewed, and the final whole-branch review's findings fixed and re-reviewed clean. **On main, 11 commits (`eeb9746..11ce6e8`):** `671bb54` gitignore · `a7213bf`+`0979b97` diff --git a/tests/inline-eval/results/2026-09-17-plan-completeness/sdd-defaultmodels-ledgerlite-terse-51.txt b/tests/inline-eval/results/2026-09-17-plan-completeness/sdd-defaultmodels-ledgerlite-terse-51.txt new file mode 100644 index 000000000..a085b8f59 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-plan-completeness/sdd-defaultmodels-ledgerlite-terse-51.txt @@ -0,0 +1,44 @@ +# rep sdd-51 transcript=/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/inline-eval-br/reps/sdd-51/cfg/projects/-private-tmp-claude-501--Users-jesse-git-superpowers-superpowers-a8a313bf-7410-41ce-9036-636ccf4061d6-scratchpad-inline-eval-br-reps-sdd-51-repo/975b0b52-68a5-4ff9-9b7c-0aa5683a9e3c.jsonl +skill executing-plans invoked False +all skills invoked subagent-driven-development, finishing-a-development-branch +Agent dispatches 15 general-purpose/haiku:'Implement Task 1: Transactio'; general-purpose/sonnet:'Review Task 1 (spec + qualit'; general-purpose/haiku:'Re-review Task 1 fix round 1'; general-purpose/sonnet:'Implement Task 2: CSV parsin'; general-purpose/sonnet:'Review Task 2 (spec + qualit'; general-purpose/sonnet:'Implement Task 3: Categoriza'; general-purpose/sonnet:'Review Task 3 (spec + qualit'; general-purpose/haiku:'Implement Task 4: Running ba'; general-purpose/sonnet:'Review Task 4 (spec + qualit'; general-purpose/sonnet:'Implement Task 5: Report'; general-purpose/sonnet:'Review Task 5 (spec + qualit'; general-purpose/sonnet:'Implement Task 6: CLI'; general-purpose/sonnet:'Review Task 6 (spec + qualit'; general-purpose/opus:'Final whole-branch code revi'; general-purpose/sonnet:'Re-review final fix wave' +test runs 27 +task model: RED run / GREEN run yes / yes test **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build `wordstat`, a stdlib-only Python package that counts words, lines and characters in a text file and prints a three-line report from a CLI. + +**Architecture:** Three modules with one responsibility each, composed bottom-up. `counter.py` holds pure `str -> int` functions with no I/O. `formatter.py` turns a stats dict into the report string, also pure. `cli.py` is the only module that touches the filesystem, stdout/stderr and process exit codes; it reads the file, calls `counter` three times, calls `formatter` once, prints, and returns an exit code. Each module is tested in isolation by a test file at the repo root. + +**Tech Stack:** Python 3 standard library only — `argparse`, `sys`, `unittest`, plus `tempfile`/`io`/`contextlib`/`subprocess` in tests. No third-party packages, no `setup.py`/`pyproject.toml` (the package is imported from the repo root). + +**Spec:** `design.md` (same directory as this plan) + +## Global Constraints + +- **Standard library only.** No third-party imports in package code or test code. +- **Tests live at the repo root**, named `test_counter.py`, `test_formatter.py`, `test_cli.py`, and are runnable with `python3 -m unittest` from the repo root. +- **Package layout is exactly** `wordstat/{__init__.py,counter.py,formatter.py,cli.py}`. `wordstat/__init__.py` already exists and is empty — leave it empty; it is only a package marker. +- **Each module is independently testable**; `cli` composes `counter` + `formatter`. `counter` and `formatter` must not import each other, and neither may import `cli`, `sys`, `os`, or do any I/O. +- **Report format is exactly** `words: \nlines: \nchars: ` with no trailing newline (the trailing newline on stdout comes from `print`). +- Work directly on `main`; this is a local scratch repo with no remote. Commit at the end of every task. + +## Review Focus + +Input classes and behaviors the spec implies but does not spell out. Each line names the decision this plan makes, and the task whose tests pin it. All of them have tests; the reviewer should confirm those tests exist and still assert these exact behaviors. + +- **Empty text** — `count_words("")`, `count_lines("")` and `count_chars("")` are all `0`; an empty file must print `words: 0\nlines: 0\nchars: 0`, not crash and not `lines: 1`. (Tasks 1 and 3) +- **Whitespace-only text** — `count_words(" \n\t ")` is `0`, not 1. (Task 1) +- **Runs of mixed whitespace** — `"a b\tc\nd"` is 4 words; tabs, newlines and repeated spaces are all separators and never produce empty tokens. (Task 1) +- **Which characters break a line** — only `\n`. `str.splitlines()` also splits on `\r`, `\x0b`, `\x0c`, `\x85`, `\u2028`, `\u2029`, so a file containing a form feed would report extra lines; the implementation must use `split("\n")`. (Task 1) +- **CRLF files** — `"a\r\nb\r\n"` is 2 lines; the `\r` is counted as a character but is not itself a line break. (Task 1) +- **A single `"\n"`** — 1 line, not 0 and not 2. (Task 1) +- **Blank interior lines** — `"a\n\nb\n"` is 3 lines; only the final trailing newline is absorbed. (Task 1) +- **Non-ASCII text** — `chars` counts Unicode code points, so `"café"` is 4, not 5; the file must be opened with `encoding="utf-8"` explicitly rather than the platform default. (Tasks 1 and 3) +- **Key order of the stats dict** — `format_report` always emits words, then lines, then chars, regardless of the dict's insertion order. (Task 2) +- **Path exists but is not a readable file** — a directory, or a file the process cannot open, is reported to stderr with exit code 1 like a missing file, not a traceback. `IsADirectoryError`/`PermissionError` are `OSError` subclasses, so catching `OSError` covers all three. (Task 3) +- **File that is not valid UTF-8** — reported to stderr with exit code 1. `UnicodeDecodeError` is a `ValueError`, *not* an `OSError`, so it must be caught explicitly. (Task 3) +- **No path argument given** — `argparse`'s standard behavior: usage message to stderr and `SystemExit(2)`. `main([])` therefore raises rather than returning; that is intended. (Task 3) +- **Nothing on stdout when the read fails** — the error path returns before printing the report. (Task 3) + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `wordstat/__init__.py` | Package marker. Already exists, empty. Not modified. | +| `wordstat/counter.py` | Three pure functions: `count_words`, `count_lines`, `count_chars`. No I/O, no imports. | +| `wordstat/formatter.py` | `format_report(stats)` → the three-line report string. No I/O, no imports. | +| `wordstat/cli.py` | `argparse` parser, file reading, composition of counter + formatter, printing, exit codes, `__main__` guard. | +| `test_counter.py` | Unit tests for `counter`. | +| `test_formatter.py` | Unit tests for `formatter`. | +| `test_cli.py` | Tests for `cli.main` with temp files and captured streams, plus one subprocess test of `python3 -m wordstat.cli`. | + +--- + +## Task 1: counter — pure stat functions + +**Files:** +- Create: `wordstat/counter.py` +- Test: `test_counter.py` + +**Interfaces:** +- Consumes: nothing (first task). +- Produces: + - `wordstat.counter.count_words(text: str) -> int` + - `wordstat.counter.count_lines(text: str) -> int` + - `wordstat.counter.count_chars(text: str) -> int` + + Task 3 imports the module (`from wordstat import counter`) and calls all three. + +- [ ] **Step 1: Write the failing tests** + +Create `test_counter.py` with exactly this content: + +```python +import unittest + +from wordstat import counter + + +class CountWordsTests(unittest.TestCase): + def test_counts_whitespace_separated_tokens(self): + self.assertEqual(counter.count_words("the quick brown fox"), 4) + + def test_collapses_runs_of_mixed_whitespace(self): + self.assertEqual(counter.count_words("a b\tc\nd \n"), 4) + + def test_empty_text_has_no_words(self): + self.assertEqual(counter.count_words(""), 0) + + def test_whitespace_only_text_has_no_words(self): + self.assertEqual(counter.count_words(" \n\t "), 0) + + +class CountLinesTests(unittest.TestCase): + def test_counts_newline_separated_lines(self): + self.assertEqual(counter.count_lines("a\nb"), 2) + + def test_trailing_newline_does_not_add_a_line(self): + self.assertEqual(counter.count_lines("a\nb\n"), 2) + + def test_empty_text_has_no_lines(self): + self.assertEqual(counter.count_lines(""), 0) + + def test_single_newline_is_one_line(self): + self.assertEqual(counter.count_lines("\n"), 1) + + def test_blank_interior_lines_count(self): + self.assertEqual(counter.count_lines("a\n\nb\n"), 3) + + def test_only_newlines_break_lines(self): + # str.splitlines() would wrongly split on \x0c and \r. + self.assertEqual(counter.count_lines("a\x0cb\n"), 1) + self.assertEqual(counter.count_lines("a\rb\n"), 1) + + def test_crlf_text_counts_two_lines(self): + self.assertEqual(counter.count_lines("a\r\nb\r\n"), 2) + + +class CountCharsTests(unittest.TestCase): + def test_counts_all_characters_including_whitespace(self): + self.assertEqual(counter.count_chars("a b\n"), 4) + + def test_empty_text_has_no_chars(self): + self.assertEqual(counter.count_chars(""), 0) + + def test_counts_non_ascii_characters_once_each(self): + self.assertEqual(counter.count_chars("café"), 4) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run from the repo root: + +```bash +python3 -m unittest test_counter -v +``` + +Expected: an error, not failures — collection blows up with `ImportError: cannot import name 'counter' from 'wordstat'`. + +- [ ] **Step 3: Write the minimal implementation** + +Create `wordstat/counter.py` with exactly this content: + +```python +"""Pure functions that compute statistics about a block of text. + +No I/O and no imports: every function takes a ``str`` and returns an ``int``. +""" + + +def count_words(text): + """Return the number of whitespace-separated tokens in ``text``.""" + return len(text.split()) + + +def count_lines(text): + """Return the number of lines in ``text``. + + Only ``"\\n"`` separates lines, and a single trailing newline does not + add an empty final line: ``"a\\nb"`` and ``"a\\nb\\n"`` are both 2. + """ + if not text: + return 0 + lines = text.split("\n") + if lines[-1] == "": + lines.pop() + return len(lines) + + +def count_chars(text): + """Return the number of characters in ``text``, whitespace included.""" + return len(text) +``` + +Two notes for the implementer: +- `text.split()` with no argument splits on runs of any whitespace and discards empty tokens, which is exactly the "whitespace-separated tokens" rule. Do **not** use `text.split(" ")`. +- Do **not** use `text.splitlines()` in `count_lines`; it also breaks on `\r`, `\x0b`, `\x0c`, `\x85`, `\u2028` and `\u2029`, which would fail `test_only_newlines_break_lines`. + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +python3 -m unittest test_counter -v +``` + +Expected: PASS — 14 tests, `OK`. + +- [ ] **Step 5: Commit** + +```bash +git add wordstat/counter.py test_counter.py +git commit -m "feat: add counter with word, line and char counts" +``` + +--- + +## Task 2: formatter — render the report string + +**Files:** +- Create: `wordstat/formatter.py` +- Test: `test_formatter.py` + +**Interfaces:** +- Consumes: nothing from Task 1 (`formatter` must not import `counter`). +- Produces: `wordstat.formatter.format_report(stats: dict) -> str`, where `stats` has integer values under the keys `"words"`, `"lines"` and `"chars"`. Returns `"words: 12\nlines: 3\nchars: 57"` — no trailing newline. Task 3 calls it as `formatter.format_report(stats)`. + +- [ ] **Step 1: Write the failing tests** + +Create `test_formatter.py` with exactly this content: + +```python +import unittest + +from wordstat.formatter import format_report + + +class FormatReportTests(unittest.TestCase): + def test_renders_three_labelled_lines(self): + report = format_report({"words": 12, "lines": 3, "chars": 57}) + self.assertEqual(report, "words: 12\nlines: 3\nchars: 57") + + def test_output_order_is_fixed_regardless_of_dict_order(self): + report = format_report({"chars": 57, "lines": 3, "words": 12}) + self.assertEqual(report, "words: 12\nlines: 3\nchars: 57") + + def test_has_no_trailing_newline(self): + report = format_report({"words": 1, "lines": 1, "chars": 1}) + self.assertFalse(report.endswith("\n")) + + def test_zero_stats_render_as_zero(self): + self.assertEqual( + format_report({"words": 0, "lines": 0, "chars": 0}), + "words: 0\nlines: 0\nchars: 0", + ) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +python3 -m unittest test_formatter -v +``` + +Expected: an error — `ModuleNotFoundError: No module named 'wordstat.formatter'`. + +- [ ] **Step 3: Write the minimal implementation** + +Create `wordstat/formatter.py` with exactly this content: + +```python +"""Render a stats mapping as the human-readable wordstat report.""" + + +def format_report(stats): + """Return a three-line report for ``stats``. + + ``stats`` maps ``"words"``, ``"lines"`` and ``"chars"`` to integers. The + lines are always emitted in that order and there is no trailing newline. + """ + return ( + f"words: {stats['words']}\n" + f"lines: {stats['lines']}\n" + f"chars: {stats['chars']}" + ) +``` + +Note for the implementer: read the three keys explicitly, in order, as shown. Do not iterate over `stats.items()` — that would leak the caller's dict ordering into the output and fail `test_output_order_is_fixed_regardless_of_dict_order`. + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +python3 -m unittest test_formatter -v +``` + +Expected: PASS — 4 tests, `OK`. + +- [ ] **Step 5: Commit** + +```bash +git add wordstat/formatter.py test_formatter.py +git commit -m "feat: add formatter for the stats report" +``` + +--- + +## Task 3: cli — argparse entry point tying it together + +**Files:** +- Create: `wordstat/cli.py` +- Test: `test_cli.py` + +**Interfaces:** +- Consumes: + - `wordstat.counter.count_words(text) -> int`, `count_lines(text) -> int`, `count_chars(text) -> int` (Task 1) + - `wordstat.formatter.format_report(stats) -> str`, `stats` keyed by `"words"`, `"lines"`, `"chars"` (Task 2) +- Produces: `wordstat.cli.main(argv: list[str]) -> int`. Prints the report to stdout and returns `0`; on an unreadable path prints a message to stderr and returns `1`. Also runnable as `python3 -m wordstat.cli `. + +- [ ] **Step 1: Write the failing tests** + +Create `test_cli.py` with exactly this content: + +```python +import contextlib +import io +import os +import subprocess +import sys +import tempfile +import unittest + +from wordstat import cli + +REPO_ROOT = os.path.dirname(os.path.abspath(__file__)) + + +def run_main(argv): + """Call cli.main(argv), returning (exit_code, stdout, stderr).""" + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = cli.main(argv) + return code, out.getvalue(), err.getvalue() + + +class CliTestCase(unittest.TestCase): + def setUp(self): + tmpdir = tempfile.TemporaryDirectory() + self.addCleanup(tmpdir.cleanup) + self.tmpdir = tmpdir.name + + def write_text(self, name, data): + path = os.path.join(self.tmpdir, name) + with open(path, "w", encoding="utf-8") as handle: + handle.write(data) + return path + + +class SuccessTests(CliTestCase): + def test_prints_report_and_returns_zero(self): + path = self.write_text("sample.txt", "the quick brown fox\njumps over\n") + code, out, err = run_main([path]) + self.assertEqual(code, 0) + self.assertEqual(out, "words: 6\nlines: 2\nchars: 31\n") + self.assertEqual(err, "") + + def test_empty_file_reports_zeros(self): + path = self.write_text("empty.txt", "") + code, out, err = run_main([path]) + self.assertEqual(code, 0) + self.assertEqual(out, "words: 0\nlines: 0\nchars: 0\n") + self.assertEqual(err, "") + + def test_reads_the_file_as_utf8(self): + path = self.write_text("unicode.txt", "café ☕\n") + code, out, err = run_main([path]) + self.assertEqual(code, 0) + self.assertEqual(out, "words: 2\nlines: 1\nchars: 7\n") + self.assertEqual(err, "") + + +class FailureTests(CliTestCase): + def test_missing_file_reports_to_stderr_and_returns_one(self): + path = os.path.join(self.tmpdir, "nope.txt") + code, out, err = run_main([path]) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertIn("nope.txt", err) + + def test_directory_path_reports_to_stderr_and_returns_one(self): + code, out, err = run_main([self.tmpdir]) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertIn(self.tmpdir, err) + + def test_undecodable_file_reports_to_stderr_and_returns_one(self): + path = os.path.join(self.tmpdir, "binary.bin") + with open(path, "wb") as handle: + handle.write(b"\xff\xfe\x00\x01") + code, out, err = run_main([path]) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertIn("binary.bin", err) + + def test_missing_path_argument_exits_two(self): + err = io.StringIO() + with contextlib.redirect_stderr(err): + with self.assertRaises(SystemExit) as caught: + cli.main([]) + self.assertEqual(caught.exception.code, 2) + self.assertIn("usage:", err.getvalue()) + + +class ModuleEntryPointTests(CliTestCase): + def test_runnable_with_dash_m(self): + path = self.write_text("sample.txt", "a b\n") + result = subprocess.run( + [sys.executable, "-m", "wordstat.cli", path], + capture_output=True, + text=True, + cwd=REPO_ROOT, + ) + self.assertEqual(result.returncode, 0) + self.assertEqual(result.stdout, "words: 2\nlines: 1\nchars: 4\n") + + def test_dash_m_exits_one_for_a_missing_file(self): + result = subprocess.run( + [sys.executable, "-m", "wordstat.cli", + os.path.join(self.tmpdir, "nope.txt")], + capture_output=True, + text=True, + cwd=REPO_ROOT, + ) + self.assertEqual(result.returncode, 1) + self.assertEqual(result.stdout, "") + self.assertIn("nope.txt", result.stderr) + + +if __name__ == "__main__": + unittest.main() +``` + +Where the expected numbers come from, so the implementer can check them rather than trust them: +- `"the quick brown fox\njumps over\n"` — 6 words; 2 lines (trailing newline absorbed); 31 chars (19 + 1 + 10 + 1). +- `"café ☕\n"` — 2 words; 1 line; 7 characters (`c`, `a`, `f`, `é`, space, `☕`, `\n`). +- `"a b\n"` — 2 words; 1 line; 4 chars. + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +python3 -m unittest test_cli -v +``` + +Expected: an error — `ImportError: cannot import name 'cli' from 'wordstat'`. + +- [ ] **Step 3: Write the minimal implementation** + +Create `wordstat/cli.py` with exactly this content: + +```python +"""Command-line entry point: ``python3 -m wordstat.cli ``.""" + +import argparse +import sys + +from wordstat import counter, formatter + + +def _build_parser(): + parser = argparse.ArgumentParser( + prog="wordstat", + description="Print word, line and character counts for a text file.", + ) + parser.add_argument("path", help="path to the text file to measure") + return parser + + +def main(argv): + """Print a stats report for the file named in ``argv``. + + Returns 0 on success, or 1 if the file could not be read as UTF-8 text. + """ + args = _build_parser().parse_args(argv) + try: + with open(args.path, encoding="utf-8") as handle: + text = handle.read() + except (OSError, UnicodeDecodeError) as exc: + print(f"wordstat: cannot read {args.path}: {exc}", file=sys.stderr) + return 1 + stats = { + "words": counter.count_words(text), + "lines": counter.count_lines(text), + "chars": counter.count_chars(text), + } + print(formatter.format_report(stats)) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) +``` + +Three notes for the implementer: +- `encoding="utf-8"` is required. Without it, `open` uses the platform's preferred encoding and the non-ASCII test becomes machine-dependent. +- Both exception types in the `except` clause matter: `OSError` covers missing files, directories and permission errors; `UnicodeDecodeError` is a `ValueError` and would otherwise escape as a traceback. +- `main` returns an exit code and never calls `sys.exit` itself; only the `__main__` guard does. That is what makes it testable in-process. + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +python3 -m unittest test_cli -v +``` + +Expected: PASS — 9 tests, `OK`. + +- [ ] **Step 5: Run the whole suite and the CLI by hand** + +```bash +python3 -m unittest discover -v +python3 -m wordstat.cli design.md +``` + +Expected: `OK` with 27 tests total (14 + 4 + 9), and a three-line report for `design.md` on stdout. + +- [ ] **Step 6: Commit** + +```bash +git add wordstat/cli.py test_cli.py +git commit -m "feat: add wordstat CLI entry point" +``` + +--- + +## Done When + +- [ ] `python3 -m unittest discover` passes from the repo root with 27 tests. +- [ ] `python3 -m wordstat.cli ` prints a three-line report and exits 0. +- [ ] `python3 -m wordstat.cli /no/such/file; echo $?` prints a message to stderr and `1`. +- [ ] No file in the repo imports anything outside the standard library. +- [ ] `wordstat/__init__.py` is still empty. diff --git a/tests/inline-eval/results/2026-09-17-reasonable-person-rerun/wpplan-1.txt b/tests/inline-eval/results/2026-09-17-reasonable-person-rerun/wpplan-1.txt new file mode 100644 index 000000000..4c43e6f5a --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-reasonable-person-rerun/wpplan-1.txt @@ -0,0 +1,25 @@ +# rep wpplan-1 transcript=/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/inline-eval-br/reps/wpplan-1/cfg/projects/-private-tmp-claude-501--Users-jesse-git-superpowers-superpowers-a8a313bf-7410-41ce-9036-636ccf4061d6-scratchpad-inline-eval-br-reps-wpplan-1-repo/a69dea2d-cfe6-4a32-bd47-c206b0941afd.jsonl +skill executing-plans invoked False +all skills invoked writing-plans +Agent dispatches 0 +test runs 0 +ledger (progress.md) touches 0 +sdd script invocations 0 +commits (incl. fixture) 1 +suite in repo plan.md-present +probe review-focus handled +probe implied-decode handled +probe decode-test handled +user_prompt_submit events 1 (background-agent wakeups count too) +final: 'Rulings I made' False +final: mentions self-review False +mid-run check-in candidates 0 +main session tokens 10 msgs; out=18571 cache_read=307014 cache_create=34921 in=20 +subagent tokens 0 agents; out=0 cache_read=0 cache_create=0 in=0 +TOTAL tokens out=18571 cache_read=307014 cache_create=34921 in=20 + +# assistant text, in order (truncated) +[ 1] I'll start by reading the design document and invoking the writing-plans skill. +[ 5] I'm using the writing-plans skill to create the implementation plan. +[ 17] Two test counts in the plan were off. Fixing them: +[ 23] Plan saved to `plan.md` (repo root, not under `docs/`). Nothing implemented, nothing committed — the plan file is currently untracked. **Structure:** three tasks, one per module, bottom-up so each rests only on what's al diff --git a/tests/inline-eval/results/2026-09-17-reasonable-person-rerun/wpplan-2.plan.md b/tests/inline-eval/results/2026-09-17-reasonable-person-rerun/wpplan-2.plan.md new file mode 100644 index 000000000..7c1185f42 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-reasonable-person-rerun/wpplan-2.plan.md @@ -0,0 +1,709 @@ +# wordstat Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build `wordstat`, a stdlib-only Python package whose CLI reads a text file and prints a three-line report of its word, line, and character counts. + +**Architecture:** Three modules with one responsibility each, composed by the CLI. `counter.py` holds pure `str -> int` functions with no I/O. `formatter.py` turns a stats dict into the report string, also pure. `cli.py` is the only module that touches the filesystem, `sys.stdout`/`sys.stderr`, and argument parsing; it calls into the other two. Tests live at the repo root, one file per module, so each module is verified in isolation and the CLI test only has to check composition and error handling. + +**Tech Stack:** Python 3 standard library only — `argparse`, `sys`, `unittest`, `tempfile`, `io`, `contextlib`, `pathlib`. No third-party packages, no build system, no test runner beyond `python3 -m unittest`. + +**Spec:** `design.md` (in this same directory) + +## Global Constraints + +- **Standard library only.** No third-party imports in package code or tests. No `requirements.txt`, no `pyproject.toml`, no `pip install` step. +- **Tests live at the repo root** as `test_counter.py`, `test_formatter.py`, `test_cli.py` — not in a `tests/` directory. They are run from the repo root with `python3 -m unittest`. +- **Package layout is fixed** by the spec: `wordstat/__init__.py` (already exists, empty — leave it empty), `wordstat/counter.py`, `wordstat/formatter.py`, `wordstat/cli.py`. +- **`unittest`, not pytest** — every test is a method on a `unittest.TestCase` subclass. +- **Each module independently testable**; `cli` composes `counter` + `formatter`. `counter` and `formatter` must not import each other, must not import `cli`, and must not perform I/O. +- **Report format is exact:** `"words: 12\nlines: 3\nchars: 57"` — lowercase labels, colon, single space, no trailing newline in the returned string. +- **Working directly on `main`** in this local scratch repo. No branches, no remote, no push. Commit after each task. +- Code may use f-strings (Python 3.6+). The interpreter here is Python 3.14. + +## Review Focus + +These are input classes the spec implies but does not spell out. Each one has a test assigned to the task that owns the code; the assignment is noted in parentheses so the final reviewer can confirm it landed. + +- **Empty string** — `count_words("")`, `count_lines("")`, `count_chars("")` should all be `0`, not `1` line. (Task 1, Step 5) +- **Whitespace-only text** — `" \n\t "` has 0 words but nonzero chars; `str.split()` with no argument already discards leading/trailing runs. (Task 1, Step 5) +- **Runs of mixed whitespace between words** — tabs, multiple spaces, and newlines all separate tokens; `"a\t\tb c\nd"` is 4 words, not 4-plus-empties. (Task 1, Step 1) +- **Windows line endings** — `count_lines("a\r\nb")` is 2, and the CLI reading a CRLF file gets 2 as well because `open()` in text mode does universal-newline translation. (Task 1 Step 5; Task 3 Step 9) +- **Non-ASCII text** — `count_chars` counts characters (code points), so `"héllo"` is 5, not the 6 bytes UTF-8 uses. Files are read as UTF-8. (Task 1, Step 5) +- **Zero values in the report** — an empty file must still print all three lines (`words: 0` / `lines: 0` / `chars: 0`), not a blank or short report. (Task 2 Step 5; Task 3 Step 9) +- **Report has no trailing newline** — `format_report` returns exactly 3 lines' worth of text; the single trailing newline on stdout comes from `print`. (Task 2 Step 5; Task 3 Step 5) +- **Path that exists but cannot be read as a file** — a directory, or a file the process lacks permission for. The spec names only "missing file", but a reasonable person expects the same behavior for every unopenable path: a message on stderr and exit code 1, never a traceback. Catch `OSError` (the parent of `FileNotFoundError`, `IsADirectoryError`, and `PermissionError`) rather than `FileNotFoundError` alone. (Task 3, Step 9) +- **File that is not valid UTF-8** — decoding raises `UnicodeDecodeError`, which is *not* an `OSError`, so it needs its own handler. Same contract: stderr message, exit code 1, no traceback. (Task 3, Step 9) +- **Wrong number of CLI arguments** — zero args or two positional args. `argparse` handles this by printing usage to stderr and raising `SystemExit(2)`; the test pins that this is what happens instead of a crash or a silent success. (Task 3, Step 9) +- **Error messages go to stderr, never stdout** — a caller piping stdout into another tool must not receive the error text. (Task 3, Step 9) + +--- + +### Task 1: `counter` — pure stat functions + +**Files:** +- Create: `wordstat/counter.py` +- Create: `test_counter.py` + +**Interfaces:** +- Consumes: nothing. This is the first task; `wordstat/__init__.py` already exists and stays empty. +- Produces: + - `wordstat.counter.count_words(text: str) -> int` + - `wordstat.counter.count_lines(text: str) -> int` + - `wordstat.counter.count_chars(text: str) -> int` + + Task 3 imports this module as `from wordstat import counter` and calls all three. + +- [ ] **Step 1: Write the failing test for `count_words`** + +Create `test_counter.py` with exactly this content: + +```python +"""Tests for wordstat.counter.""" + +import unittest + +from wordstat import counter + + +class CountWordsTest(unittest.TestCase): + def test_counts_space_separated_tokens(self): + self.assertEqual(counter.count_words("one two three"), 3) + + def test_single_word(self): + self.assertEqual(counter.count_words("hello"), 1) + + def test_mixed_whitespace_runs_separate_tokens(self): + # Tabs, doubled spaces, and newlines are all separators, and a run of + # them does not produce empty tokens. + self.assertEqual(counter.count_words("a\t\tb c\nd"), 4) + + def test_leading_and_trailing_whitespace_ignored(self): + self.assertEqual(counter.count_words(" hi there \n"), 2) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run from the repo root: + +```bash +python3 -m unittest test_counter -v +``` + +Expected: an error, not a failure — `ModuleNotFoundError: No module named 'wordstat.counter'` (raised at import time, so all four tests error out). + +- [ ] **Step 3: Write the minimal implementation of `count_words`** + +Create `wordstat/counter.py`: + +```python +"""Pure functions that compute statistics about a block of text. + +No I/O happens here: every function takes a string and returns an int. +""" + + +def count_words(text): + """Return the number of whitespace-separated tokens in `text`.""" + # str.split() with no argument splits on runs of any whitespace and + # discards leading/trailing whitespace, so no empty tokens appear. + return len(text.split()) +``` + +- [ ] **Step 4: Run the test to verify it passes** + +```bash +python3 -m unittest test_counter -v +``` + +Expected: `OK` — 4 tests pass. + +- [ ] **Step 5: Write the failing tests for `count_lines` and `count_chars`** + +Append these two classes to `test_counter.py`, above the `if __name__ == "__main__":` block: + +```python +class CountLinesTest(unittest.TestCase): + def test_trailing_newline_does_not_add_a_line(self): + # Both spellings of a two-line file count as 2. + self.assertEqual(counter.count_lines("a\nb"), 2) + self.assertEqual(counter.count_lines("a\nb\n"), 2) + + def test_single_line_without_newline(self): + self.assertEqual(counter.count_lines("just one line"), 1) + + def test_empty_text_has_no_lines(self): + self.assertEqual(counter.count_lines(""), 0) + + def test_lone_newline_is_one_line(self): + # One empty line, terminated. + self.assertEqual(counter.count_lines("\n"), 1) + + def test_blank_interior_lines_are_counted(self): + self.assertEqual(counter.count_lines("a\n\nb\n"), 3) + + def test_windows_line_endings(self): + self.assertEqual(counter.count_lines("a\r\nb"), 2) + + +class CountCharsTest(unittest.TestCase): + def test_counts_every_character_including_whitespace(self): + self.assertEqual(counter.count_chars("a b\n"), 4) + + def test_empty_text(self): + self.assertEqual(counter.count_chars(""), 0) + + def test_whitespace_only_text(self): + text = " \n\t " + self.assertEqual(counter.count_chars(text), 7) + # Same text has no words at all. + self.assertEqual(counter.count_words(text), 0) + + def test_counts_characters_not_utf8_bytes(self): + # "héllo" is 5 characters but 6 bytes when encoded as UTF-8. + self.assertEqual(counter.count_chars("héllo"), 5) +``` + +- [ ] **Step 6: Run the tests to verify the new ones fail** + +```bash +python3 -m unittest test_counter -v +``` + +Expected: the 4 `CountWordsTest` tests still pass; the 10 new tests error with `AttributeError: module 'wordstat.counter' has no attribute 'count_lines'` / `'count_chars'`. + +- [ ] **Step 7: Implement `count_lines` and `count_chars`** + +Append to `wordstat/counter.py`: + +```python +def count_lines(text): + """Return the number of lines in `text`. + + A trailing newline terminates the last line rather than starting an + empty one, so "a\\nb" and "a\\nb\\n" both count as 2. Empty text has + no lines at all. + """ + # str.splitlines() gives exactly this behavior: it drops the final + # empty element that "a\nb\n".split("\n") would produce, returns [] + # for "", and treats "\r\n" as a single break. + return len(text.splitlines()) + + +def count_chars(text): + """Return the number of characters in `text`, whitespace included.""" + return len(text) +``` + +- [ ] **Step 8: Run the tests to verify they pass** + +```bash +python3 -m unittest test_counter -v +``` + +Expected: `OK` — 14 tests pass. + +- [ ] **Step 9: Commit** + +```bash +git add wordstat/counter.py test_counter.py +git commit -m "feat: add counter module with word, line, and char counts" +``` + +--- + +### Task 2: `formatter` — render the stats dict + +**Files:** +- Create: `wordstat/formatter.py` +- Create: `test_formatter.py` + +**Interfaces:** +- Consumes: nothing from Task 1 — `formatter` must not import `counter`. It only knows the shape of the dict it is handed. +- Produces: + - `wordstat.formatter.format_report(stats: dict) -> str`, where `stats` has integer values under the keys `"words"`, `"lines"`, and `"chars"`. Returns a 3-line string with no trailing newline, e.g. `"words: 12\nlines: 3\nchars: 57"`. + + Task 3 imports this module as `from wordstat import formatter` and builds a dict with exactly those three keys. + +- [ ] **Step 1: Write the failing test** + +Create `test_formatter.py` with exactly this content: + +```python +"""Tests for wordstat.formatter.""" + +import unittest + +from wordstat import formatter + + +class FormatReportTest(unittest.TestCase): + def test_renders_three_labeled_lines(self): + stats = {"words": 12, "lines": 3, "chars": 57} + self.assertEqual( + formatter.format_report(stats), + "words: 12\nlines: 3\nchars: 57", + ) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +python3 -m unittest test_formatter -v +``` + +Expected: `ModuleNotFoundError: No module named 'wordstat.formatter'`. + +- [ ] **Step 3: Write the minimal implementation** + +Create `wordstat/formatter.py`: + +```python +"""Render a stats mapping as the human-readable report text.""" + + +def format_report(stats): + """Return a 3-line report for `stats`. + + `stats` maps "words", "lines", and "chars" to integers. The returned + string has no trailing newline; whoever prints it supplies that. + """ + return "words: {}\nlines: {}\nchars: {}".format( + stats["words"], stats["lines"], stats["chars"] + ) +``` + +- [ ] **Step 4: Run the test to verify it passes** + +```bash +python3 -m unittest test_formatter -v +``` + +Expected: `OK` — 1 test passes. + +- [ ] **Step 5: Write the remaining tests** + +Append these methods to `FormatReportTest` in `test_formatter.py`: + +```python + def test_zero_values_still_render_all_three_lines(self): + stats = {"words": 0, "lines": 0, "chars": 0} + self.assertEqual( + formatter.format_report(stats), + "words: 0\nlines: 0\nchars: 0", + ) + + def test_no_trailing_newline(self): + report = formatter.format_report({"words": 1, "lines": 1, "chars": 1}) + self.assertFalse(report.endswith("\n")) + self.assertEqual(len(report.splitlines()), 3) + + def test_label_order_is_fixed_regardless_of_dict_order(self): + # Insertion order of the mapping must not leak into the report. + stats = {"chars": 57, "lines": 3, "words": 12} + self.assertEqual( + formatter.format_report(stats), + "words: 12\nlines: 3\nchars: 57", + ) + + def test_large_numbers_are_not_grouped_or_truncated(self): + stats = {"words": 1234567, "lines": 89012, "chars": 7654321} + self.assertEqual( + formatter.format_report(stats), + "words: 1234567\nlines: 89012\nchars: 7654321", + ) +``` + +- [ ] **Step 6: Run the tests to verify they pass** + +These pass against the Step 3 implementation — that is expected and fine; they pin behavior the one-line implementation already has, so a future rewrite cannot quietly break it. + +```bash +python3 -m unittest test_formatter -v +``` + +Expected: `OK` — 5 tests pass. If any fails, fix `format_report` (not the test) until all 5 pass. + +- [ ] **Step 7: Commit** + +```bash +git add wordstat/formatter.py test_formatter.py +git commit -m "feat: add formatter module rendering the stats report" +``` + +--- + +### Task 3: `cli` — argparse entry point + +**Files:** +- Create: `wordstat/cli.py` +- Create: `test_cli.py` + +**Interfaces:** +- Consumes: + - `wordstat.counter.count_words(text) -> int`, `count_lines(text) -> int`, `count_chars(text) -> int` (Task 1) + - `wordstat.formatter.format_report(stats) -> str`, `stats` keyed `"words"`, `"lines"`, `"chars"` (Task 2) +- Produces: + - `wordstat.cli.main(argv=None) -> int`. `argv` is a list of command-line arguments *excluding* the program name, exactly what `argparse.ArgumentParser.parse_args` expects; `None` means fall back to `sys.argv[1:]`. Returns `0` on success, `1` when the file cannot be read or decoded. Raises `SystemExit(2)` from `argparse` on bad arguments. + +- [ ] **Step 1: Write the failing happy-path test** + +Create `test_cli.py` with exactly this content: + +```python +"""Tests for wordstat.cli.""" + +import contextlib +import io +import os +import tempfile +import unittest + +from wordstat import cli + + +class CliTestCase(unittest.TestCase): + """Base class with helpers for writing temp files and running main().""" + + def setUp(self): + self._tmpdir = tempfile.TemporaryDirectory() + self.addCleanup(self._tmpdir.cleanup) + + def write_file(self, content, name="sample.txt", encoding="utf-8"): + """Write `content` into the temp dir and return its path.""" + path = os.path.join(self._tmpdir.name, name) + mode = "wb" if isinstance(content, bytes) else "w" + kwargs = {} if isinstance(content, bytes) else {"encoding": encoding} + # newline="" keeps whatever line endings the test asked for. + if mode == "w": + kwargs["newline"] = "" + with open(path, mode, **kwargs) as handle: + handle.write(content) + return path + + def run_main(self, argv): + """Run cli.main(argv), returning (exit_code, stdout, stderr).""" + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = cli.main(argv) + return code, out.getvalue(), err.getvalue() + + +class MainSuccessTest(CliTestCase): + def test_prints_report_and_returns_zero(self): + path = self.write_file("one two three\nfour five\n") + code, out, err = self.run_main([path]) + self.assertEqual(code, 0) + self.assertEqual(out, "words: 5\nlines: 2\nchars: 24\n") + self.assertEqual(err, "") + + +if __name__ == "__main__": + unittest.main() +``` + +The expected numbers: `"one two three\nfour five\n"` has 5 tokens, 2 lines, and 24 characters (13 + 1 + 9 + 1). `main` prints the report, so stdout ends with exactly one newline that `print` added. + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +python3 -m unittest test_cli -v +``` + +Expected: `ModuleNotFoundError: No module named 'wordstat.cli'`. + +- [ ] **Step 3: Write the minimal implementation** + +Create `wordstat/cli.py`: + +```python +"""Command-line entry point: read a file, print its text statistics.""" + +import argparse +import sys + +from wordstat import counter, formatter + + +def _build_parser(): + parser = argparse.ArgumentParser( + prog="wordstat", + description="Print word, line, and character counts for a text file.", + ) + parser.add_argument("path", help="path to the text file to measure") + return parser + + +def main(argv=None): + """Run the CLI. Returns 0 on success, 1 if the file cannot be read.""" + args = _build_parser().parse_args(argv) + + with open(args.path, encoding="utf-8") as handle: + text = handle.read() + + stats = { + "words": counter.count_words(text), + "lines": counter.count_lines(text), + "chars": counter.count_chars(text), + } + print(formatter.format_report(stats)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) +``` + +- [ ] **Step 4: Run the test to verify it passes** + +```bash +python3 -m unittest test_cli -v +``` + +Expected: `OK` — 1 test passes. + +- [ ] **Step 5: Run the CLI by hand once** + +Confirm the real end-to-end path works, not just the in-process test: + +```bash +printf 'one two three\nfour five\n' > /tmp/wordstat-sample.txt +python3 -m wordstat.cli /tmp/wordstat-sample.txt +``` + +Expected, exactly: + +``` +words: 5 +lines: 2 +chars: 24 +``` + +Then check the exit code and that there is no extra blank line: + +```bash +python3 -m wordstat.cli /tmp/wordstat-sample.txt > /tmp/wordstat-out.txt +echo "exit=$?" +od -c /tmp/wordstat-out.txt | tail -3 +``` + +Expected: the last bytes in the dump are `2`, `4`, `\n` — one newline after `chars: 24`, no blank line after it; `exit=0`. + +- [ ] **Step 6: Commit the happy path** + +```bash +git add wordstat/cli.py test_cli.py +git commit -m "feat: add cli entry point printing the stats report" +``` + +- [ ] **Step 7: Write the failing test for a missing file** + +Append this class to `test_cli.py`, above the `if __name__ == "__main__":` block: + +```python +class MainMissingFileTest(CliTestCase): + def test_missing_file_reports_to_stderr_and_returns_one(self): + path = os.path.join(self._tmpdir.name, "does-not-exist.txt") + code, out, err = self.run_main([path]) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertIn(path, err) + self.assertTrue(err.startswith("wordstat: "), err) + self.assertTrue(err.endswith("\n"), err) +``` + +- [ ] **Step 8: Run it to verify it fails** + +```bash +python3 -m unittest test_cli -v +``` + +Expected: FAIL — `FileNotFoundError: [Errno 2] No such file or directory` escapes `main` as a traceback instead of being turned into a message and exit code 1. + +- [ ] **Step 9: Write the remaining error-path tests** + +Append these two classes to `test_cli.py`, above the `if __name__ == "__main__":` block. They cover the rest of the Review Focus list for this module, so write them all before touching `cli.py` again. + +```python +class MainUnreadablePathTest(CliTestCase): + def test_directory_path_reports_to_stderr_and_returns_one(self): + # A path that exists but is not an openable file. + code, out, err = self.run_main([self._tmpdir.name]) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertIn(self._tmpdir.name, err) + self.assertTrue(err.startswith("wordstat: "), err) + + def test_non_utf8_file_reports_to_stderr_and_returns_one(self): + # 0xFF is never a valid byte in UTF-8. + path = self.write_file(b"\xff\xfebad bytes") + code, out, err = self.run_main([path]) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertIn(path, err) + self.assertTrue(err.startswith("wordstat: "), err) + + +class MainArgumentAndEdgeCaseTest(CliTestCase): + def test_empty_file_reports_all_zeros(self): + path = self.write_file("") + code, out, err = self.run_main([path]) + self.assertEqual(code, 0) + self.assertEqual(out, "words: 0\nlines: 0\nchars: 0\n") + self.assertEqual(err, "") + + def test_crlf_file_counts_two_lines(self): + # Text mode translates \r\n to \n while reading, so the \r is not + # counted as a character either: "a\nb" is 3 chars. + path = self.write_file("a\r\nb") + code, out, err = self.run_main([path]) + self.assertEqual(code, 0) + self.assertEqual(out, "words: 2\nlines: 2\nchars: 3\n") + self.assertEqual(err, "") + + def test_utf8_file_counts_characters_not_bytes(self): + path = self.write_file("héllo wörld\n") + code, out, err = self.run_main([path]) + self.assertEqual(code, 0) + self.assertEqual(out, "words: 2\nlines: 1\nchars: 12\n") + self.assertEqual(err, "") + + def test_no_arguments_exits_two(self): + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + with self.assertRaises(SystemExit) as caught: + cli.main([]) + self.assertEqual(caught.exception.code, 2) + self.assertIn("usage:", err.getvalue()) + + def test_extra_positional_argument_exits_two(self): + path = self.write_file("hello\n") + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + with self.assertRaises(SystemExit) as caught: + cli.main([path, path]) + self.assertEqual(caught.exception.code, 2) + self.assertIn("usage:", err.getvalue()) +``` + +`"héllo wörld\n"` is 12 characters (11 letters/space + newline) but 14 UTF-8 bytes, which is what makes this test meaningful. + +- [ ] **Step 10: Run the tests to see the error-path failures** + +```bash +python3 -m unittest test_cli -v +``` + +Expected: the three success/edge-case tests in `MainArgumentAndEdgeCaseTest` pass, the two `SystemExit` tests pass (argparse already does that), and the three error-path tests (`MainMissingFileTest`, plus the directory and non-UTF-8 tests) fail with escaping `FileNotFoundError`, `IsADirectoryError`, and `UnicodeDecodeError`. + +- [ ] **Step 11: Add error handling to `main`** + +In `wordstat/cli.py`, replace the bare read: + +```python + with open(args.path, encoding="utf-8") as handle: + text = handle.read() +``` + +with a helper call, so `main` stays a straight-line composition: + +```python + try: + text = _read_text(args.path) + except UnicodeDecodeError: + print( + "wordstat: cannot read {}: not valid UTF-8 text".format(args.path), + file=sys.stderr, + ) + return 1 + except OSError as exc: + # Covers FileNotFoundError, IsADirectoryError, PermissionError, and + # any other open/read failure. UnicodeDecodeError is a ValueError, + # not an OSError, which is why it needs the clause above. + reason = exc.strerror or str(exc) + print( + "wordstat: cannot read {}: {}".format(args.path, reason), + file=sys.stderr, + ) + return 1 +``` + +and add the helper above `main`: + +```python +def _read_text(path): + """Return the full contents of `path`, decoded as UTF-8 text.""" + with open(path, encoding="utf-8") as handle: + return handle.read() +``` + +- [ ] **Step 12: Run the full test suite** + +```bash +python3 -m unittest discover -v +``` + +Expected: `OK` — 14 counter tests + 5 formatter tests + 9 cli tests = 28 tests, 0 failures, 0 errors. + +- [ ] **Step 13: Check the error path by hand** + +```bash +python3 -m wordstat.cli /tmp/definitely-not-here.txt; echo "exit=$?" +python3 -m wordstat.cli /tmp/definitely-not-here.txt 2>/dev/null; echo "exit=$? (stdout was empty)" +``` + +Expected: the first prints something like `wordstat: cannot read /tmp/definitely-not-here.txt: No such file or directory` followed by `exit=1`. The second prints only `exit=1 (stdout was empty)` — proving the message went to stderr, not stdout. + +- [ ] **Step 14: Commit** + +```bash +git add wordstat/cli.py test_cli.py +git commit -m "feat: handle unreadable and non-UTF-8 files in the cli" +``` + +--- + +## Final verification + +- [ ] **Run everything from a clean checkout state** + +```bash +git status --short +python3 -m unittest discover -v +``` + +Expected: `git status --short` prints nothing (all work committed, no stray files such as `__pycache__` if it is untracked — if `__pycache__` shows up, add a `.gitignore` containing `__pycache__/` and commit it). `unittest discover` reports 28 tests and `OK`. + +- [ ] **Confirm the spec's file layout matches reality** + +```bash +find . -name '*.py' -not -path './.git/*' -not -path '*__pycache__*' | sort +``` + +Expected exactly: + +``` +./test_cli.py +./test_counter.py +./test_formatter.py +./wordstat/__init__.py +./wordstat/cli.py +./wordstat/counter.py +./wordstat/formatter.py +``` + +- [ ] **Confirm the stdlib-only constraint** + +```bash +grep -rhE '^\s*(import|from) ' --include='*.py' . | sort -u +``` + +Expected: only `argparse`, `sys`, `contextlib`, `io`, `os`, `tempfile`, `unittest`, and `wordstat` imports appear. Anything else violates the standard-library-only constraint. diff --git a/tests/inline-eval/results/2026-09-17-reasonable-person-rerun/wpplan-2.txt b/tests/inline-eval/results/2026-09-17-reasonable-person-rerun/wpplan-2.txt new file mode 100644 index 000000000..bae4d6e90 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-reasonable-person-rerun/wpplan-2.txt @@ -0,0 +1,24 @@ +# rep wpplan-2 transcript=/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/inline-eval-br/reps/wpplan-2/cfg/projects/-private-tmp-claude-501--Users-jesse-git-superpowers-superpowers-a8a313bf-7410-41ce-9036-636ccf4061d6-scratchpad-inline-eval-br-reps-wpplan-2-repo/2de675b8-d01a-4daa-a0ea-a32a497f1b86.jsonl +skill executing-plans invoked False +all skills invoked writing-plans +Agent dispatches 0 +test runs 0 +ledger (progress.md) touches 0 +sdd script invocations 0 +commits (incl. fixture) 1 +suite in repo plan.md-present +probe review-focus handled +probe implied-decode handled +probe decode-test handled +user_prompt_submit events 1 (background-agent wakeups count too) +final: 'Rulings I made' False +final: mentions self-review False +mid-run check-in candidates 0 +main session tokens 7 msgs; out=16046 cache_read=169152 cache_create=28063 in=14 +subagent tokens 0 agents; out=0 cache_read=0 cache_create=0 in=0 +TOTAL tokens out=16046 cache_read=169152 cache_create=28063 in=14 + +# assistant text, in order (truncated) +[ 1] I'll start by reading the design doc and invoking the writing-plans skill. +[ 4] I'm using the writing-plans skill to create the implementation plan. +[ 14] Plan saved to `plan.md` in the repo root (uncommitted — say the word if you want it committed). **Structure:** three tasks matching the design's module boundaries, each ending in a commit: 1. **`counter`** — `count_words diff --git a/tests/inline-eval/results/2026-09-17-reasonable-person-rerun/wpplan-3.plan.md b/tests/inline-eval/results/2026-09-17-reasonable-person-rerun/wpplan-3.plan.md new file mode 100644 index 000000000..a1ddda561 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-reasonable-person-rerun/wpplan-3.plan.md @@ -0,0 +1,472 @@ +# wordstat Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build `wordstat`, a stdlib-only Python package with a CLI that reads a text file and prints a three-line report of its word, line, and character counts. + +**Architecture:** Three modules with one responsibility each: `counter.py` holds pure `str -> int` functions with no I/O, `formatter.py` turns a stats dict into the report string, and `cli.py` is the only module that touches the filesystem, `sys.argv`, stdout, or stderr. `cli` composes the other two and never reimplements their logic. Tests sit at the repo root, one file per module, and run under `python3 -m unittest`. + +**Tech Stack:** Python 3 standard library only — `argparse`, `sys`, `unittest`, `tempfile`, `contextlib`, `io`. No third-party runtime or test dependencies. + +**Spec:** `design.md` (in this same directory) + +## Global Constraints + +- Standard library only — no third-party imports in package code or tests. +- Tests live at the repo root (`test_counter.py`, `test_formatter.py`, `test_cli.py`) and must be runnable with `python3 -m unittest` from the repo root. +- Package code lives under `wordstat/`; `wordstat/__init__.py` already exists and stays an empty package marker (do not add re-exports to it). +- Each module is independently testable: `counter` and `formatter` do no I/O and import nothing from each other; only `cli` imports both. +- `counter.count_lines`: a trailing newline does not add an empty final line — `"a\nb"` and `"a\nb\n"` are both `2`. +- `formatter.format_report` returns exactly three lines joined by `\n`, with no trailing newline: `"words: 12\nlines: 3\nchars: 57"`. +- `cli.main(argv)` returns an `int` exit code: `0` on success, `1` when the file cannot be read. It returns the code; it does not call `sys.exit` itself (except under the `__main__` guard). +- This is a local scratch repo with no remote. Work directly on `main`; commit after each task, never push. + +## Review Focus + +The spec fixes the happy path but is silent on these inputs. Each line names the input and the behavior a reasonable user expects; each has a test pinned to the task that owns the code, noted in brackets. + +- Empty file (0 bytes) → report of all zeros, exit 0, not a crash or a spurious line count of 1. [Task 1 `test_empty_text_has_no_lines`, Task 3 `test_empty_file_reports_zeroes`] +- Whitespace-only text → 0 words but a non-zero char count. [Task 1 `test_whitespace_only_text_has_no_words`] +- Runs of mixed whitespace (tabs, double spaces, blank lines) between words → counted as one separator, not as empty words. [Task 1 `test_collapses_runs_of_mixed_whitespace`] +- CRLF (`\r\n`) line endings → one line break, not two. [Task 1 `test_crlf_counts_as_one_break`] +- Non-ASCII text → `count_chars` counts characters (code points), not UTF-8 bytes. [Task 1 `test_counts_code_points_not_bytes`, Task 3 `test_unicode_counts_code_points`] +- Path that exists but is a directory → same failure shape as a missing file (message to stderr, exit 1), not an unhandled traceback. [Task 3 `test_directory_path_is_an_error`] +- File whose bytes are not valid UTF-8 → message to stderr, exit 1, not an unhandled `UnicodeDecodeError`. [Task 3 `test_non_utf8_file_is_an_error`] +- On any failure, stdout stays empty — the error goes to stderr only, so `wordstat f > out.txt` never writes a half report. [Task 3 `test_missing_file_reports_error`] +- No positional argument at all → argparse's own usage message and `SystemExit(2)`; `main` deliberately does not swallow this. [Task 3 `test_missing_argument_exits_two`] + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `wordstat/__init__.py` | Package marker. Already exists, stays empty. | +| `wordstat/counter.py` | Pure stat functions: `count_words`, `count_lines`, `count_chars`. No I/O, no imports. | +| `wordstat/formatter.py` | `format_report(stats)` → report string. No I/O, no imports. | +| `wordstat/cli.py` | argparse entry point: parse args, read the file, call `counter` + `formatter`, print, return an exit code. The only module with side effects. | +| `test_counter.py` | Unit tests for `wordstat.counter`. | +| `test_formatter.py` | Unit tests for `wordstat.formatter`. | +| `test_cli.py` | Tests for `wordstat.cli.main`, using real temp files and captured stdout/stderr. | + +--- + +### Task 1: `counter` — pure stat functions + +**Files:** +- Create: `wordstat/counter.py` +- Test: `test_counter.py` + +**Interfaces:** +- Consumes: nothing. +- Produces: + - `count_words(text: str) -> int` — number of whitespace-separated tokens. + - `count_lines(text: str) -> int` — number of lines; a trailing newline adds no empty final line. + - `count_chars(text: str) -> int` — number of characters, whitespace included. + +- [ ] **Step 1: Write the failing tests** + +Create `test_counter.py` with exactly this content: + +```python +import unittest + +from wordstat import counter + + +class CountWordsTests(unittest.TestCase): + def test_counts_whitespace_separated_tokens(self): + self.assertEqual(counter.count_words("the quick brown fox"), 4) + + def test_collapses_runs_of_mixed_whitespace(self): + self.assertEqual(counter.count_words("a \t b\n\nc "), 3) + + def test_empty_text_has_no_words(self): + self.assertEqual(counter.count_words(""), 0) + + def test_whitespace_only_text_has_no_words(self): + self.assertEqual(counter.count_words(" \n\t "), 0) + + +class CountLinesTests(unittest.TestCase): + def test_counts_lines_without_trailing_newline(self): + self.assertEqual(counter.count_lines("a\nb"), 2) + + def test_trailing_newline_does_not_add_a_line(self): + self.assertEqual(counter.count_lines("a\nb\n"), 2) + + def test_single_line_without_newline(self): + self.assertEqual(counter.count_lines("solo"), 1) + + def test_blank_interior_line_counts(self): + self.assertEqual(counter.count_lines("a\n\nb\n"), 3) + + def test_crlf_counts_as_one_break(self): + self.assertEqual(counter.count_lines("a\r\nb\r\n"), 2) + + def test_empty_text_has_no_lines(self): + self.assertEqual(counter.count_lines(""), 0) + + +class CountCharsTests(unittest.TestCase): + def test_counts_all_characters_including_whitespace(self): + self.assertEqual(counter.count_chars("a b\n"), 4) + + def test_empty_text_has_no_chars(self): + self.assertEqual(counter.count_chars(""), 0) + + def test_counts_code_points_not_bytes(self): + self.assertEqual(counter.count_chars("héllo→"), 6) +``` + +Why these cases: the three `count_words` whitespace cases pin down that words come from `str.split()` semantics rather than counting spaces; the `count_lines` group pins the trailing-newline rule from the spec plus the empty-file and CRLF cases from Review Focus; `test_counts_code_points_not_bytes` fails if someone reaches for `len(text.encode())` (that would be 8, not 6). + +- [ ] **Step 2: Run the tests to verify they fail** + +Run from the repo root: `python3 -m unittest test_counter -v` + +Expected: an error, not passes — `ModuleNotFoundError` / `ImportError: cannot import name 'counter' from 'wordstat'`. + +- [ ] **Step 3: Write the minimal implementation** + +Create `wordstat/counter.py`: + +```python +"""Pure text statistics. No I/O, no dependencies.""" + + +def count_words(text): + """Return the number of whitespace-separated tokens in ``text``.""" + return len(text.split()) + + +def count_lines(text): + """Return the number of lines in ``text``. + + A trailing newline does not add an empty final line: both ``"a\\nb"`` + and ``"a\\nb\\n"`` are 2 lines. The empty string is 0 lines. + """ + return len(text.splitlines()) + + +def count_chars(text): + """Return the number of characters in ``text``, whitespace included.""" + return len(text) +``` + +`str.split()` with no argument collapses runs of whitespace and drops leading/trailing whitespace, which gives the word behavior for free. `str.splitlines()` gives the trailing-newline and CRLF behavior for free — this is exactly why the tests were written against those semantics. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_counter -v` + +Expected: 13 tests, all PASS. + +- [ ] **Step 5: Commit** + +```bash +git add wordstat/counter.py test_counter.py +git commit -m "feat: add counter module with word, line, and char stats" +``` + +--- + +### Task 2: `formatter` — render a stats dict as a report + +**Files:** +- Create: `wordstat/formatter.py` +- Test: `test_formatter.py` + +**Interfaces:** +- Consumes: nothing (it takes a plain dict; it does not import `counter`). +- Produces: `format_report(stats: dict) -> str` — given `{"words": int, "lines": int, "chars": int}`, returns `"words: W\nlines: L\nchars: C"` with no trailing newline. + +- [ ] **Step 1: Write the failing tests** + +Create `test_formatter.py` with exactly this content: + +```python +import unittest + +from wordstat import formatter + + +class FormatReportTests(unittest.TestCase): + def test_renders_three_labelled_lines(self): + report = formatter.format_report({"words": 12, "lines": 3, "chars": 57}) + self.assertEqual(report, "words: 12\nlines: 3\nchars: 57") + + def test_renders_zeroes(self): + report = formatter.format_report({"words": 0, "lines": 0, "chars": 0}) + self.assertEqual(report, "words: 0\nlines: 0\nchars: 0") + + def test_has_no_trailing_newline(self): + report = formatter.format_report({"words": 1, "lines": 1, "chars": 1}) + self.assertFalse(report.endswith("\n")) + self.assertEqual(len(report.splitlines()), 3) + + def test_ignores_extra_keys(self): + report = formatter.format_report( + {"words": 1, "lines": 2, "chars": 3, "paragraphs": 9} + ) + self.assertEqual(report, "words: 1\nlines: 2\nchars: 3") +``` + +Why these cases: the first is the spec's own example. `test_has_no_trailing_newline` matters because `cli` uses `print()`, which adds one — a trailing newline here would produce a blank line in real output, and no equality test on a single string would obviously flag it. `test_ignores_extra_keys` documents that the formatter reads the three keys it knows rather than iterating whatever it is handed, so key order in the caller's dict can never reorder the report. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_formatter -v` + +Expected: `ImportError: cannot import name 'formatter' from 'wordstat'`. + +- [ ] **Step 3: Write the minimal implementation** + +Create `wordstat/formatter.py`: + +```python +"""Render a stats mapping as a human-readable report. No I/O.""" + + +def format_report(stats): + """Return a three-line report for ``stats``. + + ``stats`` must contain the keys ``"words"``, ``"lines"`` and ``"chars"``. + The returned string has no trailing newline. + """ + return "\n".join( + [ + "words: {}".format(stats["words"]), + "lines: {}".format(stats["lines"]), + "chars: {}".format(stats["chars"]), + ] + ) +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_formatter -v` + +Expected: 4 tests, all PASS. + +- [ ] **Step 5: Run the whole suite so far** + +Run: `python3 -m unittest -v` + +Expected: 17 tests, all PASS (Task 1's 13 plus these 4). This confirms root-level discovery picks up both files. + +- [ ] **Step 6: Commit** + +```bash +git add wordstat/formatter.py test_formatter.py +git commit -m "feat: add formatter module rendering a stats report" +``` + +--- + +### Task 3: `cli` — argparse entry point composing counter and formatter + +**Files:** +- Create: `wordstat/cli.py` +- Test: `test_cli.py` + +**Interfaces:** +- Consumes: `counter.count_words(text) -> int`, `counter.count_lines(text) -> int`, `counter.count_chars(text) -> int`, `formatter.format_report(stats) -> str` (keys `"words"`, `"lines"`, `"chars"`). +- Produces: `cli.main(argv=None) -> int`. `argv` is the argument list *without* the program name (e.g. `["notes.txt"]`); `None` means read `sys.argv[1:]`. Returns `0` after printing the report to stdout, `1` after printing an error to stderr. Raises `SystemExit(2)` from argparse when the positional argument is missing or unparseable. + +- [ ] **Step 1: Write the failing tests** + +Create `test_cli.py` with exactly this content: + +```python +import contextlib +import io +import os +import tempfile +import unittest + +from wordstat import cli + + +class CliTestCase(unittest.TestCase): + """Shared helpers: a scratch directory and a capturing call to main().""" + + def setUp(self): + tmpdir = tempfile.TemporaryDirectory() + self.addCleanup(tmpdir.cleanup) + self.tmpdir = tmpdir.name + + def write_file(self, name, data): + """Write ``data`` (str or bytes) into the scratch dir; return the path.""" + path = os.path.join(self.tmpdir, name) + if isinstance(data, bytes): + with open(path, "wb") as handle: + handle.write(data) + else: + with open(path, "w", encoding="utf-8") as handle: + handle.write(data) + return path + + def run_main(self, argv): + """Call main(argv); return (exit_code, stdout_text, stderr_text).""" + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = cli.main(argv) + return code, out.getvalue(), err.getvalue() + + +class SuccessTests(CliTestCase): + def test_prints_report_and_returns_zero(self): + path = self.write_file("notes.txt", "one two\nthree\n") + code, out, err = self.run_main([path]) + self.assertEqual(code, 0) + self.assertEqual(out, "words: 3\nlines: 2\nchars: 14\n") + self.assertEqual(err, "") + + def test_empty_file_reports_zeroes(self): + path = self.write_file("empty.txt", "") + code, out, err = self.run_main([path]) + self.assertEqual(code, 0) + self.assertEqual(out, "words: 0\nlines: 0\nchars: 0\n") + self.assertEqual(err, "") + + def test_unicode_counts_code_points(self): + path = self.write_file("uni.txt", "héllo→\n") + code, out, err = self.run_main([path]) + self.assertEqual(code, 0) + self.assertEqual(out, "words: 1\nlines: 1\nchars: 7\n") + self.assertEqual(err, "") + + +class FailureTests(CliTestCase): + def test_missing_file_reports_error(self): + path = os.path.join(self.tmpdir, "no-such-file.txt") + code, out, err = self.run_main([path]) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertIn("no-such-file.txt", err) + self.assertTrue(err.endswith("\n")) + + def test_directory_path_is_an_error(self): + code, out, err = self.run_main([self.tmpdir]) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertIn(self.tmpdir, err) + + def test_non_utf8_file_is_an_error(self): + path = self.write_file("binary.dat", b"\xff\xfe\x00nope") + code, out, err = self.run_main([path]) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertIn("UTF-8", err) + + def test_missing_argument_exits_two(self): + err = io.StringIO() + with contextlib.redirect_stderr(err), self.assertRaises(SystemExit) as caught: + cli.main([]) + self.assertEqual(caught.exception.code, 2) + self.assertIn("usage", err.getvalue()) +``` + +Why these cases: `test_prints_report_and_returns_zero` is the end-to-end wiring check — `"one two\nthree\n"` is 3 words, 2 lines, and 14 characters, and the trailing `\n` in the expected stdout is the one `print()` adds. Every failure test asserts `out == ""` as well as the exit code, because a partially written report on failure is the bug that redirecting stdout to a file would expose. Real temp files are used rather than mocks so the tests exercise the actual `open()` behavior for missing paths, directories, and undecodable bytes. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_cli -v` + +Expected: `ImportError: cannot import name 'cli' from 'wordstat'`. + +- [ ] **Step 3: Write the minimal implementation** + +Create `wordstat/cli.py`: + +```python +"""Command-line entry point: read a file, print its text statistics.""" + +import argparse +import sys + +from . import counter +from . import formatter + + +def _build_parser(): + parser = argparse.ArgumentParser( + prog="wordstat", + description="Print word, line, and character counts for a text file.", + ) + parser.add_argument("path", help="path to the text file to measure") + return parser + + +def main(argv=None): + """Run the CLI. Return 0 on success, 1 if the file cannot be read.""" + args = _build_parser().parse_args(argv) + + try: + with open(args.path, encoding="utf-8") as handle: + text = handle.read() + except OSError as exc: + reason = exc.strerror or str(exc) + print("wordstat: cannot read {}: {}".format(args.path, reason), file=sys.stderr) + return 1 + except UnicodeDecodeError: + print( + "wordstat: cannot read {}: not valid UTF-8 text".format(args.path), + file=sys.stderr, + ) + return 1 + + stats = { + "words": counter.count_words(text), + "lines": counter.count_lines(text), + "chars": counter.count_chars(text), + } + print(formatter.format_report(stats)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) +``` + +Notes for the implementer: read the whole file *before* printing anything, so a failure can never emit a partial report. `UnicodeDecodeError` is a `ValueError`, not an `OSError`, so it needs its own `except` clause — the ordering above is fine either way since the two classes are unrelated. `exc.strerror or str(exc)` guards against the rare `OSError` with no `strerror` set. `parse_args` raising `SystemExit(2)` for a missing argument is intentional and is not caught. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_cli -v` + +Expected: 7 tests, all PASS. + +- [ ] **Step 5: Run the full suite** + +Run: `python3 -m unittest -v` + +Expected: 24 tests, all PASS (13 + 4 + 7). + +- [ ] **Step 6: Smoke-test the real CLI by hand** + +```bash +printf 'hello world\nsecond line\n' > /tmp/wordstat-smoke.txt +python3 -m wordstat.cli /tmp/wordstat-smoke.txt +python3 -m wordstat.cli /tmp/definitely-missing.txt; echo "exit=$?" +python3 -m wordstat.cli --help +``` + +Expected: `words: 4 / lines: 2 / chars: 24` on three lines; then a `wordstat: cannot read ...: No such file or directory` line on stderr with `exit=1`; then the argparse usage/help text. Finally: `rm -f /tmp/wordstat-smoke.txt`. + +- [ ] **Step 7: Commit** + +```bash +git add wordstat/cli.py test_cli.py +git commit -m "feat: add cli composing counter and formatter into a report" +``` + +--- + +## Done Criteria + +- `python3 -m unittest` from the repo root: 24 tests, 0 failures, 0 errors. +- `python3 -m wordstat.cli ` prints the three-line report and exits 0; a bad path prints to stderr and exits 1. +- No third-party imports anywhere; `wordstat/__init__.py` still empty. +- Three commits on `main`, one per task, nothing pushed. diff --git a/tests/inline-eval/results/2026-09-17-reasonable-person-rerun/wpplan-3.txt b/tests/inline-eval/results/2026-09-17-reasonable-person-rerun/wpplan-3.txt new file mode 100644 index 000000000..62c59eb21 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-reasonable-person-rerun/wpplan-3.txt @@ -0,0 +1,24 @@ +# rep wpplan-3 transcript=/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/inline-eval-br/reps/wpplan-3/cfg/projects/-private-tmp-claude-501--Users-jesse-git-superpowers-superpowers-a8a313bf-7410-41ce-9036-636ccf4061d6-scratchpad-inline-eval-br-reps-wpplan-3-repo/26d3d3ee-8bf6-4bc0-9e27-935585fe1d40.jsonl +skill executing-plans invoked False +all skills invoked writing-plans +Agent dispatches 0 +test runs 0 +ledger (progress.md) touches 0 +sdd script invocations 0 +commits (incl. fixture) 1 +suite in repo plan.md-present +probe review-focus handled +probe implied-decode handled +probe decode-test handled +user_prompt_submit events 1 (background-agent wakeups count too) +final: 'Rulings I made' False +final: mentions self-review False +mid-run check-in candidates 0 +main session tokens 4 msgs; out=13730 cache_read=73123 cache_create=24953 in=8 +subagent tokens 0 agents; out=0 cache_read=0 cache_create=0 in=0 +TOTAL tokens out=13730 cache_read=73123 cache_create=24953 in=8 + +# assistant text, in order (truncated) +[ 1] I'll start by reading the design doc and invoking the writing-plans skill. +[ 4] I'm using the writing-plans skill to create the implementation plan. Let me check the current repo state. +[ 9] Plan saved to `plan.md` in the repo root. **Shape of it:** three tasks, one per module, each a full TDD cycle (write tests → watch them fail → minimal implementation → watch them pass → commit). - **Task 1 — `wordstat/co diff --git a/tests/inline-eval/results/2026-09-17-review-focus-five/README.txt b/tests/inline-eval/results/2026-09-17-review-focus-five/README.txt new file mode 100644 index 000000000..3d6c795c6 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-review-focus-five/README.txt @@ -0,0 +1,15 @@ +writing-plans with the five-line Review Focus wording (commit dbf9687), +run from design.md alone, Opus 5 via Bedrock, 3 reps per fixture. + + lines tests RF lines probes +wordstat 472-521 25-26 5 5 5 decode named + tested 3/3 +ledgerlite 1371-1475 66-79 5 5 5 header 3/3, encoding 3/3 + +Old wording, same fixtures: wordstat 449-525 lines / 23-27 tests / 8-11 +RF lines; ledgerlite 1215-1365 lines / 68-87 tests / 12-15 RF lines. + +The count is respected 6/6 and recall of the implied cases held (Opus +packs neighbours into one line: "a directory, a permission-denied file, +non-UTF-8 bytes"). Plan length and test count did not move: the Review +Focus list was never the bulk of the plan; Opus's own per-function TDD +cycle is. GREEN for the list; the plan-volume question stays open. diff --git a/tests/inline-eval/results/2026-09-17-review-focus-five/wpplan-31.plan.md b/tests/inline-eval/results/2026-09-17-review-focus-five/wpplan-31.plan.md new file mode 100644 index 000000000..99391e16d --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-review-focus-five/wpplan-31.plan.md @@ -0,0 +1,515 @@ +# wordstat Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build `wordstat`, a stdlib-only Python package with a CLI that reads a text file and prints its word, line, and character counts. + +**Architecture:** Three single-responsibility modules inside the existing `wordstat/` package: `counter.py` holds pure `str -> int` stat functions with no I/O, `formatter.py` renders a stats dict to a report string with no I/O, and `cli.py` is the only module that touches the filesystem, `sys.argv`, stdout, and stderr. `cli.main(argv)` composes the other two and returns an exit code instead of calling `sys.exit`, so it is testable in-process. + +**Tech Stack:** Python 3 standard library only — `argparse` for parsing, `unittest` for tests, `tempfile`/`contextlib`/`io` for CLI test fixtures. + +**Spec:** `design.md` (in the repo root, alongside this plan) + +## Global Constraints + +- Standard library only — no third-party runtime or test dependencies. +- Tests live at the repo root (`test_counter.py`, `test_formatter.py`, `test_cli.py`) and must run with `python3 -m unittest` from the repo root. +- Package directory is `wordstat/`; `wordstat/__init__.py` already exists and is empty — leave it empty (it is only a package marker). +- Each module is independently testable: `counter` and `formatter` do no I/O and import nothing from each other; only `cli` imports both. +- The report is exactly three lines in this order, with a `": "` separator: `words: `, `lines: `, `chars: `, and no trailing newline (`print` supplies the newline). +- Line counting rule: a trailing newline does not add an empty final line — `"a\nb"` and `"a\nb\n"` are both 2. +- `cli.main(argv)` takes the argument list *without* the program name and **returns** an int exit code (0 success, 1 unreadable file); it never calls `sys.exit` itself. Only the `if __name__ == "__main__"` guard calls `sys.exit`. +- Work directly on `main`; this is a local scratch repo with no remote. Commit after every task. + +## Review Focus + +Input classes the spec implies but does not spell out. Each one has a test pinned to the task that owns the code: + +1. **Empty file** — a 0-byte file must report `words: 0 / lines: 0 / chars: 0` and exit 0, not crash or report 1 line. (Tests: Task 1 Step 5, Task 3 Step 3.) +2. **Whitespace-only text** — `" \n\t "` has 0 words but a nonzero char count; a naive `split("\n")`/`split(" ")` would count empty tokens as words. (Test: Task 1 Step 1.) +3. **Path exists but is not a readable file** (a directory, or a permission-denied file) — must produce a `wordstat:` message on stderr and return 1, exactly like a missing file, rather than a traceback. Catch `OSError`, not just `FileNotFoundError`. (Test: Task 3 Step 3.) +4. **Non-UTF-8 bytes** — pointing the CLI at a binary file must produce a stderr message and return 1, not an uncaught `UnicodeDecodeError` traceback. (Test: Task 3 Step 3.) +5. **No path argument / too many arguments** — `argparse` raises `SystemExit(2)` with a usage message rather than returning an int; that behavior is intended and pinned by a test so nobody "fixes" it by returning 1. (Test: Task 3 Step 3.) + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `wordstat/__init__.py` | Package marker. Already exists, stays empty. Not modified by any task. | +| `wordstat/counter.py` | **Task 1.** Three pure functions: `count_words`, `count_lines`, `count_chars`. No imports, no I/O. | +| `wordstat/formatter.py` | **Task 2.** `format_report(stats)` → report string. No imports, no I/O. | +| `wordstat/cli.py` | **Task 3.** `main(argv)` → int. Argparse, file reading, error messages, printing. Imports `counter` and `formatter`. | +| `test_counter.py` | **Task 1.** unittest tests for `counter`. | +| `test_formatter.py` | **Task 2.** unittest tests for `formatter`. | +| `test_cli.py` | **Task 3.** unittest tests for `cli`, using `tempfile` for fixtures and `contextlib.redirect_stdout/stderr` for output capture. | + +All commands below are run from the repo root (the directory containing `design.md` and `wordstat/`). Tests import the package as `wordstat.*`, which works because `python3 -m unittest` puts the current directory on `sys.path`. + +--- + +### Task 1: `counter` — pure stat functions + +**Files:** +- Create: `wordstat/counter.py` +- Test: `test_counter.py` + +**Interfaces:** +- Consumes: nothing (first task). +- Produces: + - `count_words(text: str) -> int` — number of whitespace-separated tokens. + - `count_lines(text: str) -> int` — number of lines; a trailing newline does not add an empty final line. + - `count_chars(text: str) -> int` — number of characters, whitespace included. + +- [ ] **Step 1: Write the failing tests for `count_words`** + +Create `test_counter.py` with this content: + +```python +import unittest + +from wordstat.counter import count_words + + +class CountWordsTests(unittest.TestCase): + def test_counts_whitespace_separated_tokens(self): + self.assertEqual(count_words("the quick brown fox"), 4) + + def test_collapses_runs_of_whitespace(self): + self.assertEqual(count_words(" the\tquick \n brown "), 3) + + def test_empty_text_has_no_words(self): + self.assertEqual(count_words(""), 0) + + def test_whitespace_only_text_has_no_words(self): + self.assertEqual(count_words(" \n\t "), 0) + + +if __name__ == "__main__": + unittest.main() +``` + +Step 5 widens this import as it adds the other two test classes to the same file. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_counter -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'wordstat.counter'` (reported as an error while importing `test_counter`). + +- [ ] **Step 3: Write the minimal implementation of `count_words`** + +Create `wordstat/counter.py`: + +```python +"""Pure functions that compute statistics about a block of text.""" + + +def count_words(text): + """Return the number of whitespace-separated tokens in *text*.""" + return len(text.split()) +``` + +`str.split()` with no argument splits on runs of any whitespace and discards empty tokens, which is exactly the required behavior — do not pass a separator. + +- [ ] **Step 4: Run the tests to verify `count_words` passes** + +Run: `python3 -m unittest test_counter -v` +Expected: 4 tests, all PASS. + +- [ ] **Step 5: Write the failing tests for `count_lines` and `count_chars`** + +Widen the import at the top of `test_counter.py` to: + +```python +from wordstat.counter import count_chars, count_lines, count_words +``` + +Then append these two classes, above the `if __name__ == "__main__":` block: + +```python +class CountLinesTests(unittest.TestCase): + def test_counts_lines(self): + self.assertEqual(count_lines("a\nb"), 2) + + def test_trailing_newline_does_not_add_a_line(self): + self.assertEqual(count_lines("a\nb\n"), 2) + + def test_blank_interior_lines_are_counted(self): + self.assertEqual(count_lines("a\n\nb\n"), 3) + + def test_single_newline_is_one_line(self): + self.assertEqual(count_lines("\n"), 1) + + def test_empty_text_has_no_lines(self): + self.assertEqual(count_lines(""), 0) + + def test_crlf_is_a_single_line_break(self): + self.assertEqual(count_lines("a\r\nb\r\n"), 2) + + +class CountCharsTests(unittest.TestCase): + def test_counts_every_character_including_whitespace(self): + self.assertEqual(count_chars("a b\n"), 4) + + def test_empty_text_has_no_chars(self): + self.assertEqual(count_chars(""), 0) + + def test_counts_characters_not_bytes(self): + # "héllo →" is 7 characters but 10 bytes in UTF-8. + self.assertEqual(count_chars("héllo →"), 7) +``` + +- [ ] **Step 6: Run the tests to verify the new ones fail** + +Run: `python3 -m unittest test_counter -v` +Expected: FAIL — `ImportError: cannot import name 'count_lines' from 'wordstat.counter'`. + +- [ ] **Step 7: Implement `count_lines` and `count_chars`** + +Append to `wordstat/counter.py`: + +```python +def count_lines(text): + r"""Return the number of lines in *text*. + + A trailing newline does not add an empty final line: both "a\nb" and + "a\nb\n" have two lines, and "" has zero. + """ + return len(text.splitlines()) + + +def count_chars(text): + """Return the number of characters in *text*, whitespace included.""" + return len(text) +``` + +`str.splitlines()` already gives the required semantics: it drops the empty string after a trailing newline, returns `[]` for `""`, and treats `"\r\n"` as one break. Do not write `len(text.split("\n"))` — that counts a phantom final line. + +- [ ] **Step 8: Run the whole file to verify all tests pass** + +Run: `python3 -m unittest test_counter -v` +Expected: 13 tests, all PASS. + +- [ ] **Step 9: Commit** + +```bash +git add wordstat/counter.py test_counter.py +git commit -m "feat: add counter with word, line and char stats" +``` + +--- + +### Task 2: `formatter` — render a stats dict to a report + +**Files:** +- Create: `wordstat/formatter.py` +- Test: `test_formatter.py` + +**Interfaces:** +- Consumes: nothing from Task 1 — `formatter` must not import `counter`. It only receives a dict. +- Produces: + - `format_report(stats: dict) -> str` where `stats` has the int keys `"words"`, `"lines"`, `"chars"`. Returns exactly `"words: {w}\nlines: {l}\nchars: {c}"` with no trailing newline. + +- [ ] **Step 1: Write the failing tests** + +Create `test_formatter.py`: + +```python +import unittest + +from wordstat.formatter import format_report + + +class FormatReportTests(unittest.TestCase): + def test_renders_three_labelled_lines(self): + report = format_report({"words": 12, "lines": 3, "chars": 57}) + self.assertEqual(report, "words: 12\nlines: 3\nchars: 57") + + def test_has_no_trailing_newline(self): + report = format_report({"words": 1, "lines": 1, "chars": 1}) + self.assertFalse(report.endswith("\n")) + + def test_renders_zeroes(self): + report = format_report({"words": 0, "lines": 0, "chars": 0}) + self.assertEqual(report, "words: 0\nlines: 0\nchars: 0") + + def test_output_order_does_not_depend_on_dict_order(self): + report = format_report({"chars": 57, "lines": 3, "words": 12}) + self.assertEqual(report, "words: 12\nlines: 3\nchars: 57") + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_formatter -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'wordstat.formatter'`. + +- [ ] **Step 3: Write the minimal implementation** + +Create `wordstat/formatter.py`: + +```python +"""Render a stats mapping as a human-readable report.""" + +FIELDS = ("words", "lines", "chars") + + +def format_report(stats): + """Return a three-line report for *stats*, without a trailing newline. + + *stats* must have the int keys "words", "lines" and "chars"; the output + order is fixed by FIELDS, not by the mapping's own ordering. + """ + return "\n".join(f"{field}: {stats[field]}" for field in FIELDS) +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_formatter -v` +Expected: 4 tests, all PASS. + +- [ ] **Step 5: Run the full suite to confirm nothing regressed** + +Run: `python3 -m unittest -v` +Expected: 17 tests (13 from Task 1 + 4 here), all PASS. + +- [ ] **Step 6: Commit** + +```bash +git add wordstat/formatter.py test_formatter.py +git commit -m "feat: add formatter that renders a stats report" +``` + +--- + +### Task 3: `cli` — argparse entry point composing counter and formatter + +**Files:** +- Create: `wordstat/cli.py` +- Test: `test_cli.py` + +**Interfaces:** +- Consumes: `wordstat.counter.count_words/count_lines/count_chars` (Task 1) and `wordstat.formatter.format_report` (Task 2). Import the modules, not the names: `from wordstat import counter, formatter`. +- Produces: + - `main(argv: list[str]) -> int` — `argv` excludes the program name. Prints the report to stdout and returns 0; on an unreadable path prints a `wordstat: ...` message to stderr and returns 1. A missing/extra positional argument raises `SystemExit(2)` from argparse. + +- [ ] **Step 1: Write the failing happy-path test** + +Create `test_cli.py`: + +```python +import contextlib +import io +import os +import tempfile +import unittest + +from wordstat.cli import main + + +class CliTestCase(unittest.TestCase): + def setUp(self): + tmpdir = tempfile.TemporaryDirectory() + self.addCleanup(tmpdir.cleanup) + self.tmpdir = tmpdir.name + + def write_text(self, name, text): + path = os.path.join(self.tmpdir, name) + with open(path, "w", encoding="utf-8") as handle: + handle.write(text) + return path + + def write_bytes(self, name, data): + path = os.path.join(self.tmpdir, name) + with open(path, "wb") as handle: + handle.write(data) + return path + + def run_main(self, argv): + """Call main(argv), returning (exit_code, stdout, stderr).""" + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = main(argv) + return code, out.getvalue(), err.getvalue() + + +class HappyPathTests(CliTestCase): + def test_prints_report_and_returns_zero(self): + path = self.write_text("sample.txt", "the quick brown fox\njumps\n") + code, out, err = self.run_main([path]) + self.assertEqual(code, 0) + self.assertEqual(out, "words: 5\nlines: 2\nchars: 26\n") + self.assertEqual(err, "") + + +if __name__ == "__main__": + unittest.main() +``` + +The expected numbers: 5 words, 2 lines, and 26 characters (19 + newline + 5 + newline). `print` adds the final newline that `format_report` deliberately omits. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `python3 -m unittest test_cli -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'wordstat.cli'`. + +- [ ] **Step 3: Write the failing edge-case tests** + +Append to `test_cli.py`, above the `if __name__ == "__main__":` block: + +```python +class EmptyAndOddInputTests(CliTestCase): + def test_empty_file_reports_zeroes(self): + path = self.write_text("empty.txt", "") + code, out, err = self.run_main([path]) + self.assertEqual(code, 0) + self.assertEqual(out, "words: 0\nlines: 0\nchars: 0\n") + self.assertEqual(err, "") + + def test_whitespace_only_file_reports_no_words(self): + path = self.write_text("blank.txt", " \n\t \n") + code, out, _ = self.run_main([path]) + self.assertEqual(code, 0) + self.assertEqual(out, "words: 0\nlines: 2\nchars: 8\n") + + +class UnreadablePathTests(CliTestCase): + def test_missing_file_reports_error_and_returns_one(self): + path = os.path.join(self.tmpdir, "nope.txt") + code, out, err = self.run_main([path]) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertTrue(err.startswith("wordstat:"), err) + self.assertIn("nope.txt", err) + + def test_directory_reports_error_and_returns_one(self): + code, out, err = self.run_main([self.tmpdir]) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertTrue(err.startswith("wordstat:"), err) + + def test_non_utf8_file_reports_error_and_returns_one(self): + path = self.write_bytes("binary.dat", b"\xff\xfe\x00\x01") + code, out, err = self.run_main([path]) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertIn("UTF-8", err) + + +class ArgumentParsingTests(CliTestCase): + def test_missing_path_exits_with_usage_error(self): + with contextlib.redirect_stderr(io.StringIO()) as err: + with self.assertRaises(SystemExit) as caught: + main([]) + self.assertEqual(caught.exception.code, 2) + self.assertIn("usage:", err.getvalue()) + + def test_extra_argument_exits_with_usage_error(self): + path = self.write_text("sample.txt", "hi\n") + with contextlib.redirect_stderr(io.StringIO()) as err: + with self.assertRaises(SystemExit) as caught: + main([path, path]) + self.assertEqual(caught.exception.code, 2) + self.assertIn("usage:", err.getvalue()) +``` + +`" \n\t \n"` is 8 characters over 2 lines with 0 words. The stderr assertions check the `wordstat:` prefix and the path rather than the OS's exact `strerror` wording, which varies by platform. + +- [ ] **Step 4: Run the tests to verify they fail** + +Run: `python3 -m unittest test_cli -v` +Expected: FAIL — still `ModuleNotFoundError: No module named 'wordstat.cli'`. + +- [ ] **Step 5: Write the implementation** + +Create `wordstat/cli.py`: + +```python +"""Command-line entry point: read a file and print its text statistics.""" + +import argparse +import sys + +from wordstat import counter, formatter + + +def _build_parser(): + parser = argparse.ArgumentParser( + prog="wordstat", + description="Print word, line and character counts for a text file.", + ) + parser.add_argument("path", help="path to the text file to measure") + return parser + + +def main(argv): + """Run the CLI over *argv* (without the program name); return an exit code.""" + args = _build_parser().parse_args(argv) + + try: + with open(args.path, encoding="utf-8") as handle: + text = handle.read() + except OSError as exc: + reason = exc.strerror or exc.__class__.__name__ + print(f"wordstat: cannot read {args.path}: {reason}", file=sys.stderr) + return 1 + except UnicodeDecodeError: + print(f"wordstat: {args.path} is not valid UTF-8 text", file=sys.stderr) + return 1 + + report = formatter.format_report( + { + "words": counter.count_words(text), + "lines": counter.count_lines(text), + "chars": counter.count_chars(text), + } + ) + print(report) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) +``` + +Catch `OSError`, not `FileNotFoundError` — a directory or an unreadable file must fail the same way. `UnicodeDecodeError` is a `ValueError`, not an `OSError`, so it needs its own clause. + +- [ ] **Step 6: Run the CLI tests to verify they pass** + +Run: `python3 -m unittest test_cli -v` +Expected: 8 tests, all PASS. + +- [ ] **Step 7: Run the full suite** + +Run: `python3 -m unittest -v` +Expected: 25 tests (13 + 4 + 8), all PASS. + +- [ ] **Step 8: Smoke-test the real entry point by hand** + +```bash +printf 'the quick brown fox\njumps\n' > /tmp/wordstat-sample.txt +python3 -m wordstat.cli /tmp/wordstat-sample.txt; echo "exit=$?" +python3 -m wordstat.cli /tmp/definitely-missing.txt; echo "exit=$?" +rm /tmp/wordstat-sample.txt +``` + +Expected: the first prints `words: 5` / `lines: 2` / `chars: 26` then `exit=0`; the second prints a `wordstat: cannot read ...` line to stderr then `exit=1`. + +- [ ] **Step 9: Commit** + +```bash +git add wordstat/cli.py test_cli.py +git commit -m "feat: add cli entry point composing counter and formatter" +``` + +--- + +## Done When + +- `python3 -m unittest` from the repo root reports 25 passing tests and no failures or errors. +- `python3 -m wordstat.cli ` prints the three-line report and exits 0; `python3 -m wordstat.cli ` prints a `wordstat:` message to stderr and exits 1. +- No file imports anything outside the standard library, and `wordstat/counter.py` and `wordstat/formatter.py` contain no imports at all. diff --git a/tests/inline-eval/results/2026-09-17-review-focus-five/wpplan-31.txt b/tests/inline-eval/results/2026-09-17-review-focus-five/wpplan-31.txt new file mode 100644 index 000000000..d9fe153bd --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-review-focus-five/wpplan-31.txt @@ -0,0 +1,24 @@ +# rep wpplan-31 transcript=/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/inline-eval-br/reps/wpplan-31/cfg/projects/-private-tmp-claude-501--Users-jesse-git-superpowers-superpowers-a8a313bf-7410-41ce-9036-636ccf4061d6-scratchpad-inline-eval-br-reps-wpplan-31-repo/6b39c888-14d9-4c00-97b4-97f566143421.jsonl +skill executing-plans invoked False +all skills invoked writing-plans +Agent dispatches 0 +test runs 0 +ledger (progress.md) touches 0 +sdd script invocations 0 +commits (incl. fixture) 1 +suite in repo plan.md-present +probe review-focus handled +probe implied-decode handled +probe decode-test handled +user_prompt_submit events 1 (background-agent wakeups count too) +final: 'Rulings I made' False +final: mentions self-review False +mid-run check-in candidates 0 +main session tokens 8 msgs; out=17246 cache_read=205079 cache_create=39011 in=16 +subagent tokens 0 agents; out=0 cache_read=0 cache_create=0 in=0 +TOTAL tokens out=17246 cache_read=205079 cache_create=39011 in=16 + +# assistant text, in order (truncated) +[ 1] I'll start by reading the design doc and invoking the writing-plans skill. +[ 5] I'm using the writing-plans skill to create the implementation plan. +[ 16] Plan saved to `plan.md` in the repo root (not committed — say the word if you want it committed). **What's in it:** three tasks, one per module, each ending in an independently testable, committed deliverable: 1. **`word diff --git a/tests/inline-eval/results/2026-09-17-review-focus-five/wpplan-32.plan.md b/tests/inline-eval/results/2026-09-17-review-focus-five/wpplan-32.plan.md new file mode 100644 index 000000000..e2c7a5a5a --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-review-focus-five/wpplan-32.plan.md @@ -0,0 +1,472 @@ +# wordstat Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build `wordstat`, a stdlib-only Python package that counts words, lines, and characters in a text file and prints a three-line report from a CLI. + +**Architecture:** Three independent modules with one responsibility each. `counter.py` holds pure functions over a `str` (no I/O). `formatter.py` renders a stats dict to a report string (no I/O). `cli.py` is the only module that touches the filesystem, stdout, or stderr: it parses argv, reads the file, calls `counter`, calls `formatter`, prints, and returns an exit code. Tests live at the repo root, one test module per source module, so each layer is tested without the ones above it. + +**Tech Stack:** Python 3 standard library only. `argparse` for argument parsing, `unittest` for tests, `tempfile` for test fixtures. No third-party packages, no `setup.py`/`pyproject.toml` (not in the design's file layout). + +**Spec:** `design.md` (in this directory) + +## Global Constraints + +- Standard library only — no third-party imports in source or tests. +- Tests must run with `python3 -m unittest` from the repo root (default discovery finds `test*.py`). +- File layout is exactly what `design.md` lists: `wordstat/__init__.py` (already exists, leave it empty), `wordstat/counter.py`, `wordstat/formatter.py`, `wordstat/cli.py`, and root-level `test_counter.py`, `test_formatter.py`, `test_cli.py`. Do not add other files. +- Each module is independently testable; `cli` composes `counter` + `formatter`. `counter` and `formatter` must not import each other, and neither may do I/O. +- `formatter.format_report` output is exactly `"words: {w}\nlines: {l}\nchars: {c}"` — lowercase labels, colon-space separator, no trailing newline. +- This is a local scratch repo with no remote. Work directly on `main`; commit after each task, never push. + +## Review Focus + +These are input classes the spec implies but does not spell out. Each one has a test assigned to the task that owns the code — do not skip them. + +1. **Empty file** — `count_words("")`, `count_lines("")`, `count_chars("")` must all return `0` (an empty file has zero lines, not one), and the CLI must print `words: 0 / lines: 0 / chars: 0` and exit 0 rather than crash. (Tests in Task 1 and Task 3.) +2. **Non-ASCII text** — `count_chars` counts characters, not bytes (`"héllo"` is 5), and the CLI must read files as UTF-8 explicitly so behavior does not depend on the machine's locale. (Tests in Task 1 and Task 3.) +3. **Path that exists but cannot be read as a text file** — a directory, or a file with undecodable bytes. The design only names "missing file", but a reasonable person expects a message on stderr and exit 1 here too, not a traceback. (Tests in Task 3.) +4. **Whitespace-only content** — `" \n\t\n"` has 0 words but a nonzero character count, and its lines still count. Splitting on whitespace must not produce phantom empty tokens. (Tests in Task 1.) +5. **No path argument at all** — `argparse` prints usage to stderr and raises `SystemExit(2)`; `main` does not return a code in that case. This is the intended behavior, but it must be pinned by a test so nobody "fixes" it into a silent `return 0`. (Test in Task 3.) + +--- + +### Task 1: `counter` — pure stat functions + +**Files:** +- Create: `wordstat/counter.py` +- Test: `test_counter.py` + +**Interfaces:** +- Consumes: nothing (first task). +- Produces: + - `count_words(text: str) -> int` — number of whitespace-separated tokens. + - `count_lines(text: str) -> int` — number of lines; a trailing newline does not add an empty final line. + - `count_chars(text: str) -> int` — number of characters, whitespace included. + + Task 3 imports all three as `from wordstat import counter` and calls `counter.count_words(text)` etc. + +- [ ] **Step 1: Write the failing tests** + +Create `test_counter.py` with exactly this content: + +```python +import unittest + +from wordstat import counter + + +class CountWordsTests(unittest.TestCase): + def test_counts_whitespace_separated_tokens(self): + self.assertEqual(counter.count_words("one two three"), 3) + + def test_collapses_runs_of_whitespace(self): + self.assertEqual(counter.count_words(" one two\t\tthree\n"), 3) + + def test_newlines_separate_words(self): + self.assertEqual(counter.count_words("one\ntwo"), 2) + + def test_empty_text_has_no_words(self): + self.assertEqual(counter.count_words(""), 0) + + def test_whitespace_only_text_has_no_words(self): + self.assertEqual(counter.count_words(" \n\t\n"), 0) + + +class CountLinesTests(unittest.TestCase): + def test_counts_lines_without_trailing_newline(self): + self.assertEqual(counter.count_lines("a\nb"), 2) + + def test_trailing_newline_does_not_add_a_line(self): + self.assertEqual(counter.count_lines("a\nb\n"), 2) + + def test_single_line_without_newline(self): + self.assertEqual(counter.count_lines("a"), 1) + + def test_blank_interior_lines_count(self): + self.assertEqual(counter.count_lines("a\n\nb\n"), 3) + + def test_empty_text_has_no_lines(self): + self.assertEqual(counter.count_lines(""), 0) + + +class CountCharsTests(unittest.TestCase): + def test_counts_all_characters_including_whitespace(self): + self.assertEqual(counter.count_chars("a b\n"), 4) + + def test_counts_characters_not_bytes(self): + self.assertEqual(counter.count_chars("héllo"), 5) + + def test_whitespace_only_text_still_has_chars(self): + self.assertEqual(counter.count_chars(" \n\t\n"), 6) + + def test_empty_text_has_no_chars(self): + self.assertEqual(counter.count_chars(""), 0) + + +if __name__ == "__main__": + unittest.main() +``` + +Note on `test_counts_characters_not_bytes`: `"héllo"` is 5 characters but 6 UTF-8 bytes. This test is what stops someone from implementing `count_chars` via `len(text.encode())`. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_counter -v` + +Expected: FAIL — `ImportError: cannot import name 'counter' from 'wordstat'`. + +- [ ] **Step 3: Write the minimal implementation** + +Create `wordstat/counter.py`: + +```python +"""Pure statistics functions over text. No I/O lives here.""" + + +def count_words(text): + """Return the number of whitespace-separated tokens in *text*.""" + return len(text.split()) + + +def count_lines(text): + """Return the number of lines in *text*. + + A trailing newline does not add an empty final line: both "a\\nb" and + "a\\nb\\n" have 2 lines. Empty text has 0 lines. + """ + return len(text.splitlines()) + + +def count_chars(text): + """Return the number of characters in *text*, whitespace included.""" + return len(text) +``` + +Why these three one-liners are correct rather than lazy: +- `str.split()` with no argument splits on runs of whitespace *and* discards leading/trailing whitespace, so `" a b "` yields 2 tokens and `" "` yields 0. Do not pass a separator. +- `str.splitlines()` yields `[]` for `""` and does not produce a trailing empty string for `"a\n"`, which is exactly the spec's rule. Do not use `text.count("\n")` or `text.split("\n")`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_counter -v` + +Expected: PASS — 14 tests, OK. + +- [ ] **Step 5: Commit** + +```bash +git add wordstat/counter.py test_counter.py +git commit -m "feat: add counter module with word, line, and char counts" +``` + +--- + +### Task 2: `formatter` — render a stats dict to a report string + +**Files:** +- Create: `wordstat/formatter.py` +- Test: `test_formatter.py` + +**Interfaces:** +- Consumes: nothing from Task 1 — `formatter` must not import `counter`. +- Produces: + - `format_report(stats: dict) -> str` — given `{"words": int, "lines": int, "chars": int}`, returns `"words: {words}\nlines: {lines}\nchars: {chars}"`. Exactly three lines, no trailing newline. + + Task 3 imports it as `from wordstat import formatter` and calls `formatter.format_report(stats)`. + +- [ ] **Step 1: Write the failing tests** + +Create `test_formatter.py` with exactly this content: + +```python +import unittest + +from wordstat import formatter + + +class FormatReportTests(unittest.TestCase): + def test_renders_three_labelled_lines(self): + report = formatter.format_report({"words": 12, "lines": 3, "chars": 57}) + self.assertEqual(report, "words: 12\nlines: 3\nchars: 57") + + def test_has_no_trailing_newline(self): + report = formatter.format_report({"words": 1, "lines": 1, "chars": 1}) + self.assertFalse(report.endswith("\n")) + + def test_line_order_is_words_lines_chars(self): + report = formatter.format_report({"words": 1, "lines": 2, "chars": 3}) + self.assertEqual(report.split("\n"), ["words: 1", "lines: 2", "chars: 3"]) + + def test_key_order_in_the_dict_does_not_matter(self): + report = formatter.format_report({"chars": 57, "lines": 3, "words": 12}) + self.assertEqual(report, "words: 12\nlines: 3\nchars: 57") + + def test_renders_zeros(self): + report = formatter.format_report({"words": 0, "lines": 0, "chars": 0}) + self.assertEqual(report, "words: 0\nlines: 0\nchars: 0") + + +if __name__ == "__main__": + unittest.main() +``` + +`test_key_order_in_the_dict_does_not_matter` is what stops someone from implementing this by iterating over `stats.items()`, which would silently reorder the report depending on how the caller built the dict. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_formatter -v` + +Expected: FAIL — `ImportError: cannot import name 'formatter' from 'wordstat'`. + +- [ ] **Step 3: Write the minimal implementation** + +Create `wordstat/formatter.py`: + +```python +"""Rendering of a stats mapping into a human-readable report.""" + + +def format_report(stats): + """Return a three-line report for *stats*. + + *stats* is a mapping with "words", "lines" and "chars" keys. The lines are + always emitted in that order, and the result has no trailing newline -- + the caller's ``print()`` supplies it. + """ + return ( + f"words: {stats['words']}\n" + f"lines: {stats['lines']}\n" + f"chars: {stats['chars']}" + ) +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_formatter -v` + +Expected: PASS — 5 tests, OK. + +- [ ] **Step 5: Commit** + +```bash +git add wordstat/formatter.py test_formatter.py +git commit -m "feat: add formatter module rendering stats to a report" +``` + +--- + +### Task 3: `cli` — argparse entry point composing counter and formatter + +**Files:** +- Create: `wordstat/cli.py` +- Test: `test_cli.py` + +**Interfaces:** +- Consumes: + - `counter.count_words(text) -> int`, `counter.count_lines(text) -> int`, `counter.count_chars(text) -> int` from Task 1. + - `formatter.format_report(stats) -> str` from Task 2, where `stats` is `{"words": int, "lines": int, "chars": int}`. +- Produces: + - `main(argv=None) -> int` — parses a single positional `path`, reads that file as UTF-8, prints the report to stdout, returns `0`. On an unreadable path, prints a message to stderr and returns `1`. With no `path`, `argparse` raises `SystemExit(2)`. + + `argv` defaults to `None` so that `main()` reads `sys.argv[1:]`, which is what the `__main__` guard relies on. + +- [ ] **Step 1: Write the failing tests** + +Create `test_cli.py` with exactly this content: + +```python +import io +import os +import tempfile +import unittest +from contextlib import redirect_stderr, redirect_stdout + +from wordstat import cli + + +def run_cli(argv): + """Run cli.main(argv), capturing output. Returns (code, stdout, stderr).""" + out, err = io.StringIO(), io.StringIO() + with redirect_stdout(out), redirect_stderr(err): + code = cli.main(argv) + return code, out.getvalue(), err.getvalue() + + +class CliTestCase(unittest.TestCase): + def write_file(self, data, encoding="utf-8"): + """Write *data* to a temp file cleaned up after the test; return path.""" + directory = tempfile.mkdtemp() + self.addCleanup(lambda: _remove_tree(directory)) + path = os.path.join(directory, "sample.txt") + mode = "wb" if isinstance(data, bytes) else "w" + kwargs = {} if isinstance(data, bytes) else {"encoding": encoding} + with open(path, mode, **kwargs) as handle: + handle.write(data) + return path + + +def _remove_tree(directory): + for name in os.listdir(directory): + os.remove(os.path.join(directory, name)) + os.rmdir(directory) + + +class SuccessTests(CliTestCase): + def test_prints_report_and_returns_zero(self): + path = self.write_file("one two\nthree\n") + code, out, err = run_cli([path]) + self.assertEqual(code, 0) + self.assertEqual(out, "words: 3\nlines: 2\nchars: 14\n") + self.assertEqual(err, "") + + def test_empty_file_reports_all_zeros(self): + path = self.write_file("") + code, out, err = run_cli([path]) + self.assertEqual(code, 0) + self.assertEqual(out, "words: 0\nlines: 0\nchars: 0\n") + self.assertEqual(err, "") + + def test_reads_file_as_utf8_regardless_of_locale(self): + path = self.write_file("héllo wörld\n") + code, out, err = run_cli([path]) + self.assertEqual(code, 0) + self.assertEqual(out, "words: 2\nlines: 1\nchars: 12\n") + self.assertEqual(err, "") + + +class FailureTests(CliTestCase): + def test_missing_file_reports_to_stderr_and_returns_one(self): + directory = tempfile.mkdtemp() + self.addCleanup(os.rmdir, directory) + path = os.path.join(directory, "does-not-exist.txt") + code, out, err = run_cli([path]) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertIn(path, err) + self.assertTrue(err.endswith("\n")) + + def test_directory_path_reports_to_stderr_and_returns_one(self): + directory = tempfile.mkdtemp() + self.addCleanup(os.rmdir, directory) + code, out, err = run_cli([directory]) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertIn(directory, err) + + def test_undecodable_file_reports_to_stderr_and_returns_one(self): + path = self.write_file(b"\xff\xfe not utf-8 \xff") + code, out, err = run_cli([path]) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertIn(path, err) + + def test_missing_argument_exits_two(self): + with self.assertRaises(SystemExit) as caught: + with redirect_stderr(io.StringIO()): + cli.main([]) + self.assertEqual(caught.exception.code, 2) + + +if __name__ == "__main__": + unittest.main() +``` + +Two notes on the expected numbers, so you can check them by hand rather than by running the code: +- `"one two\nthree\n"` — 3 words, 2 lines, and 14 characters (`one two` = 7, `\n` = 1, `three` = 5, `\n` = 1). +- `"héllo wörld\n"` — 2 words, 1 line, 12 characters. It is 14 *bytes* in UTF-8; if this test reports 14 chars, the implementation is measuring bytes. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_cli -v` + +Expected: FAIL — `ImportError: cannot import name 'cli' from 'wordstat'`. + +- [ ] **Step 3: Write the minimal implementation** + +Create `wordstat/cli.py`: + +```python +"""Command-line entry point: read a file, compute stats, print a report.""" + +import argparse +import sys + +from wordstat import counter, formatter + + +def _build_parser(): + parser = argparse.ArgumentParser( + prog="wordstat", + description="Print simple statistics about a text file.", + ) + parser.add_argument("path", help="path to the text file to analyze") + return parser + + +def main(argv=None): + """Run the CLI. Returns 0 on success, 1 if *path* cannot be read. + + *argv* is the argument list without the program name; None means use + sys.argv[1:]. A missing path argument makes argparse exit with status 2. + """ + args = _build_parser().parse_args(argv) + + try: + with open(args.path, encoding="utf-8") as handle: + text = handle.read() + except OSError as error: + print(f"wordstat: {args.path}: {error.strerror or error}", file=sys.stderr) + return 1 + except UnicodeDecodeError: + print(f"wordstat: {args.path}: not valid UTF-8 text", file=sys.stderr) + return 1 + + stats = { + "words": counter.count_words(text), + "lines": counter.count_lines(text), + "chars": counter.count_chars(text), + } + print(formatter.format_report(stats)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) +``` + +Why the error handling looks like this: +- Catch `OSError`, not `FileNotFoundError`. A directory raises `IsADirectoryError` and an unreadable file raises `PermissionError`; both are `OSError` subclasses, and both deserve the same message-and-exit-1 treatment rather than a traceback. +- `error.strerror or error` because `strerror` is `None` for some `OSError`s, and `"wordstat: /path: None"` is a useless message. +- `UnicodeDecodeError` is a `ValueError`, *not* an `OSError`, so it needs its own clause. It fires from `handle.read()` inside the `with`, which is why the `read()` is inside the `try`. +- `encoding="utf-8"` is explicit. Without it, `open()` uses the locale's preferred encoding and the same file would count differently on different machines. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_cli -v` + +Expected: PASS — 7 tests, OK. + +- [ ] **Step 5: Run the whole suite** + +Run: `python3 -m unittest -v` + +Expected: PASS — 26 tests across `test_cli`, `test_counter`, and `test_formatter`, OK. + +- [ ] **Step 6: Check the CLI by hand** + +```bash +printf 'one two\nthree\n' > /tmp/wordstat-sample.txt +python3 -m wordstat.cli /tmp/wordstat-sample.txt; echo "exit=$?" +python3 -m wordstat.cli /tmp/definitely-missing.txt; echo "exit=$?" +rm /tmp/wordstat-sample.txt +``` + +Expected: the first prints `words: 3` / `lines: 2` / `chars: 14` then `exit=0`; the second prints a `wordstat: /tmp/definitely-missing.txt: No such file or directory` line on stderr then `exit=1`. + +- [ ] **Step 7: Commit** + +```bash +git add wordstat/cli.py test_cli.py +git commit -m "feat: add cli composing counter and formatter" +``` diff --git a/tests/inline-eval/results/2026-09-17-review-focus-five/wpplan-32.txt b/tests/inline-eval/results/2026-09-17-review-focus-five/wpplan-32.txt new file mode 100644 index 000000000..89d2c5ac1 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-review-focus-five/wpplan-32.txt @@ -0,0 +1,24 @@ +# rep wpplan-32 transcript=/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/inline-eval-br/reps/wpplan-32/cfg/projects/-private-tmp-claude-501--Users-jesse-git-superpowers-superpowers-a8a313bf-7410-41ce-9036-636ccf4061d6-scratchpad-inline-eval-br-reps-wpplan-32-repo/6c2f0fbd-51d1-48a4-8a46-5a976a56c550.jsonl +skill executing-plans invoked False +all skills invoked writing-plans +Agent dispatches 0 +test runs 0 +ledger (progress.md) touches 0 +sdd script invocations 0 +commits (incl. fixture) 1 +suite in repo plan.md-present +probe review-focus handled +probe implied-decode handled +probe decode-test handled +user_prompt_submit events 1 (background-agent wakeups count too) +final: 'Rulings I made' False +final: mentions self-review False +mid-run check-in candidates 0 +main session tokens 6 msgs; out=13492 cache_read=130286 cache_create=35568 in=12 +subagent tokens 0 agents; out=0 cache_read=0 cache_create=0 in=0 +TOTAL tokens out=13492 cache_read=130286 cache_create=35568 in=12 + +# assistant text, in order (truncated) +[ 1] I'll start by reading the design doc and invoking the writing-plans skill. +[ 5] I'm using the writing-plans skill to create the implementation plan. +[ 16] Plan saved to `plan.md` (repo root, as you asked — not under `docs/`). **Structure:** three tasks, one per module in the design, each with a full TDD cycle (write tests → watch them fail → implement → watch them pass → c diff --git a/tests/inline-eval/results/2026-09-17-review-focus-five/wpplan-33.plan.md b/tests/inline-eval/results/2026-09-17-review-focus-five/wpplan-33.plan.md new file mode 100644 index 000000000..c5fd68834 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-review-focus-five/wpplan-33.plan.md @@ -0,0 +1,521 @@ +# wordstat Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build `wordstat`, a stdlib-only Python package whose CLI reads a text file and prints a three-line report of word, line, and character counts. + +**Architecture:** Three modules with one responsibility each: `counter.py` holds pure `str -> int` stat functions, `formatter.py` turns a stats dict into the report string, and `cli.py` is the only module that touches the filesystem, `sys.stderr`, and exit codes. Because `counter` and `formatter` are pure, they are tested directly with values; `cli` is tested end-to-end against real temp files with stdout/stderr captured. + +**Tech Stack:** Python 3 standard library only — `argparse`, `sys`, and `unittest` (plus `tempfile`, `io`, `contextlib`, `os` in tests). No third-party packages, no `pip install`, no `pyproject.toml`. + +**Spec:** `design.md` (in this same directory) + +## Global Constraints + +- Standard library only — no third-party runtime or test dependencies. +- Tests live at the repo root as `test_counter.py`, `test_formatter.py`, `test_cli.py` and must be runnable with `python3 -m unittest`. +- Package modules live in `wordstat/`: `counter.py`, `formatter.py`, `cli.py`. `wordstat/__init__.py` already exists and stays an empty package marker. +- Each module is independently testable; `cli` composes `counter` + `formatter` and no other module reads files or writes to stdout/stderr. +- Line-counting rule, verbatim from the spec: "a trailing newline does not add an empty final line; `"a\nb"` and `"a\nb\n"` are both 2". +- Report format, verbatim from the spec: `"words: 12\nlines: 3\nchars: 57"` (no trailing newline — `print()` in the CLI supplies it). +- `cli.main` returns `0` on success and `1` on a file it cannot read; it never calls `sys.exit()` itself except under `__main__`. +- Work directly on `main`. This is a local scratch repo with no remote — never run `git push`. + +## Review Focus + +Input classes the spec implies but does not spell out. Each one has a test in the task named beside it. + +1. **Empty file** — every count is `0` (including lines: an empty file has no lines) and the CLI still exits `0`. Tests: Task 1 (`counter`), Task 3 (CLI end-to-end). +2. **Whitespace-only and blank-line-heavy text** — `" \n\t \n"` has `0` words; a blank line between two lines of text counts as a line, and `"a\n\n"` is 2 lines under the trailing-newline rule. Test: Task 1. +3. **Non-ASCII text** — the file is decoded as UTF-8 explicitly (not the locale default) and `count_chars` counts characters, not bytes, so `"héllo wörld\n"` is 12 chars. Tests: Task 1 (`counter`), Task 3 (CLI reads with `encoding="utf-8"`). +4. **A path that exists but is not a readable regular file** — a directory, or a file the user lacks permission for, gets the same treatment as a missing file: a message on stderr and exit `1`, never a traceback. Test: Task 3. +5. **A file whose bytes are not valid UTF-8** — e.g. pointing the CLI at a binary file. `UnicodeDecodeError` is not an `OSError`, so it needs its own handler: message on stderr, exit `1`. Test: Task 3. + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `wordstat/__init__.py` | Package marker. Already exists, stays empty. Not modified by any task. | +| `wordstat/counter.py` | Three pure functions: `count_words`, `count_lines`, `count_chars`. No I/O, no imports. | +| `wordstat/formatter.py` | One pure function: `format_report(stats)` → report string. No I/O. | +| `wordstat/cli.py` | `main(argv=None)`: argparse, file reading, error messages, exit codes. The only module that does I/O. | +| `test_counter.py` | Unit tests for `counter`, at repo root. | +| `test_formatter.py` | Unit tests for `formatter`, at repo root. | +| `test_cli.py` | End-to-end tests for `cli` against real temp files, at repo root. | + +--- + +## Task 1: counter — pure stat functions + +**Files:** +- Create: `wordstat/counter.py` +- Test: `test_counter.py` + +**Interfaces:** +- Consumes: nothing (first task). +- Produces: + - `count_words(text: str) -> int` — number of whitespace-separated tokens. + - `count_lines(text: str) -> int` — number of lines; `""` → 0; a single trailing newline does not add an empty final line. + - `count_chars(text: str) -> int` — number of characters, whitespace included. + +- [ ] **Step 1: Write the failing tests** + +Create `test_counter.py` with exactly this content: + +```python +import unittest + +from wordstat import counter + + +class CountWordsTests(unittest.TestCase): + def test_counts_whitespace_separated_tokens(self): + self.assertEqual(counter.count_words("the quick brown fox"), 4) + + def test_collapses_runs_of_whitespace(self): + self.assertEqual(counter.count_words("a\t\tb\n c \n"), 3) + + def test_empty_text_has_no_words(self): + self.assertEqual(counter.count_words(""), 0) + + def test_whitespace_only_text_has_no_words(self): + self.assertEqual(counter.count_words(" \n\t \n"), 0) + + +class CountLinesTests(unittest.TestCase): + def test_counts_lines_without_trailing_newline(self): + self.assertEqual(counter.count_lines("a\nb"), 2) + + def test_trailing_newline_does_not_add_a_line(self): + self.assertEqual(counter.count_lines("a\nb\n"), 2) + + def test_blank_interior_line_counts(self): + self.assertEqual(counter.count_lines("a\n\nb\n"), 3) + + def test_trailing_blank_line_counts(self): + self.assertEqual(counter.count_lines("a\n\n"), 2) + + def test_single_line_without_newline(self): + self.assertEqual(counter.count_lines("just one line"), 1) + + def test_lone_newline_is_one_line(self): + self.assertEqual(counter.count_lines("\n"), 1) + + def test_empty_text_has_no_lines(self): + self.assertEqual(counter.count_lines(""), 0) + + +class CountCharsTests(unittest.TestCase): + def test_counts_all_characters_including_whitespace(self): + self.assertEqual(counter.count_chars("a b\n"), 4) + + def test_empty_text_has_no_chars(self): + self.assertEqual(counter.count_chars(""), 0) + + def test_counts_characters_not_bytes(self): + self.assertEqual(counter.count_chars("héllo wörld\n"), 12) + + +if __name__ == "__main__": + unittest.main() +``` + +Why these cases: the first three tests of each class pin the spec's stated behavior; `test_whitespace_only_text_has_no_words`, `test_trailing_blank_line_counts`, `test_lone_newline_is_one_line`, `test_empty_text_has_no_lines`, and `test_counts_characters_not_bytes` pin Review Focus items 1–3. `"héllo wörld\n"` is 12 characters but 14 UTF-8 bytes, so a byte-based implementation fails that test. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_counter -v` + +Expected: FAIL/ERROR — `ImportError: cannot import name 'counter' from 'wordstat'`. + +- [ ] **Step 3: Write the minimal implementation** + +Create `wordstat/counter.py` with exactly this content: + +```python +"""Pure functions that compute simple statistics about a block of text.""" + + +def count_words(text): + """Return the number of whitespace-separated tokens in ``text``.""" + return len(text.split()) + + +def count_lines(text): + """Return the number of lines in ``text``. + + A single trailing newline does not add an empty final line, so both + ``"a\\nb"`` and ``"a\\nb\\n"`` are 2 lines. Empty text has 0 lines. + """ + if not text: + return 0 + if text.endswith("\n"): + text = text[:-1] + return text.count("\n") + 1 + + +def count_chars(text): + """Return the number of characters in ``text``, whitespace included.""" + return len(text) +``` + +Note on `count_lines`: strip **one** trailing newline, not all of them. `text.rstrip("\n")` would turn `"a\n\n"` into `"a"` and report 1 line, failing `test_trailing_blank_line_counts`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_counter -v` + +Expected: PASS — 14 tests, `OK`. + +- [ ] **Step 5: Commit** + +```bash +git add test_counter.py wordstat/counter.py +git commit -m "feat: add counter module with word, line, and char counts" +``` + +--- + +## Task 2: formatter — render the report string + +**Files:** +- Create: `wordstat/formatter.py` +- Test: `test_formatter.py` + +**Interfaces:** +- Consumes: nothing from Task 1 — `formatter` is independent of `counter` and imports nothing. +- Produces: `format_report(stats: dict) -> str`, where `stats` has integer keys `"words"`, `"lines"`, `"chars"`. Returns exactly three lines separated by `\n`, with **no** trailing newline. + +- [ ] **Step 1: Write the failing tests** + +Create `test_formatter.py` with exactly this content: + +```python +import unittest + +from wordstat import formatter + + +class FormatReportTests(unittest.TestCase): + def test_renders_three_labeled_lines(self): + report = formatter.format_report({"words": 12, "lines": 3, "chars": 57}) + self.assertEqual(report, "words: 12\nlines: 3\nchars: 57") + + def test_renders_zeros(self): + report = formatter.format_report({"words": 0, "lines": 0, "chars": 0}) + self.assertEqual(report, "words: 0\nlines: 0\nchars: 0") + + def test_has_no_trailing_newline(self): + report = formatter.format_report({"words": 1, "lines": 1, "chars": 1}) + self.assertFalse(report.endswith("\n")) + + def test_key_order_in_the_dict_does_not_change_the_report(self): + report = formatter.format_report({"chars": 57, "lines": 3, "words": 12}) + self.assertEqual(report, "words: 12\nlines: 3\nchars: 57") + + +if __name__ == "__main__": + unittest.main() +``` + +Why these cases: `test_renders_three_labeled_lines` uses the spec's own example values. `test_has_no_trailing_newline` protects the contract with `cli`, which uses `print()` and would otherwise emit a blank line. `test_key_order_...` rules out an implementation that iterates the dict, which would put the labels in the caller's order instead of the spec's. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_formatter -v` + +Expected: FAIL/ERROR — `ImportError: cannot import name 'formatter' from 'wordstat'`. + +- [ ] **Step 3: Write the minimal implementation** + +Create `wordstat/formatter.py` with exactly this content: + +```python +"""Render a stats mapping as a human-readable report.""" + + +def format_report(stats): + """Return a three-line report for ``stats``. + + ``stats`` maps ``"words"``, ``"lines"``, and ``"chars"`` to integers. The + returned string has no trailing newline. + """ + return "\n".join( + ( + f"words: {stats['words']}", + f"lines: {stats['lines']}", + f"chars: {stats['chars']}", + ) + ) +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_formatter -v` + +Expected: PASS — 4 tests, `OK`. + +- [ ] **Step 5: Commit** + +```bash +git add test_formatter.py wordstat/formatter.py +git commit -m "feat: add formatter module that renders the stats report" +``` + +--- + +## Task 3: cli — argparse entry point + +**Files:** +- Create: `wordstat/cli.py` +- Test: `test_cli.py` + +**Interfaces:** +- Consumes: `counter.count_words(text)`, `counter.count_lines(text)`, `counter.count_chars(text)` from Task 1; `formatter.format_report(stats)` from Task 2 (`stats` keys: `"words"`, `"lines"`, `"chars"`). +- Produces: `main(argv=None) -> int`. Takes one positional `path`. Prints the report to stdout and returns `0`; on an unreadable path prints a message to stderr, prints nothing to stdout, and returns `1`. `argv=None` means "read `sys.argv[1:]`", which is what the `__main__` block relies on. + +This task has three TDD cycles: happy path, unreadable inputs, then module-level entry point. + +- [ ] **Step 1: Write the failing happy-path tests** + +Create `test_cli.py` with exactly this content: + +```python +import contextlib +import io +import os +import tempfile +import unittest + +from wordstat import cli + + +def run_cli(argv): + """Run ``cli.main(argv)`` and return ``(exit_code, stdout, stderr)``.""" + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = cli.main(argv) + return code, out.getvalue(), err.getvalue() + + +class CliTestCase(unittest.TestCase): + def setUp(self): + tmpdir = tempfile.TemporaryDirectory() + self.addCleanup(tmpdir.cleanup) + self.tmpdir = tmpdir.name + + def write_file(self, text, name="sample.txt"): + path = os.path.join(self.tmpdir, name) + with open(path, "w", encoding="utf-8") as handle: + handle.write(text) + return path + + +class SuccessTests(CliTestCase): + def test_prints_report_and_returns_zero(self): + path = self.write_file("the quick brown fox\njumps over\n") + code, out, err = run_cli([path]) + self.assertEqual(code, 0) + self.assertEqual(out, "words: 6\nlines: 2\nchars: 31\n") + self.assertEqual(err, "") + + def test_empty_file_reports_zeros(self): + path = self.write_file("") + code, out, err = run_cli([path]) + self.assertEqual(code, 0) + self.assertEqual(out, "words: 0\nlines: 0\nchars: 0\n") + self.assertEqual(err, "") + + def test_reads_utf8_text(self): + path = self.write_file("héllo wörld\n") + code, out, err = run_cli([path]) + self.assertEqual(code, 0) + self.assertEqual(out, "words: 2\nlines: 1\nchars: 12\n") + self.assertEqual(err, "") + + +if __name__ == "__main__": + unittest.main() +``` + +Why these numbers: `"the quick brown fox\njumps over\n"` is 6 tokens, 2 lines, and 31 characters (19 + newline + 10 + newline). `"héllo wörld\n"` is 12 characters. The exact-equality assertion on `out` pins the trailing newline that `print()` adds — exactly one, no blank line after it. `test_empty_file_reports_zeros` and `test_reads_utf8_text` cover Review Focus items 1 and 3. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_cli -v` + +Expected: FAIL/ERROR — `ImportError: cannot import name 'cli' from 'wordstat'`. + +- [ ] **Step 3: Write the minimal happy-path implementation** + +Create `wordstat/cli.py` with exactly this content: + +```python +"""Command-line entry point: read a text file and print its statistics.""" + +import argparse + +from . import counter, formatter + + +def _build_parser(): + parser = argparse.ArgumentParser( + prog="wordstat", + description="Print simple statistics about a text file.", + ) + parser.add_argument("path", help="path to the text file to analyze") + return parser + + +def main(argv=None): + """Print a stats report for the given file. Return an exit code.""" + args = _build_parser().parse_args(argv) + with open(args.path, encoding="utf-8") as handle: + text = handle.read() + stats = { + "words": counter.count_words(text), + "lines": counter.count_lines(text), + "chars": counter.count_chars(text), + } + print(formatter.format_report(stats)) + return 0 +``` + +`encoding="utf-8"` is deliberate: without it, `open()` uses the locale encoding and the same file gives different character counts on different machines. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_cli -v` + +Expected: PASS — 3 tests, `OK`. + +- [ ] **Step 5: Commit the happy path** + +```bash +git add test_cli.py wordstat/cli.py +git commit -m "feat: add cli entry point that prints the stats report" +``` + +- [ ] **Step 6: Write the failing tests for unreadable inputs** + +Append this class to `test_cli.py`, directly after `SuccessTests` and before the `if __name__ == "__main__":` block: + +```python +class FailureTests(CliTestCase): + def test_missing_file_returns_one_with_stderr_message(self): + path = os.path.join(self.tmpdir, "nope.txt") + code, out, err = run_cli([path]) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertIn("nope.txt", err) + + def test_directory_path_returns_one_with_stderr_message(self): + code, out, err = run_cli([self.tmpdir]) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertIn(self.tmpdir, err) + + def test_undecodable_bytes_return_one_with_stderr_message(self): + path = os.path.join(self.tmpdir, "binary.dat") + with open(path, "wb") as handle: + handle.write(b"\xff\xfe\x00\x01") + code, out, err = run_cli([path]) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertIn("binary.dat", err) + + def test_missing_path_argument_exits_two(self): + with self.assertRaises(SystemExit) as caught: + run_cli([]) + self.assertEqual(caught.exception.code, 2) +``` + +Why these cases: the spec names only the missing-file case, but a directory raises `IsADirectoryError` and a binary file raises `UnicodeDecodeError` — neither is caught by an `except FileNotFoundError`, and both would print a traceback (Review Focus items 4 and 5). The last test pins that a forgotten argument is argparse's usage error (exit 2), not a `TypeError` from our code. + +- [ ] **Step 7: Run the tests to verify the new ones fail** + +Run: `python3 -m unittest test_cli -v` + +Expected: `SuccessTests` and `test_missing_path_argument_exits_two` PASS; the other three `FailureTests` ERROR with `FileNotFoundError`, `IsADirectoryError`, and `UnicodeDecodeError` respectively. + +- [ ] **Step 8: Add the error handling** + +In `wordstat/cli.py`, add `import sys` below `import argparse`, then replace the `with open(...)` block in `main` so the function reads: + +```python +def main(argv=None): + """Print a stats report for the given file. Return an exit code.""" + args = _build_parser().parse_args(argv) + try: + with open(args.path, encoding="utf-8") as handle: + text = handle.read() + except OSError as exc: + reason = exc.strerror or str(exc) + print(f"wordstat: cannot read {args.path}: {reason}", file=sys.stderr) + return 1 + except UnicodeDecodeError: + print( + f"wordstat: cannot read {args.path}: not valid UTF-8 text", + file=sys.stderr, + ) + return 1 + stats = { + "words": counter.count_words(text), + "lines": counter.count_lines(text), + "chars": counter.count_chars(text), + } + print(formatter.format_report(stats)) + return 0 +``` + +Catching `OSError` (not just `FileNotFoundError`) covers missing files, directories, and permission errors with one branch. `UnicodeDecodeError` is a `ValueError`, not an `OSError`, so it needs the second handler. + +- [ ] **Step 9: Run the tests to verify they pass** + +Run: `python3 -m unittest test_cli -v` + +Expected: PASS — 7 tests, `OK`. + +- [ ] **Step 10: Commit the error handling** + +```bash +git add test_cli.py wordstat/cli.py +git commit -m "feat: report unreadable files on stderr and exit 1" +``` + +- [ ] **Step 11: Add the `python3 -m wordstat.cli` entry point** + +Append to `wordstat/cli.py`: + +```python +if __name__ == "__main__": # pragma: no cover + sys.exit(main()) +``` + +- [ ] **Step 12: Verify the CLI works when actually run** + +Run these commands from the repo root: + +```bash +printf 'the quick brown fox\njumps over\n' > /tmp/wordstat-demo.txt +python3 -m wordstat.cli /tmp/wordstat-demo.txt; echo "exit=$?" +python3 -m wordstat.cli /tmp/definitely-not-here.txt; echo "exit=$?" +rm /tmp/wordstat-demo.txt +``` + +Expected: the first run prints `words: 6` / `lines: 2` / `chars: 31` and `exit=0`; the second prints `wordstat: cannot read /tmp/definitely-not-here.txt: No such file or directory` to stderr and `exit=1`. + +- [ ] **Step 13: Run the whole suite** + +Run: `python3 -m unittest discover -v` + +Expected: PASS — 25 tests total (14 counter + 4 formatter + 7 cli), `OK`. + +- [ ] **Step 14: Commit** + +```bash +git add wordstat/cli.py +git commit -m "feat: allow running wordstat with python3 -m wordstat.cli" +``` diff --git a/tests/inline-eval/results/2026-09-17-review-focus-five/wpplan-33.txt b/tests/inline-eval/results/2026-09-17-review-focus-five/wpplan-33.txt new file mode 100644 index 000000000..0700828f4 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-review-focus-five/wpplan-33.txt @@ -0,0 +1,24 @@ +# rep wpplan-33 transcript=/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/inline-eval-br/reps/wpplan-33/cfg/projects/-private-tmp-claude-501--Users-jesse-git-superpowers-superpowers-a8a313bf-7410-41ce-9036-636ccf4061d6-scratchpad-inline-eval-br-reps-wpplan-33-repo/aed7f4ba-cfdf-4c47-a51d-a6a08cfbd3e8.jsonl +skill executing-plans invoked False +all skills invoked writing-plans +Agent dispatches 0 +test runs 0 +ledger (progress.md) touches 0 +sdd script invocations 0 +commits (incl. fixture) 1 +suite in repo plan.md-present +probe review-focus handled +probe implied-decode handled +probe decode-test handled +user_prompt_submit events 1 (background-agent wakeups count too) +final: 'Rulings I made' False +final: mentions self-review False +mid-run check-in candidates 0 +main session tokens 5 msgs; out=15467 cache_read=109253 cache_create=27630 in=10 +subagent tokens 0 agents; out=0 cache_read=0 cache_create=0 in=0 +TOTAL tokens out=15467 cache_read=109253 cache_create=27630 in=10 + +# assistant text, in order (truncated) +[ 1] I'll start by reading the design and invoking the writing-plans skill. +[ 5] I'm using the writing-plans skill to create the implementation plan. +[ 13] Plan complete and saved to `plan.md` (repo root, per your instruction — not under `docs/`). Nothing implemented; it's left uncommitted in the working tree. How it's structured — three tasks matching the design's module b diff --git a/tests/inline-eval/results/2026-09-17-review-focus-five/wpplan-41.plan.md b/tests/inline-eval/results/2026-09-17-review-focus-five/wpplan-41.plan.md new file mode 100644 index 000000000..6cbd84194 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-review-focus-five/wpplan-41.plan.md @@ -0,0 +1,1394 @@ +# ledgerlite Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build `ledgerlite`, a standard-library-only Python command-line tool that reads a bank-transaction CSV, categorizes each row with a rules file, and prints per-category totals plus the closing balance. + +**Architecture:** Six small modules with one responsibility each, wired together by `cli.py`: `model` (the `Transaction` record), `parse` (field validation + CSV → `list[Transaction]`, raising `ParseError` carrying a line number), `rules` (rules text → ordered `(substring, category)` pairs, plus `categorize`), `balance` (date ordering and closing balance), `report` (per-category totals and text formatting), `cli` (argparse, file I/O, exit codes, error messages). All I/O lives in `cli.py`; every other module takes iterables of lines or lists of objects, so tests never need temp files except for the CLI task. Money is `decimal.Decimal` end to end — never `float`. + +**Tech Stack:** Python 3.11+, standard library only (`argparse`, `csv`, `dataclasses`, `datetime`, `decimal`, `re`, `unittest`). Tests are `unittest` files at the repo root, run with `python3 -m unittest`. + +**Spec:** `design.md` (in this directory — read it before starting; every task argues from it) + +## Global Constraints + +- Python 3.11+. Standard library only — no third-party dependencies, no `pyproject.toml`, no packaging metadata. +- Money is parsed and summed as `decimal.Decimal`. `float` must not appear anywhere in the package. +- Package layout is fixed by the spec: `ledgerlite/{__init__,model,parse,rules,balance,report,cli}.py`, plus `ledgerlite/__main__.py` so `python3 -m ledgerlite` works (see Decisions). +- Tests live at the repo root as `test_.py` and run with `python3 -m unittest` from the repo root. +- Exit codes: `0` success, `1` a named file cannot be read, `2` a file's contents are malformed (and, from argparse, bad command-line usage). +- Error messages, verbatim formats: `ledgerlite: cannot read : ` and `ledgerlite: :: `, each on stderr with a trailing newline. +- When any error is reported, stdout stays empty. +- Amounts print with exactly two fractional digits, a leading `-` only for negatives, and no thousands separators: `-12.50`, `0.00`, `1200.00`. +- The uncategorized bucket is spelled `uncategorized` and is always listed last. +- Work directly on `main` in this repo (local scratch repo, no remote). Commit at the end of every task. + +## Decisions where the spec is silent + +These are judgment calls, made once here so tasks stay consistent. Each is pinned by a test in the task that owns the code. + +1. **Header row is validated.** The first CSV line must be `date,amount,description` (field names compared after `.strip().lower()`). A file whose first line is something else is malformed at line 1 — otherwise a header-less file silently loses its first transaction. A completely empty file is malformed at line 1 (`missing header row date,amount,description`). A header-only file is valid and yields zero transactions. +2. **`date` and `amount` fields are `.strip()`ped before validation** (so `2026-03-04, -7.50, Coffee` parses); `description` is preserved byte-for-byte, including leading spaces, because it is free text. +3. **Amounts are validated by regex, not by `Decimal`'s tolerance.** `Decimal` accepts `NaN`, `Infinity`, `-inf`, `1e2`, and `1_0`; none of those is "a decimal number with up to two fractional digits", so all are malformed. `1.5`, `1.50`, `1200`, `.50`, `+3.00` are fine; `1.005` is not. +4. **Dates must be exactly `YYYY-MM-DD`.** `date.fromisoformat` on 3.11+ also accepts `20260304` and week dates; the spec pins one form, so a regex gates it first. +5. **Rules-file problems are reported like transaction problems**: `ledgerlite: :: `, exit 2. Blank and whitespace-only lines are skipped. A line with no `=`, an empty substring, or an empty category is malformed. Substring and category are stripped of surrounding whitespace; the split is on the *first* `=`, so a category may contain `=`. +6. **An unreadable `--rules` file** is reported with the same `cannot read` message and exit 1 as an unreadable transactions file. +7. **Precedence:** the transactions file is read and parsed first, so its problems are reported before any rules-file problem. +8. **Zero categories still get the blank separator line.** With no transactions the whole report is `"\nclosing balance: 0.00\n"` — the spec says "then a blank line, then `closing balance`" unconditionally. +9. **Category ordering is a plain codepoint sort** (`sorted()`), so `Food` sorts before `auto`. Categories differing only in case are distinct categories. +10. **`--opening` is validated by the same amount rules.** An invalid value is an argparse error: usage on stderr, exit 2 (argparse's own code, which matches the malformed-input code). +11. **Files are opened with `encoding="utf-8-sig"`** so a UTF-8 BOM before the header does not break header validation. Undecodable bytes are reported as `cannot read`, exit 1 — never a traceback. +12. **Invocation is `python3 -m ledgerlite report ...`.** A `ledgerlite` console script would need packaging metadata, which the stdlib-only constraint rules out. + +## Review Focus + +These are the input classes the spec implies but never spells out, most likely to bite first. Each already has a test in the task that owns the code — listed here so a reviewer can check them as a set. + +1. **Whitespace-padded CSV fields** (`2026-03-04, -7.50, Coffee`) — extremely common in real exports; must parse, not be rejected as malformed. (Tasks 2, 3) +2. **Strings `Decimal` accepts but the spec does not** — `NaN`, `Infinity`, `-inf`, `1e2`, `1_0`, `""` — must be malformed with exit 2, never silently summed into the balance. (Tasks 2, 7) +3. **A header-only file and a truly empty file** — zero transactions must print the blank line plus `closing balance: ` and exit 0; an empty file must be a clean exit 2, not an `IndexError`. (Tasks 3, 6, 7) +4. **A category whose amounts sum to zero** must print `0.00`, never `-0.00`, and `-0.001`-style residue must be impossible because inputs are capped at two decimals. (Task 6) +5. **Paths that exist but cannot be read as text** — a directory, a permission-denied file, non-UTF-8 bytes — must produce `ledgerlite: cannot read : ` and exit 1, never a traceback. (Task 7) + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `ledgerlite/__init__.py` | Package docstring only. No re-exports (keeps import graph obvious). | +| `ledgerlite/model.py` | `Transaction` frozen dataclass: `date`, `amount`, `description`. | +| `ledgerlite/parse.py` | `ParseError`; `parse_amount`, `parse_date` field validators; `parse_transactions(lines)` → `list[Transaction]`. | +| `ledgerlite/rules.py` | `Rule` alias; `parse_rules(lines)` → `list[Rule]`; `categorize(description, rules)` → `str | None`. | +| `ledgerlite/balance.py` | `order_by_date(transactions)`; `closing_balance(opening, transactions)`. | +| `ledgerlite/report.py` | `UNCATEGORIZED`; `format_amount`; `category_totals`; `ordered_categories`; `format_report`. | +| `ledgerlite/cli.py` | argparse wiring, file reading, error messages, exit codes; `main(argv) -> int`. | +| `ledgerlite/__main__.py` | `sys.exit(main())` so `python3 -m ledgerlite` runs. | +| `test_model.py` … `test_cli.py` | One `unittest` file per module under test, at the repo root. | + +Task order follows the dependency order: model → parse → rules → balance → report → cli. + +--- + +### Task 1: Package skeleton and Transaction model + +**Files:** +- Create: `ledgerlite/__init__.py` +- Create: `ledgerlite/model.py` +- Test: `test_model.py` + +**Interfaces:** +- Consumes: nothing. +- Produces: `ledgerlite.model.Transaction(date: datetime.date, amount: decimal.Decimal, description: str)` — a frozen dataclass with keyword or positional construction, field order `date, amount, description`. Every later task imports it. + +- [ ] **Step 1: Write the failing test** + +Create `test_model.py`: + +```python +import dataclasses +import datetime +import unittest +from decimal import Decimal + +from ledgerlite.model import Transaction + + +class TransactionTest(unittest.TestCase): + def test_holds_date_amount_and_description(self): + txn = Transaction( + date=datetime.date(2026, 3, 4), + amount=Decimal("-7.50"), + description="Coffee Shop", + ) + self.assertEqual(txn.date, datetime.date(2026, 3, 4)) + self.assertEqual(txn.amount, Decimal("-7.50")) + self.assertEqual(txn.description, "Coffee Shop") + + def test_field_order_is_date_amount_description(self): + txn = Transaction(datetime.date(2026, 3, 4), Decimal("1.00"), "x") + self.assertEqual(txn.date, datetime.date(2026, 3, 4)) + self.assertEqual(txn.amount, Decimal("1.00")) + self.assertEqual(txn.description, "x") + + def test_is_frozen(self): + txn = Transaction(datetime.date(2026, 3, 4), Decimal("1.00"), "x") + with self.assertRaises(dataclasses.FrozenInstanceError): + txn.amount = Decimal("2.00") + + def test_equal_values_compare_equal(self): + first = Transaction(datetime.date(2026, 3, 4), Decimal("1.00"), "x") + second = Transaction(datetime.date(2026, 3, 4), Decimal("1.00"), "x") + self.assertEqual(first, second) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `python3 -m unittest test_model -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite'` + +- [ ] **Step 3: Write the minimal implementation** + +Create `ledgerlite/__init__.py`: + +```python +"""ledgerlite: summarize bank transactions by category.""" +``` + +Create `ledgerlite/model.py`: + +```python +"""The transaction record shared by every other module.""" + +from __future__ import annotations + +import datetime +from dataclasses import dataclass +from decimal import Decimal + + +@dataclass(frozen=True) +class Transaction: + """One row of the transactions CSV, with its fields already validated.""" + + date: datetime.date + amount: Decimal + description: str +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `python3 -m unittest test_model -v` +Expected: PASS (4 tests) + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/__init__.py ledgerlite/model.py test_model.py +git commit -m "feat: add Transaction model and package skeleton" +``` + +--- + +### Task 2: Field validation — amounts, dates, and ParseError + +**Files:** +- Create: `ledgerlite/parse.py` +- Test: `test_parse.py` + +**Interfaces:** +- Consumes: `ledgerlite.model.Transaction` (imported here, used in Task 3). +- Produces: + - `ParseError(line: int, message: str)` — exception with `.line` and `.message` attributes; `str(err)` is `": "`. + - `parse_amount(text: str) -> Decimal` — raises `ValueError` whose message is a complete "what is wrong" phrase. + - `parse_date(text: str) -> datetime.date` — same error contract. + - Both are reused by Task 3 (`parse_transactions`) and Task 7 (`--opening`). + +- [ ] **Step 1: Write the failing tests** + +Create `test_parse.py`: + +```python +import datetime +import unittest +from decimal import Decimal + +from ledgerlite.parse import ParseError, parse_amount, parse_date + + +class ParseErrorTest(unittest.TestCase): + def test_records_line_and_message(self): + error = ParseError(7, "expected 3 columns, got 2") + self.assertEqual(error.line, 7) + self.assertEqual(error.message, "expected 3 columns, got 2") + self.assertEqual(str(error), "7: expected 3 columns, got 2") + + +class ParseAmountTest(unittest.TestCase): + def test_accepts_two_fractional_digits(self): + self.assertEqual(parse_amount("-12.50"), Decimal("-12.50")) + + def test_accepts_fewer_fractional_digits(self): + self.assertEqual(parse_amount("1.5"), Decimal("1.5")) + self.assertEqual(parse_amount("1200"), Decimal("1200")) + + def test_accepts_leading_sign_and_bare_fraction(self): + self.assertEqual(parse_amount("+3.00"), Decimal("3.00")) + self.assertEqual(parse_amount(".50"), Decimal("0.50")) + + def test_accepts_surrounding_whitespace(self): + self.assertEqual(parse_amount(" -7.50 "), Decimal("-7.50")) + + def test_rejects_more_than_two_fractional_digits(self): + with self.assertRaises(ValueError): + parse_amount("1.005") + + def test_rejects_strings_decimal_would_accept(self): + for text in ("NaN", "Infinity", "-inf", "1e2", "1_0", "", " ", "1,000.00", "$1.00"): + with self.subTest(text=text): + with self.assertRaises(ValueError): + parse_amount(text) + + def test_message_quotes_the_offending_text(self): + with self.assertRaises(ValueError) as caught: + parse_amount("abc") + self.assertIn("'abc'", str(caught.exception)) + self.assertIn("amount", str(caught.exception)) + + +class ParseDateTest(unittest.TestCase): + def test_accepts_iso_date(self): + self.assertEqual(parse_date("2026-03-04"), datetime.date(2026, 3, 4)) + + def test_accepts_surrounding_whitespace(self): + self.assertEqual(parse_date(" 2026-03-04 "), datetime.date(2026, 3, 4)) + + def test_rejects_other_shapes_and_impossible_dates(self): + for text in ("20260304", "2026-3-4", "03/04/2026", "2026-13-01", "2026-02-30", "", "today"): + with self.subTest(text=text): + with self.assertRaises(ValueError): + parse_date(text) + + def test_message_quotes_the_offending_text(self): + with self.assertRaises(ValueError) as caught: + parse_date("03/04/2026") + self.assertIn("'03/04/2026'", str(caught.exception)) + self.assertIn("date", str(caught.exception)) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_parse -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.parse'` + +- [ ] **Step 3: Write the minimal implementation** + +Create `ledgerlite/parse.py`: + +```python +"""Read a transactions CSV, validating every field.""" + +from __future__ import annotations + +import datetime +import re +from decimal import Decimal + +_DATE_PATTERN = re.compile(r"\A\d{4}-\d{2}-\d{2}\Z") +# A decimal number with at most two fractional digits. Deliberately stricter +# than Decimal(), which also accepts NaN, Infinity, 1e2 and 1_0. +_AMOUNT_PATTERN = re.compile(r"\A[+-]?(?:\d+(?:\.\d{1,2})?|\.\d{1,2})\Z") + + +class ParseError(Exception): + """A line of an input file could not be understood.""" + + def __init__(self, line: int, message: str) -> None: + super().__init__(f"{line}: {message}") + self.line = line + self.message = message + + +def parse_amount(text: str) -> Decimal: + """Return `text` as a Decimal, or raise ValueError explaining why not.""" + candidate = text.strip() + if not _AMOUNT_PATTERN.match(candidate): + raise ValueError( + f"invalid amount {candidate!r}: " + "expected a number with at most two fractional digits" + ) + return Decimal(candidate) + + +def parse_date(text: str) -> datetime.date: + """Return `text` as a date, or raise ValueError explaining why not.""" + candidate = text.strip() + if _DATE_PATTERN.match(candidate): + try: + return datetime.date.fromisoformat(candidate) + except ValueError: + pass + raise ValueError(f"invalid date {candidate!r}: expected YYYY-MM-DD") +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_parse -v` +Expected: PASS (12 tests) + +- [ ] **Step 5: Run the whole suite** + +Run: `python3 -m unittest` +Expected: PASS, no errors + +- [ ] **Step 6: Commit** + +```bash +git add ledgerlite/parse.py test_parse.py +git commit -m "feat: validate transaction amount and date fields" +``` + +--- + +### Task 3: CSV parsing into transactions + +**Files:** +- Modify: `ledgerlite/parse.py` (add `HEADER` and `parse_transactions`) +- Modify: `test_parse.py` (add `ParseTransactionsTest`) + +**Interfaces:** +- Consumes: `ParseError`, `parse_amount`, `parse_date` (Task 2); `Transaction` (Task 1). +- Produces: `parse_transactions(lines: Iterable[str]) -> list[Transaction]`, in file order, raising `ParseError` on the first bad line. Takes an iterable of lines (an open file or `io.StringIO`), never a path — Task 7 owns file I/O. + +- [ ] **Step 1: Write the failing tests** + +Append to `test_parse.py` (and add `import io` to the imports at the top, plus `from ledgerlite.model import Transaction` and `parse_transactions` to the existing `ledgerlite.parse` import): + +```python +HEADER_LINE = "date,amount,description\n" + + +def parse_csv(text): + return parse_transactions(io.StringIO(text)) + + +class ParseTransactionsTest(unittest.TestCase): + def test_parses_rows_in_file_order(self): + transactions = parse_csv( + HEADER_LINE + + "2026-03-05,-900.00,Monthly rent\n" + + "2026-03-04,-7.50,Coffee Shop\n" + ) + self.assertEqual( + transactions, + [ + Transaction(datetime.date(2026, 3, 5), Decimal("-900.00"), "Monthly rent"), + Transaction(datetime.date(2026, 3, 4), Decimal("-7.50"), "Coffee Shop"), + ], + ) + + def test_header_only_file_yields_no_transactions(self): + self.assertEqual(parse_csv(HEADER_LINE), []) + + def test_empty_file_is_malformed_at_line_one(self): + with self.assertRaises(ParseError) as caught: + parse_csv("") + self.assertEqual(caught.exception.line, 1) + self.assertIn("header", caught.exception.message) + + def test_wrong_header_is_malformed_at_line_one(self): + with self.assertRaises(ParseError) as caught: + parse_csv("2026-03-04,-7.50,Coffee Shop\n") + self.assertEqual(caught.exception.line, 1) + self.assertIn("header", caught.exception.message) + + def test_header_may_vary_in_case_and_padding(self): + self.assertEqual(parse_csv(" Date , Amount , Description \n"), []) + + def test_padded_fields_are_accepted_and_description_is_verbatim(self): + transactions = parse_csv(HEADER_LINE + "2026-03-04, -7.50, Coffee Shop\n") + self.assertEqual(transactions[0].date, datetime.date(2026, 3, 4)) + self.assertEqual(transactions[0].amount, Decimal("-7.50")) + self.assertEqual(transactions[0].description, " Coffee Shop") + + def test_quoted_description_may_contain_a_comma(self): + transactions = parse_csv(HEADER_LINE + '2026-03-04,-7.50,"Coffee, large"\n') + self.assertEqual(transactions[0].description, "Coffee, large") + + def test_too_few_columns_reports_the_line(self): + with self.assertRaises(ParseError) as caught: + parse_csv(HEADER_LINE + "2026-03-04,-7.50\n") + self.assertEqual(caught.exception.line, 2) + self.assertEqual(caught.exception.message, "expected 3 columns, got 2") + + def test_too_many_columns_reports_the_line(self): + with self.assertRaises(ParseError) as caught: + parse_csv(HEADER_LINE + "2026-03-04,-7.50,Coffee,extra\n") + self.assertEqual(caught.exception.line, 2) + self.assertEqual(caught.exception.message, "expected 3 columns, got 4") + + def test_blank_line_inside_the_file_is_malformed(self): + with self.assertRaises(ParseError) as caught: + parse_csv(HEADER_LINE + "2026-03-04,-7.50,Coffee\n" + "\n") + self.assertEqual(caught.exception.line, 3) + self.assertEqual(caught.exception.message, "expected 3 columns, got 0") + + def test_bad_date_reports_the_line_and_reason(self): + with self.assertRaises(ParseError) as caught: + parse_csv( + HEADER_LINE + + "2026-03-04,-7.50,Coffee\n" + + "04/03/2026,-1.00,Tea\n" + ) + self.assertEqual(caught.exception.line, 3) + self.assertIn("invalid date", caught.exception.message) + + def test_amount_with_three_fractional_digits_reports_the_line(self): + with self.assertRaises(ParseError) as caught: + parse_csv(HEADER_LINE + "2026-03-04,1.005,Coffee\n") + self.assertEqual(caught.exception.line, 2) + self.assertIn("invalid amount", caught.exception.message) + + def test_amount_decimal_would_accept_is_rejected(self): + for text in ("NaN", "Infinity", "1e2"): + with self.subTest(text=text): + with self.assertRaises(ParseError): + parse_csv(HEADER_LINE + f"2026-03-04,{text},Coffee\n") + + def test_missing_trailing_newline_is_fine(self): + transactions = parse_csv(HEADER_LINE + "2026-03-04,-7.50,Coffee") + self.assertEqual(len(transactions), 1) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_parse.ParseTransactionsTest -v` +Expected: FAIL — `ImportError: cannot import name 'parse_transactions'` + +- [ ] **Step 3: Write the minimal implementation** + +In `ledgerlite/parse.py`, add `import csv` and `from collections.abc import Iterable` to the imports, add `from .model import Transaction`, then add below the patterns: + +```python +HEADER = ["date", "amount", "description"] +``` + +and append: + +```python +def parse_transactions(lines: Iterable[str]) -> list[Transaction]: + """Return the transactions in `lines`, or raise ParseError on the first bad line. + + `lines` is any iterable of CSV text lines — an open file or an io.StringIO. + Line numbers in raised errors are 1-based file lines, header included. + """ + reader = csv.reader(lines) + try: + header = next(reader) + except StopIteration: + raise ParseError(1, "missing header row date,amount,description") from None + if [field.strip().lower() for field in header] != HEADER: + raise ParseError(1, "expected header row date,amount,description") from None + + transactions: list[Transaction] = [] + for row in reader: + line = reader.line_num + if len(row) != 3: + raise ParseError(line, f"expected 3 columns, got {len(row)}") + date_text, amount_text, description = row + try: + date = parse_date(date_text) + amount = parse_amount(amount_text) + except ValueError as error: + raise ParseError(line, str(error)) from None + transactions.append(Transaction(date=date, amount=amount, description=description)) + return transactions +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_parse -v` +Expected: PASS (26 tests) + +- [ ] **Step 5: Run the whole suite** + +Run: `python3 -m unittest` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add ledgerlite/parse.py test_parse.py +git commit -m "feat: parse transactions CSV, rejecting the whole file on a bad row" +``` + +--- + +### Task 4: Rules parsing and categorization + +**Files:** +- Create: `ledgerlite/rules.py` +- Test: `test_rules.py` + +**Interfaces:** +- Consumes: `ParseError` from `ledgerlite.parse` (one error type keeps `cli.py`'s message formatting uniform for both input files). +- Produces: + - `Rule = tuple[str, str]` — `(substring, category)`. + - `parse_rules(lines: Iterable[str]) -> list[Rule]` — file order preserved, raises `ParseError`. + - `categorize(description: str, rules: Sequence[Rule]) -> str | None` — case-insensitive, first match wins. + +- [ ] **Step 1: Write the failing tests** + +Create `test_rules.py`: + +```python +import io +import unittest + +from ledgerlite.parse import ParseError +from ledgerlite.rules import categorize, parse_rules + + +def parse_text(text): + return parse_rules(io.StringIO(text)) + + +class ParseRulesTest(unittest.TestCase): + def test_parses_one_rule_per_line_in_order(self): + self.assertEqual( + parse_text("coffee=food\nrent=housing\n"), + [("coffee", "food"), ("rent", "housing")], + ) + + def test_skips_blank_and_whitespace_only_lines(self): + self.assertEqual(parse_text("\ncoffee=food\n \n"), [("coffee", "food")]) + + def test_strips_whitespace_around_substring_and_category(self): + self.assertEqual(parse_text(" coffee shop = food \n"), [("coffee shop", "food")]) + + def test_category_may_contain_an_equals_sign(self): + self.assertEqual(parse_text("coffee=food=drink\n"), [("coffee", "food=drink")]) + + def test_empty_input_is_no_rules(self): + self.assertEqual(parse_text(""), []) + + def test_line_without_separator_is_malformed(self): + with self.assertRaises(ParseError) as caught: + parse_text("coffee=food\njust some text\n") + self.assertEqual(caught.exception.line, 2) + self.assertIn("=", caught.exception.message) + + def test_empty_substring_is_malformed(self): + with self.assertRaises(ParseError) as caught: + parse_text("=food\n") + self.assertEqual(caught.exception.line, 1) + + def test_empty_category_is_malformed(self): + with self.assertRaises(ParseError) as caught: + parse_text("coffee=\n") + self.assertEqual(caught.exception.line, 1) + + +class CategorizeTest(unittest.TestCase): + def test_matches_a_substring_of_the_description(self): + self.assertEqual(categorize("Coffee Shop", [("coffee", "food")]), "food") + + def test_matching_is_case_insensitive_in_both_directions(self): + self.assertEqual(categorize("coffee shop", [("COFFEE", "food")]), "food") + self.assertEqual(categorize("COFFEE SHOP", [("coffee", "food")]), "food") + + def test_first_matching_rule_wins(self): + rules = [("coffee", "food"), ("coffee shop", "outings")] + self.assertEqual(categorize("Coffee Shop", rules), "food") + + def test_returns_none_when_nothing_matches(self): + self.assertIsNone(categorize("Salary", [("coffee", "food")])) + + def test_returns_none_with_no_rules(self): + self.assertIsNone(categorize("Salary", [])) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_rules -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.rules'` + +- [ ] **Step 3: Write the minimal implementation** + +Create `ledgerlite/rules.py`: + +```python +"""Turn a rules file into ordered (substring, category) pairs and apply them.""" + +from __future__ import annotations + +from collections.abc import Iterable, Sequence + +from .parse import ParseError + +Rule = tuple[str, str] + +_EXPECTED = "expected =" + + +def parse_rules(lines: Iterable[str]) -> list[Rule]: + """Return the rules in `lines`, in file order, or raise ParseError.""" + rules: list[Rule] = [] + for number, raw_line in enumerate(lines, start=1): + line = raw_line.strip() + if not line: + continue + substring, separator, category = line.partition("=") + substring, category = substring.strip(), category.strip() + if not separator or not substring or not category: + raise ParseError(number, f"{_EXPECTED}, got {line!r}") + rules.append((substring, category)) + return rules + + +def categorize(description: str, rules: Sequence[Rule]) -> str | None: + """Return the category of the first rule matching `description`, else None.""" + lowered = description.lower() + for substring, category in rules: + if substring.lower() in lowered: + return category + return None +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_rules -v` +Expected: PASS (13 tests) + +- [ ] **Step 5: Run the whole suite** + +Run: `python3 -m unittest` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add ledgerlite/rules.py test_rules.py +git commit -m "feat: parse rules file and categorize descriptions" +``` + +--- + +### Task 5: Date ordering and closing balance + +**Files:** +- Create: `ledgerlite/balance.py` +- Test: `test_balance.py` + +**Interfaces:** +- Consumes: `Transaction` (Task 1). +- Produces: + - `order_by_date(transactions: Sequence[Transaction]) -> list[Transaction]` — sorted by date, ties keeping input order, input left untouched. + - `closing_balance(opening: Decimal, transactions: Iterable[Transaction]) -> Decimal`. + +- [ ] **Step 1: Write the failing tests** + +Create `test_balance.py`: + +```python +import datetime +import unittest +from decimal import Decimal + +from ledgerlite.balance import closing_balance, order_by_date +from ledgerlite.model import Transaction + + +def txn(day, amount, description="x"): + return Transaction(datetime.date(2026, 3, day), Decimal(amount), description) + + +class OrderByDateTest(unittest.TestCase): + def test_sorts_by_date(self): + unordered = [txn(5, "-900.00"), txn(1, "2500.00"), txn(4, "-7.50")] + self.assertEqual( + [t.date.day for t in order_by_date(unordered)], + [1, 4, 5], + ) + + def test_ties_keep_input_order(self): + first = txn(4, "-7.50", "first") + second = txn(4, "-1.00", "second") + ordered = order_by_date([first, second, txn(1, "5.00", "earlier")]) + self.assertEqual([t.description for t in ordered], ["earlier", "first", "second"]) + + def test_does_not_mutate_the_input(self): + unordered = [txn(5, "-900.00"), txn(1, "2500.00")] + order_by_date(unordered) + self.assertEqual([t.date.day for t in unordered], [5, 1]) + + def test_empty_input(self): + self.assertEqual(order_by_date([]), []) + + +class ClosingBalanceTest(unittest.TestCase): + def test_adds_every_amount_to_the_opening_balance(self): + transactions = [txn(4, "-7.50"), txn(5, "-900.00"), txn(1, "2500.00")] + self.assertEqual( + closing_balance(Decimal("100"), order_by_date(transactions)), + Decimal("1692.50"), + ) + + def test_no_transactions_returns_the_opening_balance(self): + self.assertEqual(closing_balance(Decimal("100.00"), []), Decimal("100.00")) + + def test_arithmetic_is_exact(self): + transactions = [txn(1, "0.10"), txn(2, "0.20")] + self.assertEqual(closing_balance(Decimal("0"), transactions), Decimal("0.30")) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_balance -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.balance'` + +- [ ] **Step 3: Write the minimal implementation** + +Create `ledgerlite/balance.py`: + +```python +"""Order transactions by date and walk the running balance.""" + +from __future__ import annotations + +from collections.abc import Iterable, Sequence +from decimal import Decimal + +from .model import Transaction + + +def order_by_date(transactions: Sequence[Transaction]) -> list[Transaction]: + """Return the transactions by date; ties keep input order (sorted is stable).""" + return sorted(transactions, key=lambda transaction: transaction.date) + + +def closing_balance(opening: Decimal, transactions: Iterable[Transaction]) -> Decimal: + """Return the running balance after the last transaction, or `opening` if none.""" + balance = opening + for transaction in transactions: + balance += transaction.amount + return balance +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_balance -v` +Expected: PASS (7 tests) + +- [ ] **Step 5: Run the whole suite** + +Run: `python3 -m unittest` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add ledgerlite/balance.py test_balance.py +git commit -m "feat: add date ordering and closing balance" +``` + +--- + +### Task 6: Per-category totals and report formatting + +**Files:** +- Create: `ledgerlite/report.py` +- Test: `test_report.py` + +**Interfaces:** +- Consumes: `Transaction` (Task 1), `Rule`/`categorize` (Task 4), `order_by_date`/`closing_balance` (Task 5). +- Produces: + - `UNCATEGORIZED = "uncategorized"`. + - `format_amount(amount: Decimal) -> str`. + - `category_totals(transactions: Iterable[Transaction], rules: Sequence[Rule]) -> dict[str, Decimal]`. + - `ordered_categories(totals: Mapping[str, Decimal]) -> list[str]`. + - `format_report(transactions: Sequence[Transaction], rules: Sequence[Rule], opening: Decimal) -> str` — the complete report, newline-terminated. Task 7 prints it verbatim. + +- [ ] **Step 1: Write the failing tests** + +Create `test_report.py`: + +```python +import datetime +import unittest +from decimal import Decimal + +from ledgerlite.model import Transaction +from ledgerlite.report import ( + UNCATEGORIZED, + category_totals, + format_amount, + format_report, + ordered_categories, +) + +RULES = [("coffee", "food"), ("rent", "housing")] + + +def txn(day, amount, description): + return Transaction(datetime.date(2026, 3, day), Decimal(amount), description) + + +class FormatAmountTest(unittest.TestCase): + def test_two_fractional_digits_always(self): + self.assertEqual(format_amount(Decimal("-12.50")), "-12.50") + self.assertEqual(format_amount(Decimal("0")), "0.00") + self.assertEqual(format_amount(Decimal("1.5")), "1.50") + self.assertEqual(format_amount(Decimal("1200")), "1200.00") + + def test_no_thousands_separators(self): + self.assertEqual(format_amount(Decimal("-1234567.89")), "-1234567.89") + + def test_negative_zero_prints_as_zero(self): + self.assertEqual(format_amount(Decimal("-0.00")), "0.00") + self.assertEqual(format_amount(Decimal("-7.50") + Decimal("7.50")), "0.00") + + +class CategoryTotalsTest(unittest.TestCase): + def test_sums_amounts_per_category(self): + transactions = [ + txn(4, "-7.50", "Coffee Shop"), + txn(6, "-2.50", "coffee beans"), + txn(5, "-900.00", "Monthly rent"), + ] + self.assertEqual( + category_totals(transactions, RULES), + {"food": Decimal("-10.00"), "housing": Decimal("-900.00")}, + ) + + def test_unmatched_transactions_land_in_uncategorized(self): + transactions = [txn(1, "2500.00", "Salary"), txn(2, "-20.00", "Bookshop")] + self.assertEqual( + category_totals(transactions, RULES), + {UNCATEGORIZED: Decimal("2480.00")}, + ) + + def test_no_transactions_is_no_categories(self): + self.assertEqual(category_totals([], RULES), {}) + + +class OrderedCategoriesTest(unittest.TestCase): + def test_alphabetical_with_uncategorized_last(self): + totals = { + UNCATEGORIZED: Decimal("1"), + "housing": Decimal("1"), + "food": Decimal("1"), + } + self.assertEqual(ordered_categories(totals), ["food", "housing", UNCATEGORIZED]) + + def test_omits_uncategorized_when_absent(self): + totals = {"housing": Decimal("1"), "food": Decimal("1")} + self.assertEqual(ordered_categories(totals), ["food", "housing"]) + + def test_empty_totals(self): + self.assertEqual(ordered_categories({}), []) + + +class FormatReportTest(unittest.TestCase): + def test_matches_the_design_example(self): + transactions = [ + txn(5, "-900.00", "Monthly rent"), + txn(1, "2500.00", "Salary"), + txn(4, "-7.50", "Coffee Shop"), + ] + self.assertEqual( + format_report(transactions, RULES, Decimal("100")), + "food: -7.50\n" + "housing: -900.00\n" + "uncategorized: 2500.00\n" + "\n" + "closing balance: 1692.50\n", + ) + + def test_no_transactions_reports_the_opening_balance(self): + self.assertEqual( + format_report([], RULES, Decimal("0")), + "\nclosing balance: 0.00\n", + ) + + def test_category_summing_to_zero_prints_zero(self): + transactions = [txn(1, "-7.50", "Coffee Shop"), txn(2, "7.50", "Coffee refund")] + self.assertEqual( + format_report(transactions, RULES, Decimal("0")), + "food: 0.00\n\nclosing balance: 0.00\n", + ) + + def test_without_rules_everything_is_uncategorized(self): + transactions = [txn(1, "-7.50", "Coffee Shop")] + self.assertEqual( + format_report(transactions, [], Decimal("10.00")), + "uncategorized: -7.50\n\nclosing balance: 2.50\n", + ) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_report -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.report'` + +- [ ] **Step 3: Write the minimal implementation** + +Create `ledgerlite/report.py`: + +```python +"""Per-category totals and the text of the report.""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping, Sequence +from decimal import Decimal + +from .balance import closing_balance, order_by_date +from .model import Transaction +from .rules import Rule, categorize + +UNCATEGORIZED = "uncategorized" +_CENTS = Decimal("0.01") + + +def format_amount(amount: Decimal) -> str: + """Return `amount` with exactly two fractional digits and no separators.""" + quantized = amount.quantize(_CENTS) + if quantized == 0: + quantized = abs(quantized) # never print "-0.00" + return f"{quantized:f}" + + +def category_totals( + transactions: Iterable[Transaction], rules: Sequence[Rule] +) -> dict[str, Decimal]: + """Return the sum of amounts per category; unmatched rows go to UNCATEGORIZED.""" + totals: dict[str, Decimal] = {} + for transaction in transactions: + category = categorize(transaction.description, rules) + if category is None: + category = UNCATEGORIZED + totals[category] = totals.get(category, Decimal("0")) + transaction.amount + return totals + + +def ordered_categories(totals: Mapping[str, Decimal]) -> list[str]: + """Return category names alphabetically, with UNCATEGORIZED always last.""" + names = sorted(name for name in totals if name != UNCATEGORIZED) + if UNCATEGORIZED in totals: + names.append(UNCATEGORIZED) + return names + + +def format_report( + transactions: Sequence[Transaction], rules: Sequence[Rule], opening: Decimal +) -> str: + """Return the whole report, newline-terminated.""" + ordered = order_by_date(transactions) + totals = category_totals(ordered, rules) + lines = [ + f"{name}: {format_amount(totals[name])}" for name in ordered_categories(totals) + ] + lines.append("") + lines.append(f"closing balance: {format_amount(closing_balance(opening, ordered))}") + return "\n".join(lines) + "\n" +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_report -v` +Expected: PASS (13 tests) + +- [ ] **Step 5: Run the whole suite** + +Run: `python3 -m unittest` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add ledgerlite/report.py test_report.py +git commit -m "feat: format per-category totals and closing balance" +``` + +--- + +### Task 7: Command-line interface + +**Files:** +- Create: `ledgerlite/cli.py` +- Create: `ledgerlite/__main__.py` +- Test: `test_cli.py` + +**Interfaces:** +- Consumes: `ParseError`, `parse_amount`, `parse_transactions` (Tasks 2–3); `parse_rules` (Task 4); `format_report` (Task 6). +- Produces: `main(argv: Sequence[str] | None = None) -> int` — the only public entry point. `python3 -m ledgerlite report TRANSACTIONS [--rules RULES] [--opening AMOUNT]`. + +- [ ] **Step 1: Write the failing tests** + +Create `test_cli.py`: + +```python +import contextlib +import io +import os +import subprocess +import sys +import tempfile +import unittest + +from ledgerlite.cli import main + +TRANSACTIONS = ( + "date,amount,description\n" + "2026-03-05,-900.00,Monthly rent\n" + "2026-03-01,2500.00,Salary\n" + "2026-03-04,-7.50,Coffee Shop\n" +) +RULES = "coffee=food\nrent=housing\n" +EXPECTED_REPORT = ( + "food: -7.50\n" + "housing: -900.00\n" + "uncategorized: 2500.00\n" + "\n" + "closing balance: 1692.50\n" +) + + +class CliTest(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + + def write(self, name, text, encoding="utf-8"): + path = os.path.join(self.directory.name, name) + with open(path, "w", encoding=encoding, newline="") as handle: + handle.write(text) + return path + + def write_bytes(self, name, data): + path = os.path.join(self.directory.name, name) + with open(path, "wb") as handle: + handle.write(data) + return path + + def run_cli(self, *argv): + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = main(list(argv)) + return code, out.getvalue(), err.getvalue() + + def test_reports_with_rules_and_opening(self): + transactions = self.write("txns.csv", TRANSACTIONS) + rules = self.write("rules.txt", RULES) + code, out, err = self.run_cli( + "report", transactions, "--rules", rules, "--opening", "100" + ) + self.assertEqual((code, out, err), (0, EXPECTED_REPORT, "")) + + def test_opening_defaults_to_zero_and_rules_are_optional(self): + transactions = self.write("txns.csv", TRANSACTIONS) + code, out, err = self.run_cli("report", transactions) + self.assertEqual(code, 0) + self.assertEqual(out, "uncategorized: 1592.50\n\nclosing balance: 1592.50\n") + self.assertEqual(err, "") + + def test_header_only_file_reports_the_opening_balance(self): + transactions = self.write("empty.csv", "date,amount,description\n") + code, out, err = self.run_cli("report", transactions, "--opening", "-12.50") + self.assertEqual(code, 0) + self.assertEqual(out, "\nclosing balance: -12.50\n") + self.assertEqual(err, "") + + def test_utf8_bom_before_the_header_is_tolerated(self): + transactions = self.write("bom.csv", TRANSACTIONS, encoding="utf-8-sig") + code, out, _ = self.run_cli("report", transactions) + self.assertEqual(code, 0) + self.assertIn("closing balance: 1592.50", out) + + def test_missing_transactions_file_exits_one(self): + missing = os.path.join(self.directory.name, "nope.csv") + code, out, err = self.run_cli("report", missing) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertTrue(err.startswith(f"ledgerlite: cannot read {missing}: ")) + self.assertTrue(err.endswith("\n")) + + def test_directory_as_transactions_file_exits_one(self): + code, out, err = self.run_cli("report", self.directory.name) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertIn("cannot read", err) + + def test_undecodable_bytes_exit_one_without_a_traceback(self): + transactions = self.write_bytes("bad.csv", b"date,amount,description\n\xff\xfe\n") + code, out, err = self.run_cli("report", transactions) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertIn(f"cannot read {transactions}", err) + + def test_malformed_row_exits_two_naming_the_line(self): + transactions = self.write( + "bad.csv", "date,amount,description\n2026-03-04,-7.50\n" + ) + code, out, err = self.run_cli("report", transactions) + self.assertEqual(code, 2) + self.assertEqual(out, "") + self.assertEqual( + err, f"ledgerlite: {transactions}:2: expected 3 columns, got 2\n" + ) + + def test_amount_with_three_decimals_exits_two(self): + transactions = self.write( + "bad.csv", "date,amount,description\n2026-03-04,1.005,Coffee\n" + ) + code, out, err = self.run_cli("report", transactions) + self.assertEqual(code, 2) + self.assertEqual(out, "") + self.assertIn(f"{transactions}:2: invalid amount", err) + + def test_empty_transactions_file_exits_two(self): + transactions = self.write("empty.csv", "") + code, out, err = self.run_cli("report", transactions) + self.assertEqual(code, 2) + self.assertEqual(out, "") + self.assertIn(f"{transactions}:1:", err) + + def test_missing_rules_file_exits_one(self): + transactions = self.write("txns.csv", TRANSACTIONS) + missing = os.path.join(self.directory.name, "nope.txt") + code, out, err = self.run_cli("report", transactions, "--rules", missing) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertTrue(err.startswith(f"ledgerlite: cannot read {missing}: ")) + + def test_malformed_rules_file_exits_two(self): + transactions = self.write("txns.csv", TRANSACTIONS) + rules = self.write("rules.txt", "coffee=food\nnonsense\n") + code, out, err = self.run_cli("report", transactions, "--rules", rules) + self.assertEqual(code, 2) + self.assertEqual(out, "") + self.assertIn(f"{rules}:2:", err) + + def test_transactions_problem_is_reported_before_rules_problem(self): + transactions = self.write("bad.csv", "date,amount,description\nnope\n") + missing = os.path.join(self.directory.name, "nope.txt") + code, _, err = self.run_cli("report", transactions, "--rules", missing) + self.assertEqual(code, 2) + self.assertIn(transactions, err) + self.assertNotIn(missing, err) + + def test_invalid_opening_is_a_usage_error(self): + transactions = self.write("txns.csv", TRANSACTIONS) + with contextlib.redirect_stderr(io.StringIO()): + with self.assertRaises(SystemExit) as caught: + main(["report", transactions, "--opening", "1.005"]) + self.assertEqual(caught.exception.code, 2) + + def test_missing_subcommand_is_a_usage_error(self): + with contextlib.redirect_stderr(io.StringIO()): + with self.assertRaises(SystemExit) as caught: + main([]) + self.assertEqual(caught.exception.code, 2) + + +class ModuleEntryPointTest(unittest.TestCase): + def test_python_m_ledgerlite_runs_the_cli(self): + with tempfile.TemporaryDirectory() as directory: + path = os.path.join(directory, "txns.csv") + with open(path, "w", encoding="utf-8", newline="") as handle: + handle.write(TRANSACTIONS) + rules_path = os.path.join(directory, "rules.txt") + with open(rules_path, "w", encoding="utf-8", newline="") as handle: + handle.write(RULES) + result = subprocess.run( + [ + sys.executable, "-m", "ledgerlite", "report", path, + "--rules", rules_path, "--opening", "100", + ], + capture_output=True, + text=True, + cwd=os.path.dirname(os.path.abspath(__file__)), + ) + self.assertEqual(result.returncode, 0) + self.assertEqual(result.stdout, EXPECTED_REPORT) + self.assertEqual(result.stderr, "") + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_cli -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.cli'` + +- [ ] **Step 3: Write the minimal implementation** + +Create `ledgerlite/cli.py`: + +```python +"""Command-line entry point: python3 -m ledgerlite report TRANSACTIONS ...""" + +from __future__ import annotations + +import argparse +import sys +from collections.abc import Sequence +from decimal import Decimal + +from .parse import ParseError, parse_amount, parse_transactions +from .report import format_report +from .rules import Rule, parse_rules + +PROGRAM = "ledgerlite" + + +def _opening_amount(text: str) -> Decimal: + try: + return parse_amount(text) + except ValueError as error: + raise argparse.ArgumentTypeError(str(error)) from None + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog=PROGRAM, description="Summarize bank transactions by category." + ) + subcommands = parser.add_subparsers(dest="command", required=True) + report = subcommands.add_parser("report", help="print a per-category summary") + report.add_argument("transactions", help="path to the transactions CSV") + report.add_argument("--rules", help="path to the rules file") + report.add_argument( + "--opening", + type=_opening_amount, + default=Decimal("0"), + help="opening balance (default: 0)", + ) + return parser + + +def _read_lines(path: str) -> list[str]: + """Return the lines of `path`. utf-8-sig strips a BOM before the CSV header.""" + with open(path, encoding="utf-8-sig", newline="") as handle: + return handle.readlines() + + +def _reason(error: Exception) -> str: + if isinstance(error, OSError) and error.strerror: + return error.strerror + return str(error) + + +def _cannot_read(path: str, error: Exception) -> int: + print(f"{PROGRAM}: cannot read {path}: {_reason(error)}", file=sys.stderr) + return 1 + + +def _malformed(path: str, error: ParseError) -> int: + print(f"{PROGRAM}: {path}:{error.line}: {error.message}", file=sys.stderr) + return 2 + + +def main(argv: Sequence[str] | None = None) -> int: + """Run the CLI and return the process exit code.""" + args = _build_parser().parse_args(argv) + + try: + transaction_lines = _read_lines(args.transactions) + except (OSError, UnicodeDecodeError) as error: + return _cannot_read(args.transactions, error) + try: + transactions = parse_transactions(transaction_lines) + except ParseError as error: + return _malformed(args.transactions, error) + + rules: list[Rule] = [] + if args.rules is not None: + try: + rules_lines = _read_lines(args.rules) + except (OSError, UnicodeDecodeError) as error: + return _cannot_read(args.rules, error) + try: + rules = parse_rules(rules_lines) + except ParseError as error: + return _malformed(args.rules, error) + + print(format_report(transactions, rules, args.opening), end="") + return 0 +``` + +Create `ledgerlite/__main__.py`: + +```python +"""Allow `python3 -m ledgerlite`.""" + +import sys + +from .cli import main + +if __name__ == "__main__": + sys.exit(main()) +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_cli -v` +Expected: PASS (16 tests) + +- [ ] **Step 5: Run the whole suite** + +Run: `python3 -m unittest` +Expected: PASS, all tests from Tasks 1–7, no errors + +- [ ] **Step 6: Check the spec's example by hand** + +```bash +printf 'date,amount,description\n2026-03-05,-900.00,Monthly rent\n2026-03-01,2500.00,Salary\n2026-03-04,-7.50,Coffee Shop\n' > /tmp/txns.csv +printf 'coffee=food\nrent=housing\n' > /tmp/rules.txt +python3 -m ledgerlite report /tmp/txns.csv --rules /tmp/rules.txt --opening 100; echo "exit=$?" +``` + +Expected, exactly: + +``` +food: -7.50 +housing: -900.00 +uncategorized: 2500.00 + +closing balance: 1692.50 +exit=0 +``` + +- [ ] **Step 7: Confirm no floats crept in** + +Run: `grep -rn "float" ledgerlite/` +Expected: no output. + +- [ ] **Step 8: Commit** + +```bash +git add ledgerlite/cli.py ledgerlite/__main__.py test_cli.py +git commit -m "feat: add ledgerlite report command line interface" +``` + +--- + +## Spec coverage check + +| Spec requirement | Task | +|---|---| +| `date,amount,description` header, ISO dates, `Decimal` amounts, free-text description | 1, 2, 3 | +| Rows in any order; two rows may share a date | 5 (`order_by_date` stability) | +| Rules file `=`, case-insensitive, first match wins, no match → no category | 4 | +| `ledgerlite report TRANSACTIONS [--rules RULES] [--opening AMOUNT]` | 7 | +| Report to stdout, exit 0 | 7 | +| Unreadable TRANSACTIONS → `cannot read` message, exit 1 | 7 | +| Malformed row (columns, date, amount, >2 decimals) → `::` message, exit 2, whole file rejected, no stdout | 3, 7 | +| `--opening` defaults to 0; `--rules` optional → all uncategorized | 6, 7 | +| Date ordering, running balance, closing balance (opening when empty) | 5 | +| One line per category alphabetically, `uncategorized` last, blank line, `closing balance:` | 6 | +| Two-fractional-digit formatting, leading `-`, no separators | 6 | +| Spec's worked example reproduced exactly | 6 (unit), 7 (end to end) | +| Package layout, stdlib only, Python 3.11+, root `test_.py` via `python3 -m unittest` | all | diff --git a/tests/inline-eval/results/2026-09-17-review-focus-five/wpplan-41.txt b/tests/inline-eval/results/2026-09-17-review-focus-five/wpplan-41.txt new file mode 100644 index 000000000..b0c535864 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-review-focus-five/wpplan-41.txt @@ -0,0 +1,26 @@ +# rep wpplan-41 transcript=/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/inline-eval-br/reps/wpplan-41/cfg/projects/-private-tmp-claude-501--Users-jesse-git-superpowers-superpowers-a8a313bf-7410-41ce-9036-636ccf4061d6-scratchpad-inline-eval-br-reps-wpplan-41-repo/440f8243-98e6-43bc-a858-a356379553c5.jsonl +skill executing-plans invoked False +all skills invoked writing-plans +Agent dispatches 0 +test runs 0 +ledger (progress.md) touches 0 +sdd script invocations 0 +commits (incl. fixture) 1 +suite in repo plan.md-present +probe review-focus handled +probe implied-header handled +probe implied-encoding handled +probe size plan= 1394 review-focus-lines=5 tests=79 tasks= +user_prompt_submit events 1 (background-agent wakeups count too) +final: 'Rulings I made' False +final: mentions self-review False +mid-run check-in candidates 0 +main session tokens 8 msgs; out=40509 cache_read=320590 cache_create=57534 in=16 +subagent tokens 0 agents; out=0 cache_read=0 cache_create=0 in=0 +TOTAL tokens out=40509 cache_read=320590 cache_create=57534 in=16 + +# assistant text, in order (truncated) +[ 1] I'll start by reading the design and invoking the writing-plans skill. +[ 4] I'm using the writing-plans skill to create the implementation plan. +[ 7] Design read. Now writing the plan. +[ 18] Plan complete and saved to `plan.md` (repo root, next to `design.md`). Not committed — say the word if you want it committed. **Shape of it:** 7 tasks, dependency-ordered, each one module + its root-level `test_. diff --git a/tests/inline-eval/results/2026-09-17-review-focus-five/wpplan-42.plan.md b/tests/inline-eval/results/2026-09-17-review-focus-five/wpplan-42.plan.md new file mode 100644 index 000000000..499a0bce7 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-review-focus-five/wpplan-42.plan.md @@ -0,0 +1,1475 @@ +# ledgerlite Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build `ledgerlite`, a stdlib-only Python package and CLI that reads a bank-transaction CSV, categorizes each row with a rules file, and prints per-category totals plus the closing balance. + +**Architecture:** Six small modules with one responsibility each, composed by a thin `cli.py`. Pure functions take already-read text (iterables of lines) and return values; only `cli.py` touches the filesystem, `sys.stdout`/`sys.stderr`, and exit codes. That split keeps every parsing and formatting rule testable without temp files, and keeps all three exit codes in one place. Money is `decimal.Decimal` end to end — never `float`. + +**Tech Stack:** Python 3.11+, standard library only (`csv`, `decimal`, `datetime`, `argparse`, `dataclasses`, `re`), `unittest` for tests. + +**Spec:** `design.md` (repo root) + +## Global Constraints + +- Python 3.11+. Standard library only — no third-party dependencies, no `pyproject.toml`/`setup.py` needed. +- Money is `decimal.Decimal` everywhere. Never `float`, not even transiently. +- Amounts are printed with exactly two fractional digits, a leading `-` only for negative values, and no thousands separators: `-12.50`, `0.00`, `1200.00`. +- Package layout is exactly as the spec lists it: `ledgerlite/{__init__,model,parse,rules,balance,report,cli}.py`. One addition (see Task 5): a three-line `ledgerlite/__main__.py`, because without it there is no way to invoke the tool at all in a repo with no packaging metadata. +- Tests live at the repo root as `test_.py` and run with `python3 -m unittest`. +- Exit codes: `0` success, `1` transactions/rules file unreadable, `2` malformed row. +- Error text, verbatim, to stderr: + - unreadable file: `ledgerlite: cannot read : ` + - malformed row: `ledgerlite: :: ` +- CLI surface, verbatim: `ledgerlite report TRANSACTIONS [--rules RULES] [--opening AMOUNT]`. `--opening` defaults to `0`; `--rules` is optional. +- Work directly on `main` in this local scratch repo. There is no remote and no worktree; never `git push`. +- Commit after every green test run, using the commit commands given in the steps. + +## Review Focus + +Input classes the spec implies but never names. Each has a test in the task that owns the code: + +1. **Whitespace around fields** — `2026-03-04, -7.50, coffee` (spaces after commas) is an ordinary hand-edited CSV and must parse, not be rejected as a malformed amount. Covered in Task 1, Step 11. +2. **Blank lines in the CSV** — a file ending in a blank line, or with one between rows, must be skipped, not rejected as "wrong column count". Rejecting a whole file over a trailing newline is the single most likely way this tool annoys someone. Covered in Task 1, Step 11. +3. **Amount strings `Decimal()` accepts but a bank statement never means** — `NaN`, `Infinity`, `1e5`, `1,000`, `1.` must all be rejected as malformed (exit 2), never silently turned into a total. A `NaN` amount would poison every sum and print garbage. Covered in Task 1, Step 6. +4. **A malformed `--opening`** — `--opening abc` or `--opening 1.005` must produce an argparse usage error, not a `decimal.InvalidOperation` traceback. Covered in Task 5, Step 1. +5. **An unreadable `--rules` path** — the spec only promises a message for TRANSACTIONS, but a typo'd rules path must get the same `cannot read` message and exit 1 rather than a traceback, and must never print a silently uncategorized report. Covered in Task 5, Step 6. + +Also pinned by tests, one notch below the above: an impossible calendar date (`2026-02-30`, Task 1 Step 6), a literal `-0.00` amount printing as `0.00` rather than `-0.00` (Task 4, Step 6), and a rules file that names a category `uncategorized` (Task 4, Step 6). + +## File Structure + +| File | Responsibility | +| --- | --- | +| `ledgerlite/__init__.py` | Package docstring only. No re-exports. | +| `ledgerlite/model.py` | `Transaction` frozen dataclass. No logic. | +| `ledgerlite/parse.py` | `ParseError`, `parse_date`, `parse_amount`, `parse_transactions`. Validation lives here and nowhere else. | +| `ledgerlite/rules.py` | `Rule` alias, `parse_rules`, `categorize`. | +| `ledgerlite/balance.py` | `order_by_date`, `closing_balance`. | +| `ledgerlite/report.py` | `UNCATEGORIZED`, `category_totals`, `format_amount`, `sorted_categories`, `format_report`. | +| `ledgerlite/cli.py` | `build_parser`, `main(argv) -> int`. Only module doing I/O or exit codes. | +| `ledgerlite/__main__.py` | `sys.exit(main())` shim. | +| `test_parse.py`, `test_rules.py`, `test_balance.py`, `test_report.py`, `test_cli.py` | One test module per implementation module with behavior worth testing. `model.py` is a bare dataclass and is exercised through the others. | + +--- + +### Task 1: Transaction model and CSV parsing + +The whole validation surface of the tool. Everything downstream can assume a +`list[Transaction]` with a real `date`, a `Decimal` amount with at most two +fractional digits, and a `str` description. + +**Files:** +- Create: `ledgerlite/__init__.py` +- Create: `ledgerlite/model.py` +- Create: `ledgerlite/parse.py` +- Test: `test_parse.py` + +**Interfaces:** +- Consumes: nothing (first task). +- Produces: + - `ledgerlite.model.Transaction(date: datetime.date, amount: Decimal, description: str)` — frozen dataclass, keyword or positional construction. + - `ledgerlite.parse.ParseError(Exception)` with attributes `line: int` and `problem: str`. + - `ledgerlite.parse.parse_date(raw: str) -> datetime.date` — raises `ValueError` whose message is the `problem` text. + - `ledgerlite.parse.parse_amount(raw: str) -> Decimal` — raises `ValueError` whose message is the `problem` text. Reused by the CLI for `--opening`. + - `ledgerlite.parse.parse_transactions(lines: Iterable[str]) -> list[Transaction]` — raises `ParseError`. Takes lines, not a path: the caller owns file I/O. + +- [ ] **Step 1: Write the failing test** + +Create `test_parse.py`: + +```python +"""Tests for ledgerlite.parse.""" + +import datetime +import unittest +from decimal import Decimal + +from ledgerlite.model import Transaction +from ledgerlite.parse import ParseError, parse_amount, parse_date, parse_transactions + +HEADER = "date,amount,description\n" + + +class ParseTransactionsTests(unittest.TestCase): + def test_parses_rows_in_input_order(self): + lines = [ + HEADER, + "2026-03-04,-7.50,Coffee Bar\n", + "2026-03-01,2500.00,ACME payroll\n", + ] + + self.assertEqual( + parse_transactions(lines), + [ + Transaction(datetime.date(2026, 3, 4), Decimal("-7.50"), "Coffee Bar"), + Transaction(datetime.date(2026, 3, 1), Decimal("2500.00"), "ACME payroll"), + ], + ) + + def test_amount_is_decimal_not_float(self): + transactions = parse_transactions([HEADER, "2026-03-04,0.10,dime\n"]) + + self.assertIsInstance(transactions[0].amount, Decimal) + self.assertEqual(transactions[0].amount, Decimal("0.10")) + + def test_header_only_file_has_no_transactions(self): + self.assertEqual(parse_transactions([HEADER]), []) + + def test_empty_file_has_no_transactions(self): + self.assertEqual(parse_transactions([]), []) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python3 -m unittest test_parse -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite'` + +- [ ] **Step 3: Write minimal implementation** + +Create `ledgerlite/__init__.py`: + +```python +"""ledgerlite: categorize bank transactions and report per-category totals.""" +``` + +Create `ledgerlite/model.py`: + +```python +"""The transaction record shared by every other module.""" + +import datetime +from dataclasses import dataclass +from decimal import Decimal + + +@dataclass(frozen=True) +class Transaction: + """One row of the transactions CSV.""" + + date: datetime.date + amount: Decimal + description: str +``` + +Create `ledgerlite/parse.py`: + +```python +"""Turn transactions-CSV lines into Transaction objects.""" + +from __future__ import annotations + +import csv +import datetime +from decimal import Decimal +from typing import Iterable + +from .model import Transaction + + +class ParseError(Exception): + """A row of the transactions file could not be parsed.""" + + def __init__(self, line: int, problem: str) -> None: + super().__init__(f"line {line}: {problem}") + self.line = line + self.problem = problem + + +def parse_date(raw: str) -> datetime.date: + """Parse an ISO 8601 calendar date (`2026-03-04`).""" + return datetime.datetime.strptime(raw, "%Y-%m-%d").date() + + +def parse_amount(raw: str) -> Decimal: + """Parse a signed decimal amount with at most two fractional digits.""" + return Decimal(raw) + + +def parse_transactions(lines: Iterable[str]) -> list[Transaction]: + """Parse CSV lines into transactions, preserving input order. + + `lines` is any iterable of lines (an open file, a list of strings). The + first row is the header and is skipped. + """ + rows = iter(csv.reader(lines)) + try: + next(rows) + except StopIteration: + return [] + + transactions: list[Transaction] = [] + for row in rows: + raw_date, raw_amount, description = row + transactions.append( + Transaction( + date=parse_date(raw_date), + amount=parse_amount(raw_amount), + description=description, + ) + ) + return transactions +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python3 -m unittest test_parse -v` +Expected: PASS (4 tests) + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/__init__.py ledgerlite/model.py ledgerlite/parse.py test_parse.py +git commit -m "feat: parse transactions CSV into Transaction records" +``` + +- [ ] **Step 6: Write the failing tests for malformed rows** + +The spec rejects the whole file on any malformed row. `Decimal()` happily +accepts `NaN`, `Infinity`, and `1e5`, so validation has to be stricter than +"did `Decimal()` raise" — that is Review Focus item 3. `2026-3-4` and +`2026-02-30` are not valid ISO calendar dates and must be rejected too. + +Append to `test_parse.py`, before the `if __name__` block: + +```python +class MalformedRowTests(unittest.TestCase): + def assertRejected(self, row, line, problem): + with self.assertRaises(ParseError) as caught: + parse_transactions([HEADER, row]) + + self.assertEqual(caught.exception.line, line) + self.assertEqual(caught.exception.problem, problem) + + def test_too_few_columns(self): + self.assertRejected("2026-03-04,-7.50\n", 2, "expected 3 columns, got 2") + + def test_too_many_columns(self): + self.assertRejected("2026-03-04,-7.50,coffee,extra\n", 2, "expected 3 columns, got 4") + + def test_non_iso_date(self): + self.assertRejected("2026-3-4,-7.50,coffee\n", 2, "invalid date: '2026-3-4'") + + def test_impossible_date(self): + self.assertRejected("2026-02-30,-7.50,coffee\n", 2, "invalid date: '2026-02-30'") + + def test_empty_date(self): + self.assertRejected(",-7.50,coffee\n", 2, "invalid date: ''") + + def test_three_fractional_digits(self): + self.assertRejected( + "2026-03-04,1.005,coffee\n", + 2, + "amount has more than two decimal places: '1.005'", + ) + + def test_rejects_amounts_decimal_would_accept(self): + for raw in ["NaN", "nan", "Infinity", "-Infinity", "1e5", "1_000", "1.", "", "--1", "$5.00"]: + with self.subTest(raw=raw): + self.assertRejected(f"2026-03-04,{raw},coffee\n", 2, f"invalid amount: {raw!r}") + + def test_thousands_separator_is_invalid(self): + # Quoted, or the comma would look like a fourth column. + self.assertRejected('2026-03-04,"1,000",coffee\n', 2, "invalid amount: '1,000'") + + def test_reports_the_line_number_of_the_bad_row(self): + lines = [ + HEADER, + "2026-03-01,1.00,good\n", + "2026-03-02,2.00,good\n", + "2026-03-03,oops,bad\n", + ] + + with self.assertRaises(ParseError) as caught: + parse_transactions(lines) + + self.assertEqual(caught.exception.line, 4) + self.assertEqual(caught.exception.problem, "invalid amount: 'oops'") + + def test_accepts_one_or_zero_fractional_digits(self): + transactions = parse_transactions( + [HEADER, "2026-03-04,1.5,a\n", "2026-03-04,1.50,b\n", "2026-03-04,7,c\n", "2026-03-04,+7,d\n"] + ) + + self.assertEqual( + [t.amount for t in transactions], + [Decimal("1.5"), Decimal("1.50"), Decimal("7"), Decimal("7")], + ) + + +class ParseFieldTests(unittest.TestCase): + def test_parse_date_raises_value_error_with_message(self): + with self.assertRaises(ValueError) as caught: + parse_date("nope") + + self.assertEqual(str(caught.exception), "invalid date: 'nope'") + + def test_parse_amount_raises_value_error_with_message(self): + with self.assertRaises(ValueError) as caught: + parse_amount("1.005") + + self.assertEqual(str(caught.exception), "amount has more than two decimal places: '1.005'") + + def test_parse_amount_returns_decimal(self): + self.assertEqual(parse_amount("-12.50"), Decimal("-12.50")) +``` + +- [ ] **Step 7: Run tests to verify they fail** + +Run: `python3 -m unittest test_parse -v` +Expected: FAIL — the column-count tests raise `ValueError: not enough values to unpack`, the date tests raise a raw `ValueError` from `strptime` instead of `ParseError`, and `test_rejects_amounts_decimal_would_accept` shows `NaN`/`1e5` being accepted. + +- [ ] **Step 8: Write the validation implementation** + +In `ledgerlite/parse.py`, add `re` to the imports and the amount pattern below +them: + +```python +import csv +import datetime +import re +from decimal import Decimal +from typing import Iterable + +from .model import Transaction + +# A signed run of digits with an optional fractional part. Deliberately +# stricter than Decimal(), which also accepts NaN, Infinity and 1e5. +_AMOUNT_RE = re.compile(r"^[+-]?(?:\d+(?:\.\d+)?|\.\d+)$") +``` + +Replace `parse_date`, `parse_amount` and the loop body of +`parse_transactions` with: + +```python +def parse_date(raw: str) -> datetime.date: + """Parse an ISO 8601 calendar date (`2026-03-04`). + + Raises ValueError whose message is report-ready. + """ + try: + return datetime.datetime.strptime(raw, "%Y-%m-%d").date() + except ValueError: + raise ValueError(f"invalid date: {raw!r}") from None + + +def parse_amount(raw: str) -> Decimal: + """Parse a signed decimal amount with at most two fractional digits. + + Raises ValueError whose message is report-ready. + """ + if not _AMOUNT_RE.match(raw): + raise ValueError(f"invalid amount: {raw!r}") + _, _, fraction = raw.partition(".") + if len(fraction) > 2: + raise ValueError(f"amount has more than two decimal places: {raw!r}") + return Decimal(raw) +``` + +```python + transactions: list[Transaction] = [] + for row in rows: + if len(row) != 3: + raise ParseError(reader.line_num, f"expected 3 columns, got {len(row)}") + raw_date, raw_amount, description = row + try: + when = parse_date(raw_date) + amount = parse_amount(raw_amount) + except ValueError as exc: + raise ParseError(reader.line_num, str(exc)) from exc + transactions.append(Transaction(date=when, amount=amount, description=description)) + return transactions +``` + +`reader.line_num` is the source line number, so keep a reference to the reader +itself. Change the top of the function to: + +```python + reader = csv.reader(lines) + rows = iter(reader) + try: + next(rows) + except StopIteration: + return [] +``` + +- [ ] **Step 9: Run tests to verify they pass** + +Run: `python3 -m unittest test_parse -v` +Expected: PASS (all tests) + +- [ ] **Step 10: Commit** + +```bash +git add ledgerlite/parse.py test_parse.py +git commit -m "feat: reject malformed rows with line number and reason" +``` + +- [ ] **Step 11: Write the failing tests for real-world CSV whitespace and blank lines** + +Review Focus items 1 and 2. Hand-edited CSVs have spaces after commas and a +trailing blank line; neither is a malformed transaction. + +Append to `test_parse.py`, before the `if __name__` block: + +```python +class ForgivingInputTests(unittest.TestCase): + def test_ignores_whitespace_around_date_and_amount(self): + transactions = parse_transactions([HEADER, "2026-03-04 , -7.50 , Coffee Bar\n"]) + + self.assertEqual( + transactions, + [Transaction(datetime.date(2026, 3, 4), Decimal("-7.50"), " Coffee Bar")], + ) + + def test_skips_trailing_blank_line(self): + transactions = parse_transactions([HEADER, "2026-03-04,-7.50,coffee\n", "\n"]) + + self.assertEqual(len(transactions), 1) + + def test_skips_blank_and_whitespace_only_lines_between_rows(self): + lines = [ + HEADER, + "2026-03-01,1.00,a\n", + "\n", + " \n", + "2026-03-02,2.00,b\n", + ] + + self.assertEqual([t.description for t in parse_transactions(lines)], ["a", "b"]) + + def test_blank_lines_do_not_shift_reported_line_numbers(self): + lines = [HEADER, "\n", "2026-03-04,oops,bad\n"] + + with self.assertRaises(ParseError) as caught: + parse_transactions(lines) + + self.assertEqual(caught.exception.line, 3) +``` + +- [ ] **Step 12: Run tests to verify they fail** + +Run: `python3 -m unittest test_parse -v` +Expected: FAIL — the whitespace test reports `invalid date: '2026-03-04 '`, and the blank-line tests report `expected 3 columns, got 0`. + +- [ ] **Step 13: Write the implementation** + +In `ledgerlite/parse.py`, replace the loop body of `parse_transactions` with: + +```python + transactions: list[Transaction] = [] + for row in rows: + if _is_blank(row): + continue + if len(row) != 3: + raise ParseError(reader.line_num, f"expected 3 columns, got {len(row)}") + raw_date, raw_amount, description = row + try: + when = parse_date(raw_date.strip()) + amount = parse_amount(raw_amount.strip()) + except ValueError as exc: + raise ParseError(reader.line_num, str(exc)) from exc + transactions.append(Transaction(date=when, amount=amount, description=description)) + return transactions +``` + +Add above `parse_transactions`: + +```python +def _is_blank(row: list[str]) -> bool: + """True for a blank line: csv gives [] or a single empty-ish field.""" + return not row or (len(row) == 1 and not row[0].strip()) +``` + +The description is deliberately *not* stripped — the spec calls it free text, +and substring matching is unaffected by surrounding spaces. + +- [ ] **Step 14: Run tests to verify they pass** + +Run: `python3 -m unittest test_parse -v` +Expected: PASS (all tests) + +- [ ] **Step 15: Commit** + +```bash +git add ledgerlite/parse.py test_parse.py +git commit -m "feat: tolerate field whitespace and blank lines in the CSV" +``` + +--- + +### Task 2: Rules file and categorization + +**Files:** +- Create: `ledgerlite/rules.py` +- Test: `test_rules.py` + +**Interfaces:** +- Consumes: nothing from Task 1 (rules are pure text; `categorize` takes a description string, not a `Transaction`). +- Produces: + - `ledgerlite.rules.Rule = tuple[str, str]` — `(substring, category)`. + - `ledgerlite.rules.parse_rules(lines: Iterable[str]) -> list[Rule]` — file order preserved. + - `ledgerlite.rules.categorize(description: str, rules: Iterable[Rule]) -> str | None` — case-insensitive substring match, first rule wins, `None` when nothing matches. + +- [ ] **Step 1: Write the failing test** + +Create `test_rules.py`: + +```python +"""Tests for ledgerlite.rules.""" + +import unittest + +from ledgerlite.rules import categorize, parse_rules + + +class ParseRulesTests(unittest.TestCase): + def test_parses_substring_and_category_in_file_order(self): + self.assertEqual( + parse_rules(["coffee=food\n", "rent=housing\n"]), + [("coffee", "food"), ("rent", "housing")], + ) + + def test_strips_surrounding_whitespace_but_keeps_interior_spaces(self): + self.assertEqual(parse_rules([" coffee shop = food \n"]), [("coffee shop", "food")]) + + def test_only_the_first_equals_separates(self): + self.assertEqual(parse_rules(["a=b=c\n"]), [("a", "b=c")]) + + def test_skips_blank_lines(self): + self.assertEqual(parse_rules(["\n", " \n", "coffee=food\n"]), [("coffee", "food")]) + + def test_skips_lines_without_a_separator(self): + self.assertEqual(parse_rules(["nonsense\n", "coffee=food\n"]), [("coffee", "food")]) + + def test_skips_rules_with_an_empty_substring_or_category(self): + self.assertEqual(parse_rules(["=food\n", "coffee=\n", "coffee=food\n"]), [("coffee", "food")]) + + def test_handles_a_final_line_without_a_newline(self): + self.assertEqual(parse_rules(["coffee=food"]), [("coffee", "food")]) + + +class CategorizeTests(unittest.TestCase): + RULES = [("coffee", "food"), ("rent", "housing")] + + def test_matches_substring_anywhere_in_description(self): + self.assertEqual(categorize("SQ *COFFEE BAR 4471", self.RULES), "food") + + def test_matching_is_case_insensitive_both_ways(self): + self.assertEqual(categorize("coffee bar", [("COFFEE", "food")]), "food") + self.assertEqual(categorize("COFFEE BAR", [("coffee", "food")]), "food") + + def test_first_matching_rule_wins(self): + rules = [("coffee", "food"), ("coffee bar", "treats")] + + self.assertEqual(categorize("coffee bar", rules), "food") + + def test_returns_none_when_nothing_matches(self): + self.assertIsNone(categorize("ACME payroll", self.RULES)) + + def test_returns_none_with_no_rules(self): + self.assertIsNone(categorize("anything", [])) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python3 -m unittest test_rules -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.rules'` + +- [ ] **Step 3: Write minimal implementation** + +Create `ledgerlite/rules.py`: + +```python +"""Rules-file parsing and description categorization.""" + +from __future__ import annotations + +from typing import Iterable + +Rule = tuple[str, str] +"""A (substring, category) pair. Both parts are non-empty.""" + + +def parse_rules(lines: Iterable[str]) -> list[Rule]: + """Parse `=` lines, preserving file order. + + Blank lines, lines with no `=`, and rules with an empty substring or + category are skipped: a rule with an empty substring would match every + transaction, and an empty category would print as a nameless total. + """ + rules: list[Rule] = [] + for line in lines: + substring, separator, category = line.partition("=") + if not separator: + continue + substring = substring.strip() + category = category.strip() + if not substring or not category: + continue + rules.append((substring, category)) + return rules + + +def categorize(description: str, rules: Iterable[Rule]) -> str | None: + """Return the category of the first rule matching `description`, else None.""" + haystack = description.lower() + for substring, category in rules: + if substring.lower() in haystack: + return category + return None +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `python3 -m unittest test_rules -v` +Expected: PASS (all tests) + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/rules.py test_rules.py +git commit -m "feat: parse rules file and categorize descriptions" +``` + +--- + +### Task 3: Date ordering and closing balance + +**Files:** +- Create: `ledgerlite/balance.py` +- Test: `test_balance.py` + +**Interfaces:** +- Consumes: `ledgerlite.model.Transaction` from Task 1. +- Produces: + - `ledgerlite.balance.order_by_date(transactions: Iterable[Transaction]) -> list[Transaction]` — stable sort by `date`, so same-date rows keep input order. + - `ledgerlite.balance.closing_balance(opening: Decimal, transactions: Iterable[Transaction]) -> Decimal` — the running balance after the last transaction in date order; `opening` when there are none. + +- [ ] **Step 1: Write the failing test** + +Create `test_balance.py`: + +```python +"""Tests for ledgerlite.balance.""" + +import datetime +import unittest +from decimal import Decimal + +from ledgerlite.balance import closing_balance, order_by_date +from ledgerlite.model import Transaction + + +def txn(day, amount, description="x"): + return Transaction(datetime.date(2026, 3, day), Decimal(amount), description) + + +class OrderByDateTests(unittest.TestCase): + def test_sorts_by_date(self): + rows = [txn(4, "1.00", "c"), txn(1, "1.00", "a"), txn(2, "1.00", "b")] + + self.assertEqual([t.description for t in order_by_date(rows)], ["a", "b", "c"]) + + def test_ties_keep_input_order(self): + rows = [txn(1, "1.00", "second"), txn(1, "1.00", "first")] + + self.assertEqual( + [t.description for t in order_by_date(rows)], ["second", "first"] + ) + + def test_does_not_mutate_the_input(self): + rows = [txn(4, "1.00", "c"), txn(1, "1.00", "a")] + + order_by_date(rows) + + self.assertEqual([t.description for t in rows], ["c", "a"]) + + +class ClosingBalanceTests(unittest.TestCase): + def test_adds_every_amount_to_the_opening_balance(self): + rows = [txn(4, "-7.50"), txn(1, "2500.00"), txn(2, "-900.00")] + + self.assertEqual(closing_balance(Decimal("100"), rows), Decimal("1692.50")) + + def test_no_transactions_leaves_the_opening_balance(self): + self.assertEqual(closing_balance(Decimal("100.00"), []), Decimal("100.00")) + + def test_result_is_decimal_not_float(self): + result = closing_balance(Decimal("0"), [txn(1, "0.10"), txn(1, "0.20")]) + + self.assertIsInstance(result, Decimal) + self.assertEqual(result, Decimal("0.30")) + + +if __name__ == "__main__": + unittest.main() +``` + +`test_result_is_decimal_not_float` is the float canary: `0.1 + 0.2` is +`0.30000000000000004` in binary floating point, so this test fails loudly if +anything in the chain touches `float`. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python3 -m unittest test_balance -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.balance'` + +- [ ] **Step 3: Write minimal implementation** + +Create `ledgerlite/balance.py`: + +```python +"""Date ordering and the running balance.""" + +from __future__ import annotations + +from decimal import Decimal +from typing import Iterable + +from .model import Transaction + + +def order_by_date(transactions: Iterable[Transaction]) -> list[Transaction]: + """Return the transactions ordered by date; ties keep input order. + + `sorted` is stable, which is exactly the tie rule the spec asks for. + """ + return sorted(transactions, key=lambda transaction: transaction.date) + + +def closing_balance(opening: Decimal, transactions: Iterable[Transaction]) -> Decimal: + """Return the running balance after the last transaction in date order.""" + balance = opening + for transaction in order_by_date(transactions): + balance += transaction.amount + return balance +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `python3 -m unittest test_balance -v` +Expected: PASS (all tests) + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/balance.py test_balance.py +git commit -m "feat: order transactions by date and compute closing balance" +``` + +--- + +### Task 4: Per-category totals and report formatting + +**Files:** +- Create: `ledgerlite/report.py` +- Test: `test_report.py` + +**Interfaces:** +- Consumes: `Transaction` (Task 1), `Rule`/`categorize` (Task 2), `closing_balance` (Task 3). +- Produces: + - `ledgerlite.report.UNCATEGORIZED = "uncategorized"` + - `ledgerlite.report.category_totals(transactions: Sequence[Transaction], rules: Sequence[Rule]) -> dict[str, Decimal]` + - `ledgerlite.report.format_amount(amount: Decimal) -> str` + - `ledgerlite.report.sorted_categories(totals: dict[str, Decimal]) -> list[str]` + - `ledgerlite.report.format_report(transactions: Sequence[Transaction], rules: Sequence[Rule], opening: Decimal) -> str` — the whole report, ending in a single `\n`. + +- [ ] **Step 1: Write the failing test** + +Create `test_report.py`: + +```python +"""Tests for ledgerlite.report.""" + +import datetime +import unittest +from decimal import Decimal + +from ledgerlite.model import Transaction +from ledgerlite.report import ( + category_totals, + format_amount, + format_report, + sorted_categories, +) + +RULES = [("coffee", "food"), ("rent", "housing")] + + +def txn(day, amount, description): + return Transaction(datetime.date(2026, 3, day), Decimal(amount), description) + + +class FormatAmountTests(unittest.TestCase): + def test_two_fractional_digits_always(self): + self.assertEqual(format_amount(Decimal("1200")), "1200.00") + self.assertEqual(format_amount(Decimal("1.5")), "1.50") + self.assertEqual(format_amount(Decimal("0")), "0.00") + + def test_leading_minus_for_negatives(self): + self.assertEqual(format_amount(Decimal("-12.5")), "-12.50") + + def test_no_thousands_separators(self): + self.assertEqual(format_amount(Decimal("1234567.89")), "1234567.89") + + +class CategoryTotalsTests(unittest.TestCase): + def test_sums_amounts_per_category(self): + rows = [txn(1, "-7.50", "Coffee Bar"), txn(2, "-3.25", "coffee cart"), txn(3, "-900", "Rent")] + + self.assertEqual( + category_totals(rows, RULES), + {"food": Decimal("-10.75"), "housing": Decimal("-900")}, + ) + + def test_unmatched_transactions_are_uncategorized(self): + rows = [txn(1, "2500.00", "ACME payroll")] + + self.assertEqual(category_totals(rows, RULES), {"uncategorized": Decimal("2500.00")}) + + def test_no_rules_means_everything_is_uncategorized(self): + rows = [txn(1, "-7.50", "Coffee Bar"), txn(2, "-900", "Rent")] + + self.assertEqual(category_totals(rows, []), {"uncategorized": Decimal("-907.50")}) + + def test_no_transactions_has_no_categories(self): + self.assertEqual(category_totals([], RULES), {}) + + +class SortedCategoriesTests(unittest.TestCase): + def test_alphabetical_with_uncategorized_last(self): + totals = { + "uncategorized": Decimal("0"), + "travel": Decimal("0"), + "food": Decimal("0"), + "housing": Decimal("0"), + } + + self.assertEqual( + sorted_categories(totals), ["food", "housing", "travel", "uncategorized"] + ) + + def test_uncategorized_omitted_when_absent(self): + self.assertEqual(sorted_categories({"food": Decimal("0")}), ["food"]) + + +class FormatReportTests(unittest.TestCase): + def test_matches_the_spec_example(self): + rows = [ + txn(4, "-7.50", "Coffee Bar"), + txn(1, "2500.00", "ACME payroll"), + txn(2, "-900.00", "Rent March"), + ] + + self.assertEqual( + format_report(rows, RULES, Decimal("100")), + "food: -7.50\n" + "housing: -900.00\n" + "uncategorized: 2500.00\n" + "\n" + "closing balance: 1692.50\n", + ) + + def test_no_transactions_prints_only_the_closing_balance(self): + self.assertEqual( + format_report([], RULES, Decimal("100")), + "\nclosing balance: 100.00\n", + ) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python3 -m unittest test_report -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.report'` + +- [ ] **Step 3: Write minimal implementation** + +Create `ledgerlite/report.py`: + +```python +"""Per-category totals and report text.""" + +from __future__ import annotations + +from decimal import ROUND_HALF_UP, Decimal +from typing import Sequence + +from .balance import closing_balance +from .model import Transaction +from .rules import Rule, categorize + +UNCATEGORIZED = "uncategorized" +_CENTS = Decimal("0.01") + + +def category_totals( + transactions: Sequence[Transaction], rules: Sequence[Rule] +) -> dict[str, Decimal]: + """Sum amounts per category; unmatched transactions land in UNCATEGORIZED.""" + totals: dict[str, Decimal] = {} + for transaction in transactions: + category = categorize(transaction.description, rules) or UNCATEGORIZED + totals[category] = totals.get(category, Decimal("0")) + transaction.amount + return totals + + +def format_amount(amount: Decimal) -> str: + """Format with exactly two fractional digits, `-` only for negatives.""" + quantized = amount.quantize(_CENTS, rounding=ROUND_HALF_UP) + return f"{quantized:f}" + + +def sorted_categories(totals: dict[str, Decimal]) -> list[str]: + """Category names alphabetically, with UNCATEGORIZED last if present.""" + names = sorted(name for name in totals if name != UNCATEGORIZED) + if UNCATEGORIZED in totals: + names.append(UNCATEGORIZED) + return names + + +def format_report( + transactions: Sequence[Transaction], rules: Sequence[Rule], opening: Decimal +) -> str: + """Render the whole report, ending in a single newline.""" + totals = category_totals(transactions, rules) + lines = [ + f"{name}: {format_amount(totals[name])}" for name in sorted_categories(totals) + ] + lines.append("") + lines.append(f"closing balance: {format_amount(closing_balance(opening, transactions))}") + return "\n".join(lines) + "\n" +``` + +Note the blank line is unconditional, as the spec words it ("Then a blank +line, then `closing balance:`"), so a report with no categories starts with an +empty line — pinned by `test_no_transactions_prints_only_the_closing_balance`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `python3 -m unittest test_report -v` +Expected: PASS (all tests) + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/report.py test_report.py +git commit -m "feat: format per-category totals and closing balance" +``` + +- [ ] **Step 6: Write the failing tests for negative zero and a category named uncategorized** + +`-0.00` is a legal input amount and `Decimal("-0.00")` formats as `-0.00`, +which the spec forbids: the leading `-` is for negatives, and zero is not +negative. A rules file may also name a category `uncategorized`; those +transactions should merge into the one bucket rather than producing two lines. + +Append to `test_report.py`, before the `if __name__` block: + +```python +class NegativeZeroTests(unittest.TestCase): + def test_negative_zero_prints_without_a_sign(self): + self.assertEqual(format_amount(Decimal("-0.00")), "0.00") + self.assertEqual(format_amount(Decimal("-0")), "0.00") + + def test_cancelling_category_prints_zero(self): + rows = [txn(1, "-7.50", "Coffee Bar"), txn(2, "7.50", "coffee refund")] + + self.assertEqual( + format_report(rows, RULES, Decimal("0")), + "food: 0.00\n\nclosing balance: 0.00\n", + ) + + def test_negative_zero_amount_in_input(self): + self.assertEqual( + format_report([txn(1, "-0.00", "Coffee Bar")], RULES, Decimal("0")), + "food: 0.00\n\nclosing balance: 0.00\n", + ) + + +class ExplicitUncategorizedCategoryTests(unittest.TestCase): + def test_a_rule_named_uncategorized_merges_and_prints_last(self): + rules = [("misc", "uncategorized"), ("rent", "housing")] + rows = [txn(1, "-5.00", "misc fee"), txn(2, "-900.00", "Rent"), txn(3, "1.00", "unmatched")] + + self.assertEqual( + format_report(rows, rules, Decimal("0")), + "housing: -900.00\nuncategorized: -4.00\n\nclosing balance: -904.00\n", + ) +``` + +- [ ] **Step 7: Run tests to verify they fail** + +Run: `python3 -m unittest test_report -v` +Expected: FAIL — `test_negative_zero_prints_without_a_sign` and `test_negative_zero_amount_in_input` get `-0.00`. (`test_cancelling_category_prints_zero` and the `uncategorized`-rule test should already pass — that is fine, they are regression pins.) + +- [ ] **Step 8: Write the implementation** + +In `ledgerlite/report.py`, replace `format_amount` with: + +```python +def format_amount(amount: Decimal) -> str: + """Format with exactly two fractional digits, `-` only for negatives. + + Decimal keeps the sign of a negative zero; zero is not negative, so drop + it rather than printing `-0.00`. + """ + quantized = amount.quantize(_CENTS, rounding=ROUND_HALF_UP) + if quantized == 0: + quantized = abs(quantized) + return f"{quantized:f}" +``` + +- [ ] **Step 9: Run tests to verify they pass** + +Run: `python3 -m unittest test_report -v` +Expected: PASS (all tests) + +- [ ] **Step 10: Commit** + +```bash +git add ledgerlite/report.py test_report.py +git commit -m "fix: never print a negative zero amount" +``` + +--- + +### Task 5: CLI, exit codes, and error messages + +**Files:** +- Create: `ledgerlite/cli.py` +- Create: `ledgerlite/__main__.py` +- Test: `test_cli.py` + +**Interfaces:** +- Consumes: `parse.parse_amount`, `parse.parse_transactions`, `parse.ParseError` (Task 1), `rules.parse_rules` (Task 2), `report.format_report` (Task 4). +- Produces: + - `ledgerlite.cli.build_parser() -> argparse.ArgumentParser` + - `ledgerlite.cli.main(argv: list[str] | None = None) -> int` — returns the exit code, never calls `sys.exit` itself (argparse still raises `SystemExit` on usage errors). + - `python3 -m ledgerlite report ...` as the invocation. + +- [ ] **Step 1: Write the failing test** + +Create `test_cli.py`: + +```python +"""Tests for ledgerlite.cli.""" + +import contextlib +import io +import os +import tempfile +import unittest + +from ledgerlite.cli import main + +TRANSACTIONS = ( + "date,amount,description\n" + "2026-03-04,-7.50,Coffee Bar\n" + "2026-03-01,2500.00,ACME payroll\n" + "2026-03-02,-900.00,Rent March\n" +) +RULES = "coffee=food\nrent=housing\n" + +EXPECTED = ( + "food: -7.50\n" + "housing: -900.00\n" + "uncategorized: 2500.00\n" + "\n" + "closing balance: 1692.50\n" +) + + +def run_cli(argv): + """Run main(argv), capturing stdout and stderr.""" + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = main(argv) + return code, out.getvalue(), err.getvalue() + + +class CliTestCase(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + + def write(self, name, text): + path = os.path.join(self.directory.name, name) + with open(path, "w", encoding="utf-8") as handle: + handle.write(text) + return path + + def path(self, name): + return os.path.join(self.directory.name, name) + + +class SuccessTests(CliTestCase): + def test_reports_the_spec_example(self): + transactions = self.write("txns.csv", TRANSACTIONS) + rules = self.write("rules.txt", RULES) + + code, out, err = run_cli(["report", transactions, "--rules", rules, "--opening", "100"]) + + self.assertEqual((code, out, err), (0, EXPECTED, "")) + + def test_without_rules_everything_is_uncategorized(self): + transactions = self.write("txns.csv", TRANSACTIONS) + + code, out, err = run_cli(["report", transactions]) + + self.assertEqual(code, 0) + self.assertEqual(out, "uncategorized: 1592.50\n\nclosing balance: 1592.50\n") + self.assertEqual(err, "") + + def test_opening_defaults_to_zero(self): + transactions = self.write("txns.csv", "date,amount,description\n2026-03-04,-7.50,Coffee Bar\n") + + code, out, _ = run_cli(["report", transactions, "--rules", self.write("r.txt", RULES)]) + + self.assertEqual(code, 0) + self.assertEqual(out, "food: -7.50\n\nclosing balance: -7.50\n") + + def test_negative_opening_is_accepted(self): + transactions = self.write("txns.csv", "date,amount,description\n2026-03-04,10.00,x\n") + + code, out, _ = run_cli(["report", transactions, "--opening", "-2.50"]) + + self.assertEqual(code, 0) + self.assertEqual(out, "uncategorized: 10.00\n\nclosing balance: 7.50\n") + + +class UnreadableTransactionsTests(CliTestCase): + def test_missing_file_exits_1(self): + missing = self.path("nope.csv") + + code, out, err = run_cli(["report", missing]) + + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertTrue(err.startswith(f"ledgerlite: cannot read {missing}: "), err) + self.assertIn("No such file or directory", err) + self.assertTrue(err.endswith("\n")) + + def test_directory_instead_of_file_exits_1(self): + code, out, err = run_cli(["report", self.directory.name]) + + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertTrue(err.startswith(f"ledgerlite: cannot read {self.directory.name}: "), err) + + +class MalformedRowTests(CliTestCase): + def test_malformed_row_exits_2_with_path_and_line(self): + transactions = self.write( + "txns.csv", + "date,amount,description\n2026-03-01,1.00,good\n2026-03-02,oops,bad\n", + ) + + code, out, err = run_cli(["report", transactions]) + + self.assertEqual(code, 2) + self.assertEqual(out, "") + self.assertEqual(err, f"ledgerlite: {transactions}:3: invalid amount: 'oops'\n") + + def test_nothing_is_printed_to_stdout_for_a_partly_good_file(self): + transactions = self.write( + "txns.csv", + "date,amount,description\n2026-03-01,1.00,good\n2026-03-02,1.005,bad\n", + ) + + code, out, err = run_cli(["report", transactions]) + + self.assertEqual(code, 2) + self.assertEqual(out, "") + self.assertEqual( + err, + f"ledgerlite: {transactions}:3: amount has more than two decimal places: '1.005'\n", + ) + + +class OpeningAmountTests(CliTestCase): + """Review Focus item 4: a bad --opening must be a usage error, not a traceback.""" + + def assertUsageError(self, argv, needle): + err = io.StringIO() + with contextlib.redirect_stderr(err), self.assertRaises(SystemExit) as caught: + main(argv) + + self.assertEqual(caught.exception.code, 2) + self.assertIn(needle, err.getvalue()) + + def test_non_numeric_opening_is_a_usage_error(self): + transactions = self.write("txns.csv", TRANSACTIONS) + + self.assertUsageError( + ["report", transactions, "--opening", "abc"], "invalid amount: 'abc'" + ) + + def test_over_precise_opening_is_a_usage_error(self): + transactions = self.write("txns.csv", TRANSACTIONS) + + self.assertUsageError( + ["report", transactions, "--opening", "1.005"], + "amount has more than two decimal places: '1.005'", + ) + + def test_opening_is_parsed_as_decimal_not_float(self): + transactions = self.write("txns.csv", "date,amount,description\n2026-03-04,0.20,x\n") + + code, out, _ = run_cli(["report", transactions, "--opening", "0.10"]) + + self.assertEqual(code, 0) + self.assertEqual(out, "uncategorized: 0.20\n\nclosing balance: 0.30\n") + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python3 -m unittest test_cli -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.cli'` + +- [ ] **Step 3: Write minimal implementation** + +Create `ledgerlite/cli.py`: + +```python +"""Command-line entry point: the only module that does I/O or exit codes.""" + +from __future__ import annotations + +import argparse +import sys +from decimal import Decimal + +from . import parse, report, rules +from .parse import ParseError + + +def _opening_amount(raw: str) -> Decimal: + """argparse type for --opening: same rules as a transaction amount. + + ArgumentTypeError makes argparse print a usage error and exit 2 instead of + letting a decimal.InvalidOperation traceback escape. + """ + try: + return parse.parse_amount(raw) + except ValueError as exc: + raise argparse.ArgumentTypeError(str(exc)) from exc + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="ledgerlite", description="Categorize bank transactions and report totals." + ) + subcommands = parser.add_subparsers(dest="command", required=True) + report_command = subcommands.add_parser("report", help="print a categorized report") + report_command.add_argument( + "transactions", metavar="TRANSACTIONS", help="transactions CSV to read" + ) + report_command.add_argument( + "--rules", metavar="RULES", help="rules file (= per line)" + ) + report_command.add_argument( + "--opening", + metavar="AMOUNT", + type=_opening_amount, + default=Decimal("0"), + help="opening balance (default: 0)", + ) + return parser + + +def _read_lines(path: str) -> list[str]: + with open(path, newline="", encoding="utf-8") as handle: + return handle.readlines() + + +def _reason(exc: BaseException) -> str: + """The human-readable half of an I/O failure.""" + return getattr(exc, "strerror", None) or str(exc) + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + + try: + transaction_lines = _read_lines(args.transactions) + except OSError as exc: + print(f"ledgerlite: cannot read {args.transactions}: {_reason(exc)}", file=sys.stderr) + return 1 + + try: + transactions = parse.parse_transactions(transaction_lines) + except ParseError as exc: + print(f"ledgerlite: {args.transactions}:{exc.line}: {exc.problem}", file=sys.stderr) + return 2 + + loaded_rules: list[rules.Rule] = [] + if args.rules is not None: + loaded_rules = rules.parse_rules(_read_lines(args.rules)) + + sys.stdout.write(report.format_report(transactions, loaded_rules, args.opening)) + return 0 +``` + +Create `ledgerlite/__main__.py`: + +```python +"""Allow `python3 -m ledgerlite`.""" + +import sys + +from .cli import main + +if __name__ == "__main__": + sys.exit(main()) +``` + +`main` returns the exit code rather than calling `sys.exit`, so tests can +assert on it directly; `__main__.py` is the only place that exits. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `python3 -m unittest test_cli -v` +Expected: PASS (all tests) + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/cli.py ledgerlite/__main__.py test_cli.py +git commit -m "feat: add report CLI with exit codes and --opening validation" +``` + +- [ ] **Step 6: Write the failing test for an unreadable rules file** + +Review Focus item 5. The spec only promises a message for TRANSACTIONS, but a +typo'd `--rules` path currently escapes as a `FileNotFoundError` traceback. +Worse would be treating it as "no rules" and printing an all-uncategorized +report that looks plausible and is wrong. + +Append to `test_cli.py`, before the `if __name__` block: + +```python +class UnreadableRulesTests(CliTestCase): + def test_missing_rules_file_exits_1_without_a_report(self): + transactions = self.write("txns.csv", TRANSACTIONS) + missing = self.path("nope.txt") + + code, out, err = run_cli(["report", transactions, "--rules", missing]) + + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertTrue(err.startswith(f"ledgerlite: cannot read {missing}: "), err) + self.assertIn("No such file or directory", err) +``` + +- [ ] **Step 7: Run test to verify it fails** + +Run: `python3 -m unittest test_cli -v` +Expected: FAIL — `FileNotFoundError` escapes `main` uncaught (the test errors +rather than getting exit code 1). + +- [ ] **Step 8: Write the implementation** + +In `ledgerlite/cli.py`, wrap the rules read in `main`, replacing the +`if args.rules is not None` block: + +```python + loaded_rules: list[rules.Rule] = [] + if args.rules is not None: + try: + loaded_rules = rules.parse_rules(_read_lines(args.rules)) + except OSError as exc: + print(f"ledgerlite: cannot read {args.rules}: {_reason(exc)}", file=sys.stderr) + return 1 +``` + +- [ ] **Step 9: Run tests to verify they pass** + +Run: `python3 -m unittest test_cli -v` +Expected: PASS (all tests) + +- [ ] **Step 10: Commit** + +```bash +git add ledgerlite/cli.py test_cli.py +git commit -m "feat: report an unreadable rules file instead of crashing" +``` + +- [ ] **Step 11: Run the whole suite** + +Run: `python3 -m unittest -v` +Expected: PASS — every test from Tasks 1–5, zero failures, zero errors. + +- [ ] **Step 12: Drive the real CLI end to end** + +The tests call `main()` in-process; this checks the actual command, the exit +codes the shell sees, and the stdout/stderr split. + +```bash +mkdir -p /tmp/ledgerlite-check +printf 'date,amount,description\n2026-03-04,-7.50,Coffee Bar\n2026-03-01,2500.00,ACME payroll\n2026-03-02,-900.00,Rent March\n' > /tmp/ledgerlite-check/txns.csv +printf 'coffee=food\nrent=housing\n' > /tmp/ledgerlite-check/rules.txt + +python3 -m ledgerlite report /tmp/ledgerlite-check/txns.csv --rules /tmp/ledgerlite-check/rules.txt --opening 100 +echo "exit=$?" +``` + +Expected, exactly: + +``` +food: -7.50 +housing: -900.00 +uncategorized: 2500.00 + +closing balance: 1692.50 +exit=0 +``` + +Then the two failure paths: + +```bash +python3 -m ledgerlite report /tmp/ledgerlite-check/missing.csv > /dev/null +echo "exit=$?" +printf 'date,amount,description\n2026-03-04,oops,bad\n' > /tmp/ledgerlite-check/bad.csv +python3 -m ledgerlite report /tmp/ledgerlite-check/bad.csv > /dev/null +echo "exit=$?" +``` + +Expected, exactly: + +``` +ledgerlite: cannot read /tmp/ledgerlite-check/missing.csv: No such file or directory +exit=1 +ledgerlite: /tmp/ledgerlite-check/bad.csv:2: invalid amount: 'oops' +exit=2 +``` + +Both messages must still appear even though stdout went to `/dev/null` — that +is the check that they go to stderr. + +- [ ] **Step 13: Commit** + +Nothing should have changed in Steps 11–12. If a fix was needed, commit it: + +```bash +git status --short +git commit -am "fix: correct CLI behavior found in end-to-end check" +``` + +Otherwise skip the commit and note that the tree is clean. diff --git a/tests/inline-eval/results/2026-09-17-review-focus-five/wpplan-42.txt b/tests/inline-eval/results/2026-09-17-review-focus-five/wpplan-42.txt new file mode 100644 index 000000000..e173985ff --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-review-focus-five/wpplan-42.txt @@ -0,0 +1,27 @@ +# rep wpplan-42 transcript=/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/inline-eval-br/reps/wpplan-42/cfg/projects/-private-tmp-claude-501--Users-jesse-git-superpowers-superpowers-a8a313bf-7410-41ce-9036-636ccf4061d6-scratchpad-inline-eval-br-reps-wpplan-42-repo/11f28639-20a1-4f06-b0fd-0009f0db4e2b.jsonl +skill executing-plans invoked False +all skills invoked writing-plans +Agent dispatches 0 +test runs 0 +ledger (progress.md) touches 0 +sdd script invocations 0 +commits (incl. fixture) 1 +suite in repo plan.md-present +probe review-focus handled +probe implied-header handled +probe implied-encoding handled +probe size plan= 1475 review-focus-lines=5 tests=66 tasks= +user_prompt_submit events 1 (background-agent wakeups count too) +final: 'Rulings I made' False +final: mentions self-review True +mid-run check-in candidates 0 +main session tokens 18 msgs; out=44212 cache_read=993734 cache_create=63307 in=36 +subagent tokens 0 agents; out=0 cache_read=0 cache_create=0 in=0 +TOTAL tokens out=44212 cache_read=993734 cache_create=63307 in=36 + +# assistant text, in order (truncated) +[ 1] I'll start by invoking the writing-plans skill and reading the design. +[ 4] I'm using the writing-plans skill to create the implementation plan. +[ 9] Now the self-review. Two issues found — fixing them inline. +[ 20] Now replacing the duplicated Step 6 block: +[ 30] Plan complete and saved to `plan.md`. **What it covers** — five tasks, each ending in an independently testable, committed deliverable: 1. **Model + parsing** (`model.py`, `parse.py`) — `Transaction`, `ParseError`, and t diff --git a/tests/inline-eval/results/2026-09-17-review-focus-five/wpplan-43.plan.md b/tests/inline-eval/results/2026-09-17-review-focus-five/wpplan-43.plan.md new file mode 100644 index 000000000..ea78ebc67 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-review-focus-five/wpplan-43.plan.md @@ -0,0 +1,1371 @@ +# ledgerlite Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build `ledgerlite`, a standard-library Python package and CLI that reads a transactions CSV, categorizes each row with a rules file, and prints per-category totals plus the closing balance. + +**Architecture:** Five small pure modules (`model`, `parse`, `rules`, `balance`, `report`) with no I/O, plus a thin `cli` module that does all file reading, error formatting, and exit codes. Money is `decimal.Decimal` end to end — never `float`. Parsing rejects the whole file on the first malformed row, so nothing reaches stdout unless every row is valid. + +**Tech Stack:** Python 3.11+, standard library only (`csv`, `datetime`, `decimal`, `argparse`, `re`, `dataclasses`), tests with `unittest`. + +**Spec:** `design.md` (repo root) — read it before starting and keep it open; this plan implements it section by section. + +## Global Constraints + +Every task's requirements implicitly include these. Values are copied verbatim from `design.md`. + +- Python 3.11+. Do not use syntax newer than 3.11 (no PEP 695 `type` statements or generic syntax). +- Standard library only. No third-party dependencies, no `pyproject.toml`, no `requirements.txt`. +- Money is parsed and carried as `decimal.Decimal`, **never** float. The string `float` must not appear anywhere in `ledgerlite/`. +- Package layout is exactly `ledgerlite/{__init__,model,parse,rules,balance,report,cli}.py`, plus `ledgerlite/__main__.py` (a 3-line addition to the layout in the design, so the tool can actually be invoked as `python3 -m ledgerlite`). +- Tests live at the repo root as `test_.py` and run with `python3 -m unittest` from the repo root. +- Exit codes: `0` success, `1` transactions (or rules) file cannot be read, `2` malformed input. +- Error text, verbatim shapes: `ledgerlite: cannot read : ` and `ledgerlite: :: `, both to stderr. +- Amounts print with exactly two fractional digits, a leading `-` only for negatives, and no thousands separators (`-12.50`, `0.00`, `1200.00`). +- Work directly on `main` in this repo (local scratch repo, no remote). Commit at the end of every task; larger tasks commit twice where marked. + +## Review Focus + +The spec is a vision document: its silence about an input is not permission for that input to break the program. These five implied-but-unstated cases are the ones most likely to bite a real user; each has a pinned test in the task that owns the code. + +1. **A UTF-8 BOM or CRLF line endings in the CSV** — the shape most bank exports actually have. A BOM must not make the header row unrecognizable, and `\r` must not end up glued to the last field. (Tests: Task 1 for CRLF, Task 5 for the BOM.) +2. **Amount spellings `decimal.Decimal` happily accepts but the spec does not** — `1e3`, `NaN`, `Infinity`, `.5`, `1.`, `1.005` must all be rejected as malformed rows, while `1.5`, `1.50` and `+5.00` are accepted. A silently-accepted `NaN` poisons every total. (Test: Task 1.) +3. **Zero and negative-zero totals** — a category whose amounts cancel out, or an input amount of `-0.00`, must print `0.00`, never `-0.00`. (Test: Task 4.) +4. **A transactions file with no data rows** — a header-only file is valid and must print no category lines with `closing balance` equal to the opening amount; a totally empty file (not even a header) is malformed and exits 2. (Tests: Tasks 1, 4, 5.) +5. **A rules line that isn't `=`** — a typo'd line, an empty substring (which would otherwise match everything), or an empty category must fail loudly with a file/line message, not be silently dropped so the user sees a mysteriously uncategorized report. (Tests: Tasks 2, 5.) + +## Resolved Ambiguities + +The spec leaves these open. Decide once, here, so every task agrees: + +- **Header row is validated, not skipped.** Line 1's three fields, stripped and lowercased, must equal `date`, `amount`, `description`; otherwise it is a malformed row at line 1. (Blindly skipping line 1 would silently swallow a transaction in a header-less file.) +- **An empty file** is malformed at line 1 (`file is empty; expected a header row date,amount,description`). +- **A blank line inside the CSV** is a wrong-column-count row (0 fields), so it is malformed. +- **Leading/trailing whitespace** is stripped from the `date` and `amount` fields and from both halves of a rule; `description` is kept verbatim (it is free text). +- **A leading `+`** on an amount is accepted (`+5.00` → `Decimal("5.00")`) — it is still "a decimal number". +- **A rules line splits at its first `=`**, so `coffee=food=drink` is the substring `coffee` with the category `food=drink`. +- **Rules-file problems reuse the malformed-input path**: unreadable rules file → exit 1 with the `cannot read` message; malformed rules line → exit 2 with `ledgerlite: :: `. +- **If both files are bad**, the transactions error is reported (it is the primary input) and the rules file is not read. +- **Alphabetical is case-insensitive**: categories sort by `(name.casefold(), name)`, so `apple` precedes `Bank`. +- **A rule whose category is literally `uncategorized`** merges into the uncategorized bucket — one line, listed last. +- **`--opening` uses the same amount grammar** as a CSV amount; a bad value prints to stderr and exits 2. +- **`balance.py` exposes the closing balance only.** The running balance is the accumulation inside `closing_balance`; no consumer needs the per-row values, so none are exposed (YAGNI). + +--- + +## File Structure + +| File | Responsibility | +|------|----------------| +| `ledgerlite/__init__.py` | Package docstring. No re-exports. | +| `ledgerlite/model.py` | `Transaction` frozen dataclass. No logic. | +| `ledgerlite/parse.py` | `ParseError`, `parse_date`, `parse_amount`, `parse_transactions`. Pure: takes an iterable of lines, never opens a file. | +| `ledgerlite/rules.py` | `parse_rules`, `categorize`. Pure. | +| `ledgerlite/balance.py` | `order_transactions`, `closing_balance`. Pure. | +| `ledgerlite/report.py` | `format_amount`, `category_totals`, `format_report`. Pure; returns a string, prints nothing. | +| `ledgerlite/cli.py` | `build_parser`, `main(argv) -> int`. The only module that opens files, writes to stdout/stderr, or knows about exit codes. | +| `ledgerlite/__main__.py` | `python3 -m ledgerlite` wiring. | +| `test_parse.py`, `test_model.py`, `test_rules.py`, `test_balance.py`, `test_report.py`, `test_cli.py` | Repo-root tests, one per module. | + +--- + +### Task 1: Transaction model and CSV parsing + +**Files:** +- Create: `ledgerlite/__init__.py`, `ledgerlite/model.py`, `ledgerlite/parse.py` +- Test: `test_model.py`, `test_parse.py` + +**Interfaces:** +- Consumes: nothing (first task). +- Produces: + - `ledgerlite.model.Transaction(date: datetime.date, amount: decimal.Decimal, description: str)` — frozen dataclass, keyword construction used everywhere. + - `ledgerlite.parse.ParseError(line: int, problem: str)` — exception with `.line` and `.problem` attributes; the CLI formats it, `rules.py` raises it too. + - `ledgerlite.parse.parse_date(raw: str) -> datetime.date` — raises `ValueError` with a human-readable problem. + - `ledgerlite.parse.parse_amount(raw: str) -> decimal.Decimal` — raises `ValueError` with a human-readable problem. Used by `cli.py` for `--opening`. + - `ledgerlite.parse.parse_transactions(lines: Iterable[str]) -> list[Transaction]` — input order preserved; raises `ParseError`. + +- [ ] **Step 1: Create the package skeleton** + +Create `ledgerlite/__init__.py` with exactly: + +```python +"""ledgerlite — categorize bank transactions and summarize them.""" +``` + +- [ ] **Step 2: Write the failing model test** + +Create `test_model.py`: + +```python +import datetime +import unittest +from decimal import Decimal + +from ledgerlite.model import Transaction + + +class TransactionTest(unittest.TestCase): + def test_holds_date_amount_and_description(self): + transaction = Transaction( + date=datetime.date(2026, 3, 4), + amount=Decimal("-7.50"), + description="Coffee Bar", + ) + + self.assertEqual(transaction.date, datetime.date(2026, 3, 4)) + self.assertEqual(str(transaction.amount), "-7.50") + self.assertEqual(transaction.description, "Coffee Bar") + + def test_is_immutable(self): + transaction = Transaction( + date=datetime.date(2026, 3, 4), + amount=Decimal("-7.50"), + description="Coffee Bar", + ) + + with self.assertRaises(Exception): + transaction.amount = Decimal("0.00") +``` + +- [ ] **Step 3: Run it to make sure it fails** + +Run: `python3 -m unittest test_model -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.model'` + +- [ ] **Step 4: Write model.py** + +Create `ledgerlite/model.py`: + +```python +"""The single record type shared by every other module.""" + +import dataclasses +import datetime +import decimal + + +@dataclasses.dataclass(frozen=True) +class Transaction: + """One row of the transactions CSV, already validated.""" + + date: datetime.date + amount: decimal.Decimal + description: str +``` + +Note the `import datetime` / `datetime.date` style: a bare `from datetime import date` would collide with the field named `date`. + +- [ ] **Step 5: Run it to make sure it passes** + +Run: `python3 -m unittest test_model -v` +Expected: PASS (2 tests) + +- [ ] **Step 6: Write the failing happy-path parse tests** + +Create `test_parse.py`: + +```python +import datetime +import io +import unittest +from decimal import Decimal + +from ledgerlite.parse import parse_transactions + +GOOD_CSV = """\ +date,amount,description +2026-03-05,-900.00,Rent March +2026-03-04,-7.50,Coffee Bar +2026-03-04,2500.00,ACME PAYROLL +""" + + +def read(text): + """Parse CSV text the way the CLI does: no newline translation.""" + return parse_transactions(io.StringIO(text, newline="")) + + +class ParseTransactionsTest(unittest.TestCase): + def test_reads_every_row_in_input_order(self): + transactions = read(GOOD_CSV) + + self.assertEqual( + [(t.date, str(t.amount), t.description) for t in transactions], + [ + (datetime.date(2026, 3, 5), "-900.00", "Rent March"), + (datetime.date(2026, 3, 4), "-7.50", "Coffee Bar"), + (datetime.date(2026, 3, 4), "2500.00", "ACME PAYROLL"), + ], + ) + + def test_amounts_are_decimals_not_floats(self): + transactions = read("date,amount,description\n2026-03-04,0.10,Dime\n") + + self.assertIsInstance(transactions[0].amount, Decimal) + self.assertEqual(transactions[0].amount * 3, Decimal("0.30")) + + def test_header_only_file_has_no_transactions(self): + self.assertEqual(read("date,amount,description\n"), []) + + def test_header_is_case_and_whitespace_insensitive(self): + transactions = read("Date, Amount , DESCRIPTION\n2026-03-04,-7.50,Coffee\n") + + self.assertEqual(len(transactions), 1) + + def test_description_keeps_commas_and_case(self): + transactions = read('date,amount,description\n2026-03-04,-7.50,"Coffee, Bar"\n') + + self.assertEqual(transactions[0].description, "Coffee, Bar") + + def test_crlf_line_endings_do_not_leak_into_fields(self): + transactions = read( + "date,amount,description\r\n2026-03-04,-7.50,Coffee Bar\r\n" + ) + + self.assertEqual(transactions[0].description, "Coffee Bar") + self.assertEqual(str(transactions[0].amount), "-7.50") + + def test_whitespace_around_date_and_amount_is_ignored(self): + transactions = read("date,amount,description\n 2026-03-04 , -7.50 ,Coffee\n") + + self.assertEqual(transactions[0].date, datetime.date(2026, 3, 4)) + self.assertEqual(str(transactions[0].amount), "-7.50") + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 7: Run it to make sure it fails** + +Run: `python3 -m unittest test_parse -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.parse'` + +- [ ] **Step 8: Write the minimal parse.py** + +Only enough for the happy path; validation arrives in Step 12. + +Create `ledgerlite/parse.py`: + +```python +"""Turn transactions CSV text into validated Transaction objects.""" + +import csv +import datetime +import decimal + +from ledgerlite.model import Transaction + +HEADER = ["date", "amount", "description"] + + +def parse_transactions(lines): + """Parse an iterable of CSV lines into Transactions, in input order.""" + reader = csv.reader(lines) + next(reader, None) # header row + transactions = [] + for row in reader: + raw_date, raw_amount, description = row + transactions.append( + Transaction( + date=datetime.date.fromisoformat(raw_date.strip()), + amount=decimal.Decimal(raw_amount.strip()), + description=description, + ) + ) + return transactions +``` + +- [ ] **Step 9: Run it to make sure it passes** + +Run: `python3 -m unittest test_parse -v` +Expected: PASS (7 tests). If `test_header_is_case_and_whitespace_insensitive` fails, the header is not being skipped — fix that before continuing. + +- [ ] **Step 10: Commit the happy path** + +```bash +git add ledgerlite/__init__.py ledgerlite/model.py ledgerlite/parse.py test_model.py test_parse.py +git commit -m "feat: parse well-formed transactions CSV into Transaction records" +``` + +- [ ] **Step 11: Write the failing malformed-input tests** + +Append to `test_parse.py`, and extend the import at the top to: + +```python +from ledgerlite.parse import ParseError, parse_amount, parse_date, parse_transactions +``` + +```python +class MalformedRowTest(unittest.TestCase): + def reject(self, text): + with self.assertRaises(ParseError) as caught: + read(text) + return caught.exception + + def test_empty_file_is_rejected_at_line_1(self): + error = self.reject("") + + self.assertEqual(error.line, 1) + self.assertIn("empty", error.problem) + + def test_wrong_header_is_rejected_at_line_1(self): + error = self.reject("date,description,amount\n2026-03-04,Coffee,-7.50\n") + + self.assertEqual(error.line, 1) + self.assertIn("header", error.problem) + + def test_too_few_fields(self): + error = self.reject("date,amount,description\n2026-03-04,-7.50\n") + + self.assertEqual(error.line, 2) + self.assertIn("3 fields", error.problem) + + def test_too_many_fields(self): + error = self.reject("date,amount,description\n2026-03-04,-7.50,Coffee,x\n") + + self.assertEqual(error.line, 2) + self.assertIn("3 fields", error.problem) + + def test_blank_line_inside_the_file(self): + error = self.reject("date,amount,description\n\n2026-03-04,-7.50,Coffee\n") + + self.assertEqual(error.line, 2) + self.assertIn("3 fields", error.problem) + + def test_non_iso_date(self): + error = self.reject("date,amount,description\n04/03/2026,-7.50,Coffee\n") + + self.assertEqual(error.line, 2) + self.assertIn("date", error.problem) + + def test_impossible_date(self): + error = self.reject("date,amount,description\n2026-13-40,-7.50,Coffee\n") + + self.assertEqual(error.line, 2) + self.assertIn("date", error.problem) + + def test_amount_with_three_fractional_digits(self): + error = self.reject("date,amount,description\n2026-03-04,1.005,Coffee\n") + + self.assertEqual(error.line, 2) + self.assertIn("amount", error.problem) + + def test_amounts_decimal_accepts_but_the_spec_does_not(self): + for raw in ["1e3", "NaN", "Infinity", "-Infinity", ".5", "1.", "1,200.00", "", "abc"]: + with self.subTest(amount=raw): + error = self.reject( + f'date,amount,description\n2026-03-04,"{raw}",Coffee\n' + ) + self.assertEqual(error.line, 2) + self.assertIn("amount", error.problem) + + def test_line_number_survives_a_quoted_newline(self): + error = self.reject( + 'date,amount,description\n' + '2026-03-04,-7.50,"Coffee\nBar"\n' + '2026-03-05,oops,Rent\n' + ) + + self.assertEqual(error.line, 4) + + def test_reports_the_first_bad_row_only(self): + error = self.reject( + "date,amount,description\n" + "2026-03-04,-7.50,Coffee\n" + "2026-03-05,oops,Rent\n" + "2026-03-06,nope,Rent\n" + ) + + self.assertEqual(error.line, 3) + + +class AmountGrammarTest(unittest.TestCase): + def test_accepts_zero_one_and_two_fractional_digits(self): + self.assertEqual(str(parse_amount("1")), "1") + self.assertEqual(str(parse_amount("1.5")), "1.5") + self.assertEqual(str(parse_amount("1.50")), "1.50") + + def test_accepts_a_leading_plus(self): + self.assertEqual(parse_amount("+5.00"), Decimal("5.00")) + + def test_rejects_more_than_two_fractional_digits(self): + with self.assertRaises(ValueError): + parse_amount("1.005") + + +class DateGrammarTest(unittest.TestCase): + def test_parses_an_iso_date(self): + self.assertEqual(parse_date("2026-03-04"), datetime.date(2026, 3, 4)) + + def test_rejects_a_non_iso_date(self): + with self.assertRaises(ValueError): + parse_date("2026/03/04") +``` + +Note the quoted `"{raw}"` in `test_amounts_decimal_accepts_but_the_spec_does_not`: it keeps `1,200.00` and the empty string as a single CSV field so the failure is about the amount, not the column count. + +- [ ] **Step 12: Run them to make sure they fail** + +Run: `python3 -m unittest test_parse -v` +Expected: FAIL — `ImportError: cannot import name 'ParseError' from 'ledgerlite.parse'` + +- [ ] **Step 13: Write the validating parse.py** + +Replace `ledgerlite/parse.py` entirely: + +```python +"""Turn transactions CSV text into validated Transaction objects.""" + +import csv +import datetime +import decimal +import re + +from ledgerlite.model import Transaction + +HEADER = ["date", "amount", "description"] + +_DATE_RE = re.compile(r"\d{4}-\d{2}-\d{2}\Z") +_AMOUNT_RE = re.compile(r"[-+]?\d+(\.\d{1,2})?\Z") + + +class ParseError(Exception): + """An input file is malformed at a specific line. + + `line` is the 1-based physical line number; `problem` says what is wrong. + """ + + def __init__(self, line, problem): + super().__init__(f"{line}: {problem}") + self.line = line + self.problem = problem + + +def parse_date(raw): + """Parse an ISO 8601 date. Raises ValueError explaining any rejection.""" + if not _DATE_RE.match(raw): + raise ValueError(f"{raw!r} is not an ISO 8601 date (YYYY-MM-DD)") + try: + return datetime.date.fromisoformat(raw) + except ValueError: + raise ValueError(f"{raw!r} is not a real date") from None + + +def parse_amount(raw): + """Parse a decimal amount with at most two fractional digits. + + Raises ValueError explaining any rejection. Deliberately stricter than + decimal.Decimal, which would accept 1e3, NaN and Infinity. + """ + if not _AMOUNT_RE.match(raw): + raise ValueError( + f"{raw!r} is not a decimal number with at most two fractional digits" + ) + return decimal.Decimal(raw) + + +def parse_transactions(lines): + """Parse an iterable of CSV lines into Transactions, in input order. + + Raises ParseError on the first malformed line; the caller is expected to + reject the whole file. + """ + reader = csv.reader(lines) + header = next(reader, None) + if header is None: + raise ParseError( + 1, "file is empty; expected a header row date,amount,description" + ) + if [field.strip().lower() for field in header] != HEADER: + raise ParseError(1, "expected header row date,amount,description") + + transactions = [] + for row in reader: + line = reader.line_num + if len(row) != 3: + raise ParseError(line, f"expected 3 fields, got {len(row)}") + raw_date, raw_amount, description = row + try: + date = parse_date(raw_date.strip()) + except ValueError as error: + raise ParseError(line, f"bad date: {error}") from None + try: + amount = parse_amount(raw_amount.strip()) + except ValueError as error: + raise ParseError(line, f"bad amount: {error}") from None + transactions.append( + Transaction(date=date, amount=amount, description=description) + ) + return transactions +``` + +- [ ] **Step 14: Run the whole suite** + +Run: `python3 -m unittest -v` +Expected: PASS — all `test_model` and `test_parse` tests green. + +- [ ] **Step 15: Commit** + +```bash +git add ledgerlite/parse.py test_parse.py +git commit -m "feat: reject malformed transaction rows with file line numbers" +``` + +--- + +### Task 2: Rules file and categorization + +**Files:** +- Create: `ledgerlite/rules.py` +- Test: `test_rules.py` + +**Interfaces:** +- Consumes: `ledgerlite.parse.ParseError(line, problem)` from Task 1 — reused verbatim so the CLI formats rules errors and CSV errors with one code path. +- Produces: + - `ledgerlite.rules.parse_rules(lines: Iterable[str]) -> list[tuple[str, str]]` — `(substring, category)` pairs in file order; raises `ParseError`. + - `ledgerlite.rules.categorize(rules: list[tuple[str, str]], description: str) -> str | None` — first matching rule's category, case-insensitive; `None` if nothing matches. + +- [ ] **Step 1: Write the failing test** + +Create `test_rules.py`: + +```python +import io +import unittest + +from ledgerlite.parse import ParseError +from ledgerlite.rules import categorize, parse_rules + + +def read(text): + return parse_rules(io.StringIO(text)) + + +class ParseRulesTest(unittest.TestCase): + def test_reads_pairs_in_file_order(self): + self.assertEqual( + read("coffee=food\nrent=housing\n"), + [("coffee", "food"), ("rent", "housing")], + ) + + def test_ignores_blank_lines_and_surrounding_whitespace(self): + self.assertEqual( + read("\n coffee = food \n\n"), + [("coffee", "food")], + ) + + def test_splits_at_the_first_equals_sign(self): + self.assertEqual(read("coffee=food=drink\n"), [("coffee", "food=drink")]) + + def test_keeps_the_category_spelled_as_written(self): + self.assertEqual(read("coffee=Food\n"), [("coffee", "Food")]) + + def test_rejects_a_line_without_an_equals_sign(self): + with self.assertRaises(ParseError) as caught: + read("coffee=food\njust-a-typo\n") + + self.assertEqual(caught.exception.line, 2) + self.assertIn("=", caught.exception.problem) + + def test_rejects_an_empty_substring(self): + with self.assertRaises(ParseError) as caught: + read("=food\n") + + self.assertEqual(caught.exception.line, 1) + self.assertIn("substring", caught.exception.problem) + + def test_rejects_an_empty_category(self): + with self.assertRaises(ParseError) as caught: + read("coffee=\n") + + self.assertEqual(caught.exception.line, 1) + self.assertIn("category", caught.exception.problem) + + +class CategorizeTest(unittest.TestCase): + def setUp(self): + self.rules = [("coffee", "food"), ("rent", "housing")] + + def test_matches_a_substring_case_insensitively(self): + self.assertEqual(categorize(self.rules, "COFFEE BAR 12"), "food") + self.assertEqual(categorize(self.rules, "morning coffee"), "food") + + def test_first_matching_rule_wins(self): + self.assertEqual(categorize(self.rules, "rent and coffee"), "housing") + self.assertEqual(categorize([("rent", "housing")] + self.rules, "coffee"), "food") + + def test_rule_substring_case_does_not_matter(self): + self.assertEqual(categorize([("COFFEE", "food")], "coffee bar"), "food") + + def test_returns_none_when_nothing_matches(self): + self.assertIsNone(categorize(self.rules, "Bookshop")) + + def test_returns_none_with_no_rules(self): + self.assertIsNone(categorize([], "Coffee Bar")) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run it to make sure it fails** + +Run: `python3 -m unittest test_rules -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.rules'` + +- [ ] **Step 3: Write the implementation** + +Create `ledgerlite/rules.py`: + +```python +"""Turn rules text into (substring, category) pairs and apply them.""" + +from ledgerlite.parse import ParseError + + +def parse_rules(lines): + """Parse `=` lines. Raises ParseError on a bad line. + + Blank lines are ignored. A line is split at its first '='. + """ + rules = [] + for number, raw in enumerate(lines, start=1): + line = raw.strip() + if not line: + continue + substring, separator, category = line.partition("=") + if not separator: + raise ParseError( + number, + f"rule {line!r} has no '='; expected =", + ) + substring = substring.strip() + category = category.strip() + if not substring: + raise ParseError(number, "rule has an empty substring before '='") + if not category: + raise ParseError(number, "rule has an empty category after '='") + rules.append((substring, category)) + return rules + + +def categorize(rules, description): + """Return the first matching rule's category, or None if none match.""" + haystack = description.casefold() + for substring, category in rules: + if substring.casefold() in haystack: + return category + return None +``` + +- [ ] **Step 4: Run it to make sure it passes** + +Run: `python3 -m unittest test_rules -v` +Expected: PASS (12 tests) + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/rules.py test_rules.py +git commit -m "feat: parse rules file and categorize descriptions" +``` + +--- + +### Task 3: Date ordering and closing balance + +**Files:** +- Create: `ledgerlite/balance.py` +- Test: `test_balance.py` + +**Interfaces:** +- Consumes: `ledgerlite.model.Transaction` from Task 1. +- Produces: + - `ledgerlite.balance.order_transactions(transactions: list[Transaction]) -> list[Transaction]` — sorted by date, ties keeping input order. + - `ledgerlite.balance.closing_balance(transactions: list[Transaction], opening: Decimal) -> Decimal` — the running balance after the last transaction in that order; `opening` when the list is empty. + +- [ ] **Step 1: Write the failing test** + +Create `test_balance.py`: + +```python +import datetime +import unittest +from decimal import Decimal + +from ledgerlite.balance import closing_balance, order_transactions +from ledgerlite.model import Transaction + + +def txn(day, amount, description="x"): + return Transaction( + date=datetime.date(2026, 3, day), + amount=Decimal(amount), + description=description, + ) + + +class OrderTransactionsTest(unittest.TestCase): + def test_sorts_by_date(self): + rows = [txn(5, "-900.00"), txn(4, "-7.50"), txn(6, "1.00")] + + self.assertEqual( + [t.date.day for t in order_transactions(rows)], [4, 5, 6] + ) + + def test_ties_keep_input_order(self): + rows = [ + txn(4, "-7.50", "second-in-file"), + txn(3, "1.00", "earlier-date"), + txn(4, "2500.00", "third-in-file"), + ] + + self.assertEqual( + [t.description for t in order_transactions(rows)], + ["earlier-date", "second-in-file", "third-in-file"], + ) + + def test_does_not_mutate_its_input(self): + rows = [txn(5, "1.00"), txn(4, "2.00")] + order_transactions(rows) + + self.assertEqual([t.date.day for t in rows], [5, 4]) + + def test_empty_list(self): + self.assertEqual(order_transactions([]), []) + + +class ClosingBalanceTest(unittest.TestCase): + def test_adds_every_amount_to_the_opening_amount(self): + rows = [txn(5, "-900.00"), txn(4, "-7.50"), txn(4, "2500.00")] + + self.assertEqual( + closing_balance(rows, Decimal("100")), Decimal("1692.50") + ) + + def test_no_transactions_leaves_the_opening_amount(self): + self.assertEqual(closing_balance([], Decimal("100.00")), Decimal("100.00")) + + def test_default_opening_of_zero(self): + self.assertEqual(closing_balance([txn(4, "-7.50")], Decimal("0")), Decimal("-7.50")) + + def test_arithmetic_is_exact(self): + rows = [txn(4, "0.10"), txn(4, "0.20")] + + self.assertEqual(str(closing_balance(rows, Decimal("0.00"))), "0.30") + + +if __name__ == "__main__": + unittest.main() +``` + +`test_arithmetic_is_exact` is the float canary: with floats, `0.10 + 0.20` would not be `0.30`. + +- [ ] **Step 2: Run it to make sure it fails** + +Run: `python3 -m unittest test_balance -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.balance'` + +- [ ] **Step 3: Write the implementation** + +Create `ledgerlite/balance.py`: + +```python +"""Date ordering and the running balance.""" + + +def order_transactions(transactions): + """Return the transactions ordered by date; ties keep input order.""" + return sorted(transactions, key=lambda transaction: transaction.date) + + +def closing_balance(transactions, opening): + """Return the running balance after the last date-ordered transaction.""" + balance = opening + for transaction in order_transactions(transactions): + balance += transaction.amount + return balance +``` + +`sorted` is stable, which is exactly the "ties keep input order" rule. + +- [ ] **Step 4: Run it to make sure it passes** + +Run: `python3 -m unittest test_balance -v` +Expected: PASS (8 tests) + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/balance.py test_balance.py +git commit -m "feat: order transactions by date and compute the closing balance" +``` + +--- + +### Task 4: Category totals and report formatting + +**Files:** +- Create: `ledgerlite/report.py` +- Test: `test_report.py` + +**Interfaces:** +- Consumes: `Transaction` (Task 1), `categorize` (Task 2), `closing_balance` (Task 3). +- Produces: + - `ledgerlite.report.UNCATEGORIZED = "uncategorized"` + - `ledgerlite.report.format_amount(value: Decimal) -> str` — two fractional digits, no separators, no `-0.00`. + - `ledgerlite.report.category_totals(transactions, rules) -> list[tuple[str, Decimal]]` — case-insensitive alphabetical, `uncategorized` last. + - `ledgerlite.report.format_report(transactions, rules, opening: Decimal) -> str` — the whole report, ending in a single newline. + +- [ ] **Step 1: Write the failing test** + +Create `test_report.py`: + +```python +import datetime +import unittest +from decimal import Decimal + +from ledgerlite.model import Transaction +from ledgerlite.report import category_totals, format_amount, format_report + +RULES = [("coffee", "food"), ("rent", "housing")] + + +def txn(day, amount, description): + return Transaction( + date=datetime.date(2026, 3, day), + amount=Decimal(amount), + description=description, + ) + + +EXAMPLE = [ + txn(5, "-900.00", "Rent March"), + txn(4, "-7.50", "Coffee Bar"), + txn(4, "2500.00", "ACME PAYROLL"), +] + + +class FormatAmountTest(unittest.TestCase): + def test_always_two_fractional_digits(self): + self.assertEqual(format_amount(Decimal("-7.5")), "-7.50") + self.assertEqual(format_amount(Decimal("1200")), "1200.00") + self.assertEqual(format_amount(Decimal("0")), "0.00") + + def test_no_thousands_separators(self): + self.assertEqual(format_amount(Decimal("1234567.89")), "1234567.89") + + def test_negative_zero_prints_as_zero(self): + self.assertEqual(format_amount(Decimal("-0.00")), "0.00") + self.assertEqual( + format_amount(Decimal("-0.50") + Decimal("0.50")), "0.00" + ) + + +class CategoryTotalsTest(unittest.TestCase): + def test_sums_each_category(self): + self.assertEqual( + category_totals(EXAMPLE, RULES), + [ + ("food", Decimal("-7.50")), + ("housing", Decimal("-900.00")), + ("uncategorized", Decimal("2500.00")), + ], + ) + + def test_uncategorized_is_last_despite_the_alphabet(self): + rows = [txn(4, "1.00", "Bookshop"), txn(4, "-7.50", "Coffee Bar")] + + self.assertEqual( + [name for name, _ in category_totals(rows, RULES)], + ["food", "uncategorized"], + ) + + def test_categories_sort_case_insensitively(self): + rules = [("a", "Zebra"), ("b", "apple"), ("c", "Bank")] + rows = [txn(4, "1.00", "a"), txn(4, "1.00", "b"), txn(4, "1.00", "c")] + + self.assertEqual( + [name for name, _ in category_totals(rows, rules)], + ["apple", "Bank", "Zebra"], + ) + + def test_two_rules_with_the_same_category_share_a_line(self): + rules = [("coffee", "food"), ("bakery", "food")] + rows = [txn(4, "-7.50", "Coffee Bar"), txn(5, "-2.50", "BAKERY")] + + self.assertEqual(category_totals(rows, rules), [("food", Decimal("-10.00"))]) + + def test_a_rule_category_named_uncategorized_merges_into_the_bucket(self): + rules = [("coffee", "uncategorized")] + rows = [txn(4, "-7.50", "Coffee Bar"), txn(5, "1.00", "Bookshop")] + + self.assertEqual( + category_totals(rows, rules), [("uncategorized", Decimal("-6.50"))] + ) + + def test_no_transactions_means_no_category_lines(self): + self.assertEqual(category_totals([], RULES), []) + + def test_with_no_rules_everything_is_uncategorized(self): + self.assertEqual( + category_totals(EXAMPLE, []), [("uncategorized", Decimal("1592.50"))] + ) + + +class FormatReportTest(unittest.TestCase): + def test_matches_the_example_from_the_design(self): + self.assertEqual( + format_report(EXAMPLE, RULES, Decimal("100")), + "food: -7.50\n" + "housing: -900.00\n" + "uncategorized: 2500.00\n" + "\n" + "closing balance: 1692.50\n", + ) + + def test_no_transactions_reports_the_opening_amount(self): + self.assertEqual( + format_report([], RULES, Decimal("100")), + "\nclosing balance: 100.00\n", + ) + + def test_a_category_that_cancels_out_prints_zero(self): + rows = [txn(4, "-7.50", "Coffee Bar"), txn(5, "7.50", "Coffee refund")] + + self.assertEqual( + format_report(rows, RULES, Decimal("0")), + "food: 0.00\n\nclosing balance: 0.00\n", + ) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run it to make sure it fails** + +Run: `python3 -m unittest test_report -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.report'` + +- [ ] **Step 3: Write the implementation** + +Create `ledgerlite/report.py`: + +```python +"""Per-category totals and the printable report.""" + +import decimal + +from ledgerlite.balance import closing_balance +from ledgerlite.rules import categorize + +UNCATEGORIZED = "uncategorized" + + +def format_amount(value): + """Format a Decimal with exactly two fractional digits, no separators.""" + if value == 0: + value = decimal.Decimal(0) # a total of -0.00 prints as 0.00 + return f"{value:.2f}" + + +def category_totals(transactions, rules): + """Total each category: alphabetical, case-insensitive, uncategorized last.""" + totals = {} + for transaction in transactions: + name = categorize(rules, transaction.description) + if name is None: + name = UNCATEGORIZED + totals[name] = totals.get(name, decimal.Decimal(0)) + transaction.amount + + named = sorted( + (name for name in totals if name != UNCATEGORIZED), + key=lambda name: (name.casefold(), name), + ) + ordered = [(name, totals[name]) for name in named] + if UNCATEGORIZED in totals: + ordered.append((UNCATEGORIZED, totals[UNCATEGORIZED])) + return ordered + + +def format_report(transactions, rules, opening): + """Render the whole report, ending in a newline.""" + lines = [ + f"{name}: {format_amount(total)}" + for name, total in category_totals(transactions, rules) + ] + lines.append("") + balance = closing_balance(transactions, opening) + lines.append(f"closing balance: {format_amount(balance)}") + return "\n".join(lines) + "\n" +``` + +- [ ] **Step 4: Run it to make sure it passes** + +Run: `python3 -m unittest test_report -v` +Expected: PASS (13 tests) + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/report.py test_report.py +git commit -m "feat: total transactions per category and format the report" +``` + +--- + +### Task 5: CLI, exit codes, and error messages + +**Files:** +- Create: `ledgerlite/cli.py`, `ledgerlite/__main__.py` +- Test: `test_cli.py` + +**Interfaces:** +- Consumes: `parse_amount`, `parse_transactions`, `ParseError` (Task 1), `parse_rules` (Task 2), `format_report` (Task 4). +- Produces: + - `ledgerlite.cli.build_parser() -> argparse.ArgumentParser` + - `ledgerlite.cli.main(argv: list[str] | None = None) -> int` — writes the report to stdout, errors to stderr, returns the exit code. Never calls `sys.exit`. + +- [ ] **Step 1: Write the failing test** + +Create `test_cli.py`: + +```python +import contextlib +import io +import pathlib +import subprocess +import sys +import tempfile +import unittest + +from ledgerlite.cli import main + +GOOD_CSV = """\ +date,amount,description +2026-03-05,-900.00,Rent March +2026-03-04,-7.50,Coffee Bar +2026-03-04,2500.00,ACME PAYROLL +""" + +GOOD_RULES = "coffee=food\nrent=housing\n" + +EXPECTED = ( + "food: -7.50\n" + "housing: -900.00\n" + "uncategorized: 2500.00\n" + "\n" + "closing balance: 1692.50\n" +) + + +class CliTest(unittest.TestCase): + def setUp(self): + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + self.directory = pathlib.Path(directory.name) + + def write(self, name, text, encoding="utf-8"): + path = self.directory / name + path.write_text(text, encoding=encoding, newline="") + return str(path) + + def run_cli(self, *argv): + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = main(list(argv)) + return code, out.getvalue(), err.getvalue() + + def test_reports_the_example(self): + transactions = self.write("t.csv", GOOD_CSV) + rules = self.write("r.txt", GOOD_RULES) + + code, out, err = self.run_cli( + "report", transactions, "--rules", rules, "--opening", "100" + ) + + self.assertEqual((code, out, err), (0, EXPECTED, "")) + + def test_opening_defaults_to_zero(self): + transactions = self.write("t.csv", GOOD_CSV) + rules = self.write("r.txt", GOOD_RULES) + + code, out, _ = self.run_cli("report", transactions, "--rules", rules) + + self.assertEqual(code, 0) + self.assertIn("closing balance: 1592.50\n", out) + + def test_without_rules_everything_is_uncategorized(self): + transactions = self.write("t.csv", GOOD_CSV) + + code, out, _ = self.run_cli("report", transactions) + + self.assertEqual(code, 0) + self.assertEqual(out, "uncategorized: 1592.50\n\nclosing balance: 1592.50\n") + + def test_utf8_bom_is_tolerated(self): + transactions = self.write("bom.csv", GOOD_CSV, encoding="utf-8-sig") + + code, out, err = self.run_cli("report", transactions) + + self.assertEqual((code, err), (0, "")) + self.assertIn("closing balance: 1592.50\n", out) + + def test_header_only_file_reports_the_opening_amount(self): + transactions = self.write("empty.csv", "date,amount,description\n") + + code, out, err = self.run_cli("report", transactions, "--opening", "100") + + self.assertEqual((code, out, err), (0, "\nclosing balance: 100.00\n", "")) + + def test_unreadable_transactions_file(self): + missing = str(self.directory / "nope.csv") + + code, out, err = self.run_cli("report", missing) + + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertTrue(err.startswith(f"ledgerlite: cannot read {missing}: ")) + self.assertIn("No such file", err) + + def test_unreadable_rules_file(self): + transactions = self.write("t.csv", GOOD_CSV) + missing = str(self.directory / "nope.txt") + + code, out, err = self.run_cli("report", transactions, "--rules", missing) + + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertTrue(err.startswith(f"ledgerlite: cannot read {missing}: ")) + + def test_malformed_row_rejects_the_whole_file(self): + transactions = self.write( + "bad.csv", + "date,amount,description\n2026-03-04,-7.50,Coffee\n2026-03-05,1.005,Rent\n", + ) + + code, out, err = self.run_cli("report", transactions) + + self.assertEqual(code, 2) + self.assertEqual(out, "") + self.assertTrue(err.startswith(f"ledgerlite: {transactions}:3: ")) + self.assertIn("amount", err) + + def test_malformed_rules_line(self): + transactions = self.write("t.csv", GOOD_CSV) + rules = self.write("r.txt", "coffee=food\noops\n") + + code, out, err = self.run_cli("report", transactions, "--rules", rules) + + self.assertEqual(code, 2) + self.assertEqual(out, "") + self.assertTrue(err.startswith(f"ledgerlite: {rules}:2: ")) + + def test_transactions_error_wins_over_a_rules_error(self): + transactions = self.write( + "bad.csv", "date,amount,description\n2026-03-04,oops,Coffee\n" + ) + rules = self.write("r.txt", "oops\n") + + code, _, err = self.run_cli("report", transactions, "--rules", rules) + + self.assertEqual(code, 2) + self.assertIn(transactions, err) + self.assertNotIn(rules, err) + + def test_bad_opening_amount(self): + transactions = self.write("t.csv", GOOD_CSV) + + code, out, err = self.run_cli("report", transactions, "--opening", "1.005") + + self.assertEqual(code, 2) + self.assertEqual(out, "") + self.assertIn("--opening", err) + + def test_missing_subcommand_is_a_usage_error(self): + with contextlib.redirect_stderr(io.StringIO()): + with self.assertRaises(SystemExit) as caught: + main([]) + + self.assertEqual(caught.exception.code, 2) + + +class ModuleEntryPointTest(unittest.TestCase): + def test_python_m_ledgerlite_prints_the_report(self): + with tempfile.TemporaryDirectory() as directory: + path = pathlib.Path(directory) / "t.csv" + path.write_text(GOOD_CSV, encoding="utf-8") + rules = pathlib.Path(directory) / "r.txt" + rules.write_text(GOOD_RULES, encoding="utf-8") + + result = subprocess.run( + [ + sys.executable, "-m", "ledgerlite", "report", str(path), + "--rules", str(rules), "--opening", "100", + ], + cwd=str(pathlib.Path(__file__).parent), + capture_output=True, + text=True, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, EXPECTED) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run it to make sure it fails** + +Run: `python3 -m unittest test_cli -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.cli'` + +- [ ] **Step 3: Write cli.py** + +Create `ledgerlite/cli.py`: + +```python +"""Command-line entry point: the only module that touches files or streams.""" + +import argparse +import io +import sys + +from ledgerlite.parse import ParseError, parse_amount, parse_transactions +from ledgerlite.report import format_report +from ledgerlite.rules import parse_rules + + +def build_parser(): + parser = argparse.ArgumentParser( + prog="ledgerlite", + description="Summarize bank transactions by category.", + ) + subcommands = parser.add_subparsers(dest="command", required=True) + report = subcommands.add_parser( + "report", help="print a per-category summary and the closing balance" + ) + report.add_argument( + "transactions", metavar="TRANSACTIONS", help="transactions CSV to read" + ) + report.add_argument( + "--rules", metavar="RULES", help="rules file (= per line)" + ) + report.add_argument( + "--opening", + metavar="AMOUNT", + default="0", + help="opening balance (default: 0)", + ) + return parser + + +def _read_text(path): + """Return the file's text, or print the reason and return None.""" + try: + with open(path, encoding="utf-8-sig", newline="") as handle: + return handle.read() + except OSError as error: + reason = error.strerror or str(error) + except UnicodeDecodeError as error: + reason = str(error) + print(f"ledgerlite: cannot read {path}: {reason}", file=sys.stderr) + return None + + +def _report_parse_error(path, error): + print(f"ledgerlite: {path}:{error.line}: {error.problem}", file=sys.stderr) + + +def main(argv=None): + args = build_parser().parse_args(argv) + + try: + opening = parse_amount(args.opening.strip()) + except ValueError as error: + print(f"ledgerlite: bad --opening: {error}", file=sys.stderr) + return 2 + + text = _read_text(args.transactions) + if text is None: + return 1 + try: + transactions = parse_transactions(io.StringIO(text, newline="")) + except ParseError as error: + _report_parse_error(args.transactions, error) + return 2 + + rules = [] + if args.rules is not None: + rules_text = _read_text(args.rules) + if rules_text is None: + return 1 + try: + rules = parse_rules(io.StringIO(rules_text)) + except ParseError as error: + _report_parse_error(args.rules, error) + return 2 + + sys.stdout.write(format_report(transactions, rules, opening)) + return 0 +``` + +Nothing is written to stdout until both files have parsed, which is what "the whole file is rejected; nothing is printed to stdout" requires. + +- [ ] **Step 4: Write `__main__.py`** + +Create `ledgerlite/__main__.py`: + +```python +"""Allow `python3 -m ledgerlite`.""" + +import sys + +from ledgerlite.cli import main + +if __name__ == "__main__": + sys.exit(main()) +``` + +- [ ] **Step 5: Run the CLI tests** + +Run: `python3 -m unittest test_cli -v` +Expected: PASS (13 tests) + +- [ ] **Step 6: Run the whole suite** + +Run: `python3 -m unittest -v` +Expected: PASS — every test in `test_model`, `test_parse`, `test_rules`, `test_balance`, `test_report`, `test_cli`. + +- [ ] **Step 7: Check the float ban and try it by hand** + +Run: + +```bash +grep -rn "float" ledgerlite/ ; echo "exit: $?" +``` + +Expected: no matches (`exit: 1`). + +Then exercise the real command: + +```bash +printf 'date,amount,description\n2026-03-05,-900.00,Rent March\n2026-03-04,-7.50,Coffee Bar\n2026-03-04,2500.00,ACME PAYROLL\n' > /tmp/ledger.csv +printf 'coffee=food\nrent=housing\n' > /tmp/ledger.rules +python3 -m ledgerlite report /tmp/ledger.csv --rules /tmp/ledger.rules --opening 100 +echo "exit: $?" +python3 -m ledgerlite report /tmp/missing.csv ; echo "exit: $?" +``` + +Expected: the design's example report then `exit: 0`; then `ledgerlite: cannot read /tmp/missing.csv: No such file or directory` and `exit: 1`. + +- [ ] **Step 8: Commit** + +```bash +git add ledgerlite/cli.py ledgerlite/__main__.py test_cli.py +git commit -m "feat: add ledgerlite report CLI with exit codes and error messages" +``` diff --git a/tests/inline-eval/results/2026-09-17-review-focus-five/wpplan-43.txt b/tests/inline-eval/results/2026-09-17-review-focus-five/wpplan-43.txt new file mode 100644 index 000000000..ed237b4b6 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-review-focus-five/wpplan-43.txt @@ -0,0 +1,27 @@ +# rep wpplan-43 transcript=/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/inline-eval-br/reps/wpplan-43/cfg/projects/-private-tmp-claude-501--Users-jesse-git-superpowers-superpowers-a8a313bf-7410-41ce-9036-636ccf4061d6-scratchpad-inline-eval-br-reps-wpplan-43-repo/1b5e41b5-bc87-4365-8b5e-35ad39f471f0.jsonl +skill executing-plans invoked False +all skills invoked writing-plans +Agent dispatches 0 +test runs 0 +ledger (progress.md) touches 0 +sdd script invocations 0 +commits (incl. fixture) 1 +suite in repo plan.md-present +probe review-focus handled +probe implied-header handled +probe implied-encoding handled +probe size plan= 1371 review-focus-lines=5 tests=71 tasks= +user_prompt_submit events 1 (background-agent wakeups count too) +final: 'Rulings I made' False +final: mentions self-review False +mid-run check-in candidates 0 +main session tokens 6 msgs; out=42842 cache_read=200070 cache_create=55271 in=12 +subagent tokens 0 agents; out=0 cache_read=0 cache_create=0 in=0 +TOTAL tokens out=42842 cache_read=200070 cache_create=55271 in=12 + +# assistant text, in order (truncated) +[ 1] I'll start by reading the design doc and invoking the writing-plans skill. +[ 5] I'm using the writing-plans skill to create the implementation plan. +[ 8] Design read. Now writing the plan. +[ 11] Now the self-review pass against the spec. Two issues found — fixing them. +[ 16] Plan complete and saved to `plan.md` (repo root, uncommitted). Spec: `design.md`. **Shape of it:** five tasks, each ending in a working, tested deliverable, all TDD with concrete test code and implementation code in ever diff --git a/tests/inline-eval/results/2026-09-17-sddrf-bedrock/README.txt b/tests/inline-eval/results/2026-09-17-sddrf-bedrock/README.txt new file mode 100644 index 000000000..860cbf1f3 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-sddrf-bedrock/README.txt @@ -0,0 +1,19 @@ +SDD with the plan carrying a Review Focus section (sddrf), wordstat +fixture, Bedrock backend, 3 reps. Controller on us.anthropic.claude-opus-5; +Bedrock resolves the skill's "sonnet"/"haiku" dispatches to Sonnet 4.5 and +Haiku 4.5 (the direct-API SDD runs used Sonnet 5 / Haiku 4.5), so cost is +not directly comparable to 2026-09-16-clean/sdd-*. + +Outcome: utf8 probe handled 3/3. In every rep the Task 3 IMPLEMENTER +(Sonnet 4.5) wrote (OSError, UnicodeDecodeError) handling with a test +before any reviewer saw it; the per-task reviewers confirm it as already +present ("catches exactly (OSError, UnicodeDecodeError) as specified"). +Without the section, no implementer in 21 clean reps and no per-task +reviewer in 9 SDD dispatches touched the decode case. The final Opus +reviewer graded the residual encoding discussion Important (rep 2) and +Minor (rep 3); rep 1's final reviewer discussed it without a finding. + +Read with the inline result (spikerf: reviewer graded it Critical 3/3): +the section's effect is to move the implied defect upstream, to the +implementer when the brief carries it and to the reviewer's grading when +it does not. diff --git a/tests/inline-eval/results/2026-09-17-sddrf-bedrock/sddrf-1.txt b/tests/inline-eval/results/2026-09-17-sddrf-bedrock/sddrf-1.txt new file mode 100644 index 000000000..35079d3ee --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-sddrf-bedrock/sddrf-1.txt @@ -0,0 +1,39 @@ +# rep sddrf-1 transcript=/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/inline-eval-br/reps/sddrf-1/cfg/projects/-private-tmp-claude-501--Users-jesse-git-superpowers-superpowers-a8a313bf-7410-41ce-9036-636ccf4061d6-scratchpad-inline-eval-br-reps-sddrf-1-repo/44a70dc1-107d-4ecf-bf01-700e1b815feb.jsonl +skill executing-plans invoked False +all skills invoked subagent-driven-development, finishing-a-development-branch +Agent dispatches 9 general-purpose/sonnet:'Implement Task 1: wordstat/c'; general-purpose/sonnet:'Review Task 1 (spec + qualit'; general-purpose/sonnet:'Implement Task 2: wordstat/f'; general-purpose/sonnet:'Review Task 2 (spec + qualit'; general-purpose/sonnet:'Implement Task 3: wordstat/c'; general-purpose/sonnet:'Review Task 3 (spec + qualit'; general-purpose/opus:'Final whole-branch code revi'; general-purpose/sonnet:'Fix final-review findings'; general-purpose/sonnet:'Re-review final fix wave' +test runs 18 +task counter: RED run / GREEN run yes / yes test, +fixture 1 = wordstat (3 modules), 2 = ledgerlite (6 modules). + + S0 control: "Each step is one action (2-5 minutes)" + S1 no time unit: "one action with a checkable result" + S2 S1 + task ceiling "at most one context window of work" + S3 "(10-20 minutes)" + + wordstat lines/steps/tests ledgerlite lines/steps/tests + S0 449-494 / 17 / 23-26 1331-1650 / 43-48 / 70-97 + S1 437-512 / 16-17 / 24-27 1157-1469 / 28-33 / 77-82 + S2 488-523 / 16-17 / 25-30 1219-1379 / 37-41 / 66-89 + S3 494-556 / 17-18 / 30-32 1363-1416 / 33-48 / 79-100 + +Task count is 3 on wordstat and 5-7 on ledgerlite under every wording: the +time unit and the context-window ceiling do not change how Opus draws task +boundaries. Plan length and test count are within rep noise across arms. +The one movement is steps per plan on the six-task design: 43-48 with the +2-5 minute unit versus 28-33 without one (S1), with S2 and S3 in between; +three reps, so treat it as a lead, not a result. The execution half was +not run: plans that do not differ cannot measure a wording through +execution. + +Where the volume comes from, from reading the plans: Opus's own TDD +granularity (a five-step cycle per behavior, ~3 tests per function), and +the Review Focus list (10-19 lines, each with a test). Neither is keyed to +the sizing sentence. diff --git a/tests/inline-eval/results/2026-09-17-sizing-wording/S1-no-time.diff b/tests/inline-eval/results/2026-09-17-sizing-wording/S1-no-time.diff new file mode 100644 index 000000000..b2e3a099f --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-sizing-wording/S1-no-time.diff @@ -0,0 +1,17 @@ +diff --git a/skills/writing-plans/SKILL.md b/skills/writing-plans/SKILL.md +index c7a6548..2cb8849 100644 +--- a/skills/writing-plans/SKILL.md ++++ b/skills/writing-plans/SKILL.md +@@ -42,9 +42,10 @@ deliverable needs them; split only where a reviewer could meaningfully + reject one task while approving its neighbor. Each task ends with an + independently testable deliverable. + +-## Bite-Sized Task Granularity ++## Step Granularity + +-**Each step is one action (2-5 minutes):** ++**Each step is one action with a checkable result** — a command and its ++expected output, or a file written: + - "Write the failing test" - step + - "Run it to make sure it fails" - step + - "Implement the minimal code to make the test pass" - step diff --git a/tests/inline-eval/results/2026-09-17-sizing-wording/S2-context-window.diff b/tests/inline-eval/results/2026-09-17-sizing-wording/S2-context-window.diff new file mode 100644 index 000000000..03c093908 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-sizing-wording/S2-context-window.diff @@ -0,0 +1,23 @@ +diff --git a/skills/writing-plans/SKILL.md b/skills/writing-plans/SKILL.md +index c7a6548..7e29763 100644 +--- a/skills/writing-plans/SKILL.md ++++ b/skills/writing-plans/SKILL.md +@@ -40,11 +40,15 @@ fresh reviewer's gate. When drawing task boundaries: fold setup, + configuration, scaffolding, and documentation steps into the task whose + deliverable needs them; split only where a reviewer could meaningfully + reject one task while approving its neighbor. Each task ends with an +-independently testable deliverable. ++independently testable deliverable, and is at most one context window of ++work: a fresh implementer reads the brief and the files the task touches, ++implements, tests, and recovers from one wrong approach without running ++out of room. + +-## Bite-Sized Task Granularity ++## Step Granularity + +-**Each step is one action (2-5 minutes):** ++**Each step is one action with a checkable result** — a command and its ++expected output, or a file written: + - "Write the failing test" - step + - "Run it to make sure it fails" - step + - "Implement the minimal code to make the test pass" - step diff --git a/tests/inline-eval/results/2026-09-17-sizing-wording/S3-bigger-time.diff b/tests/inline-eval/results/2026-09-17-sizing-wording/S3-bigger-time.diff new file mode 100644 index 000000000..ebeeb8b08 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-sizing-wording/S3-bigger-time.diff @@ -0,0 +1,13 @@ +diff --git a/skills/writing-plans/SKILL.md b/skills/writing-plans/SKILL.md +index c7a6548..acf1f15 100644 +--- a/skills/writing-plans/SKILL.md ++++ b/skills/writing-plans/SKILL.md +@@ -44,7 +44,7 @@ independently testable deliverable. + + ## Bite-Sized Task Granularity + +-**Each step is one action (2-5 minutes):** ++**Each step is one action (10-20 minutes):** + - "Write the failing test" - step + - "Run it to make sure it fails" - step + - "Implement the minimal code to make the test pass" - step diff --git a/tests/inline-eval/results/2026-09-17-sizing-wording/plans/wpplan-11.plan.md b/tests/inline-eval/results/2026-09-17-sizing-wording/plans/wpplan-11.plan.md new file mode 100644 index 000000000..7f5403908 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-sizing-wording/plans/wpplan-11.plan.md @@ -0,0 +1,494 @@ +# wordstat Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build `wordstat`, a standard-library-only Python package whose CLI prints word, line, and character counts for a text file. + +**Architecture:** Three layers, each independently testable. `counter.py` holds pure functions from `str` → `int` with no I/O. `formatter.py` turns a stats dict into the report string. `cli.py` is the only module that touches the filesystem, argv, stdout, or stderr; it composes the other two. Dependencies point one way: `cli` → (`counter`, `formatter`), and `counter` and `formatter` know nothing about each other. + +**Tech Stack:** Python 3 standard library only. Tests use `unittest` (stdlib), run from the repo root with `python3 -m unittest`. + +**Spec:** `design.md` (in this same directory) + +## Global Constraints + +- Standard library only — no third-party runtime or test dependencies, no `requirements.txt`, no `pip install`. +- Tests live at the **repo root** (`test_counter.py`, `test_formatter.py`, `test_cli.py`), not in a `tests/` directory, and are runnable with `python3 -m unittest`. +- Package modules live under `wordstat/`. `wordstat/__init__.py` already exists and is empty — leave it empty; it is only a package marker. +- All commands in this plan are run from the repo root (the directory containing `design.md`). That directory must be the working directory so that `import wordstat` resolves and `python3 -m unittest` discovers the root-level test files. +- `counter` functions are pure: they take a `str` and return an `int`. They never open files, never print, never raise for ordinary text input. +- `format_report` returns a 3-line string with **no trailing newline**: `"words: 12\nlines: 3\nchars: 57"`. Field order is always words, lines, chars. +- `cli.main(argv)` returns an `int` exit code: `0` on success, `1` when the file cannot be read. It takes the argument list explicitly (not `sys.argv`) so tests can call it directly. +- Line counting rule, verbatim from the spec: a trailing newline does not add an empty final line; `"a\nb"` and `"a\nb\n"` are both 2. +- Use `python3` (verified present: 3.14.7). Any Python 3.7+ works; the code uses only f-strings and `argparse`. + +## Review Focus + +These are input classes and failure modes the spec implies but does not spell out. Each one has a test assigned to the task that owns the code — the parenthetical says where. + +- **Empty input.** An empty file is a legal text file. All three counters must return 0 and the CLI must print `words: 0 / lines: 0 / chars: 0` and exit 0 — not crash, not report 1 line. (Task 1 counter tests; Task 3 `test_empty_file_reports_zeros`.) +- **Whitespace-only and repeated whitespace.** `" \n\t "` has 0 words; `" one\t\ntwo three \n"` has 3, not 6 — no empty tokens from runs of spaces, tabs, or newlines. (Task 1 counter tests.) +- **Blank lines in the middle.** `"a\n\nb\n"` is 3 lines; the empty middle line counts. (Task 1 counter tests.) +- **Non-ASCII text.** `count_chars` counts *characters*, not bytes, so the CLI must decode the file as UTF-8 explicitly rather than relying on the platform's locale encoding. `"héllo"` is 5 chars. (Task 1 `test_counts_non_ascii_characters_as_one_each`; Task 3 `test_counts_non_ascii_characters_not_bytes`.) +- **A path that exists but is not a readable file.** The spec only names "missing file", but a directory or a permission-denied path is the same situation for the user: message to stderr, exit 1. Catching `OSError` (the parent of `FileNotFoundError`, `IsADirectoryError`, and `PermissionError`) covers all three with one branch. (Task 3 `test_directory_path_reports_error_and_returns_one`.) +- **A file that is not valid UTF-8.** Point the CLI at a JPEG and a bare `UnicodeDecodeError` traceback is not an acceptable answer. `UnicodeDecodeError` is a `ValueError`, *not* an `OSError`, so it needs its own `except` clause; it also has no `.strerror`. Same contract: stderr message, exit 1. (Task 3 `test_undecodable_file_reports_error_and_returns_one`.) +- **No path argument at all.** `argparse` handles this by printing usage to stderr and raising `SystemExit(2)`, so `main` never returns. That is the right behavior — do not suppress it — but it is worth pinning so nobody "fixes" it into a silent 0. (Task 3 `test_missing_argument_exits_with_usage_error`.) +- **`format_report` given a dict in a different key order, or missing a key.** The report order must not depend on dict insertion order, and a missing key should fail loudly (`KeyError`) rather than print a partial report. (Task 2 `test_field_order_is_fixed_regardless_of_dict_order`, `test_missing_key_raises_key_error`.) + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `wordstat/__init__.py` | Package marker. **Already exists, empty. Do not modify.** | +| `wordstat/counter.py` | Create (Task 1). Three pure functions: `count_words`, `count_lines`, `count_chars`. No imports needed. | +| `wordstat/formatter.py` | Create (Task 2). `format_report(stats) -> str` plus the module-level field-order constant. No imports needed. | +| `wordstat/cli.py` | Create (Task 3). `build_parser()`, `main(argv) -> int`, `_fail(path, reason) -> int`, and a `__main__` guard. Imports `argparse`, `sys`, `wordstat.counter`, `wordstat.formatter`. | +| `test_counter.py` | Create (Task 1). | +| `test_formatter.py` | Create (Task 2). | +| `test_cli.py` | Create (Task 3). | + +Tasks are ordered by dependency: Task 3 imports the modules from Tasks 1 and 2. Do them in order. + +--- + +## Task 1: counter — pure stat functions + +**Files:** +- Create: `wordstat/counter.py` +- Test: `test_counter.py` + +**Interfaces:** +- Consumes: nothing. This is the first task. +- Produces (Task 3 calls all three): + - `counter.count_words(text: str) -> int` + - `counter.count_lines(text: str) -> int` + - `counter.count_chars(text: str) -> int` + +**Background for the implementer.** Python's stdlib already does all three jobs, and reaching for the stdlib primitive is the correct answer here — do not hand-roll a loop: +- `str.split()` with **no arguments** splits on runs of any whitespace and discards leading/trailing whitespace, so `" a b \n".split()` is `["a", "b"]`. This is not the same as `split(" ")`, which would produce empty strings. Use the no-argument form. +- `str.splitlines()` splits on line boundaries and does **not** produce a trailing empty element for a trailing newline: `"a\nb".splitlines()` and `"a\nb\n".splitlines()` are both `["a", "b"]`, and `"".splitlines()` is `[]`. That matches the spec's line rule exactly, including the empty-file case. `text.split("\n")` would get both wrong. +- `len(text)` counts characters in a `str` (Python strings are sequences of code points, so one accented letter is one character). + +- [ ] **Step 1: Write the failing tests** + +Create `test_counter.py` with exactly this content: + +```python +import unittest + +from wordstat import counter + + +class CountWordsTests(unittest.TestCase): + def test_counts_whitespace_separated_tokens(self): + self.assertEqual(counter.count_words("one two three"), 3) + + def test_collapses_runs_of_mixed_whitespace(self): + self.assertEqual(counter.count_words(" one\t\ntwo three \n"), 3) + + def test_single_word_without_whitespace(self): + self.assertEqual(counter.count_words("solo"), 1) + + def test_empty_text_has_no_words(self): + self.assertEqual(counter.count_words(""), 0) + + def test_whitespace_only_text_has_no_words(self): + self.assertEqual(counter.count_words(" \n\t "), 0) + + +class CountLinesTests(unittest.TestCase): + def test_counts_lines_without_trailing_newline(self): + self.assertEqual(counter.count_lines("a\nb"), 2) + + def test_trailing_newline_does_not_add_a_line(self): + self.assertEqual(counter.count_lines("a\nb\n"), 2) + + def test_single_line_without_newline(self): + self.assertEqual(counter.count_lines("just one line"), 1) + + def test_blank_interior_line_counts(self): + self.assertEqual(counter.count_lines("a\n\nb\n"), 3) + + def test_empty_text_has_no_lines(self): + self.assertEqual(counter.count_lines(""), 0) + + +class CountCharsTests(unittest.TestCase): + def test_counts_every_character_including_whitespace(self): + self.assertEqual(counter.count_chars("a b\n"), 4) + + def test_empty_text_has_no_chars(self): + self.assertEqual(counter.count_chars(""), 0) + + def test_counts_non_ascii_characters_as_one_each(self): + self.assertEqual(counter.count_chars("héllo"), 5) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_counter -v` + +Expected: an error, not a test failure — `ImportError: cannot import name 'counter' from 'wordstat'`, because `wordstat/counter.py` does not exist yet. + +- [ ] **Step 3: Write the implementation** + +Create `wordstat/counter.py` with exactly this content: + +```python +"""Pure text statistics. No I/O, no printing.""" + + +def count_words(text): + """Number of whitespace-separated tokens in ``text``.""" + return len(text.split()) + + +def count_lines(text): + """Number of lines in ``text``; a trailing newline adds no empty line.""" + return len(text.splitlines()) + + +def count_chars(text): + """Number of characters in ``text``, whitespace included.""" + return len(text) +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_counter -v` + +Expected: `Ran 13 tests ... OK` + +- [ ] **Step 5: Commit** + +```bash +git add wordstat/counter.py test_counter.py +git commit -m "feat: add counter with word, line, and char stats" +``` + +--- + +## Task 2: formatter — render the stats dict + +**Files:** +- Create: `wordstat/formatter.py` +- Test: `test_formatter.py` + +**Interfaces:** +- Consumes: nothing from Task 1. `formatter` does not import `counter`; it only receives a plain dict. +- Produces (Task 3 calls this): + - `formatter.format_report(stats: dict) -> str` where `stats` has integer values under the keys `"words"`, `"lines"`, and `"chars"`. Returns three `" --rules --opening 100` +Expected: the report on stdout, exit status 0 (`echo $?`) + +- [ ] **Step 7: Commit** + +```bash +git add ledgerlite/cli.py ledgerlite/__main__.py test_cli.py +git commit -m "feat: ledgerlite report CLI" +``` diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/C1-combined-ledgerlite-123.plan.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/C1-combined-ledgerlite-123.plan.md new file mode 100644 index 000000000..d6de06243 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/C1-combined-ledgerlite-123.plan.md @@ -0,0 +1,527 @@ +# ledgerlite Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A stdlib-only command-line tool that reads a transactions CSV, categorizes each row with a rules file, and prints per-category totals plus the closing balance. + +**Architecture:** Six small modules with one responsibility each, wired together only in `cli.py`: parsing (CSV text → `Transaction` list, raising `ParseError` with a line number), rules (rules text → substring/category pairs, plus lookup), balance (date ordering and closing balance), report (category totals and formatting). Every module is pure — it takes strings or lists and returns values — so all file I/O, exit codes, and stderr live in `cli.py`. + +**Tech Stack:** Python 3.11+, standard library only (`csv`, `decimal`, `datetime`, `argparse`, `dataclasses`, `re`, `unittest`). + +**Spec:** `design.md` + +## Global Constraints + +- Python 3.11+, standard library only — no third-party dependencies. +- Money is always `decimal.Decimal`, never `float`. No arithmetic on amounts goes through `float` at any point. +- Package lives at `ledgerlite/`; tests live at the repo root as `test_.py` and run with `python3 -m unittest`. +- Amounts are printed with exactly two fractional digits, a leading `-` only for negatives, and no thousands separators (`-12.50`, `0.00`, `1200.00`). +- Error messages are exactly: `ledgerlite: cannot read : ` (exit 1) and `ledgerlite: :: ` (exit 2). Both go to stderr; on exit 2 nothing is written to stdout. + +## Review Focus + +These are input classes the spec implies but does not call out. Each one's test is assigned to the task that owns the code. + +1. **Amounts that `Decimal()` happily accepts but are not decimal numbers** — `NaN`, `Infinity`, `1e3`, `+1.50`, `1,000.00`, empty — must be rejected as malformed, not silently parsed. (Task 1) +2. **Loose date forms that `date.fromisoformat()` accepts on 3.11+** — `20260304`, `2026-3-4`, `2026-W10-3` — must be rejected; the spec pins `2026-03-04`. (Task 1) +3. **A `--rules` path that cannot be read** — the spec only names TRANSACTIONS, but the reasonable expectation is the same `cannot read` message and exit 1, not a traceback. (Task 5) +4. **Negative zero** — a `-0.00` amount or total must print `0.00`; the leading `-` is for negatives, and zero is not negative. (Task 4) +5. **A file with no transactions** (header only, or empty) — must print no category lines and a closing balance equal to the opening amount, not crash and not invent an `uncategorized: 0.00` line. (Task 4) + +--- + +### Task 1: Transaction model and CSV parsing + +**Files:** +- Create: `ledgerlite/__init__.py` (empty) +- Create: `ledgerlite/model.py` +- Create: `ledgerlite/parse.py` +- Test: `test_parse.py` + +**Interfaces:** +- Consumes: nothing. +- Produces: + - `model.Transaction` — frozen dataclass with fields `date: datetime.date`, `amount: decimal.Decimal`, `description: str`, in that order. + - `parse.ParseError(Exception)` with attributes `line: int` and `message: str`, constructed as `ParseError(line, message)`. + - `parse.parse_transactions(lines: Iterable[str]) -> list[Transaction]` — takes any iterable of CSV lines (an open file, or a list of strings), returns transactions in input order. No file I/O. + - `parse.AMOUNT_PATTERN` — the module-level compiled amount regex (below), reused by Task 5 for `--opening`. + +**Decisions this task pins:** +- The first row is the header and is skipped unconditionally; its contents are not validated. +- A row with no fields at all (a blank line) is skipped. A row with a field count other than 3 is an error. +- Dates are parsed with `datetime.datetime.strptime(text, "%Y-%m-%d").date()`, *not* `date.fromisoformat`, so only the spec's form is accepted. +- An amount must match `AMOUNT_PATTERN = re.compile(r"-?\d+(\.\d{1,2})?")` via `fullmatch` before being passed to `Decimal`. This rejects `NaN`, `Infinity`, exponents, leading `+`, thousands separators, and more than two fractional digits. +- The date and amount fields are stripped of surrounding whitespace before validation; the description is kept verbatim. +- Checks run in this order per row: field count, then date, then amount. The first bad row raises immediately; later rows are not examined. +- Line numbers come from `csv.reader`'s `line_num`, so the header is line 1 and the first data row is line 2. + +- [ ] **Step 1: Write the failing tests** + +```python +import unittest +from datetime import date +from decimal import Decimal + +from ledgerlite.model import Transaction +from ledgerlite.parse import ParseError, parse_transactions + +HEADER = "date,amount,description\n" + + +class ParseTransactionsTest(unittest.TestCase): + def test_parses_rows_in_input_order(self): + rows = parse_transactions([HEADER, "2026-03-04,-7.50,Coffee Shop\n", "2026-03-01,2500.00,Salary\n"]) + self.assertEqual(rows, [ + Transaction(date(2026, 3, 4), Decimal("-7.50"), "Coffee Shop"), + Transaction(date(2026, 3, 1), Decimal("2500.00"), "Salary"), + ]) + + def test_header_only_yields_no_transactions(self): + self.assertEqual(parse_transactions([HEADER]), []) + + def test_empty_input_yields_no_transactions(self): + self.assertEqual(parse_transactions([]), []) + + def test_blank_lines_are_skipped(self): + rows = parse_transactions([HEADER, "\n", "2026-03-04,-7.50,Coffee\n", "\n"]) + self.assertEqual(len(rows), 1) + + def test_accepts_one_or_two_fractional_digits(self): + rows = parse_transactions([HEADER, "2026-03-04,1.5,a\n", "2026-03-04,1.50,b\n", "2026-03-04,-12,c\n"]) + self.assertEqual([r.amount for r in rows], [Decimal("1.5"), Decimal("1.50"), Decimal("-12")]) + + def test_strips_whitespace_around_date_and_amount(self): + rows = parse_transactions([HEADER, " 2026-03-04 , -7.50 , Coffee Shop \n"]) + self.assertEqual(rows[0].date, date(2026, 3, 4)) + self.assertEqual(rows[0].amount, Decimal("-7.50")) + self.assertEqual(rows[0].description, " Coffee Shop ") + + def test_wrong_column_count_is_an_error(self): + with self.assertRaises(ParseError) as ctx: + parse_transactions([HEADER, "2026-03-04,-7.50\n"]) + self.assertEqual(ctx.exception.line, 2) + self.assertEqual(ctx.exception.message, "expected 3 columns, got 2") + + def test_rejects_bad_dates(self): + for text in ["2026-13-40", "20260304", "2026-3-4", "2026-W10-3", "04/03/2026", ""]: + with self.subTest(text=text), self.assertRaises(ParseError) as ctx: + parse_transactions([HEADER, f"{text},-7.50,Coffee\n"]) + self.assertEqual(ctx.exception.message, f"invalid date '{text}'") + + def test_rejects_bad_amounts(self): + for text in ["1.005", "abc", "", "NaN", "Infinity", "1e3", "+1.50", "1,000.00", "-"]: + with self.subTest(text=text), self.assertRaises(ParseError) as ctx: + parse_transactions([HEADER, f'2026-03-04,"{text}",Coffee\n']) + self.assertEqual(ctx.exception.message, f"invalid amount '{text}'") + + def test_reports_the_first_bad_row_only(self): + with self.assertRaises(ParseError) as ctx: + parse_transactions([HEADER, "2026-03-04,-7.50,ok\n", "nope,-1.00,bad\n", "also-bad,x,bad\n"]) + self.assertEqual(ctx.exception.line, 3) + self.assertEqual(ctx.exception.message, "invalid date 'nope'") +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `python3 -m unittest test_parse -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite'` + +- [ ] **Step 3: Implement `ledgerlite/__init__.py`, `ledgerlite/model.py`, and `ledgerlite/parse.py`** + +`model.Transaction` is a `@dataclass(frozen=True)`. `parse.ParseError.__init__(self, line, message)` stores both attributes and passes `message` to `super().__init__`. `parse_transactions` wraps the iterable in `csv.reader` and applies the decisions listed above. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `python3 -m unittest test_parse -v` +Expected: PASS (all tests) + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/__init__.py ledgerlite/model.py ledgerlite/parse.py test_parse.py +git commit -m "feat: parse transactions CSV into Transaction records" +``` + +--- + +### Task 2: Rules parsing and categorization + +**Files:** +- Create: `ledgerlite/rules.py` +- Test: `test_rules.py` + +**Interfaces:** +- Consumes: nothing. +- Produces: + - `rules.parse_rules(lines: Iterable[str]) -> list[tuple[str, str]]` — `(substring, category)` pairs in file order. + - `rules.categorize(description: str, rules: list[tuple[str, str]]) -> str | None` — the category of the first matching rule, or `None`. + +**Decisions this task pins:** +- A rule line splits on its *first* `=`; later `=` characters belong to the category. +- Lines that are blank/whitespace-only, contain no `=`, or have an empty substring before the `=` are skipped. A bad rules file is never an error — the spec defines no exit code for one. +- The substring and category keep their text as written except for surrounding whitespace, which is stripped from both. +- Matching is `substring.lower() in description.lower()`. + +- [ ] **Step 1: Write the failing tests** + +```python +import unittest + +from ledgerlite.rules import categorize, parse_rules + + +class ParseRulesTest(unittest.TestCase): + def test_parses_rules_in_file_order(self): + self.assertEqual(parse_rules(["coffee=food\n", "rent=housing\n"]), [("coffee", "food"), ("rent", "housing")]) + + def test_splits_on_first_equals(self): + self.assertEqual(parse_rules(["a=b=c\n"]), [("a", "b=c")]) + + def test_strips_surrounding_whitespace(self): + self.assertEqual(parse_rules([" coffee = food \n"]), [("coffee", "food")]) + + def test_skips_blank_and_unparseable_lines(self): + self.assertEqual(parse_rules(["\n", " \n", "no-equals-here\n", "=food\n", "coffee=food\n"]), [("coffee", "food")]) + + +class CategorizeTest(unittest.TestCase): + RULES = [("coffee", "food"), ("rent", "housing")] + + def test_matches_substring_case_insensitively(self): + self.assertEqual(categorize("MORNING COFFEE", self.RULES), "food") + self.assertEqual(categorize("Monthly Rent", self.RULES), "housing") + + def test_first_matching_rule_wins(self): + rules = [("coffee", "food"), ("shop", "retail")] + self.assertEqual(categorize("Coffee Shop", rules), "food") + + def test_returns_none_when_nothing_matches(self): + self.assertIsNone(categorize("Salary", self.RULES)) + + def test_returns_none_with_no_rules(self): + self.assertIsNone(categorize("Coffee Shop", [])) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `python3 -m unittest test_rules -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.rules'` + +- [ ] **Step 3: Implement `parse_rules` and `categorize` in `ledgerlite/rules.py`** + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `python3 -m unittest test_rules -v` +Expected: PASS (all tests) + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/rules.py test_rules.py +git commit -m "feat: parse rules file and categorize descriptions" +``` + +--- + +### Task 3: Date ordering and closing balance + +**Files:** +- Create: `ledgerlite/balance.py` +- Test: `test_balance.py` + +**Interfaces:** +- Consumes: `model.Transaction` (Task 1). +- Produces: + - `balance.order_by_date(transactions: list[Transaction]) -> list[Transaction]` — new list, sorted by `date`, ties keeping input order. + - `balance.closing_balance(transactions: list[Transaction], opening: Decimal) -> Decimal` — `opening` plus each amount in the order given; returns `opening` for an empty list. + +**Decisions this task pins:** +- `order_by_date` returns a new list and does not mutate its argument; tie stability comes from `sorted`'s stability (no tiebreaker key, no index field on `Transaction`). + +- [ ] **Step 1: Write the failing tests** + +```python +import unittest +from datetime import date +from decimal import Decimal + +from ledgerlite.balance import closing_balance, order_by_date +from ledgerlite.model import Transaction + + +def txn(day, amount, description="x"): + return Transaction(date(2026, 3, day), Decimal(amount), description) + + +class OrderByDateTest(unittest.TestCase): + def test_sorts_by_date(self): + rows = [txn(4, "-7.50"), txn(1, "2500.00")] + self.assertEqual([t.date.day for t in order_by_date(rows)], [1, 4]) + + def test_ties_keep_input_order(self): + rows = [txn(1, "1.00", "first"), txn(1, "2.00", "second")] + self.assertEqual([t.description for t in order_by_date(rows)], ["first", "second"]) + + def test_does_not_mutate_input(self): + rows = [txn(4, "-7.50"), txn(1, "2500.00")] + order_by_date(rows) + self.assertEqual([t.date.day for t in rows], [4, 1]) + + +class ClosingBalanceTest(unittest.TestCase): + def test_adds_amounts_to_opening(self): + rows = [txn(1, "2500.00"), txn(2, "-900.00"), txn(3, "-7.50")] + self.assertEqual(closing_balance(rows, Decimal("100")), Decimal("1692.50")) + + def test_no_transactions_returns_opening(self): + self.assertEqual(closing_balance([], Decimal("100")), Decimal("100")) + + def test_result_is_decimal_not_float(self): + self.assertIsInstance(closing_balance([txn(1, "0.10")], Decimal("0.20")), Decimal) + self.assertEqual(closing_balance([txn(1, "0.10")], Decimal("0.20")), Decimal("0.30")) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `python3 -m unittest test_balance -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.balance'` + +- [ ] **Step 3: Implement `order_by_date` and `closing_balance` in `ledgerlite/balance.py`** + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `python3 -m unittest test_balance -v` +Expected: PASS (all tests) + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/balance.py test_balance.py +git commit -m "feat: add date ordering and closing balance" +``` + +--- + +### Task 4: Category totals and report formatting + +**Files:** +- Create: `ledgerlite/report.py` +- Test: `test_report.py` + +**Interfaces:** +- Consumes: `model.Transaction` (Task 1), `rules.categorize` (Task 2), `balance.closing_balance` (Task 3). +- Produces: + - `report.format_amount(amount: Decimal) -> str` + - `report.category_totals(transactions: list[Transaction], rules: list[tuple[str, str]]) -> list[tuple[str, Decimal]]` — alphabetical by category, with `uncategorized` last if present. + - `report.format_report(transactions: list[Transaction], rules: list[tuple[str, str]], opening: Decimal) -> str` — the whole report, ending in a single `\n`. Assumes `transactions` is already date-ordered (Task 5 calls `order_by_date` first). + +**Decisions this task pins:** +- `format_amount` is `f"{amount:.2f}"`, with zero normalized first (`if amount == 0: amount = abs(amount)`) so `Decimal("-0.00")` prints `0.00`. +- A category appears only if at least one transaction has it; `uncategorized` is not printed when every transaction matched a rule, and no category lines are printed when there are no transactions. +- The blank line before `closing balance:` is always present, so a report with no categories starts with a blank line. + +- [ ] **Step 1: Write the failing tests** + +```python +import unittest +from datetime import date +from decimal import Decimal + +from ledgerlite.model import Transaction +from ledgerlite.report import category_totals, format_amount, format_report + +RULES = [("coffee", "food"), ("rent", "housing")] + + +def txn(day, amount, description): + return Transaction(date(2026, 3, day), Decimal(amount), description) + + +class FormatAmountTest(unittest.TestCase): + def test_formats_two_fractional_digits(self): + cases = [("-12.5", "-12.50"), ("0", "0.00"), ("1200", "1200.00"), ("1.5", "1.50"), ("1234567.89", "1234567.89")] + for value, expected in cases: + with self.subTest(value=value): + self.assertEqual(format_amount(Decimal(value)), expected) + + def test_negative_zero_prints_without_sign(self): + self.assertEqual(format_amount(Decimal("-0.00")), "0.00") + + +class CategoryTotalsTest(unittest.TestCase): + def test_sums_per_category_alphabetically_with_uncategorized_last(self): + rows = [txn(1, "2500.00", "Salary"), txn(2, "-900.00", "Rent"), txn(3, "-7.50", "Coffee Shop")] + self.assertEqual(category_totals(rows, RULES), [ + ("food", Decimal("-7.50")), + ("housing", Decimal("-900.00")), + ("uncategorized", Decimal("2500.00")), + ]) + + def test_omits_uncategorized_when_everything_matches(self): + self.assertEqual(category_totals([txn(1, "-7.50", "Coffee")], RULES), [("food", Decimal("-7.50"))]) + + def test_no_rules_puts_everything_in_uncategorized(self): + rows = [txn(1, "-7.50", "Coffee"), txn(2, "-2.50", "Rent")] + self.assertEqual(category_totals(rows, []), [("uncategorized", Decimal("-10.00"))]) + + def test_no_transactions_yields_no_categories(self): + self.assertEqual(category_totals([], RULES), []) + + +class FormatReportTest(unittest.TestCase): + def test_matches_the_spec_example(self): + rows = [txn(1, "2500.00", "Salary"), txn(2, "-900.00", "Rent"), txn(3, "-7.50", "Coffee Shop")] + self.assertEqual( + format_report(rows, RULES, Decimal("100")), + "food: -7.50\nhousing: -900.00\nuncategorized: 2500.00\n\nclosing balance: 1692.50\n", + ) + + def test_no_transactions_reports_only_the_opening_balance(self): + self.assertEqual(format_report([], RULES, Decimal("100")), "\nclosing balance: 100.00\n") +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `python3 -m unittest test_report -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.report'` + +- [ ] **Step 3: Implement `format_amount`, `category_totals`, and `format_report` in `ledgerlite/report.py`** + +`category_totals` accumulates into a dict keyed by `categorize(...) or "uncategorized"`, then sorts alphabetically with `uncategorized` forced last (e.g. `sorted(..., key=lambda kv: (kv[0] == "uncategorized", kv[0]))`). `format_report` joins `f"{name}: {format_amount(total)}"` lines, then a blank line, then `f"closing balance: {format_amount(closing_balance(transactions, opening))}"`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `python3 -m unittest test_report -v` +Expected: PASS (all tests) + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/report.py test_report.py +git commit -m "feat: add category totals and report formatting" +``` + +--- + +### Task 5: CLI entry point + +**Files:** +- Create: `ledgerlite/cli.py` +- Create: `ledgerlite/__main__.py` +- Test: `test_cli.py` + +**Interfaces:** +- Consumes: `parse.parse_transactions`/`parse.ParseError` (Task 1), `rules.parse_rules` (Task 2), `balance.order_by_date` (Task 3), `report.format_report` (Task 4). +- Produces: `cli.main(argv: list[str] | None = None) -> int`. `__main__.py` is three lines: `import sys`, `from ledgerlite.cli import main`, `sys.exit(main())`. + +**Decisions this task pins:** +- Command line: `ledgerlite report TRANSACTIONS [--rules RULES] [--opening AMOUNT]`, built with `argparse` subparsers; `report` is the only subcommand. Run as `python3 -m ledgerlite report ...`. (`__main__.py` is not in the spec's layout; it is what makes the package runnable.) +- `--opening` defaults to `Decimal("0")` and uses an argparse `type=` callable that applies Task 1's amount pattern and raises `argparse.ArgumentTypeError(f"invalid amount '{text}'")`. Invalid arguments therefore exit via argparse's own `SystemExit(2)` with its usage message — `main` does not catch it. +- Files are opened with `open(path, newline="", encoding="utf-8")`. `OSError` becomes `ledgerlite: cannot read {path}: {exc.strerror}` on stderr, return 1 — for the transactions path *and* the rules path. +- `ParseError` becomes `ledgerlite: {path}:{err.line}: {err.message}` on stderr, return 2, with nothing written to stdout. The transactions file is read and fully parsed before the rules file is opened, so a malformed row is reported even if the rules path is also bad. +- Output is written with `print(text, end="")` since `format_report` already ends in a newline. + +- [ ] **Step 1: Write the failing tests** + +```python +import io +import unittest +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path +from tempfile import TemporaryDirectory + +from ledgerlite.cli import main + +TRANSACTIONS = """date,amount,description +2026-03-04,-7.50,Coffee Shop +2026-03-01,2500.00,Salary +2026-03-02,-900.00,Monthly Rent +""" +RULES = "coffee=food\nrent=housing\n" +EXPECTED = "food: -7.50\nhousing: -900.00\nuncategorized: 2500.00\n\nclosing balance: 1692.50\n" + + +class CliTest(unittest.TestCase): + def setUp(self): + self.tmp = TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.dir = Path(self.tmp.name) + self.txns = self.dir / "txns.csv" + self.txns.write_text(TRANSACTIONS) + self.rules = self.dir / "rules.txt" + self.rules.write_text(RULES) + + def run_cli(self, *argv): + out, err = io.StringIO(), io.StringIO() + with redirect_stdout(out), redirect_stderr(err): + code = main(list(argv)) + return code, out.getvalue(), err.getvalue() + + def test_prints_the_spec_example(self): + code, out, err = self.run_cli("report", str(self.txns), "--rules", str(self.rules), "--opening", "100") + self.assertEqual((code, out, err), (0, EXPECTED, "")) + + def test_defaults_to_zero_opening_and_no_rules(self): + code, out, _ = self.run_cli("report", str(self.txns)) + self.assertEqual(code, 0) + self.assertEqual(out, "uncategorized: 1592.50\n\nclosing balance: 1592.50\n") + + def test_missing_transactions_file(self): + missing = self.dir / "nope.csv" + code, out, err = self.run_cli("report", str(missing)) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertEqual(err, f"ledgerlite: cannot read {missing}: No such file or directory\n") + + def test_unreadable_rules_file(self): + missing = self.dir / "nope.txt" + code, out, err = self.run_cli("report", str(self.txns), "--rules", str(missing)) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertEqual(err, f"ledgerlite: cannot read {missing}: No such file or directory\n") + + def test_malformed_row_rejects_whole_file(self): + bad = self.dir / "bad.csv" + bad.write_text("date,amount,description\n2026-03-04,-7.50,Coffee\n2026-03-05,1.005,Odd\n") + code, out, err = self.run_cli("report", str(bad), "--rules", str(self.rules)) + self.assertEqual(code, 2) + self.assertEqual(out, "") + self.assertEqual(err, f"ledgerlite: {bad}:3: invalid amount '1.005'\n") + + def test_empty_file_reports_opening_balance(self): + empty = self.dir / "empty.csv" + empty.write_text("date,amount,description\n") + code, out, _ = self.run_cli("report", str(empty), "--opening", "42.50") + self.assertEqual((code, out), (0, "\nclosing balance: 42.50\n")) + + def test_invalid_opening_exits_two(self): + with self.assertRaises(SystemExit) as ctx, redirect_stderr(io.StringIO()): + main(["report", str(self.txns), "--opening", "abc"]) + self.assertEqual(ctx.exception.code, 2) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `python3 -m unittest test_cli -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.cli'` + +- [ ] **Step 3: Implement `main` in `ledgerlite/cli.py` and `ledgerlite/__main__.py`** + +Follow the decisions above. Reuse Task 1's amount pattern for `--opening` by importing it from `parse` rather than re-writing the regex. + +- [ ] **Step 4: Run the whole suite** + +Run: `python3 -m unittest -v` +Expected: PASS (all tests from Tasks 1–5) + +- [ ] **Step 5: Verify the tool runs end to end** + +Run: `printf 'date,amount,description\n2026-03-04,-7.50,Coffee Shop\n2026-03-01,2500.00,Salary\n2026-03-02,-900.00,Monthly Rent\n' > /tmp/t.csv && printf 'coffee=food\nrent=housing\n' > /tmp/r.txt && python3 -m ledgerlite report /tmp/t.csv --rules /tmp/r.txt --opening 100` +Expected: the spec's example report, and `echo $?` prints `0` + +- [ ] **Step 6: Commit** + +```bash +git add ledgerlite/cli.py ledgerlite/__main__.py test_cli.py +git commit -m "feat: add ledgerlite report CLI" +``` diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/C3-planset-cosmic-91/2026-09-18-cosmic-tetris-1-engine.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/C3-planset-cosmic-91/2026-09-18-cosmic-tetris-1-engine.md new file mode 100644 index 000000000..a95352c4f --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/C3-planset-cosmic-91/2026-09-18-cosmic-tetris-1-engine.md @@ -0,0 +1,777 @@ +# Cosmic Tetris — Plan 1: Headless Game Engine + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build `internal/game` — a deterministic, clock-free falling-block engine with pieces, board, 7-bag, movement, wall kicks, gravity, lock delay, line clears, hold, scoring and game over, fully unit tested. + +**Architecture:** One package, no dependencies outside the standard library. The engine is a pure state machine: every mutator returns a `[]Event` describing what happened, and time enters only through `Advance(dt)`. Nothing in the package reads a clock or renders anything. FX and UI packages built in later plans observe events; they never call into engine internals. + +**Tech Stack:** Go 1.26, standard library only (`math`, `math/rand`, `time` for `time.Duration`). + +**Spec:** `design.md` (this plan implements §5–§13, §34, §35, §40, §42 Phase 1, §49.1, §49.2, §49.4 geometry, §49.6) + +## Global Constraints + +- Language: Go. Module path: `cosmic-tetris`. Go directive: `go 1.26`. +- UI libraries (later plans): `charm.land/bubbletea/v2`, `charm.land/lipgloss/v2`, `charm.land/bubbles/v2`. Verified available at v2.0.9 / v2.0.6 / v2.2.1. +- Repository layout is fixed by §33. Do not add packages beyond `cmd/cosmic-tetris`, `internal/game`, `internal/app`, `internal/render`, `internal/fx`, `internal/flavor`. Extra *files* inside those packages are allowed where a plan names them. +- No networking, no profiles, no achievements, no plugin system, no database (§2). +- `internal/game` never calls `time.Now()` (§49.2). Bubble Tea owns the clock. +- Game RNG and FX RNG are separate `*rand.Rand` instances and never share (§49.6). +- Logical board: width 10, height 22, visible rows 20, hidden spawn rows 2 (§5). +- Combo bonus is exactly `50 × (combo - 1) × level` (§49.1). +- Drop interval is `800ms × 0.86^(level-1)`, clamped at a 60ms floor (§11). +- Lock delay 500ms; max 15 lock resets (§12). +- Effects may never modify game state (§14, §44). + +## Review Focus + +Input classes the spec implies but does not describe, most likely to bite first. Each has a test in the task named. + +1. `Advance(dt)` with a `dt` far larger than one drop interval — a suspended process or a debugger pause must not spin through thousands of gravity steps or skip locking (Task 6). +2. A hold swap whose incoming piece cannot spawn — must reach game over with the board intact, never a half-committed piece (Task 8). +3. `New(seed)` with seed `0` and negative seeds — the bag must still yield all seven kinds; `0` is a real seed, not "pick one for me" (Task 3). +4. Rotation in the hidden spawn rows where the `(0,-1)` kick would push cells above row 0 — rotation must fail rather than write out of bounds (Task 5). +5. A line clear whose row set includes a hidden spawn row — collapse must preserve board height and never index out of range (Task 7). + +## Plan Set + +Run in this order. A ruling that changes a name, signature, or value a later plan consumes is applied to that plan's file before the next task starts. + +1. `plans/2026-09-18-cosmic-tetris-1-engine.md` — headless deterministic engine in `internal/game`. Consumes: nothing. +2. `plans/2026-09-18-cosmic-tetris-2-playable-terminal.md` — Bubble Tea app, canvas renderer, layout, HUD, hold/next/ghost, CLI flags, pause/help/game-over card, golden tests. Consumes Plan 1's `game.Game`, `Advance`, `Event`, `Board`, `Piece`, `GhostY`. +3. `plans/2026-09-18-cosmic-tetris-3-cosmic-foundation.md` — `internal/fx` (particles, starfield), animated border, piece trails, `internal/flavor` mission control. Consumes Plan 1's `Event`/`Cell` and Plan 2's `render.Canvas`, `render.Layout`, `render.Snapshot`, `app.Model`. +4. `plans/2026-09-18-cosmic-tetris-4-violence.md` — hard-drop impact, screen shake, line supernova, shockwaves, hyperdrive, four-line sequence, combo/level overlays. Consumes Plan 3's `fx.World` and the render FX layer. +5. `plans/2026-09-18-cosmic-tetris-5-polish.md` — boot sequence, game-over black hole, ASCII/no-FX guarantees, §45 details, README, definition-of-done sweep. Consumes everything above. + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `go.mod` | module `cosmic-tetris`, `go 1.26` | +| `internal/game/piece.go` | `PieceKind`, rotation tables, `Piece`, spawn geometry | +| `internal/game/board.go` | `Cell`, `Board`, collision, row completion, clear + collapse | +| `internal/game/bag.go` | 7-bag generator over the game RNG | +| `internal/game/scoring.go` | line values, combo bonus, level, drop interval | +| `internal/game/rules.go` | tuning constants and kick offsets | +| `internal/game/events.go` | `EventKind`, `Event` | +| `internal/game/game.go` | `Game` state, mutators, `Advance`, hold, lock, game over | + +--- + +### Task 1: Module and piece geometry + +**Files:** +- Create: `go.mod`, `internal/game/piece.go` +- Test: `internal/game/piece_test.go` + +**Interfaces:** +- Consumes: nothing. +- Produces: +```go +type PieceKind int +const (KindI PieceKind = iota; KindJ; KindL; KindO; KindS; KindT; KindZ) +const KindCount = 7 +func (k PieceKind) String() string // "I", "J", "L", "O", "S", "T", "Z" +func (k PieceKind) Letter() byte // 'I' … 'Z' + +type Point struct{ X, Y int } + +type Piece struct { + Kind PieceKind + Rotation int // 0..3 + X, Y int // board coords of the piece box's top-left corner +} +func (p Piece) Cells() [4]Point // absolute board coords, +Y is down +func (p Piece) BoxSize() int // 4 for I, 2 for O, 3 otherwise +func SpawnPiece(k PieceKind) Piece // Rotation 0, Y=0, X=spawnX(k) +``` + +Rotation data: rotation 0 of each kind is a literal glyph grid; rotations 1–3 are produced by rotating that grid clockwise inside its own box and are baked into a package-level table at init. Grids (fixed — do not adjust): + +```go +var spawnGrids = map[PieceKind][]string{ + KindI: {"....", "XXXX", "....", "...."}, + KindJ: {"X..", "XXX", "..."}, + KindL: {"..X", "XXX", "..."}, + KindO: {"XX", "XX"}, + KindS: {".XX", "XX.", "..."}, + KindT: {".X.", "XXX", "..."}, + KindZ: {"XX.", ".XX", "..."}, +} +``` + +Spawn X is `(Width - BoxSize) / 2` — 3 for I, 4 for O, 3 for the rest. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestKindStrings(t *testing.T) +// KindI.String() == "I" … KindZ.String() == "Z"; Letter() matches String()[0]. + +func TestEveryRotationHasFourCells(t *testing.T) +// for each of the 7 kinds, for r in 0..3: len(unique cells of Piece{Kind:k, Rotation:r}.Cells()) == 4 + +func TestFourRotationsReturnToStart(t *testing.T) +// cell set of rotation 0 == cell set of rotation 4%4 reached by rotating the table four times; +// i.e. rotationCells(k,0) equals rotationCells(k,0) after four clockwise applications. + +func TestIPieceRotationZeroAndOne(t *testing.T) +// Piece{KindI, 0, 0, 0}.Cells() == {{0,1},{1,1},{2,1},{3,1}} +// Piece{KindI, 1, 0, 0}.Cells() == {{2,0},{2,1},{2,2},{2,3}} + +func TestOPieceIdenticalThroughRotation(t *testing.T) +// all four rotations of KindO give the same cell set {{0,0},{1,0},{0,1},{1,1}} + +func TestSpawnPositions(t *testing.T) +// SpawnPiece(KindI) == Piece{KindI, 0, 3, 0}; SpawnPiece(KindO) == Piece{KindO, 0, 4, 0}; +// SpawnPiece(KindT) == Piece{KindT, 0, 3, 0} + +func TestSpawnCellsStayInHiddenRows(t *testing.T) +// for every kind: every cell of SpawnPiece(k).Cells() has Y < 2 and 0 <= X < 10 +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/game/ -run 'TestKind|TestEvery|TestFour|TestIPiece|TestOPiece|TestSpawn' -v` +Expected: FAIL — build error, undefined identifiers. + +- [ ] **Step 3: Create `go.mod`** + +`module cosmic-tetris` with `go 1.26`. + +- [ ] **Step 4: Implement `internal/game/piece.go`** + +Parse `spawnGrids` into `[KindCount][4][4]Point` at init via a `rotateCW([]string) []string` helper. `Cells()` adds `p.X, p.Y` to the table entry. Cell order within a rotation is row-major over the grid, so the test literals above hold. + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add go.mod internal/game/piece.go internal/game/piece_test.go +git commit -m "feat(game): piece kinds, rotation tables and spawn geometry" +``` + +--- + +### Task 2: Board — cells, collision, row clear and collapse + +**Files:** +- Create: `internal/game/board.go` +- Test: `internal/game/board_test.go` + +**Interfaces:** +- Consumes: `Piece`, `PieceKind`, `Point` (Task 1). +- Produces: +```go +const (Width = 10; Height = 22; VisibleRows = 20; HiddenRows = 2) + +type Cell uint8 +const CellEmpty Cell = 0 +func CellFor(k PieceKind) Cell // k+1 +func (c Cell) Empty() bool +func (c Cell) Kind() PieceKind // panics if Empty + +type Board struct{ Cells [Height][Width]Cell } +func (b *Board) At(x, y int) Cell +func (b *Board) Set(x, y int, c Cell) +func (b *Board) InBounds(x, y int) bool // 0<=x At(4,20)==At(5,20)==At(4,21)==At(5,21)==CellFor(KindO) + +func TestCompleteRowsFindsAllFullRows(t *testing.T) +// fill rows 19 and 21 completely, row 20 with 9 cells => CompleteRows() == []int{19,21} + +func TestClearRowsCollapsesAbove(t *testing.T) +// fill row 21 completely; put a single cell at (0,20); ClearRows([]int{21}) +// => At(0,21) == CellFor(...) (the lone cell fell), row 20 empty, row 0 empty + +func TestClearRowsHandlesHiddenRows(t *testing.T) +// fill rows 0 and 21; ClearRows([]int{0,21}); board still Height rows, +// no panic, board is empty afterwards + +func TestBoardStringShape(t *testing.T) +// empty board String() has Height lines each of Width '.'; a KindT cell renders 'T' +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/game/ -run TestOccupied -v` +Expected: FAIL — undefined `Board`. + +- [ ] **Step 3: Implement `internal/game/board.go`** + +`ClearRows` copies surviving rows downward from the bottom and zeroes the remainder; it must tolerate an unsorted or duplicate `rows` slice. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/board.go internal/game/board_test.go +git commit -m "feat(game): board with collision, row completion and collapse" +``` + +--- + +### Task 3: 7-bag piece generator + +**Files:** +- Create: `internal/game/bag.go` +- Test: `internal/game/bag_test.go` + +**Interfaces:** +- Consumes: `PieceKind`, `KindCount` (Task 1). +- Produces: +```go +type Bag struct{ /* unexported: rng *rand.Rand; queue []PieceKind */ } +func NewBag(rng *rand.Rand) *Bag +func (b *Bag) Next() PieceKind +``` + +Refill puts one of every kind in the bag and shuffles with `rng.Shuffle`. The bag's `rng` is the game RNG passed in by `Game` (§49.6); the bag never creates its own. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestEachBagContainsAllSevenExactlyOnce(t *testing.T) +// draw 14 kinds; first 7 and second 7 each contain every kind exactly once + +func TestSeededBagIsReproducible(t *testing.T) +// two bags from rand.New(rand.NewSource(8675309)) produce identical 30-kind sequences + +func TestBagWorksWithZeroAndNegativeSeeds(t *testing.T) +// for seed in {0, -1, math.MinInt64}: first 7 draws contain every kind exactly once +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/game/ -run TestEachBag -v` +Expected: FAIL — undefined `NewBag`. + +- [ ] **Step 3: Implement `internal/game/bag.go`** + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/bag.go internal/game/bag_test.go +git commit -m "feat(game): seeded 7-bag generator" +``` + +--- + +### Task 4: Scoring, level and timing rules + +**Files:** +- Create: `internal/game/scoring.go`, `internal/game/rules.go` +- Test: `internal/game/scoring_test.go` + +**Interfaces:** +- Consumes: `Point` (Task 1). +- Produces: +```go +// rules.go +const ( + LockDelay = 500 * time.Millisecond + MaxLockResets = 15 + BaseInterval = 800 * time.Millisecond + MinInterval = 60 * time.Millisecond + IntervalFactor = 0.86 + LinesPerLevel = 10 +) +var KickOffsets = [8]Point{{0,0},{-1,0},{1,0},{-2,0},{2,0},{0,-1},{-1,-1},{1,-1}} + +// scoring.go +func LineScore(lines, level int) int // 0/100/300/500/800 × level +func ComboBonus(combo, level int) int // 50 × (combo-1) × level, 0 when combo < 2 +func LevelFor(lines int) int // lines/LinesPerLevel + 1 +func DropInterval(level int) time.Duration // rounded to whole ms, floor MinInterval +const (SoftDropPoints = 1; HardDropPoints = 2) // per cell +``` + +`KickOffsets` are `(dx, dy)` in board coordinates where `+y` is down, so `{0,-1}` lifts the piece one row (§7). + +- [ ] **Step 1: Write the failing tests** + +```go +func TestLineScoreTable(t *testing.T) +// level 1: 0→0, 1→100, 2→300, 3→500, 4→800 +// level 7: 1→700, 2→2100, 3→3500, 4→5600 + +func TestComboBonus(t *testing.T) +// ComboBonus(0,5)==0; ComboBonus(1,5)==0; ComboBonus(2,5)==250; ComboBonus(3,2)==200 + +func TestLevelFor(t *testing.T) +// 0→1, 9→1, 10→2, 19→2, 20→3, 127→13 + +func TestDropIntervalCurveAndFloor(t *testing.T) +// DropInterval(1) == 800*time.Millisecond +// DropInterval(2) == 688*time.Millisecond +// DropInterval(3) == 592*time.Millisecond +// strictly decreasing for level 1..19 +// DropInterval(19) == MinInterval and DropInterval(30) == MinInterval +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/game/ -run 'TestLineScore|TestCombo|TestLevelFor|TestDropInterval' -v` +Expected: FAIL — undefined `LineScore`. + +- [ ] **Step 3: Implement `scoring.go` and `rules.go`** + +`DropInterval` computes `BaseInterval × IntervalFactor^(level-1)` in float milliseconds, rounds to the nearest millisecond, then applies the floor. (`800 × 0.86 = 688`, `× 0.86 = 591.68 → 592`.) + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/scoring.go internal/game/rules.go internal/game/scoring_test.go +git commit -m "feat(game): scoring, level progression and gravity curve" +``` + +--- + +### Task 5: Game state, events, movement and rotation + +**Files:** +- Create: `internal/game/events.go`, `internal/game/game.go` +- Test: `internal/game/game_test.go` + +**Interfaces:** +- Consumes: everything from Tasks 1–4. +- Produces: +```go +// events.go +type EventKind int +const ( + PieceMoved EventKind = iota + PieceRotated + PieceSoftDropped + PieceHardDropped + PieceLocked + HoldUsed + LinesCleared + ComboChanged + LevelChanged + GameOver +) +func (k EventKind) String() string + +type Event struct { + Kind EventKind + Piece Piece // the piece involved; for PieceLocked its resting place + Rows []int // LinesCleared: cleared row indices, ascending + Cells [][]Cell // LinesCleared: contents of those rows before clearing, same order + Count int // LinesCleared: line count. ComboChanged: new combo. LevelChanged: new level + Distance int // PieceSoftDropped / PieceHardDropped: cells descended + ScoreDelta int +} + +// game.go +type Game struct { + Board Board + Active Piece + Hold *PieceKind + CanHold bool + Next []PieceKind // always len NextCount + Bag *Bag + + Score, Lines, Level, Combo int + + GravityAccumulator time.Duration + LockAccumulator time.Duration + LockResets int + Over bool + + Seed int64 + rng *rand.Rand +} +const NextCount = 5 + +func New(seed int64) *Game +func (g *Game) MoveLeft() []Event +func (g *Game) MoveRight() []Event +func (g *Game) RotateCW() []Event +func (g *Game) RotateCCW() []Event +``` + +`New` seeds one `*rand.Rand`, builds the bag, fills `Next` to `NextCount`, and spawns the first piece. Movement and rotation return a single-element `[]Event` on success and `nil` on failure, and never mutate state on failure. A successful move or rotation while grounded resets `LockAccumulator` and increments `LockResets`, but only while `LockResets < MaxLockResets` (§12). + +- [ ] **Step 1: Write the failing tests** + +```go +func TestNewGameInitialState(t *testing.T) +// Score/Lines/Combo == 0; Level == 1; len(Next) == NextCount; Hold == nil; CanHold == true; +// Over == false; Seed == the seed passed in; Active is a spawn piece (Rotation 0, Y 0) + +func TestMoveLeftAndRight(t *testing.T) +// x := g.Active.X; evs := g.MoveLeft(); len(evs)==1; evs[0].Kind==PieceMoved; g.Active.X == x-1 +// MoveRight twice => X == x+1 + +func TestBlockedMoveEmitsNothing(t *testing.T) +// push the active piece to the left wall in a loop; the move that fails returns nil +// and leaves Active unchanged + +func TestRotationEmitsPieceRotated(t *testing.T) +// g.Active = Piece{KindT,0,4,10}; evs := g.RotateCW(); evs[0].Kind == PieceRotated; +// g.Active.Rotation == 1; RotateCCW from rotation 0 gives Rotation 3 + +func TestRotationWallKicks(t *testing.T) +// g.Active = Piece{KindI,1,-1,5} placed flush against the left wall such that rotation 2 +// would overlap x<0; RotateCW succeeds and the resulting piece has all cells in bounds + +func TestRotationFailsInTightPocket(t *testing.T) +// fill the board except a 1-wide vertical slot; put a vertical I in the slot; +// RotateCW returns nil, Rotation and X/Y unchanged + +func TestRotationNeverEscapesTheCeiling(t *testing.T) +// spawn state: g.Active = Piece{KindI,1,3,0}; RotateCW must either succeed with every cell +// at Y >= 0 or return nil — assert no cell has Y < 0 and the board is untouched +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/game/ -run 'TestNewGame|TestMove|TestBlocked|TestRotation' -v` +Expected: FAIL — undefined `New`. + +- [ ] **Step 3: Implement `events.go` and the movement half of `game.go`** + +Rotation tries `KickOffsets` in order and accepts the first non-colliding position (§7). Add unexported helpers `tryMove(dx, dy int) bool`, `grounded() bool`, `spawn() []Event`, `noteGroundedReset()`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/events.go internal/game/game.go internal/game/game_test.go +git commit -m "feat(game): game state, event stream, movement and kicked rotation" +``` + +--- + +### Task 6: Advance — gravity, soft drop, ghost and lock delay + +**Files:** +- Modify: `internal/game/game.go` +- Test: `internal/game/advance_test.go` + +**Interfaces:** +- Consumes: Task 5's `Game`, `Event`; Task 4's `DropInterval`, `LockDelay`, `MaxLockResets`. +- Produces: +```go +func (g *Game) Advance(dt time.Duration) []Event +func (g *Game) SoftDrop() []Event // one cell; PieceSoftDropped with ScoreDelta = SoftDropPoints +func (g *Game) GhostY() int // Y the active piece would rest at +func (g *Game) Interval() time.Duration // DropInterval(g.Level), exposed for the HUD +const maxCatchUpSteps = 20 +``` + +`Advance` is the only way time enters the engine. Algorithm (fixed — the tests do not fully determine it): + +```go +func (g *Game) Advance(dt time.Duration) []Event { + if g.Over { return nil } + var evs []Event + interval := g.Interval() + g.GravityAccumulator += dt + for steps := 0; g.GravityAccumulator >= interval; steps++ { + if steps >= maxCatchUpSteps { g.GravityAccumulator = 0; break } + g.GravityAccumulator -= interval + if !g.tryMove(0, 1) { g.GravityAccumulator = 0; break } + evs = append(evs, Event{Kind: PieceMoved, Piece: g.Active}) + g.LockAccumulator, g.LockResets = 0, 0 // a new row is a fresh lock budget + } + if g.grounded() { + g.LockAccumulator += dt + if g.LockAccumulator >= LockDelay { evs = append(evs, g.lock()...) } + } else { + g.LockAccumulator = 0 + } + return evs +} +``` + +- [ ] **Step 1: Write the failing tests** + +```go +func TestAdvanceBelowIntervalDoesNothing(t *testing.T) +// g := New(1); y := g.Active.Y; g.Advance(100*time.Millisecond); Active.Y == y, no events + +func TestAdvanceAtIntervalDropsOneRow(t *testing.T) +// g.Advance(800*time.Millisecond) => one PieceMoved, Active.Y == y+1 + +func TestAdvanceHugeDtIsBoundedAndLeavesPieceGrounded(t *testing.T) +// g.Advance(10*time.Second) returns in well under a second, emits at most maxCatchUpSteps+3 events, +// GravityAccumulator < g.Interval(), and the piece is either locked or resting on the floor + +func TestGhostY(t *testing.T) +// empty board: GhostY() == the lowest Y where the piece does not collide +// with a locked cell at (Active.X, 15) under the piece, GhostY() is above it + +func TestSoftDropScoresOnePoint(t *testing.T) +// evs := g.SoftDrop(); evs[0].Kind == PieceSoftDropped; evs[0].ScoreDelta == 1; +// g.Score == 1; Active.Y increased by 1 +// soft drop into the floor returns nil and does not score + +func TestGroundedPieceLocksAfterLockDelay(t *testing.T) +// drop the piece to the floor, then Advance(499ms) => no PieceLocked; +// one more Advance(1ms) => a PieceLocked event + +func TestMovementWhileGroundedResetsLockTimer(t *testing.T) +// grounded, Advance(400ms), MoveLeft(), Advance(400ms) => no PieceLocked yet + +func TestLockResetsAreCapped(t *testing.T) +// grounded; loop 20 times { Advance(400ms); MoveLeft() or MoveRight() alternating } +// => a PieceLocked event occurs within the loop; g.LockResets <= MaxLockResets +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/game/ -run TestAdvance -v` +Expected: FAIL — undefined `Advance`. + +- [ ] **Step 3: Implement `Advance`, `SoftDrop`, `GhostY`, `Interval` and a stub `lock()`** + +`lock()` for now commits the piece and emits `PieceLocked` plus a spawn; Task 7 adds clearing and scoring. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/game.go internal/game/advance_test.go +git commit -m "feat(game): elapsed-time gravity, soft drop, ghost and lock delay" +``` + +--- + +### Task 7: Hard drop, locking, line clears, combo and level + +**Files:** +- Modify: `internal/game/game.go` +- Test: `internal/game/clear_test.go` + +**Interfaces:** +- Consumes: Task 6's `Advance`/`lock`; Task 4's scoring functions; Task 2's `CompleteRows`/`ClearRows`. +- Produces: +```go +func (g *Game) HardDrop() []Event +``` + +Lock sequence, in this order (§12): commit piece → `PieceLocked` → detect complete rows → emit `LinesCleared` (carrying `Rows` and the pre-clear `Cells`, and `ScoreDelta = LineScore + ComboBonus`) → clear rows → update `Score`/`Lines`/`Combo` → emit `ComboChanged` when the combo value changed → emit `LevelChanged` when `LevelFor(Lines)` changed → spawn next → `CanHold = true`. + +`HardDrop` emits `PieceHardDropped{Distance, ScoreDelta: 2×Distance}` then locks immediately (no lock delay). + +- [ ] **Step 1: Write the failing tests** + +```go +func TestHardDropScoresTwoPerCell(t *testing.T) +// g := New(1); evs := g.HardDrop(); first event is PieceHardDropped with +// Distance == cells travelled and ScoreDelta == 2*Distance; the event list also contains PieceLocked + +func TestHardDropOfAGroundedPieceStillLocks(t *testing.T) +// piece already resting on the floor: HardDrop emits PieceHardDropped{Distance:0} and PieceLocked + +func TestSingleLineClearScoresAndCollapses(t *testing.T) +// construct a board with row 21 filled except x=0..? and drop a piece to complete it; +// LinesCleared event has Count 1, Rows []int{21}, len(Cells)==1 and Cells[0] holds the +// pre-clear row; ScoreDelta == 100*level; g.Lines == 1; row 21 is empty afterwards + +func TestFourLineClearScores800TimesLevel(t *testing.T) +// stack rows 18..21 nine wide, hard drop a vertical I into the tenth column +// => LinesCleared{Count:4}, ScoreDelta == 800*g.Level (+0 combo bonus at combo 1) + +func TestComboAccumulatesAndResets(t *testing.T) +// first clearing placement: Combo == 1, ComboChanged{Count:1}, no combo bonus +// second consecutive clearing placement: Combo == 2, ScoreDelta includes 50*1*level +// a placement that clears nothing: Combo == 0 and one ComboChanged{Count:0} +// a second non-clearing placement emits no ComboChanged (value did not change) + +func TestLevelIncreasesEveryTenLines(t *testing.T) +// drive Lines to 10 => LevelChanged{Count:2} emitted once and g.Level == 2; +// g.Interval() == DropInterval(2) + +func TestClearIncludingHiddenRowKeepsBoardIntact(t *testing.T) +// fill row 1 (hidden) and row 21 completely via Board.Set, then lock any piece to trigger +// the clear path => no panic, board still Height rows, both rows empty +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/game/ -run 'TestHardDrop|TestSingleLine|TestFourLine|TestCombo|TestLevel|TestClearIncluding' -v` +Expected: FAIL — undefined `HardDrop`. + +- [ ] **Step 3: Implement `HardDrop` and the full `lock()` sequence** + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/game.go internal/game/clear_test.go +git commit -m "feat(game): hard drop, lock sequence, line clears, combo and level" +``` + +--- + +### Task 8: Hold, game over and replay determinism + +**Files:** +- Modify: `internal/game/game.go` +- Test: `internal/game/hold_test.go`, `internal/game/determinism_test.go` + +**Interfaces:** +- Consumes: Tasks 5–7. +- Produces: +```go +func (g *Game) HoldPiece() []Event // HoldUsed, or nil when hold is unavailable +``` + +Hold rules (§9): `HoldPiece` swaps the active piece with the held kind, or stores the active kind and spawns the next when hold is empty. The incoming piece arrives at spawn rotation and spawn position. Hold is available once per piece: `CanHold` goes false on use and true on lock. If the incoming piece collides at spawn, the game is over (`Over = true`, `GameOver` event) and the board is left untouched. + +Spawn blocking (§12, §28): when `spawn()` produces a piece that collides, set `Over` and emit `GameOver`. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestFirstHoldStoresAndSpawnsNext(t *testing.T) +// kind := g.Active.Kind; next := g.Next[0]; evs := g.HoldPiece() +// evs[0].Kind == HoldUsed; *g.Hold == kind; g.Active.Kind == next; +// g.Active == SpawnPiece(next); g.CanHold == false + +func TestSecondHoldIsBlocked(t *testing.T) +// after one HoldPiece, a second returns nil and changes nothing + +func TestHoldSwapsAndKeepsSpawnRotation(t *testing.T) +// hold once, lock the piece, rotate the new active piece twice, hold again +// => the piece coming out of hold has Rotation 0 and SpawnPiece coordinates + +func TestHoldAvailableAgainAfterLock(t *testing.T) +// HoldPiece, HardDrop, then CanHold == true + +func TestBlockedSpawnEndsTheGame(t *testing.T) +// fill rows 0..3 completely via Board.Set, HardDrop the active piece +// => a GameOver event, g.Over == true, and Advance(1*time.Second) afterwards returns nil + +func TestHoldIntoBlockedSpawnEndsTheGameCleanly(t *testing.T) +// fill the hidden rows so any spawn collides, then HoldPiece +// => GameOver event, g.Over == true, board string unchanged from before the call +``` + +```go +func TestReplayIsReproducible(t *testing.T) +// script: a fixed []struct{ key string; dt time.Duration } of ~200 entries covering +// left/right/rotate/soft/hard/hold and varied dt (7ms, 16ms, 250ms, 900ms). +// Run it twice against New(8675309) and assert equal Score, Lines, Level, Combo, +// Over, Next, *Hold and Board.String(). + +func TestDifferentSeedsDiverge(t *testing.T) +// the same script against New(1) and New(2) produces different Board.String() + +func TestEngineNeverReadsTheClock(t *testing.T) +// walk internal/game/*.go (excluding _test.go) and fail if any file contains "time.Now(" +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/game/ -run 'TestHold|TestBlocked|TestReplay|TestDifferentSeeds|TestEngineNever' -v` +Expected: FAIL — undefined `HoldPiece`. + +- [ ] **Step 3: Implement `HoldPiece` and the game-over path in `spawn()`** + +- [ ] **Step 4: Run the whole suite** + +Run: `go test ./... -v && go vet ./...` +Expected: PASS, no vet findings. + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/game.go internal/game/hold_test.go internal/game/determinism_test.go +git commit -m "feat(game): hold, game over and deterministic replay" +``` + +--- + +### Task 9: Engine acceptance sweep + +**Files:** +- Test: `internal/game/acceptance_test.go` + +**Interfaces:** +- Consumes: the whole package. +- Produces: nothing new — this task is the Phase 1 gate from §42 ("tests must pass before proceeding"). + +- [ ] **Step 1: Write the test** + +```go +func TestLongRandomSessionStaysConsistent(t *testing.T) +// For seeds 1..20: drive 5000 pseudo-random inputs (from a separate local rand, so the +// game RNG is untouched) with 16ms Advance steps between them. After every step assert: +// - every cell of g.Active.Cells() is in bounds +// - g.Board has no complete row left uncleared +// - len(g.Next) == NextCount +// - g.Score, g.Lines, g.Level, g.Combo are all >= 0 and Level == LevelFor(g.Lines) +// - once g.Over is true, no further events are produced +// The loop must finish (games end) for every seed. +``` + +- [ ] **Step 2: Run it** + +Run: `go test ./internal/game/ -run TestLongRandomSession -v` +Expected: PASS (fix any invariant violation it surfaces in the owning file) + +- [ ] **Step 3: Commit** + +```bash +git add internal/game/acceptance_test.go +git commit -m "test(game): long random session invariants" +``` diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/C3-planset-cosmic-91/2026-09-18-cosmic-tetris-2-playable-terminal.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/C3-planset-cosmic-91/2026-09-18-cosmic-tetris-2-playable-terminal.md new file mode 100644 index 000000000..6d3c9c53e --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/C3-planset-cosmic-91/2026-09-18-cosmic-tetris-2-playable-terminal.md @@ -0,0 +1,787 @@ +# Cosmic Tetris — Plan 2: Playable Terminal + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Turn the Plan 1 engine into a genuinely good, fully playable terminal game: Bubble Tea app loop, cell-accurate canvas renderer, responsive layout, board/ghost/hold/next/HUD, pause, help, game-over card, CLI flags, and ANSI-stripped golden tests. + +**Architecture:** `internal/render` owns a `Canvas` of styled cells; every visual element draws into the canvas and `Canvas.String()` emits one styled string. `render.Render(Snapshot, Options)` is a pure function of a snapshot — it never mutates game state. `internal/app` is the Bubble Tea model: it owns the clock, turns key presses into engine calls immediately, and feeds elapsed time to `Game.Advance`. Render does not import app; app builds a `render.Snapshot`. + +**Tech Stack:** Go 1.26, `charm.land/bubbletea/v2` v2.0.9, `charm.land/lipgloss/v2` v2.0.6, `charm.land/bubbles/v2` v2.2.1 (key, help), `github.com/charmbracelet/x/ansi` (test-side ANSI stripping). + +**Spec:** `design.md` (§4, §5, §8, §9, §10, §13 HUD, §26, §27 line slot, §28 final card, §30, §31, §32 modes, §33, §34, §36, §37, §39, §41, §42 Phase 2, §46, §49.3, §49.4, §49.7) + +## Global Constraints + +- Language: Go. Module `cosmic-tetris`, `go 1.26`. +- `charm.land/bubbletea/v2`, `charm.land/lipgloss/v2`, `charm.land/bubbles/v2` — do not wrap Bubble Tea in a homegrown framework (§3). +- Bubble Tea v2 API as installed (verified): `tea.Model` is `Init() tea.Cmd`, `Update(tea.Msg) (tea.Model, tea.Cmd)`, `View() tea.View`. Full-screen is `v := tea.NewView(s); v.AltScreen = true`. Key presses arrive as `tea.KeyPressMsg`. Terminal size is `tea.WindowSizeMsg{Width, Height}`. Color support arrives as `tea.ColorProfileMsg{colorprofile.Profile}`. `key.Matches` is generic over `fmt.Stringer` and accepts a `tea.KeyPressMsg`. Lip Gloss colors are `lipgloss.Color(string) color.Color`. +- Repository layout is fixed by §33; extra files inside the named packages are allowed where this plan names them. +- Logical board: width 10, height 22, visible 20, hidden 2. One logical cell renders as **2 terminal columns × 1 row** (§5). +- Glyphs (§49.4): blocks `██` (`[]` in ASCII), ghost `░░` (`··` in ASCII). Pieces use filled glyphs with a bright foreground — never a foreground+background pairing. The active piece renders one step brighter than locked cells. +- Minimum usable terminal 40×24; below that show the too-small notice (§31). Never crash on resize. +- Rendering must not mutate game state (§37). Animations must never block input (§44). +- The §4 mockup is mood, not geometry; the ANSI-stripped goldens are the binding layout contract (§49.7). +- CLI surface is exactly: no flags, `--seed N`, `--ascii`, `--no-fx`, `--reduced-motion`, `--help` (§49.5). + +## Review Focus + +1. Terminal reported as 0×0, 1×1, or any size below 40×24 — render the notice, never panic or divide by zero (Task 2, Task 6). +2. Key presses arriving before the first `WindowSizeMsg`, i.e. while width and height are still 0 — the model must absorb them without panicking (Task 6). +3. A terminal far larger than any expected size (300×100) — regions center and still never overlap (Task 2). +4. A score wider than its HUD field (8+ digits) and a level past 99 — the HUD grows or truncates without pushing the board out of place (Task 5). +5. A resize that lands between `Update` and `View` — `View` must render only from the size stored on the model, so the frame is internally consistent (Task 6). + +## Plan Set + +Run in this order. A ruling that changes a name, signature, or value a later plan consumes is applied to that plan's file before the next task starts. + +1. `plans/2026-09-18-cosmic-tetris-1-engine.md` — headless deterministic engine in `internal/game`. Consumes: nothing. +2. `plans/2026-09-18-cosmic-tetris-2-playable-terminal.md` — Bubble Tea app, canvas renderer, layout, HUD, hold/next/ghost, CLI flags, pause/help/game-over card, golden tests. Consumes Plan 1's `game.Game`, `Advance`, `Event`, `Board`, `Piece`, `GhostY`. +3. `plans/2026-09-18-cosmic-tetris-3-cosmic-foundation.md` — `internal/fx` (particles, starfield), animated border, piece trails, `internal/flavor` mission control. Consumes Plan 1's `Event`/`Cell` and Plan 2's `render.Canvas`, `render.Layout`, `render.Snapshot`, `app.Model`. +4. `plans/2026-09-18-cosmic-tetris-4-violence.md` — hard-drop impact, screen shake, line supernova, shockwaves, hyperdrive, four-line sequence, combo/level overlays. Consumes Plan 3's `fx.World` and the render FX layer. +5. `plans/2026-09-18-cosmic-tetris-5-polish.md` — boot sequence, game-over black hole, ASCII/no-FX guarantees, §45 details, README, definition-of-done sweep. Consumes everything above. + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `internal/render/canvas.go` | styled cell grid, clipping writes, run-length styled output | +| `internal/render/layout.go` | `Rect`, `Layout`, `Compute(w,h)` responsive geometry and drop order | +| `internal/render/palette.go` | `Mode`, `Options`, glyphs, piece/HUD/border colors | +| `internal/render/board.go` | locked cells, ghost, active piece, board border | +| `internal/render/hud.go` | hold, next queue, stats, mission line, controls line, frame | +| `internal/render/overlay.go` | centered cards: pause, help, game over, too-small notice | +| `internal/render/render.go` | `Phase`, `Snapshot`, `Render` — the §37 pipeline | +| `internal/render/testdata/*.txt` | ANSI-stripped goldens | +| `internal/app/keys.go` | `KeyMap` (bubbles/key) and help groupings | +| `internal/app/messages.go` | `FrameMsg`, frame command | +| `internal/app/options.go` | CLI flag parsing, usage text | +| `internal/app/model.go` | `Model`, `New`, `Init`, `View` | +| `internal/app/update.go` | `Update`: resize, color profile, keys, frame clock | +| `cmd/cosmic-tetris/main.go` | flags → `app.New` → `tea.NewProgram(...).Run()` | + +`canvas.go` and `overlay.go` are additions to §33's file list. They are load-bearing: compositing FX over the board (Plans 3–4) and shifting the board by one cell for screen shake are only sane against a cell grid, and the four overlay cards share one centering routine. + +--- + +### Task 1: Canvas — the styled cell grid + +**Files:** +- Create: `internal/render/canvas.go` +- Test: `internal/render/canvas_test.go` + +**Interfaces:** +- Consumes: nothing. +- Produces: +```go +type Paint struct { + FG, BG color.Color // nil means "terminal default" + Bold, Faint bool +} + +type Canvas struct{ /* unexported: w, h int; runes []rune; paints []Paint */ } +func NewCanvas(w, h int) *Canvas +func (c *Canvas) Size() (w, h int) +func (c *Canvas) Set(x, y int, r rune, p Paint) // no-op when out of bounds +func (c *Canvas) SetString(x, y int, s string, p Paint) int // returns columns written +func (c *Canvas) Fill(r Rect, ch rune, p Paint) +func (c *Canvas) Blit(src *Canvas, dx, dy int) // clipped; skips src cells that are unset +func (c *Canvas) String() string +func (c *Canvas) Plain() string // unstyled, for tests +``` + +Unset cells hold rune 0 and render as a space; `Blit` treats them as transparent. `String()` walks each row, groups runs of cells with an equal `Paint`, renders each run through one `lipgloss.NewStyle()`, joins rows with `"\n"`, and trims trailing whitespace on each row. `SetString` writes one rune per column (callers pass `██` as two runes, which is what makes a logical cell 2 columns wide). + +- [ ] **Step 1: Write the failing tests** + +```go +func TestCanvasPlainRendersGrid(t *testing.T) +// c := NewCanvas(4,2); c.Set(0,0,'A',Paint{}); c.Set(3,1,'B',Paint{}) +// c.Plain() == "A\n B" (trailing spaces trimmed) + +func TestZeroSizedCanvasIsEmptyAndSafe(t *testing.T) +// NewCanvas(0,0).Plain() == ""; Set(0,0,'x',Paint{}) does not panic +// NewCanvas(-5,-5) behaves as 0x0 + +func TestWritesOutsideBoundsAreClipped(t *testing.T) +// c := NewCanvas(3,1); c.SetString(-2,0,"abcde",Paint{}) leaves "cde"; +// c.SetString(2,0,"xy",Paint{}) leaves "abx" untouched beyond the edge; no panic + +func TestBlockGlyphOccupiesTwoColumns(t *testing.T) +// c := NewCanvas(4,1); c.SetString(0,0,"██",Paint{}) returns 2 and Plain() == "██" + +func TestBlitIsTransparentForUnsetCells(t *testing.T) +// dst filled with '.', src 2x2 with only (1,1) set to '#': Blit(src,1,0) +// => only one '.' replaced, at (2,1) + +func TestStringCarriesStyleAndPlainStripsIt(t *testing.T) +// c.Set(0,0,'X',Paint{FG: lipgloss.Color("#ff00ff")}) +// ansi.Strip(c.String()) == c.Plain() and c.String() != c.Plain() +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/render/ -v` +Expected: FAIL — undefined `NewCanvas`. + +- [ ] **Step 3: Add the dependencies and implement `internal/render/canvas.go`** + +```bash +go get charm.land/lipgloss/v2@v2.0.6 github.com/charmbracelet/x/ansi@v0.11.8 +``` + +`Rect` is defined in Task 2; for this task declare `Fill` against it after Task 2 lands, or define `Rect` here and leave layout to Task 2 — implement `Rect` in `layout.go` and have Task 1 `Fill` take `Rect` once Task 2 exists. To keep Task 1 self-contained, put the `Rect` type in `canvas.go`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add go.mod go.sum internal/render/canvas.go internal/render/canvas_test.go +git commit -m "feat(render): styled cell canvas with clipping and transparent blit" +``` + +--- + +### Task 2: Responsive layout + +**Files:** +- Create: `internal/render/layout.go` +- Test: `internal/render/layout_test.go` + +**Interfaces:** +- Consumes: `Rect` (Task 1). +- Produces: +```go +const ( + MinWidth = 40 + MinHeight = 24 + BoardCols = game.Width * 2 // 20 + BoardRows = game.VisibleRows // 20 +) + +type Tier int +const (TierSmall Tier = iota; TierMedium; TierWide) + +type Layout struct { + Screen Rect + Tier Tier + TooSmall bool + + Frame Rect // full-screen title border; Empty when dropped + Border Rect // board border box: BoardCols+2 × BoardRows+2 + Board Rect // board interior: BoardCols × BoardRows + Hold Rect + Next Rect + Stats Rect + Mission Rect // Empty when dropped + Controls Rect + + NextCount int // 5 or 3 + ShowStatLabels bool +} + +func (r Rect) Empty() bool +func (r Rect) Intersects(o Rect) bool +func (r Rect) Contains(o Rect) bool +func Compute(w, h int) Layout +``` + +Rules (fixed): + +| Condition | Result | +|---|---| +| `w < MinWidth \|\| h < MinHeight` | `TooSmall: true`, only `Screen` set | +| `w >= 64 && h >= 28` | `TierWide`: HOLD + SCORE/LINES/LEVEL column left of the board, NEXT column right | +| `w >= 50` | `TierMedium`: one compact HUD column right of the board (HOLD, NEXT, stats stacked) | +| otherwise | `TierSmall`: one narrow HUD column right of the board | +| `h >= 28` | `Frame` set (full-screen title border) | +| `h >= 25` | `Mission` set (one row above `Controls`) | +| `h >= 26` | `ShowStatLabels: true` | +| `w >= 50 && h >= 26` | `NextCount: 5`, else `3` | + +`Controls` is always one row, the last row of the screen. The board box is horizontally centered in the space left after the HUD columns and vertically centered in the space left after frame/mission/controls. NEXT never stacks above or below the board (§49.3). + +- [ ] **Step 1: Write the failing tests** + +```go +func TestWideLayoutHasEveryRegion(t *testing.T) +// l := Compute(100,40): Tier==TierWide, !TooSmall, NextCount==5, ShowStatLabels, +// none of Frame/Border/Board/Hold/Next/Stats/Mission/Controls is Empty, +// Board.W==BoardCols, Board.H==BoardRows + +func TestMinimumLayoutDropsChromeInOrder(t *testing.T) +// l := Compute(40,24): !TooSmall, Tier==TierSmall, Frame.Empty(), Mission.Empty(), +// !ShowStatLabels, NextCount==3, Board.W==BoardCols, Board.H==BoardRows, +// Controls.Y == 23, l.Screen.Contains(l.Border) + +func TestMissionSurvivesAtTwentyFiveRows(t *testing.T) +// Compute(60,25): !Mission.Empty(), Frame.Empty(), !ShowStatLabels + +func TestBelowMinimumIsTooSmall(t *testing.T) +// Compute(39,24), Compute(40,23), Compute(0,0), Compute(1,1) all TooSmall + +func TestNoRegionsEverOverlap(t *testing.T) +// for w in 40..140 step 1, h in 24..60 step 1: every pair of non-empty regions +// (Border, Hold, Next, Stats, Mission, Controls) has !Intersects, and +// Screen.Contains each of them + +func TestHugeTerminalCentersTheBoard(t *testing.T) +// Compute(300,100): Board.X > 100 and Screen.Contains(Border) and Tier==TierWide +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/render/ -run 'TestWideLayout|TestMinimum|TestMission|TestBelow|TestNoRegions|TestHuge' -v` +Expected: FAIL — undefined `Compute`. + +- [ ] **Step 3: Implement `internal/render/layout.go`** + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/layout.go internal/render/layout_test.go +git commit -m "feat(render): responsive layout with pinned drop order" +``` + +--- + +### Task 3: Palette, modes and glyphs + +**Files:** +- Create: `internal/render/palette.go` +- Test: `internal/render/palette_test.go` + +**Interfaces:** +- Consumes: `game.PieceKind`, `Paint` (Task 1). +- Produces: +```go +type Mode int +const (ModeFull Mode = iota; ModeReduced; ModeASCII) +func ModeFor(p colorprofile.Profile, asciiFlag bool) Mode + +type Options struct { + Mode Mode + FXEnabled bool + ReducedMotion bool +} + +func BlockGlyph(m Mode) string // "██" / "██" / "[]" +func GhostGlyph(m Mode) string // "░░" / "░░" / "··" +func BoxChars(m Mode) BoxSet // ╔ ═ ╗ ║ ╚ ╝ in Unicode modes; + - | in ASCII +type BoxSet struct{ TL, T, TR, V, BL, BR rune } + +func PiecePaint(k game.PieceKind, active bool, m Mode) Paint +func GhostPaint(m Mode) Paint +func HUDPaint(m Mode) Paint +func LabelPaint(m Mode) Paint +func AccentPaint(m Mode) Paint +var BorderPalette = []string{"#7A3CFF", "#22E1FF", "#FF37E0", "#3B6BFF", "#FFFFFF"} // §25 +``` + +Piece hues (§26), used as the locked-cell foreground; active cells use a lighter step of the same hue: + +```text +I plasma cyan #22E1FF J deep electric blue #3B6BFF +L solar orange #FF8A2B O stellar gold #FFD23F +S alien green #49F27A T ultraviolet #9D4EFF +Z supernova red #FF3B6B +``` + +`ModeFull` uses these hex values directly; `ModeReduced` and `ModeASCII` round them through `colorprofile.Profile.Convert`. Only the `--ascii` flag selects `ModeASCII` (§32). + +- [ ] **Step 1: Write the failing tests** + +```go +func TestModeForProfile(t *testing.T) +// ModeFor(colorprofile.TrueColor,false)==ModeFull; ANSI256 and ANSI => ModeReduced; +// NoTTY/Ascii => ModeASCII; asciiFlag true forces ModeASCII from any profile + +func TestGlyphWidths(t *testing.T) +// for every mode: ansi.StringWidth(BlockGlyph(m))==2 and ansi.StringWidth(GhostGlyph(m))==2 + +func TestGhostGlyphsArePinned(t *testing.T) +// BlockGlyph(ModeFull)=="██"; BlockGlyph(ModeASCII)=="[]" +// GhostGlyph(ModeFull)=="░░"; GhostGlyph(ModeReduced)=="░░"; GhostGlyph(ModeASCII)=="··" + +func TestASCIIModeEmitsOnlyASCII(t *testing.T) +// every rune of BlockGlyph/GhostGlyph(ModeASCII) and every field of BoxChars(ModeASCII) is < 128 + +func TestActivePieceIsBrighterThanLocked(t *testing.T) +// for all 7 kinds: PiecePaint(k,true,ModeFull).FG != PiecePaint(k,false,ModeFull).FG +// and the active colour has a strictly higher luminance + +func TestEveryKindHasADistinctColour(t *testing.T) +// the 7 locked FG values are pairwise different +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/render/ -run 'TestMode|TestGlyph|TestGhost|TestASCII|TestActive|TestEveryKind' -v` +Expected: FAIL — undefined `ModeFor`. + +- [ ] **Step 3: Implement `internal/render/palette.go`** + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/palette.go internal/render/palette_test.go +git commit -m "feat(render): neon space palette, render modes and glyph sets" +``` + +--- + +### Task 4: Board, ghost, active piece and border + +**Files:** +- Create: `internal/render/board.go` +- Test: `internal/render/board_test.go` + +**Interfaces:** +- Consumes: Tasks 1–3. +- Produces: +```go +func DrawBoardBorder(c *Canvas, l Layout, o Options, p Paint) +func DrawLocked(c *Canvas, l Layout, b *game.Board, o Options) +func DrawGhost(c *Canvas, l Layout, g *game.Game, o Options) +func DrawActive(c *Canvas, l Layout, p game.Piece, o Options) +func CellOrigin(l Layout, x, y int) (col, row int) // logical cell -> canvas cell; y is a board row +``` + +`CellOrigin` maps logical `(x, y)` to `(l.Board.X + x*2, l.Board.Y + y - game.HiddenRows)`. Rows above `game.HiddenRows` are not drawn — a piece straddling the hidden rows shows only its visible part. Draw order is locked → ghost → active (§37), and the ghost writes only into cells that are empty on the board (§10). + +- [ ] **Step 1: Write the failing tests** + +```go +func TestEmptyBoardDrawsNothing(t *testing.T) +// canvas sized to a wide layout, DrawLocked with an empty board => Plain() is all blank + +func TestLockedCellLandsAtTheRightColumns(t *testing.T) +// board.Set(0,21,CellFor(KindT)) and board.Set(9,21,CellFor(KindI)); +// the last board row of Plain() has BlockGlyph at columns Board.X..Board.X+1 +// and Board.X+18..Board.X+19 and blanks between + +func TestGhostSitsAtTheLandingRow(t *testing.T) +// empty board, active I at spawn: DrawGhost puts GhostGlyph on the row +// CellOrigin(l,0,g.GhostY()+1).row (the piece's own cell row), not on row 0 + +func TestGhostNeverOverwritesLockedCells(t *testing.T) +// fill row 21 with locked cells, ghost resting on row 20: +// row 21 of Plain() contains only block glyphs, no ghost glyph + +func TestActivePieceDrawsOverGhost(t *testing.T) +// draw ghost then active for a piece one row above its landing spot: +// the active piece's rows hold block glyphs, not ghost glyphs + +func TestHiddenRowsAreNotRendered(t *testing.T) +// a piece at Y=0 (entirely in hidden rows) draws nothing + +func TestBorderBoxMatchesTheLayout(t *testing.T) +// DrawBoardBorder: Plain() row l.Border.Y starts with BoxChars.TL at l.Border.X and +// ends with TR at l.Border.X+l.Border.W-1; every interior row has V at both edges +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/render/ -run 'TestEmptyBoard|TestLockedCell|TestGhost|TestActive|TestHidden|TestBorderBox' -v` +Expected: FAIL — undefined `DrawLocked`. + +- [ ] **Step 3: Implement `internal/render/board.go`** + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/board.go internal/render/board_test.go +git commit -m "feat(render): board, ghost, active piece and machinery border" +``` + +--- + +### Task 5: HUD, render pipeline and goldens + +**Files:** +- Create: `internal/render/hud.go`, `internal/render/render.go`, `internal/render/golden_test.go`, `internal/render/testdata/` +- Test: `internal/render/hud_test.go`, `internal/render/render_test.go` + +**Interfaces:** +- Consumes: Tasks 1–4. +- Produces: +```go +type Phase int +const (PhaseBoot Phase = iota; PhasePlaying; PhasePaused; PhaseGameOver) + +type Snapshot struct { + Game *game.Game + Width int + Height int + Phase Phase + ShowHelp bool + Mission string // already-formatted mission text, "" hides the channel + Elapsed time.Duration // total elapsed play time, for animated chrome + Seed int64 +} + +func Render(s Snapshot, o Options) string + +// hud.go +func DrawFrame(c *Canvas, l Layout, o Options, seed int64) // title bar: "✦ COSMIC TETRIS" + "LOCAL UNIVERSE 7F3A" +func DrawHold(c *Canvas, l Layout, hold *game.PieceKind, o Options) +func DrawNext(c *Canvas, l Layout, next []game.PieceKind, o Options) +func DrawStats(c *Canvas, l Layout, g *game.Game, o Options) +func DrawMission(c *Canvas, l Layout, text string, o Options) +func DrawControls(c *Canvas, l Layout, o Options) +func UniverseLabel(seed int64) string // fmt.Sprintf("%04X", uint16(seed)) +``` + +`Render` builds a canvas of `s.Width × s.Height` and follows §37's order, skipping the FX steps (Plans 3–4 add them). When `Compute(w,h).TooSmall`, it renders only the §31 notice with the live `current:` and `needed:` lines. Stats are drawn as label+value when `ShowStatLabels`, value-only otherwise (§49.3). Score renders zero-padded to 8 digits and is left-truncated to the region width if it overflows; level and lines render zero-padded to 2 and 3 digits and grow when they exceed it. + +Golden helper (in `golden_test.go`): +```go +var update = flag.Bool("update", false, "rewrite golden files") +func assertGolden(t *testing.T, name, got string) // compares ansi.Strip(got) to testdata/.txt +func fixtureGame(t *testing.T) *game.Game // game.New(8675309) driven by one canned script +``` +`fixtureGame` is shared by every golden in this plan and Plans 3–5, so its script is fixed once here: 12 scripted placements producing a non-trivial stack, one single-line clear, score > 0, level 1. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestStatsShowValuesWithoutLabelsAtSmallSize(t *testing.T) +// l := Compute(40,24); DrawStats => Plain() contains the zero-padded score +// and does not contain "LINES" or "LEVEL" + +func TestStatsShowLabelsWhenThereIsRoom(t *testing.T) +// l := Compute(100,40) => Plain() contains "SCORE", "LINES", "LEVEL" + +func TestNextRendersTheRequestedCount(t *testing.T) +// wide layout: DrawNext with 5 kinds draws 5 previews (count non-blank preview groups); +// small layout: only l.NextCount == 3 previews are drawn and nothing spills outside l.Next + +func TestHoldEmptyDrawsOnlyTheLabel(t *testing.T) +// hold == nil => no block glyphs inside l.Hold + +func TestHugeScoreDoesNotDisturbTheBoard(t *testing.T) +// g.Score = 1234567890; Render at 100x40; the 20 board rows of the output are +// byte-identical to the same render with Score = 0 (board untouched) + +func TestRenderIsPureAndDeterministic(t *testing.T) +// before := g.Board.String() + fmt.Sprint(g.Score, g.Lines, g.Level, g.Combo, g.Active) +// Render(...) twice => identical strings; the `before` fingerprint is unchanged + +func TestEveryRenderedLineFitsTheTerminal(t *testing.T) +// for w in 40..120 step 7, h in 24..48 step 5: every line of ansi.Strip(Render(...)) +// has ansi.StringWidth <= w and the line count is <= h + +func TestTooSmallNotice(t *testing.T) +// Render at 34x19 contains "THIS UNIVERSE IS TOO SMALL", "current: 34 × 19", +// "needed: approximately 40 × 24" +``` + +```go +func TestGoldenWideLayout(t *testing.T) // 100x40, ModeFull, FX off +func TestGoldenMediumLayout(t *testing.T) // 72x30 +func TestGoldenSmallLayout(t *testing.T) // 40x24 +func TestGoldenTooSmall(t *testing.T) // 34x19 +// each: assertGolden(t, "", Render(snapshotFrom(fixtureGame(t), w, h), Options{Mode: ModeFull})) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/render/ -run 'TestStats|TestNext|TestHold|TestHugeScore|TestRenderIs|TestEveryRendered|TestTooSmall|TestGolden' -v` +Expected: FAIL — undefined `Render`. + +- [ ] **Step 3: Implement `hud.go` and `render.go`, then create the goldens** + +Run `go test ./internal/render/ -run TestGolden -update` once, then read each `testdata/*.txt` and confirm by eye that nothing overlaps, the board is 20×20, and the HUD reads cleanly. Fix the layout rather than the golden if it looks wrong. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/hud.go internal/render/render.go internal/render/golden_test.go internal/render/hud_test.go internal/render/render_test.go internal/render/testdata +git commit -m "feat(render): HUD, render pipeline and layout goldens" +``` + +--- + +### Task 6: Bubble Tea app, keys, CLI and a runnable binary + +**Files:** +- Create: `internal/app/keys.go`, `internal/app/messages.go`, `internal/app/options.go`, `internal/app/model.go`, `internal/app/update.go`, `cmd/cosmic-tetris/main.go` +- Test: `internal/app/options_test.go`, `internal/app/update_test.go` + +**Interfaces:** +- Consumes: Plan 1's `game` package; Task 5's `render.Render`, `render.Snapshot`, `render.Phase`, `render.Options`, `render.ModeFor`. +- Produces: +```go +// options.go +type Options struct { + Seed int64 + SeedSet bool + ASCII bool + NoFX bool + ReducedMotion bool +} +var ErrHelpRequested = errors.New("help requested") +func ParseFlags(args []string) (Options, error) // args excludes argv[0] +func Usage() string + +// keys.go +type KeyMap struct { + Left, Right, SoftDrop, RotateCW, RotateCCW, HardDrop, + Hold, Pause, Restart, Help, Quit key.Binding +} +func DefaultKeyMap() KeyMap +func (k KeyMap) ShortHelp() []key.Binding +func (k KeyMap) FullHelp() [][]key.Binding + +// messages.go +type FrameMsg struct{ Now time.Time } +const FrameInterval = 16 * time.Millisecond // ~60Hz (§36) +const MaxFrameDelta = 250 * time.Millisecond // clamp after a suspend +func FrameCmd() tea.Cmd // tea.Tick(FrameInterval, ...) + +// model.go +type Model struct { + Game *game.Game + Opts Options + Render render.Options + Width, Height int + Phase render.Phase + ShowHelp bool + Keys KeyMap + Help help.Model + LastFrame time.Time + Elapsed time.Duration + Seed int64 +} +func New(o Options) *Model +func (m *Model) Init() tea.Cmd +func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) +func (m *Model) View() tea.View +func (m *Model) Snapshot() render.Snapshot +``` + +Bindings (§8): left `left`/`h`/`a`; right `right`/`l`/`d`; soft drop `down`/`j`/`s`; rotate CW `up`/`k`/`x`/`w`; rotate CCW `z`; hard drop `space` ("YEET"); hold `c`; pause `p`; restart `r`; help `?`; quit `q`/`esc`. Help strings use the §39 copy. + +`Update` rules: +- `tea.WindowSizeMsg` → store `Width`/`Height` only. +- `tea.ColorProfileMsg` → `m.Render.Mode = render.ModeFor(msg.Profile, m.Opts.ASCII)`. +- `tea.KeyPressMsg` → act immediately, never wait for a tick (§8, §36). Quit returns `tea.Quit`; help toggles `ShowHelp`; pause toggles `PhasePlaying`/`PhasePaused`; restart rebuilds `m.Game = game.New(m.Seed)` and resets `Phase`. Movement keys are ignored unless `Phase == PhasePlaying`. +- `FrameMsg` → `dt := min(msg.Now.Sub(m.LastFrame), MaxFrameDelta)` (0 when `LastFrame` is zero), store `LastFrame`, then: when playing, `m.Elapsed += dt` and `m.Game.Advance(dt)`; when paused, `m.Elapsed += dt / 10` and no `Advance` (§30). If `m.Game.Over`, set `Phase = PhaseGameOver`. Always return `FrameCmd()`. +- `View()` returns `v := tea.NewView(render.Render(m.Snapshot(), m.Render)); v.AltScreen = true`. + +Repeated movement while a key is held (§8) comes from terminal auto-repeat: each repeat is another `tea.KeyPressMsg`, handled immediately. No DAS timer. + +`main.go` parses flags, prints `Usage()` and exits 0 on `ErrHelpRequested`, prints the error and exits 2 on any other parse error, seeds from `time.Now().UnixNano()` when `!SeedSet`, and runs the program. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestParseFlags(t *testing.T) +// {} => zero Options, SeedSet false +// {"--seed","1234"} => Seed 1234, SeedSet true +// {"--seed","0"} => Seed 0, SeedSet true (0 is a real seed) +// {"--seed","-7"} => Seed -7, SeedSet true +// {"--ascii"} => ASCII; {"--no-fx"} => NoFX; {"--reduced-motion"} => ReducedMotion +// {"--seed","abc"} => error (not ErrHelpRequested) +// {"--help"} => errors.Is(err, ErrHelpRequested) +// {"--wat"} => error +// Usage() mentions every one of the five flags + +func TestKeyBindingsCoverTheSpec(t *testing.T) +// DefaultKeyMap: for each of "left","h","a","right","l","d","down","j","s","up","k","x","w", +// "z"," ","c","p","r","?","q","esc" exactly one binding claims it + +func TestKeyPressMovesThePieceImmediately(t *testing.T) +// m := New(Options{Seed:1, SeedSet:true}); m.Width, m.Height = 100, 40 +// x := m.Game.Active.X; feed tea.KeyPressMsg for "left" => m.Game.Active.X == x-1 +// with no FrameMsg in between + +func TestKeysBeforeFirstWindowSizeDoNotPanic(t *testing.T) +// fresh model (Width==0, Height==0): feed "left","space","c","p","?" then call View() +// => no panic, View().Content is non-empty + +func TestResizeToDegenerateSizesDoesNotPanic(t *testing.T) +// feed WindowSizeMsg {0,0}, {1,1}, {39,23}, {200,60}, {34,19} in sequence, calling View() +// after each => no panic; at {34,19} the content contains "TOO SMALL" + +func TestFrameAdvancesGravity(t *testing.T) +// feed two FrameMsgs one second apart => Active.Y increased; m.Elapsed == 1s + +func TestFirstFrameHasZeroDelta(t *testing.T) +// a single FrameMsg on a fresh model leaves Active.Y unchanged + +func TestGiantFrameDeltaIsClamped(t *testing.T) +// two FrameMsgs one hour apart => m.Elapsed increased by MaxFrameDelta, not an hour + +func TestPauseFreezesTheGame(t *testing.T) +// press "p" => Phase == PhasePaused; two FrameMsgs one second apart leave +// Board.String(), Score and Active unchanged; m.Elapsed grew by 100ms (dt/10) +// press "p" again => PhasePlaying + +func TestRestartRebuildsTheSameSeed(t *testing.T) +// hard drop a few pieces, press "r" => Score==0, Lines==0, Phase==PhasePlaying, +// Board.String() is empty, and m.Game.Next equals a fresh game.New(seed).Next + +func TestQuitReturnsTeaQuit(t *testing.T) +// pressing "q" returns a cmd whose result is a tea.QuitMsg; same for "esc" + +func TestGameOverPhaseIsEntered(t *testing.T) +// fill the board via m.Game.Board.Set, hard drop => after the next FrameMsg, +// Phase == PhaseGameOver and further movement keys change nothing +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/app/ -v` +Expected: FAIL — package does not exist. + +- [ ] **Step 3: Add the dependencies and implement the app package plus `main.go`** + +```bash +go get charm.land/bubbletea/v2@v2.0.9 charm.land/bubbles/v2@v2.2.1 +``` + +- [ ] **Step 4: Run the tests and play the game** + +Run: `go test ./... -v && go vet ./... && go run ./cmd/cosmic-tetris --seed 1234` +Expected: tests PASS; the game is playable — pieces fall, move, rotate, hold, ghost shows, lines clear, resizing works, `p` pauses, `q` quits. + +- [ ] **Step 5: Commit** + +```bash +git add internal/app cmd/cosmic-tetris go.mod go.sum +git commit -m "feat(app): Bubble Tea loop, key map, CLI flags and playable binary" +``` + +--- + +### Task 7: Pause, help and game-over overlays + +**Files:** +- Create: `internal/render/overlay.go` +- Modify: `internal/render/render.go` (call overlays at the end of the pipeline), `internal/render/golden_test.go` (three more goldens) +- Test: `internal/render/overlay_test.go` + +**Interfaces:** +- Consumes: Tasks 1–5; `app.KeyMap` copy lives in app, so overlay help text is passed in as lines. +- Produces: +```go +func DrawCard(c *Canvas, l Layout, title string, lines []string, o Options) // centered box, clipped to Screen +func DrawPause(c *Canvas, l Layout, o Options) +func DrawHelp(c *Canvas, l Layout, lines []string, o Options) +func DrawGameOver(c *Canvas, l Layout, g *game.Game, o Options) +``` + +Copy is fixed by the spec: pause is `TEMPORAL SUSPENSION` / `SPACE IS PAUSED` / `p resume` (§30). Game over is `UNIVERSE EXPIRED`, `SCORE`, `LINES`, `LEVEL`, `r REBOOT UNIVERSE`, `q ACCEPT COSMIC DEATH`, subtitle `CAUSE: EXCESSIVE GEOMETRY` (§28). Help is `FLIGHT MANUAL` with the §39 rows; `Snapshot.HelpLines []string` carries them from `app` (built from `KeyMap.FullHelp()` via `bubbles/help`), so `render` owns no key vocabulary. Add `HelpLines []string` to `Snapshot`. + +Cards never draw outside `l.Screen` and shrink their box to fit when the terminal is small. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestPauseOverlayCopy(t *testing.T) +// Render with Phase PhasePaused contains "TEMPORAL SUSPENSION", "SPACE IS PAUSED", "p resume" + +func TestGameOverCardShowsFinalNumbers(t *testing.T) +// g.Score=483200; g.Lines=127; g.Level=13; Phase PhaseGameOver => +// output contains "UNIVERSE EXPIRED", "483,200", "127", "13", +// "r REBOOT UNIVERSE", "q ACCEPT COSMIC DEATH" + +func TestHelpOverlayListsEveryBinding(t *testing.T) +// ShowHelp true with the app-supplied lines => output contains "FLIGHT MANUAL" and +// one row per binding + +func TestOverlaysStayInsideTheScreen(t *testing.T) +// for each of pause/help/game over, at 40x24 and 100x40: every line of the output +// has width <= w and the line count <= h + +func TestOverlaysDoNotChangeTheBoardGeometry(t *testing.T) +// the board's 20 rows keep width BoardCols in every overlay state (cards may cover +// them, but no row grows or shrinks) + +func TestGoldenPause(t *testing.T) // 100x40 +func TestGoldenGameOver(t *testing.T) // 100x40 +func TestGoldenHelp(t *testing.T) // 100x40 +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/render/ -run 'TestPause|TestGameOver|TestHelp|TestOverlays|TestGolden' -v` +Expected: FAIL — undefined `DrawPause`. + +- [ ] **Step 3: Implement `overlay.go`, wire it into `Render`, wire `HelpLines` in `app`** + +In `app`, build `HelpLines` from `m.Help` + `m.Keys.FullHelp()` once per render. + +- [ ] **Step 4: Create the goldens and run everything** + +Run: `go test ./internal/render/ -run TestGolden -update && go test ./... -v` +Expected: PASS; inspect the three new `testdata` files by eye. + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/overlay.go internal/render/render.go internal/render/overlay_test.go internal/render/golden_test.go internal/render/testdata internal/app +git commit -m "feat(render): pause, help and game-over overlays with goldens" +``` + +--- + +### Task 8: Phase 2 acceptance + +**Files:** +- Test: `internal/app/acceptance_test.go` + +**Interfaces:** +- Consumes: the whole app. +- Produces: nothing new — this is §42's "at this point it should already be a genuinely good game" gate. + +- [ ] **Step 1: Write the test** + +```go +func TestFullSessionThroughGameOverNeverPanics(t *testing.T) +// m := New(Options{Seed: 42, SeedSet: true}); WindowSizeMsg{100,40}; ColorProfileMsg TrueColor. +// Loop up to 20000 iterations: every iteration feed one FrameMsg 16ms later and, every +// third iteration, one pseudo-random key from the full key set (excluding q/esc), then call +// View() and assert the content is non-empty. Every 500 iterations feed a WindowSizeMsg +// cycling through {100,40},{72,30},{40,24},{34,19},{0,0}. +// Assert the loop reaches Phase == PhaseGameOver, and that no iteration panicked. +``` + +- [ ] **Step 2: Run it** + +Run: `go test ./internal/app/ -run TestFullSession -v` +Expected: PASS + +- [ ] **Step 3: Commit** + +```bash +git add internal/app/acceptance_test.go +git commit -m "test(app): full session with resizes and random input" +``` diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/C3-planset-cosmic-91/2026-09-18-cosmic-tetris-3-cosmic-foundation.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/C3-planset-cosmic-91/2026-09-18-cosmic-tetris-3-cosmic-foundation.md new file mode 100644 index 000000000..065328e85 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/C3-planset-cosmic-91/2026-09-18-cosmic-tetris-3-cosmic-foundation.md @@ -0,0 +1,784 @@ +# Cosmic Tetris — Plan 3: Cosmic Foundation + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build `internal/fx` as an independent spectacle simulation and wire it into the renderer: particle physics, three-layer starfield, energy-reactive animated board border, piece ion trails, and the `internal/flavor` mission-control channel. + +**Architecture:** `GameState → events → FXWorld → Renderer`, one direction only. `fx` imports `game` for event and piece types and never imports `render` or `app`; `render` imports `fx` to draw it. `fx.World` holds its own `*rand.Rand`, seeded separately from the game, so particle randomness can never shift piece order. `Observe` takes events plus a read-only `GameView` value — the FX package never holds a `*game.Game`, which makes §14's "may never modify GameState" structural rather than a promise. + +**Tech Stack:** Go 1.26, `charm.land/lipgloss/v2`, `charm.land/bubbletea/v2`, standard library `math`/`math/rand`. + +**Spec:** `design.md` (§9 quantum storage, §14, §15, §17, §23, §25, §26, §27, §32 reduced modes, §33, §35, §37, §38, §42 Phase 3, §44, §49.6) + +## Global Constraints + +- Language: Go. Module `cosmic-tetris`, `go 1.26`. +- `internal/fx` must not import `internal/render`, `internal/app`, or `internal/flavor`; it may import `internal/game`. `internal/flavor` may import `internal/game` and `internal/fx`. +- FX may observe game events and never modify game state (§14, §44). +- Game RNG and FX RNG are separate `*rand.Rand` instances and never share (§35, §49.6). +- No goroutine per particle or per frame; no filesystem work during gameplay; no synchronous logging per frame (§38). +- A few hundred particles must be trivial; reuse slices (§38). Hard cap `MaxParticles = 400`. +- Effects never obscure the active piece, never delay gameplay, never alter it (§44). +- Board readability is sacred: the background must never make the board harder to read (§15, §21). +- ASCII mode emits only ASCII runes (§32). Every FX glyph needs an ASCII fallback. + +## Review Focus + +1. FX enabled versus disabled changing the game outcome — the same input script must produce an identical board, score and next queue either way (Task 2). +2. A resize while thousands of particle-seconds of state exist — off-screen stars and particles must be culled or re-seeded with no index panic (Task 3, Task 4). +3. An event burst in a single frame (lock + 4 lines + combo + level, ten events at once) — the mission line must show one message and hold it, not thrash (Task 7). +4. A long session (ten simulated minutes) — star, particle and trail slices must stay bounded (Task 1, Task 8). +5. Background versus foreground legibility — no star, particle or trail may replace a locked cell or an active-piece cell in the rendered output (Task 4, Task 6). + +## Plan Set + +Run in this order. A ruling that changes a name, signature, or value a later plan consumes is applied to that plan's file before the next task starts. + +1. `plans/2026-09-18-cosmic-tetris-1-engine.md` — headless deterministic engine in `internal/game`. Consumes: nothing. +2. `plans/2026-09-18-cosmic-tetris-2-playable-terminal.md` — Bubble Tea app, canvas renderer, layout, HUD, hold/next/ghost, CLI flags, pause/help/game-over card, golden tests. Consumes Plan 1's `game.Game`, `Advance`, `Event`, `Board`, `Piece`, `GhostY`. +3. `plans/2026-09-18-cosmic-tetris-3-cosmic-foundation.md` — `internal/fx` (particles, starfield), animated border, piece trails, `internal/flavor` mission control. Consumes Plan 1's `Event`/`Cell` and Plan 2's `render.Canvas`, `render.Layout`, `render.Snapshot`, `app.Model`. +4. `plans/2026-09-18-cosmic-tetris-4-violence.md` — hard-drop impact, screen shake, line supernova, shockwaves, hyperdrive, four-line sequence, combo/level overlays. Consumes Plan 3's `fx.World` and the render FX layer. +5. `plans/2026-09-18-cosmic-tetris-5-polish.md` — boot sequence, game-over black hole, ASCII/no-FX guarantees, §45 details, README, definition-of-done sweep. Consumes everything above. + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `internal/fx/particle.go` | `Particle`, the §23 integrator, spawn caps | +| `internal/fx/events.go` | `GameView`, event→FX reaction table, `Energy` | +| `internal/fx/world.go` | `World`: state, `Step`, `Observe`, `Resize`, FX RNG | +| `internal/fx/starfield.go` | `Star`, three layers, density, drift, wrap, level speed | +| `internal/fx/trail.go` | `Trail`: short-lived ion trails behind moving pieces; `Quantum`: the §9 hold effect | +| `internal/render/fx.go` | draw stars / particles / trails; `ASCIISafe` | +| `internal/render/border.go` | energy-reactive animated border colour | +| `internal/flavor/messages.go` | the message tables and event→category mapping | +| `internal/flavor/channel.go` | `Channel`: one line at a time, with hold and idle timers | + +`internal/fx/trail.go`, `internal/render/fx.go`, `internal/render/border.go` and `internal/flavor/channel.go` are additions to §33's file list, each a distinct responsibility inside a package §33 already names. + +--- + +### Task 1: Particle and the terminal-space integrator + +**Files:** +- Create: `internal/fx/particle.go` +- Test: `internal/fx/particle_test.go` + +**Interfaces:** +- Consumes: nothing. +- Produces: +```go +type Class int +const (ClassStar Class = iota; ClassDebris; ClassSpark; ClassEmber) + +type Particle struct { + X, Y float64 // canvas cell coordinates (column, row), fractional + VX, VY float64 // cells per second + AX, AY float64 // cells per second squared + Life float64 // seconds remaining + MaxLife float64 // seconds + Glyph rune + Brightness float64 // 0..1 + Class Class +} + +const ( + ParticleGravity = 14.0 // cells/s² downward, the default AY for debris + ParticleDrag = 0.05 // fraction of velocity surviving one second + MaxParticles = 400 +) + +func (p *Particle) Step(dt float64) +func (p Particle) Alive(w, h int) bool // Life > 0 and inside the viewport with a 2-cell margin +func (p Particle) Fade() float64 // Life / MaxLife, clamped 0..1 +``` + +Integration order is §23's, exactly: `position += velocity × dt`, then `velocity += acceleration × dt`, then `velocity *= pow(ParticleDrag, dt)`, then `life -= dt`. No collision detection. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestStepIntegratesPositionBeforeDrag(t *testing.T) +// p := Particle{VX:10, Life:2, MaxLife:2}; p.Step(1.0) +// => p.X == 10 (position used the pre-drag velocity) +// => p.VX == 10*ParticleDrag (0.5), within 1e-9 +// => p.Life == 1 + +func TestGravityAccelerates(t *testing.T) +// p := Particle{AY: ParticleGravity, Life:2, MaxLife:2}; p.Step(1.0) +// => p.VY == ParticleGravity*ParticleDrag, within 1e-9; p.Y == 0 + +func TestDeadWhenLifeRunsOut(t *testing.T) +// Life 0.5, Step(0.5) => !Alive(80,24) + +func TestDeadWhenOutsideViewport(t *testing.T) +// Particle{X:-5, Life:1, MaxLife:1}.Alive(80,24) == false +// Particle{X:200, ...}, Particle{Y:-5, ...}, Particle{Y:100, ...} all false +// Particle{X:0, Y:0, ...} true (edge cells count as inside) + +func TestFadeIsNormalised(t *testing.T) +// Life 1, MaxLife 2 => Fade() == 0.5; Life 3, MaxLife 2 => 1; Life -1 => 0 +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -v` +Expected: FAIL — package does not exist. + +- [ ] **Step 3: Implement `internal/fx/particle.go`** + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/fx/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/particle.go internal/fx/particle_test.go +git commit -m "feat(fx): terminal-space particle integrator" +``` + +--- + +### Task 2: FXWorld, event observation and engine isolation + +**Files:** +- Create: `internal/fx/world.go`, `internal/fx/events.go` +- Test: `internal/fx/world_test.go`, `internal/fx/isolation_test.go` + +**Interfaces:** +- Consumes: Task 1; `game.Event`, `game.EventKind`, `game.Piece`. +- Produces: +```go +type Options struct { + Enabled bool + ReducedMotion bool +} + +type GameView struct { + Level, Combo, Score, Lines int + Active game.Piece + Over bool +} +func ViewOf(g *game.Game) GameView // the only bridge; copies values, keeps no pointer + +type World struct { + Opts Options + W, H int + Stars []Star + Particles []Particle + Trails []Trail + Energy float64 // 0..1, the board border's "energy state" + Elapsed time.Duration + View GameView + // unexported: rng *rand.Rand +} + +func NewWorld(seed int64, w, h int, o Options) *World +func (w *World) Resize(width, height int) +func (w *World) Step(dt time.Duration) +func (w *World) Observe(evs []game.Event, v GameView) +func (w *World) Spawn(p Particle) // drops the oldest particle when at MaxParticles +func (w *World) Rand() *rand.Rand // FX RNG, for FX use only + +const ( + EnergyDecayPerSecond = 0.55 // multiplicative survival fraction per second +) +var EnergyBump = map[game.EventKind]float64{ // added on each event, Energy clamped to 1 + game.PieceLocked: 0.10, + game.PieceHardDropped: 0.25, + game.LinesCleared: 0.30, // × line count + game.LevelChanged: 0.50, + game.ComboChanged: 0.10, // × combo + game.HoldUsed: 0.08, +} +``` + +`Step` advances `Elapsed`, decays `Energy`, steps the starfield, steps and compacts particles in place (filter without reallocating), and ages trails. `Observe` records `v` into `w.View`, bumps `Energy`, and dispatches to per-event reactions (Task 6 adds trails; Plan 4 adds the rest). When `Opts.Enabled` is false, `Observe` returns immediately and `Step` only advances `Elapsed`. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestNewWorldSeedsStarsAndNothingElse(t *testing.T) +// w := NewWorld(7, 80, 24, Options{Enabled:true}) +// len(w.Stars) == StarCount(80,24); len(w.Particles) == 0; w.Energy == 0 + +func TestFXDisabledWorldStaysEmpty(t *testing.T) +// Options{Enabled:false}: len(Stars)==0; Observe(lots of events) leaves Particles empty +// and Energy at 0; Step(1s) still advances Elapsed + +func TestEnergyBumpsAndDecays(t *testing.T) +// Observe([]game.Event{{Kind: game.LinesCleared, Count: 2}}, GameView{Level:1}) +// => Energy == 0.60 (0.30 × 2), within 1e-9 +// Step(1s) => Energy == 0.60 * EnergyDecayPerSecond +// a 4-line clear at Energy 0.9 clamps to 1.0, never above + +func TestSpawnRespectsTheCap(t *testing.T) +// Spawn MaxParticles+50 particles => len(Particles) == MaxParticles and the +// most recently spawned particle is present + +func TestStepCullsDeadParticles(t *testing.T) +// spawn 10 with Life 0.1 and 10 with Life 5 => Step(200ms) leaves exactly 10 + +func TestResizeKeepsStarsInsideTheViewport(t *testing.T) +// w := NewWorld(7,120,40,...); Step a few seconds; w.Resize(40,24) +// => len(Stars)==StarCount(40,24) and every star has 0<=X<40, 0<=Y<24 +// Resize(0,0) then Step(1s) does not panic; Resize back to 80x24 re-seeds stars + +func TestLongSessionStaysBounded(t *testing.T) +// 10 simulated minutes at 16ms steps with an event every 10 frames: +// len(Particles) <= MaxParticles, len(Stars) == StarCount, len(Trails) <= 64 throughout +``` + +```go +func TestObserveNeverTouchesGameState(t *testing.T) +// g := game.New(99); before := fingerprint(g) (board string + score/lines/level/combo/active/next) +// evs := g.HardDrop(); w.Observe(evs, ViewOf(g)); w.Step(16ms) +// => fingerprint(g) equals the value taken right after HardDrop + +func TestFXDoesNotChangeTheGameOutcome(t *testing.T) +// run the same canned (key, dt) script twice through app-free helpers: +// once with a World{Enabled:true} observing every event, once with Enabled:false, +// both games seeded game.New(8675309) +// => identical Board.String(), Score, Lines, Level, Combo, Next + +func TestFXRNGIsIndependentOfTheGameRNG(t *testing.T) +// two Worlds with the same fx seed observing the event streams of two *different* +// game seeds produce the same number of Rand() draws for the same event sequence; +// and two Worlds with different fx seeds over the same event stream differ +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -v` +Expected: FAIL — undefined `NewWorld`. + +- [ ] **Step 3: Implement `world.go` and `events.go`** + +`StarCount` and `Star` land in Task 3; stub them minimally here (or land Task 3 first if the reviewer prefers) — the `world_test.go` assertions above reference `StarCount`, so implement `starfield.go`'s `StarCount`/`Star`/seed helper as part of this task and leave drift to Task 3. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/fx/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/world.go internal/fx/events.go internal/fx/world_test.go internal/fx/isolation_test.go +git commit -m "feat(fx): FX world, event observation, energy and engine isolation" +``` + +--- + +### Task 3: Three-layer starfield + +**Files:** +- Create: `internal/fx/starfield.go` (completing Task 2's stub) +- Test: `internal/fx/starfield_test.go` + +**Interfaces:** +- Consumes: Task 2's `World`. +- Produces: +```go +type Star struct { + X, Y float64 + Layer int // 0 far, 1 mid, 2 near + Glyph rune + Bright float64 // 0..1 +} + +var LayerSpeeds = [3]float64{0.8, 2.0, 5.0} // cells/s at level 1 +var LayerGlyphs = [3][]rune{{'.'}, {'·', '˚'}, {'✦', '✧', '*'}} // §15 +var LayerBright = [3]float64{0.30, 0.60, 1.0} +var LayerWeights = [3]float64{0.55, 0.30, 0.15} // share of the star budget + +func StarCount(w, h int) int // (w*h)/45, floored at 12, capped at 240 +func SpeedForLevel(level int) float64 // 1 + 0.06*(level-1), capped at 2.5 +func (w *World) StarSpeed() float64 // SpeedForLevel(w.View.Level); Plan 4 multiplies in hyperdrive +func (w *World) stepStars(dt float64) +func (w *World) seedStars() +``` + +Stars drift downward (§15). A star leaving the bottom wraps to `Y -= float64(h)` with a fresh random `X` and glyph, so the population is constant. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestStarCountScalesWithArea(t *testing.T) +// StarCount(80,24) == 42; StarCount(10,2) == 12 (floor); StarCount(400,100) == 240 (cap) + +func TestLayerDistribution(t *testing.T) +// a seeded 80x24 world has at least one star in each of the three layers, and +// layer 0 is the most populous + +func TestStarsDriftDownward(t *testing.T) +// record every star's Y, Step(500ms), assert every star moved down by +// LayerSpeeds[layer]*0.5*SpeedForLevel(1) or wrapped + +func TestStarsWrapInsteadOfDisappearing(t *testing.T) +// Step(60s) => len(Stars) unchanged and every star is inside the viewport + +func TestHigherLevelMovesStarsFaster(t *testing.T) +// SpeedForLevel(1) == 1.0; SpeedForLevel(7) == 1.36 within 1e-9; SpeedForLevel(40) == 2.5 +// a level-10 world displaces its stars strictly further than a level-1 world +// over the same 500ms, from the same fx seed + +func TestReducedMotionKeepsStarsDrifting(t *testing.T) +// Options{Enabled:true, ReducedMotion:true}: stars still move (only hyperdrive, +// shake and shockwaves are suppressed, §49.5) + +func TestStarGlyphsComeFromTheLayerTables(t *testing.T) +// every star's Glyph is in LayerGlyphs[star.Layer] +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -run TestStar -v` +Expected: FAIL — undefined `LayerSpeeds`. + +- [ ] **Step 3: Implement `internal/fx/starfield.go`** + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/fx/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/starfield.go internal/fx/starfield_test.go +git commit -m "feat(fx): three-layer starfield with level-scaled drift" +``` + +--- + +### Task 4: Render the starfield and particles + +**Files:** +- Create: `internal/render/fx.go` +- Modify: `internal/render/render.go` (add `FX *fx.World` to `Snapshot`, insert §37 steps 2, 6 and 9), `internal/render/board.go` (leave empty board cells unset so space shows through) +- Test: `internal/render/fx_test.go`, `internal/render/golden_test.go` + +**Interfaces:** +- Consumes: Plan 2's `Canvas`, `Layout`, `Options`, `Paint`; Tasks 1–3. +- Produces: +```go +func DrawStarfield(c *Canvas, l Layout, w *fx.World, o Options) +func DrawParticles(c *Canvas, l Layout, w *fx.World, o Options) +func ASCIISafe(r rune) rune +func StarPaint(s fx.Star, insideBoard bool, m Mode) Paint +func ParticlePaint(p fx.Particle, m Mode) Paint +``` + +Rules: +- Starfield draws first, across the whole canvas, into unset cells only — it never overwrites anything already drawn, and the board draws after it. +- Inside `l.Board`, only layers 0 and 1 are drawn and brightness is halved, so the board stays the most readable thing on screen (§15, §21). Stars inside the board align to even columns so they never split a 2-column cell. +- `DrawLocked` leaves empty board cells unset (previously blank), which is what lets space show through the playfield as in §4. +- Particles draw after the board (§37 step 6 board-local, step 9 global), also into unset cells only, except `ClassDebris`, which may overwrite empty board cells but never a cell holding a block or ghost glyph. +- `ASCIISafe` maps every non-ASCII glyph this project emits to an ASCII stand-in: `✦→* ✧→+ ˚→' ·→. ░→: ▒→; ▓→# █→# ○→o ◌→o ◯→O ●→@ ☄→> ✪→*`. `ModeASCII` routes every glyph through it. +- When `o.FXEnabled` is false or `s.FX` is nil, both draw functions return immediately. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestStarfieldDrawsIntoEmptySpace(t *testing.T) +// canvas at a wide layout, DrawStarfield with a seeded world +// => the number of non-blank cells is > 0 and <= len(w.Stars) + +func TestStarfieldNeverOverwritesLockedCells(t *testing.T) +// fill board rows 18..21, draw stars, then locked, then stars again in that order: +// every cell of the bottom four board rows holds a block glyph, never a star glyph + +func TestStarfieldNeverOverwritesTheActivePiece(t *testing.T) +// after the full pipeline, every cell of Active.Cells() renders BlockGlyph + +func TestStarsInsideTheBoardAreDimAndNear(t *testing.T) +// every star drawn within l.Board has Layer <= 1 and an even column offset from l.Board.X + +func TestParticlesDrawAfterTheBoard(t *testing.T) +// a ClassDebris particle placed over an empty board cell appears in the output; +// one placed over a locked cell does not + +func TestASCIISafeCoversEveryGlyphWeEmit(t *testing.T) +// for every rune in LayerGlyphs, the debris glyph set, the ghost/block glyphs and BoxChars: +// ASCIISafe(r) < 128 + +func TestFXDisabledRendersExactlyLikePlanTwo(t *testing.T) +// Render with Options{FXEnabled:false} and a non-nil World produces output identical +// to Render with FX nil — assert against testdata/wide.txt + +func TestRenderWithFXIsDeterministic(t *testing.T) +// same world (fx seed 4242, stepped 30 frames of 16ms) rendered twice => identical strings + +func TestGoldenWideWithStarfield(t *testing.T) +// 100x40, fx seed 4242, 30 frames stepped, ModeFull => testdata/wide-fx.txt +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/render/ -run 'TestStarfield|TestStars|TestParticles|TestASCII|TestFXDisabled|TestRenderWithFX' -v` +Expected: FAIL — undefined `DrawStarfield`. + +- [ ] **Step 3: Implement `internal/render/fx.go` and the `render.go` / `board.go` changes** + +- [ ] **Step 4: Create the new golden and run everything** + +Run: `go test ./internal/render/ -run TestGolden -update && go test ./... -v` +Expected: PASS. Read `testdata/wide-fx.txt` and confirm the board is still legible with stars behind it. + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/fx.go internal/render/render.go internal/render/board.go internal/render/fx_test.go internal/render/golden_test.go internal/render/testdata +git commit -m "feat(render): composite starfield and particles behind the board" +``` + +--- + +### Task 5: Animated board border + +**Files:** +- Create: `internal/render/border.go` +- Modify: `internal/render/board.go` (`DrawBoardBorder` gains `elapsed` and `energy` parameters and colours each border cell through `BorderPaintAt`, replacing Plan 2's single `Paint` argument; update its callers) +- Test: `internal/render/border_test.go` + +**Interfaces:** +- Consumes: Plan 2's `BorderPalette`, `BoxChars`, `Paint`. +- Produces: +```go +func BorderPaintAt(elapsed time.Duration, energy float64, i, n int, m Mode) Paint +func DrawBoardBorder(c *Canvas, l Layout, o Options, elapsed time.Duration, energy float64) +const BorderCyclePeriod = 12 * time.Second // calm palette drift (§25: "the shift should be subtle") +``` + +`BorderPaintAt` colours border cell `i` of `n`, walking the box clockwise from the top-left. Phase is `elapsed/BorderCyclePeriod + energy*elapsed/(1.5s)`, plus a per-cell offset of `energy × i/n` — so at rest the whole border is one slowly drifting colour, and at high energy a gradient visibly travels around it (§25). Colours are linear RGB interpolations between adjacent `BorderPalette` entries, parsed once at init. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestBorderColourDriftsOverTime(t *testing.T) +// BorderPaintAt(0,0,0,84,ModeFull).FG != BorderPaintAt(6*time.Second,0,0,84,ModeFull).FG + +func TestBorderIsUniformAtRest(t *testing.T) +// energy 0: BorderPaintAt(t,0,i,84,...) is the same colour for i = 0, 20, 40, 83 + +func TestHighEnergyMakesAGradient(t *testing.T) +// energy 1: at least 8 distinct colours appear across i = 0..83 + +func TestBorderCycleIsPeriodic(t *testing.T) +// BorderPaintAt(0,0,0,84,...) == BorderPaintAt(BorderCyclePeriod,0,0,84,...) + +func TestBorderColoursStayInThePalette(t *testing.T) +// every colour produced over a 12s sweep lies on a segment between two adjacent +// BorderPalette entries (each channel within [min,max] of that pair) + +func TestASCIIBorderUsesASCIIBoxChars(t *testing.T) +// DrawBoardBorder in ModeASCII: every rune of the border rows is < 128 +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/render/ -run TestBorder -v` +Expected: FAIL — undefined `BorderPaintAt`. + +- [ ] **Step 3: Implement `internal/render/border.go` and update `DrawBoardBorder` callers** + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS (regenerate goldens only if the border characters changed, never to hide a layout break) + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/border.go internal/render/board.go internal/render/border_test.go +git commit -m "feat(render): energy-reactive animated board border" +``` + +--- + +### Task 6: Piece ion trails and quantum storage + +**Files:** +- Create: `internal/fx/trail.go` +- Modify: `internal/fx/world.go` (`Observe` spawns trails and the hold streak, `Step` ages them), `internal/render/fx.go` (`DrawTrails`, `DrawQuantumStorage`), `internal/render/render.go` (pipeline step 6) +- Test: `internal/fx/trail_test.go`, `internal/fx/quantum_test.go`, `internal/render/trail_test.go` + +**Interfaces:** +- Consumes: Task 2's `World`; `game.Point`, `game.PieceKind`. +- Produces: +```go +type Trail struct { + Cells []game.Point // logical board cells the piece just vacated + Kind game.PieceKind + Age float64 // seconds + Life float64 // seconds +} +const ( + TrailLife = 0.14 // 140ms, inside §17's 100–160ms window + MaxTrails = 64 +) +func (t Trail) Stage() int // 0,1,2 → ▓▓ ▒▒ ░░ ; -1 when expired +func (w *World) spawnTrail(p game.Piece) + +type Quantum struct { + Out game.PieceKind // the piece being compressed away + In game.PieceKind // the piece flashing into existence + Age float64 +} +const QuantumLife = 0.12 // §9: ~120ms +func (q Quantum) Stage() int // 0 compressed, 1 streaked sideways, 2 gone; -1 expired +func (w *World) Quantum() *Quantum // nil when none + +// render +func DrawTrails(c *Canvas, l Layout, g *game.Game, w *fx.World, o Options) +func DrawQuantumStorage(c *Canvas, l Layout, w *fx.World, o Options) +var TrailGlyphs = [3]string{"▓▓", "▒▒", "░░"} // ASCII: "##", ";;", ".." +``` + +`Observe` spawns a trail from the piece's previous cells on `PieceMoved` and `PieceRotated`. Trails carry the vacated cells, so they never sit under the piece itself. `Step` ages them and drops expired ones, capped at `MaxTrails`. + +`HoldUsed` starts a `Quantum` (§9): inside the HOLD region, the outgoing piece compresses vertically (stage 0), streaks sideways out of the box with `TrailGlyphs` shoulders (stage 1), and vanishes (stage 2), while the incoming piece flashes in at full brightness for the same 120ms. Gameplay does not wait for it — the engine has already swapped the pieces, and `DrawQuantumStorage` draws only inside `l.Hold`. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestMoveSpawnsATrailOfFourCells(t *testing.T) +// Observe([]game.Event{{Kind: game.PieceMoved, Piece: p}}, view) +// => one Trail with len(Cells) == 4 and Kind == p.Kind + +func TestTrailExpiresWithinTheSpecWindow(t *testing.T) +// Step(100ms) => the trail is still present; Step(another 100ms) => Trails is empty +// Stage() walks 0 → 1 → 2 → -1 across its life + +func TestTrailsAreCapped(t *testing.T) +// 200 PieceMoved events in one Observe => len(Trails) <= MaxTrails + +func TestNoTrailsWhenFXDisabled(t *testing.T) +// Options{Enabled:false} => Observe spawns none + +func TestTrailsNeverCoverTheActivePieceOrLockedCells(t *testing.T) +// render a frame where a trail overlaps both the active piece and a locked cell: +// those cells hold BlockGlyph in the output, not a trail glyph + +func TestTrailGlyphsAreASCIISafeInASCIIMode(t *testing.T) +// every rune of the trail cells in a ModeASCII render is < 128 +``` + +```go +func TestHoldStartsQuantumStorage(t *testing.T) +// Observe([]game.Event{{Kind: game.HoldUsed, Piece: outgoing}}, view with Active = incoming) +// => Quantum() != nil with Out == outgoing.Kind and In == view.Active.Kind + +func TestQuantumStagesAndLifetime(t *testing.T) +// Stage() is 0 at Age 0.01, 1 at 0.06, 2 at 0.11; Step(120ms) => Quantum() == nil + +func TestQuantumDoesNotDelayGameplay(t *testing.T) +// app level: press "c", then feed 8 frames of 16ms => the new active piece descended +// on schedule and a second "c" is still correctly refused (hold once per piece) + +func TestQuantumDrawsOnlyInsideTheHoldRegion(t *testing.T) +// every cell the quantum effect writes lies within l.Hold; the board region is untouched +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -run 'TestTrail|TestHoldStarts|TestQuantum' -v && go test ./internal/render/ -run TestTrail -v` +Expected: FAIL — undefined `Trail`. + +- [ ] **Step 3: Implement `trail.go`, the quantum state, the `world.go` hooks, `DrawTrails` and `DrawQuantumStorage`** + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./... -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/trail.go internal/fx/world.go internal/fx/trail_test.go internal/fx/quantum_test.go internal/render/fx.go internal/render/render.go internal/render/trail_test.go +git commit -m "feat(fx): short-lived piece ion trails and quantum storage hold effect" +``` + +--- + +### Task 7: Mission control + +**Files:** +- Create: `internal/flavor/messages.go`, `internal/flavor/channel.go` +- Test: `internal/flavor/messages_test.go`, `internal/flavor/channel_test.go` + +**Interfaces:** +- Consumes: `game.Event`, `fx.GameView`. +- Produces: +```go +type Category int +const ( + CatBoot Category = iota + CatIdle + CatLock + CatClear + CatTetris + CatCombo + CatLevel + CatHold + CatHardDrop + CatGameOver + CatRare +) + +var Messages = map[Category][]string{...} +func ForEvent(ev game.Event, v fx.GameView) (Category, bool) +func Pick(cat Category, rng *rand.Rand, last string) string // never returns `last` +func Format(text string) string // "☄ MISSION CONTROL: " + text + +type Channel struct{ /* unexported */ } +const ( + MinHold = 2500 * time.Millisecond // §27: "give them time to breathe" + IdlePrompt = 20 * time.Second + RareChance = 0.02 +) +func NewChannel(seed int64) *Channel +func (c *Channel) Observe(evs []game.Event, v fx.GameView) +func (c *Channel) NoteInput() +func (c *Channel) Step(dt time.Duration) +func (c *Channel) Text() string // formatted line, "" when there is nothing to say +``` + +Copy comes verbatim from the spec: §27 for `CatLock`/`CatClear`/`CatIdle` (`NOMINALISH`, `GRAVITY REMAINS MOSTLY LEGAL`, `TETROMINO INJECTION SUCCESSFUL`, `STRUCTURAL VIBES: QUESTIONABLE`, `LOCAL UNIVERSE STABLE*`, `* DEFINITION OF STABLE UNDER REVIEW`, `MOON NOTIFIED`, `ORBITAL OSHA HAS ENTERED THE CHAT`, `WE HAVE EXCEEDED THE RECOMMENDED NUMBER OF BLOCKS`, `PHYSICS TEAM SAYS KEEP GOING`), §21 for `CatCombo` (`COMBO 5 // UNAUTHORIZED ORBITAL MANEUVER`, `COMBO 6 // STRUCTURAL REALITY FAILURE`, `COMBO 7 // NASA DENIES EVERYTHING`, with the combo number substituted), §22 for `CatLevel` (`GRAVITY TAX INCREASED`, `LOCAL PHYSICS UPDATED WITHOUT CONSENT`, `PLEASE SECURE ALL LOOSE TETROMINOES`), §45 for `CatRare` (`DID YOU KNOW YOU'RE IN A TERMINAL?`) and `CatIdle` (`CAPTAIN?`). + +Priority when several events arrive in one frame, highest first: `CatGameOver`, `CatTetris`, `CatLevel`, `CatCombo`, `CatHardDrop`, `CatClear`, `CatHold`, `CatLock`. A new message replaces the current one only when the current one has been held `MinHold` or the new category outranks it. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestEveryCategoryHasMessages(t *testing.T) +// every Category constant has at least two entries except CatBoot (>=1) + +func TestForEventMapping(t *testing.T) +// LinesCleared Count 1 => CatClear; Count 4 => CatTetris +// ComboChanged Count 5 => CatCombo; Count 0 => no message (false) +// LevelChanged => CatLevel; HoldUsed => CatHold; PieceHardDropped => CatHardDrop +// GameOver => CatGameOver; PieceMoved => false + +func TestComboMessageCarriesTheNumber(t *testing.T) +// Pick(CatCombo, rng, "") for a combo of 5 renders text containing "COMBO 5" + +func TestPickNeverRepeatsTheLastLine(t *testing.T) +// 200 Picks with `last` threaded through never return the same line twice in a row + +func TestBurstOfEventsYieldsOneHeldMessage(t *testing.T) +// Observe with 10 events (lock + 4-line clear + combo 5 + level up) in one call +// => exactly one Text(); Step(2.4s) with more low-rank events arriving keeps that +// same text; Step(0.2s) more and then a CatClear event replaces it + +func TestHigherPriorityInterruptsImmediately(t *testing.T) +// a CatLock message at age 100ms is replaced by a CatTetris event in the next Observe + +func TestIdlePromptAfterTwentySeconds(t *testing.T) +// NewChannel then Step(19s) => no "CAPTAIN?"; Step(2s) more => Text() contains "CAPTAIN?" +// NoteInput() resets the idle timer + +func TestChannelIsSeedDeterministic(t *testing.T) +// two channels with seed 5 fed the same events and steps produce identical Text() sequences + +func TestFormatPrefix(t *testing.T) +// Format("NOMINALISH") == "☄ MISSION CONTROL: NOMINALISH" +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/flavor/ -v` +Expected: FAIL — package does not exist. + +- [ ] **Step 3: Implement `messages.go` and `channel.go`** + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/flavor/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/flavor +git commit -m "feat(flavor): mission control message tables and pacing channel" +``` + +--- + +### Task 8: Wire FX and mission control into the app + +**Files:** +- Modify: `internal/app/model.go`, `internal/app/update.go`, `internal/render/render.go` (`Snapshot.Mission` already exists; pass `FX`) +- Test: `internal/app/fx_test.go` + +**Interfaces:** +- Consumes: Tasks 2–7. +- Produces: +```go +// Model gains: +FX *fx.World +Mission *flavor.Channel +FXSeed int64 // Seed ^ 0x5F3759DF — derived once, never shared with the game RNG +``` + +Wiring rules: +- `New` builds `fx.NewWorld(FXSeed, 0, 0, fx.Options{Enabled: !o.NoFX, ReducedMotion: o.ReducedMotion})` and `flavor.NewChannel(FXSeed)`. +- `tea.WindowSizeMsg` also calls `m.FX.Resize(w, h)`. +- Every engine call's returned events go to `m.FX.Observe(evs, fx.ViewOf(m.Game))` and `m.Mission.Observe(evs, fx.ViewOf(m.Game))`; key presses additionally call `m.Mission.NoteInput()`. +- `FrameMsg` steps `m.FX` and `m.Mission` with the same clamped `dt` used for `Advance`. While paused, FX steps at `dt/10` (§30: background stars keep drifting slowly) and the mission channel does not step. +- `Snapshot()` fills `FX: m.FX` and `Mission: m.Mission.Text()`. +- `--no-fx` leaves the game fully playable with an empty `World` (§32). + +- [ ] **Step 1: Write the failing tests** + +```go +func TestModelFeedsEventsToFX(t *testing.T) +// press space (hard drop) => m.FX.Energy > 0 and m.Mission.Text() != "" + +func TestFXSeedIsNotTheGameSeed(t *testing.T) +// New(Options{Seed: 1234, SeedSet: true}): m.FXSeed != m.Seed + +func TestNoFXFlagLeavesTheGamePlayable(t *testing.T) +// New(Options{NoFX:true}): a 200-frame session with keys still clears a line, +// m.FX.Particles stays empty, and View() is non-empty + +func TestPausedStarsKeepDriftingSlowly(t *testing.T) +// pause, record star Y values, feed 1s of frames => stars moved, board unchanged + +func TestSameScriptSameOutcomeWithAndWithoutFX(t *testing.T) +// the canned script from Plan 2's tests, run on New(Options{Seed:5,SeedSet:true}) and +// New(Options{Seed:5,SeedSet:true,NoFX:true}) => identical Board.String(), Score, Lines, Level + +func TestThirtySecondsOfPlayShowsTheCosmicBaseline(t *testing.T) +// 30 simulated seconds at 16ms with scripted hard drops; over the session assert: +// - at least one frame's output changed in the starfield region between consecutive frames +// - the border colour changed at least twice +// - at least one frame contained a trail glyph +// - m.Mission.Text() was non-empty for at least 10 seconds' worth of frames +// This is §43's first-30-seconds requirement, mechanised. +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/app/ -run 'TestModelFeeds|TestFXSeed|TestNoFX|TestPausedStars|TestSameScript|TestThirtySeconds' -v` +Expected: FAIL — `Model` has no field `FX`. + +- [ ] **Step 3: Implement the wiring** + +- [ ] **Step 4: Run everything and play it** + +Run: `go test ./... -v && go vet ./... && go run ./cmd/cosmic-tetris --seed 1234` +Expected: tests PASS; the terminal is visibly alive at rest — stars drift, the border shifts colour, pieces leave trails, mission control comments. + +- [ ] **Step 5: Commit** + +```bash +git add internal/app internal/render/render.go +git commit -m "feat(app): wire FX world and mission control into the frame loop" +``` diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/C3-planset-cosmic-91/2026-09-18-cosmic-tetris-4-violence.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/C3-planset-cosmic-91/2026-09-18-cosmic-tetris-4-violence.md new file mode 100644 index 000000000..0177e8a1b --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/C3-planset-cosmic-91/2026-09-18-cosmic-tetris-4-violence.md @@ -0,0 +1,632 @@ +# Cosmic Tetris — Plan 4: Violence + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the spectacle excessive: hard-drop impact, screen shake, the three-phase line-clear supernova, shockwaves, hyperdrive, and the simultaneous four-line event — plus combo escalation, the level-up card and the HUD pulse. + +**Architecture:** Every effect is another aged field on `fx.World` with a trigger in `Observe` and a draw function in `render`. Gameplay never waits: the engine has already cleared the rows and spawned the next piece while the supernova plays, so each effect carries its own snapshot of what it needs (the pre-clear row contents, the cells a hard drop crossed). Screen shake is applied by compositing the board layer into its own canvas and blitting it at an offset clipped to the board box, so shake can never bleed into the HUD. + +**Tech Stack:** Go 1.26, `charm.land/lipgloss/v2`, standard library `math`. + +**Spec:** `design.md` (§16, §17 hard-drop trail, §18, §19, §20, §21, §22, §23, §24, §25 event pulse, §38, §42 Phase 4, §43, §44, §49.5) + +## Global Constraints + +- Language: Go. Module `cosmic-tetris`, `go 1.26`. +- `internal/fx` must not import `internal/render` or `internal/app`. +- FX may observe game events and never modify game state (§14, §44). +- Never delay gameplay for animation; never make controls lag; animations never block input (§44, §47). +- Screen shake never exceeds roughly one terminal cell (§44). +- Particles never permanently alter the rendered board (§44). +- Never obscure the active piece (§44). +- `MaxParticles = 400` stays the hard cap (§38, Plan 3). +- `--reduced-motion` suppresses screen shake, hyperdrive acceleration and shockwaves, and leaves colour, trails and particles alone (§49.5). +- Board readability remains sacred at every combo level (§21). + +## Review Focus + +1. `--reduced-motion` — shake, hyperdrive and shockwaves must be fully off while trails, particles and colour still run (Task 7). +2. Shake offset larger than one cell, or shaken board content bleeding into the HUD columns (Task 1). +3. A banner or level card covering the active piece or the top of the stack (Task 6). +4. The 220ms supernova gating gameplay — the next piece must keep falling, and input must keep working, during the animation (Task 3). +5. A four-line clear that also levels up and lands at combo 5 in a single frame — every overlay must compose at once without exceeding any cap or panicking (Task 6, Task 7). + +## Plan Set + +Run in this order. A ruling that changes a name, signature, or value a later plan consumes is applied to that plan's file before the next task starts. + +1. `plans/2026-09-18-cosmic-tetris-1-engine.md` — headless deterministic engine in `internal/game`. Consumes: nothing. +2. `plans/2026-09-18-cosmic-tetris-2-playable-terminal.md` — Bubble Tea app, canvas renderer, layout, HUD, hold/next/ghost, CLI flags, pause/help/game-over card, golden tests. Consumes Plan 1's `game.Game`, `Advance`, `Event`, `Board`, `Piece`, `GhostY`. +3. `plans/2026-09-18-cosmic-tetris-3-cosmic-foundation.md` — `internal/fx` (particles, starfield), animated border, piece trails, `internal/flavor` mission control. Consumes Plan 1's `Event`/`Cell` and Plan 2's `render.Canvas`, `render.Layout`, `render.Snapshot`, `app.Model`. +4. `plans/2026-09-18-cosmic-tetris-4-violence.md` — hard-drop impact, screen shake, line supernova, shockwaves, hyperdrive, four-line sequence, combo/level overlays. Consumes Plan 3's `fx.World` and the render FX layer. +5. `plans/2026-09-18-cosmic-tetris-5-polish.md` — boot sequence, game-over black hole, ASCII/no-FX guarantees, §45 details, README, definition-of-done sweep. Consumes everything above. + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `internal/fx/shake.go` | deterministic shake pattern and offset | +| `internal/fx/impact.go` | hard-drop ion beam and debris eruption | +| `internal/fx/lineflash.go` | the three-phase supernova state | +| `internal/fx/shockwave.go` | expanding rings | +| `internal/fx/hyperdrive.go` | the §16 speed timeline and star-density boost | +| `internal/fx/banner.go` | four-line banner, level card, HUD pulse, combo tiers | +| `internal/render/boardlayer.go` | board layer canvas + shake blit, clipped to the board box | +| `internal/render/spectacle.go` | draw supernova, shockwaves, star streaks, banners, level card | + +All are new files inside packages §33 already names, each one effect's state or drawing. + +--- + +### Task 1: Screen shake + +**Files:** +- Create: `internal/fx/shake.go`, `internal/render/boardlayer.go` +- Modify: `internal/render/render.go` (route board drawing through the board layer) +- Test: `internal/fx/shake_test.go`, `internal/render/boardlayer_test.go` + +**Interfaces:** +- Consumes: Plan 3's `World`, `Options`; Plan 2's `Canvas`, `Layout`. +- Produces: +```go +type Shake struct{ Age, Life float64 } +var ShakePattern = [5]game.Point{{X: 0, Y: 1}, {X: -1, Y: 0}, {X: 1, Y: 0}, {X: 0, Y: -1}, {X: 0, Y: 0}} // §18 +const (ShakeLife = 0.08; BigShakeLife = 0.14) +func (w *World) TriggerShake(life float64) +func (w *World) ShakeOffset() (dx, dy int) // (0,0) when inactive or ReducedMotion + +// render +func DrawBoardLayer(dst *Canvas, l Layout, s Snapshot, o Options) +``` + +`ShakeOffset` indexes `ShakePattern` by `int(Age / Life * len(ShakePattern))`, clamped to the last entry. `DrawBoardLayer` renders the border, board interior, ghost, active piece and board-local FX into a canvas of `l.Border` size, then blits it into `dst` at `l.Border.X+dx, l.Border.Y+dy` with the blit clipped to the `l.Border` rectangle — content shifted outside the box is dropped rather than drawn over the HUD. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestShakeWalksThePinnedPattern(t *testing.T) +// TriggerShake(ShakeLife); sample ShakeOffset() at Age 0, 0.02, 0.04, 0.06, 0.079 +// => (0,1), (-1,0), (1,0), (0,-1), (0,0) in that order + +func TestShakeEndsAfterEightyMilliseconds(t *testing.T) +// TriggerShake(ShakeLife); Step(80ms) => ShakeOffset() == (0,0) and the shake is inactive + +func TestShakeNeverExceedsOneCell(t *testing.T) +// step through 1000 sub-steps of both ShakeLife and BigShakeLife: +// |dx| <= 1 and |dy| <= 1 at every sample (§44) + +func TestReducedMotionDisablesShake(t *testing.T) +// Options{Enabled:true, ReducedMotion:true}: TriggerShake => ShakeOffset() == (0,0) + +func TestShakenBoardNeverBleedsIntoTheHUD(t *testing.T) +// for each of the five pattern offsets: render a full frame at 100x40 with a filled board; +// every HUD region (Hold, Next, Stats) contains exactly what it contains with no shake + +func TestShakeKeepsTheBoardBoxSize(t *testing.T) +// with shake active, the rows of the output at l.Border.Y..Border.Y+H-1 are never wider +// than l.Border.W within that x-range +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -run TestShake -v` +Expected: FAIL — undefined `ShakePattern`. + +- [ ] **Step 3: Implement `shake.go`, `boardlayer.go` and the `render.go` rewiring** + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./... -v` +Expected: PASS (goldens unchanged — with no shake active the offset is (0,0)) + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/shake.go internal/fx/shake_test.go internal/render/boardlayer.go internal/render/render.go internal/render/boardlayer_test.go +git commit -m "feat(fx): deterministic one-cell screen shake on a clipped board layer" +``` + +--- + +### Task 2: Hard-drop impact + +**Files:** +- Create: `internal/fx/impact.go` +- Modify: `internal/fx/world.go` (`Observe` dispatches `PieceHardDropped`) +- Test: `internal/fx/impact_test.go`, `internal/render/impact_test.go` + +**Interfaces:** +- Consumes: Task 1's `TriggerShake`; Plan 3's `Spawn`, `Trail`, `Energy`. +- Produces: +```go +var DebrisGlyphs = []rune{'·', '*', '✦', '+'} // §18 +const ( + ImpactBeamLife = 0.18 + DebrisPerCell = 3 + DebrisBase = 6 + DebrisMax = 40 + DebrisUpMin = 6.0 // cells/s + DebrisUpMax = 14.0 + DebrisSpreadMax = 9.0 // cells/s horizontal at the edge of the contact area +) +func (w *World) impact(ev game.Event) +func (w *World) spawnBeam(cells []game.Point, life float64) +``` + +`impact` does four things (§18): a vertical ion beam through every cell the piece crossed (`spawnBeam`, `ImpactBeamLife`, capped by `MaxTrails`); `min(DebrisBase + DebrisPerCell*ev.Distance, DebrisMax)` debris particles from the contact row with horizontal velocity signed by their offset from the contact centre, upward initial `VY`, and `AY = ParticleGravity`; `TriggerShake(ShakeLife)`; and the `EnergyBump` for `PieceHardDropped`, which the border flash reads. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestImpactSpawnsBeamDebrisAndShake(t *testing.T) +// Observe a PieceHardDropped{Piece: p, Distance: 12} +// => at least one Trail with Life == ImpactBeamLife covering 12 rows of crossed cells +// => len(Particles) == min(6+3*12, DebrisMax) == 40 +// => ShakeOffset() != (0,0) +// => Energy == 0.25 + +func TestDebrisCountScalesWithDistanceAndCaps(t *testing.T) +// Distance 1 => 9 particles; Distance 4 => 18; Distance 20 => DebrisMax + +func TestDebrisFliesOutwardAndFalls(t *testing.T) +// particles left of the contact centre have VX < 0, right have VX > 0; +// every particle has VY < 0 initially (upward) and AY == ParticleGravity + +func TestDebrisGlyphsAreFromTheSpecSet(t *testing.T) +// every spawned particle's Glyph is in DebrisGlyphs + +func TestZeroDistanceHardDropStillReacts(t *testing.T) +// Distance 0 => DebrisBase particles, shake triggered, no panic, no beam cells + +func TestNoImpactWhenFXDisabled(t *testing.T) +// Options{Enabled:false} => nothing spawns, no shake + +func TestHardDropBrightensTheBorder(t *testing.T) // render side +// the border colour one frame after a hard drop differs from the resting colour, +// and moves toward the hot-white end of BorderPalette +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -run TestImpact -v` +Expected: FAIL — undefined `DebrisGlyphs`. + +- [ ] **Step 3: Implement `impact.go` and the `Observe` dispatch** + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./... -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/impact.go internal/fx/world.go internal/fx/impact_test.go internal/render/impact_test.go +git commit -m "feat(fx): hard-drop ion beam, debris eruption and shake" +``` + +--- + +### Task 3: Line-clear supernova + +**Files:** +- Create: `internal/fx/lineflash.go`, `internal/render/spectacle.go` +- Modify: `internal/fx/world.go` (`Observe` dispatches `LinesCleared`), `internal/render/render.go` +- Test: `internal/fx/lineflash_test.go`, `internal/render/spectacle_test.go` + +**Interfaces:** +- Consumes: `game.Event.Rows` and `game.Event.Cells` (the pre-clear row contents from Plan 1). +- Produces: +```go +type LineFlash struct { + Rows []int + Cells [][]game.Cell + Age float64 + // unexported: debrisSpawned bool +} +const (FlashPhaseAEnd = 0.07; FlashPhaseBEnd = 0.15; FlashLife = 0.22) // §19: ~220ms total +func (f LineFlash) Phase() int // 0 critical mass, 1 supernova, 2 collapse, -1 expired +func (w *World) Flashes() []LineFlash + +// render +func DrawLineFlash(c *Canvas, l Layout, w *fx.World, g *game.Game, o Options) +var FlashRamp = [4]string{"██", "▓▓", "▒▒", "░░"} // ASCII: "[]", "##", ";;", ".." +const FlashCoreGlyph = "✦✦" +``` + +Phases (§19), drawn at the cleared rows' original positions over the already-collapsed board: +- **A — critical mass:** the row degrades from its edges inward: outer cells step down the `FlashRamp`, the centre stays solid. +- **B — supernova:** a bright `FlashCoreGlyph` band expands from the centre outward with `FlashRamp` shoulders on either side. +- **C — collapse:** the row renders empty, and on entering this phase exactly once, debris particles spawn — one per cleared cell, `VX = (x - centre) × 2.5` cells/s, upward `VY`, `AY = ParticleGravity` (§19: particles inherit horizontal velocity from their position relative to centre). + +The flash never draws over a cell occupied by the active piece (§44). + +- [ ] **Step 1: Write the failing tests** + +```go +func TestFlashLifetimeAndPhases(t *testing.T) +// Observe a LinesCleared{Rows:[]int{21}, Cells: one row, Count:1} +// Phase() == 0 at Age 0 and 0.06; == 1 at 0.08 and 0.14; == 2 at 0.16 and 0.21; +// after Step(220ms) the flash is gone (Flashes() empty) + +func TestDebrisSpawnsExactlyOnceOnPhaseC(t *testing.T) +// step to Age 0.16 in 10 sub-steps => the debris count after the first phase-C step +// equals the count after five more phase-C steps + +func TestDebrisInheritsHorizontalVelocityFromPosition(t *testing.T) +// cleared row of 10 cells: the leftmost debris has the most negative VX, the +// rightmost the most positive, and a centre cell is near zero + +func TestFlashCarriesPreClearColours(t *testing.T) +// the LineFlash's Cells match the event's Cells, so the animation can colour the row +// after the board has already collapsed + +func TestFourLinesMakeOneFlashOfFourRows(t *testing.T) +// LinesCleared{Count:4} => exactly one LineFlash with len(Rows)==4 + +func TestGameplayContinuesDuringTheAnimation(t *testing.T) +// app-level: clear a line, then feed 14 frames of 16ms (the whole 220ms) while holding +// no keys => the newly spawned piece descends normally, key presses still move it, +// and the engine's board is already collapsed from frame one + +func TestFlashNeverCoversTheActivePiece(t *testing.T) +// place the active piece over a cleared row's position: those cells render BlockGlyph + +func TestGoldenSupernovaMidPhaseB(t *testing.T) +// fixed fx seed, a 2-line clear, stepped to Age 0.10, 100x40 => testdata/supernova.txt +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -run TestFlash -v` +Expected: FAIL — undefined `LineFlash`. + +- [ ] **Step 3: Implement `lineflash.go`, `DrawLineFlash` and the pipeline hook** + +- [ ] **Step 4: Create the golden and run everything** + +Run: `go test ./internal/render/ -run TestGolden -update && go test ./... -v` +Expected: PASS; read `testdata/supernova.txt` and confirm it reads as an explosion, not as garbage. + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/lineflash.go internal/fx/world.go internal/fx/lineflash_test.go internal/render/spectacle.go internal/render/render.go internal/render/spectacle_test.go internal/render/testdata +git commit -m "feat(fx): three-phase line-clear supernova" +``` + +--- + +### Task 4: Shockwaves + +**Files:** +- Create: `internal/fx/shockwave.go` +- Modify: `internal/render/spectacle.go` +- Test: `internal/fx/shockwave_test.go` + +**Interfaces:** +- Consumes: Plan 3's `World`. +- Produces: +```go +type Shockwave struct{ CX, CY, Age float64 } +const (ShockLife = 0.30; MaxShockwaves = 3; ShockMaxRadius = 14.0; ShockAspect = 2.0) +func (s Shockwave) Radius() float64 // eased out: ShockMaxRadius * (1 - (1-t)²), t = Age/ShockLife +func (w *World) TriggerShockwave(cx, cy float64) +func (w *World) Shockwaves() []Shockwave + +// render +var ShockRamp = [4]rune{'·', '○', '◌', '◯'} // §24; ASCII: '.', 'o', 'o', 'O' +func DrawShockwaves(c *Canvas, l Layout, w *fx.World, o Options) +``` + +Rings are faked in terminal space: a cell `(x, y)` is on the ring when `hypot((x-CX)/ShockAspect, y-CY)` is within 0.6 of `Radius()`, which compensates for the 2:1 cell aspect. Glyph comes from `ShockRamp` indexed by age. Shockwaves are used sparingly (§24) — only by four-line clears, combo ≥ 4 and level-up (wired in Task 6) — suppressed under `ReducedMotion`, and capped at `MaxShockwaves` (oldest dropped). + +- [ ] **Step 1: Write the failing tests** + +```go +func TestRadiusGrowsAndDies(t *testing.T) +// TriggerShockwave(10,10): Radius() at Age 0 is 0, strictly increases across +// 10 samples, and reaches ShockMaxRadius at ShockLife; Step(300ms) => Shockwaves() empty + +func TestReducedMotionSuppressesShockwaves(t *testing.T) +// ReducedMotion: TriggerShockwave => Shockwaves() empty + +func TestShockwavesAreCapped(t *testing.T) +// trigger 6 => len(Shockwaves()) == MaxShockwaves + +func TestRingCellsFollowTheEllipse(t *testing.T) +// at Age 0.15, the drawn cells all satisfy the ring predicate within tolerance, +// and the ring is about twice as wide as it is tall + +func TestNoShockwaveWhenFXDisabled(t *testing.T) +// Options{Enabled:false} => nothing +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -run 'TestRadius|TestReducedMotionSuppresses|TestShockwaves|TestRing|TestNoShockwave' -v` +Expected: FAIL — undefined `Shockwave`. + +- [ ] **Step 3: Implement `shockwave.go` and `DrawShockwaves`** + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./... -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/shockwave.go internal/fx/shockwave_test.go internal/render/spectacle.go +git commit -m "feat(fx): faked-geometry radial shockwaves" +``` + +--- + +### Task 5: Hyperdrive + +**Files:** +- Create: `internal/fx/hyperdrive.go` +- Modify: `internal/fx/starfield.go` (`StarSpeed` multiplies in the hyperdrive factor and the density boost), `internal/render/fx.go` (near-layer stars render as streaks while stretching) +- Test: `internal/fx/hyperdrive_test.go`, `internal/render/hyperdrive_test.go` + +**Interfaces:** +- Consumes: Plan 3's `StarSpeed`, `Stars`, `StarCount`. +- Produces: +```go +type Hyper struct{ Age float64; Active bool } +const ( + HyperLife = 1.10 + HyperPeakFactor = 8.0 + LargeCombo = 4 // §16 "large combo" +) +func (w *World) TriggerHyperdrive() +func (w *World) StarSpeedMultiplier() float64 // §16 timeline; 1.0 when inactive or ReducedMotion +func (w *World) Stretching() bool // true in the 50–150ms stretch window +func (w *World) BoostStarDensity(amount, seconds float64) +func (w *World) StarTarget() int // StarCount(W,H) scaled by the live density boost + +// render +var StreakGlyphs = [2]rune{'│', '┃'} // ASCII: '|', '!' +``` + +Timeline (§16), as `StarSpeedMultiplier()`: + +| Age | Value | +|---|---| +| 0 – 50ms | `0` (stars pause) | +| 50 – 100ms | `0.2`, with `Stretching() == true` | +| 100 – 500ms | linear ramp `0.2 → HyperPeakFactor` | +| 500 – 800ms | `HyperPeakFactor` (peak) | +| 800 – 1100ms | linear decay `HyperPeakFactor → 1.0` | +| past 1100ms | `1.0`, inactive | + +Triggers (§16): a four-line clear, a combo of `LargeCombo` or more, and a new session high score — `World` tracks the best score it has seen and fires only when a later score beats a non-zero best. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestHyperdriveTimeline(t *testing.T) +// TriggerHyperdrive then sample StarSpeedMultiplier() at Age +// 0.00 → 0; 0.04 → 0; 0.07 → 0.2; 0.30 → between 0.2 and HyperPeakFactor (monotone); +// 0.60 → HyperPeakFactor; 0.95 → between 1 and HyperPeakFactor; 1.20 → 1.0 and inactive + +func TestStretchWindow(t *testing.T) +// Stretching() is false at Age 0.02, true at 0.07 and 0.12, false at 0.30 + +func TestTriggersAreTheSpecifiedThree(t *testing.T) +// Observe LinesCleared{Count:4} => active +// Observe ComboChanged{Count:4} => active; Count:3 => not active +// GameView Score 5000 after a previous best of 4000 => active; +// the very first score seen does not trigger + +func TestReducedMotionKeepsStarsAtNormalSpeed(t *testing.T) +// ReducedMotion: TriggerHyperdrive => StarSpeedMultiplier() == 1.0 always, Stretching() false + +func TestStarSpeedCombinesLevelAndHyperdrive(t *testing.T) +// level 10 world with hyperdrive at peak: StarSpeed() == SpeedForLevel(10)*HyperPeakFactor + +func TestDensityBoostAddsAndThenRemovesStars(t *testing.T) +// BoostStarDensity(0.4, 3.0): StarTarget() > StarCount(W,H); after Step(3s), +// StarTarget() == StarCount(W,H) and len(Stars) matches it again + +func TestNearStarsStreakWhileStretching(t *testing.T) // render side +// during the stretch window, layer-2 stars render a StreakGlyphs rune; +// outside it, they render their normal glyph +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -run 'TestHyper|TestStretch|TestTriggers|TestReducedMotionKeeps|TestStarSpeed|TestDensity' -v` +Expected: FAIL — undefined `TriggerHyperdrive`. + +- [ ] **Step 3: Implement `hyperdrive.go` and the starfield/render changes** + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./... -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/hyperdrive.go internal/fx/starfield.go internal/fx/hyperdrive_test.go internal/render/fx.go internal/render/hyperdrive_test.go +git commit -m "feat(fx): hyperdrive timeline, star streaks and density boost" +``` + +--- + +### Task 6: Four-line sequence, combo escalation and the level-up card + +**Files:** +- Create: `internal/fx/banner.go` +- Modify: `internal/fx/world.go` (`Observe` composes the big events), `internal/render/spectacle.go` (banner, level card, HUD pulse) +- Test: `internal/fx/banner_test.go`, `internal/render/banner_test.go` + +**Interfaces:** +- Consumes: Tasks 1–5. +- Produces: +```go +type Banner struct{ Text string; Age float64 } +const BannerLife = 0.70 // §20 + +var TetrisBanners = []string{ // §20, verbatim + "✦ EVENT HORIZON ✦", + "QUADRUPLE COSMIC INCIDENT", + "FOUR ROWS HAVE LEFT THE CHAT", + "SPACE-TIME HAS FILED A COMPLAINT", +} + +type LevelCard struct{ Level int; Subtitle string; Age float64 } +const LevelCardLife = 1.20 // §22: slides/fades away without pausing the game +var LevelSubtitles = []string{ // §22, verbatim + "GRAVITY TAX INCREASED", + "LOCAL PHYSICS UPDATED WITHOUT CONSENT", + "PLEASE SECURE ALL LOOSE TETROMINOES", +} + +func ComboTier(combo int) int // 0 for <2; 1 at 2; 2 at 3; 3 at 4; 4 at 5+ +func (w *World) HUDPulse() float64 // 0 below tier 3; a 0..1 sine otherwise (§21) +func (w *World) Banner() *Banner // nil when none +func (w *World) LevelCard() *LevelCard // nil when none +func (w *World) tetris(ev game.Event) // the §20 simultaneous bundle + +// render +func DrawBanner(c *Canvas, l Layout, w *fx.World, g *game.Game, o Options) +func DrawLevelCard(c *Canvas, l Layout, w *fx.World, o Options) +``` + +`tetris` fires all of §20 at once: `TriggerHyperdrive()`, `TriggerShake(BigShakeLife)`, a border gradient pulse (`Energy` to 1.0), a particle eruption (a `DebrisMax`-sized burst from the cleared band), `HUDPulse` via the combo tier, `BoostStarDensity(0.4, 3.0)`, and a `Banner` picked from `TetrisBanners` with the FX RNG. + +Combo tiers (§21): tier 1 spawns a few `ClassSpark` particles, tier 2 spawns `ClassEmber` meteors that drift across the board, tier 3 turns on `HUDPulse`, tier 4 adds a `TriggerShockwave` at the board centre. + +`DrawBanner` centres the text in the board's top three visible rows and skips any cell already holding a block, ghost or active-piece glyph — so the banner can never obscure the active piece or the stack (§44). `DrawLevelCard` draws the §22 box, sliding in from the right edge of the board area and fading out over `LevelCardLife`. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestFourLineClearFiresEverythingAtOnce(t *testing.T) +// one Observe of LinesCleared{Count:4} => +// Banner() != nil and its Text is one of TetrisBanners +// StarSpeedMultiplier() reflects an active hyperdrive +// ShakeOffset() != (0,0) +// Energy == 1.0 +// len(Particles) > 20 +// StarTarget() > StarCount(W,H) + +func TestBannerLifetime(t *testing.T) +// Step(690ms) => Banner() != nil; Step(20ms) more => nil + +func TestComboTiers(t *testing.T) +// ComboTier(0)==0; (1)==0; (2)==1; (3)==2; (4)==3; (5)==4; (12)==4 +// Observe ComboChanged{Count:2} spawns ClassSpark particles; +// Count:3 spawns ClassEmber; Count:4 => HUDPulse() > 0; Count:5 => a shockwave exists + +func TestLevelCardCopyAndLifetime(t *testing.T) +// Observe LevelChanged{Count:8} => LevelCard().Level == 8 and Subtitle is in LevelSubtitles +// Step(1.2s) => LevelCard() == nil + +func TestLevelCardDoesNotPauseTheGame(t *testing.T) +// app level: a level-up mid-session, then 40 frames => the piece kept descending and +// key presses kept moving it during the card's lifetime + +func TestBannerNeverCoversTheActivePieceOrStack(t *testing.T) +// active piece placed in the top three visible rows with a banner showing: +// every active-piece cell renders BlockGlyph in the output, and no locked cell is +// replaced by banner text + +func TestSimultaneousTetrisLevelAndComboFive(t *testing.T) +// one Observe carrying LinesCleared{Count:4} + ComboChanged{Count:5} + LevelChanged{Count:5} +// => no panic, Banner() != nil, LevelCard() != nil, len(Particles) <= MaxParticles, +// len(Shockwaves()) <= MaxShockwaves, Energy == 1.0, and a full Render at 100x40 succeeds +// with every line within the terminal width + +func TestGoldenFourLineClear(t *testing.T) +// fixed fx seed, LinesCleared{Count:4}, stepped to Age 0.12, 100x40 => testdata/tetris.txt +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -run 'TestFourLine|TestBanner|TestCombo|TestLevelCard|TestSimultaneous' -v` +Expected: FAIL — undefined `TetrisBanners`. + +- [ ] **Step 3: Implement `banner.go`, the `Observe` composition and the two draw functions** + +- [ ] **Step 4: Create the golden, run everything, and look at it** + +Run: `go test ./internal/render/ -run TestGolden -update && go test ./... -v && go run ./cmd/cosmic-tetris --seed 1234` +Expected: PASS; a four-line clear in the live binary produces §43's reaction. + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/banner.go internal/fx/world.go internal/fx/banner_test.go internal/render/spectacle.go internal/render/banner_test.go internal/render/testdata +git commit -m "feat(fx): four-line event bundle, combo escalation and level-up card" +``` + +--- + +### Task 7: Reduced motion, intensity caps and performance + +**Files:** +- Modify: `internal/fx/world.go` (single intensity gate), `internal/app/update.go` (flag plumb-through already exists) +- Test: `internal/fx/reducedmotion_test.go`, `internal/fx/bench_test.go`, `internal/app/violence_test.go` + +**Interfaces:** +- Consumes: Tasks 1–6. +- Produces: +```go +func (o Options) AllowsMotion() bool // !ReducedMotion — the single gate every motion effect asks +``` + +Every motion trigger (`TriggerShake`, `TriggerHyperdrive`, `TriggerShockwave`) consults this one predicate, so §49.5's ten-line promise stays ten lines. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestReducedMotionMatrix(t *testing.T) +// Options{Enabled:true, ReducedMotion:true}, observe a four-line clear + combo 5 + level up: +// suppressed: ShakeOffset()==(0,0); StarSpeedMultiplier()==1.0; Shockwaves() empty +// still running: len(Particles) > 0; len(Trails) > 0 after a move; Energy > 0; +// Banner() != nil; LevelCard() != nil; stars still drift + +func TestNoFXSuppressesEverything(t *testing.T) +// Options{Enabled:false}: the same observation leaves every collection empty and Energy 0 + +func TestParticleCapHoldsUnderSustainedViolence(t *testing.T) +// 60 seconds of simulated frames with a four-line clear every 10 frames: +// len(Particles) <= MaxParticles, len(Trails) <= MaxTrails, +// len(Shockwaves()) <= MaxShockwaves, len(Stars) <= 240 at every frame + +func BenchmarkStepAndRenderUnderLoad(b *testing.B) +// a 100x40 world at MaxParticles with a live supernova, shockwave and hyperdrive: +// one fx.Step(16ms) + one render.Render per iteration + +func TestFrameBudgetUnderLoad(t *testing.T) +// the same load, 120 frames, measured: the mean Step+Render wall time is under 8ms +// (skip under -short and on -race) + +func TestNoGoroutinesAreSpawned(t *testing.T) +// runtime.NumGoroutine() before and after 600 frames of heavy FX is unchanged (§38) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -run 'TestReducedMotionMatrix|TestNoFXSuppresses|TestParticleCap|TestFrameBudget|TestNoGoroutines' -v` +Expected: FAIL — undefined `AllowsMotion`. + +- [ ] **Step 3: Route every motion trigger through `AllowsMotion` and fix any cap violations the tests find** + +- [ ] **Step 4: Run everything** + +Run: `go test ./... -v && go test ./internal/fx/ -bench . -run XXX && go vet ./...` +Expected: PASS; the benchmark reports a per-frame cost well inside a 16ms budget. + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx internal/app +git commit -m "feat(fx): single reduced-motion gate, intensity caps and frame budget tests" +``` diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/C3-planset-cosmic-91/2026-09-18-cosmic-tetris-5-polish.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/C3-planset-cosmic-91/2026-09-18-cosmic-tetris-5-polish.md new file mode 100644 index 000000000..72a57f6fb --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/C3-planset-cosmic-91/2026-09-18-cosmic-tetris-5-polish.md @@ -0,0 +1,456 @@ +# Cosmic Tetris — Plan 5: Absurd Polish + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Finish the game: the boot sequence, the game-over black hole collapse, guaranteed ASCII and no-FX modes, the §45 tiny details, the README, and the §43/§47 acceptance sweep. + +**Architecture:** Two new app phases get their own aged FX state and draw function, following the pattern established in Plans 3–4 (`fx` holds the timeline, `render` draws it, `app` owns the clock). The mode guarantees are enforced by tests that walk every rune of every view rather than by trusting call sites. + +**Tech Stack:** Go 1.26, `charm.land/bubbletea/v2`, `charm.land/bubbles/v2` (spinner), `charm.land/lipgloss/v2`. + +**Spec:** `design.md` (§28, §29, §30, §32, §39, §42 Phase 5, §43, §45, §46, §47, §48, §49.4, §49.5) + +## Global Constraints + +- Language: Go. Module `cosmic-tetris`, `go 1.26`. +- `internal/fx` must not import `internal/render` or `internal/app`. +- CLI surface is exactly: no flags, `--seed N`, `--ascii`, `--no-fx`, `--reduced-motion`, `--help`. Nothing else (§46, §49.5). +- ASCII mode emits only ASCII runes — glyphs, box drawing, flavor text and banners included (§32). +- The boring modes (`--no-fx`, `--ascii`) must still be a good game (§32, §47). +- Effects never modify game state; animations never block input (§44, §47). +- Boot drama lasts about one second and any key skips it; no menu (§29). +- The codebase should stay small enough to understand in an afternoon (§48). Do not add architecture beyond the files this plan names. + +## Review Focus + +1. A key pressed inside the very first boot frame — must skip cleanly into play, with no double initialisation and no lost key (Task 1). +2. Game over arriving while a banner, hyperdrive or supernova is mid-flight — the collapse must still start and finish, never wedge (Task 2). +3. `r` pressed partway through the collapse — restart must work from every sub-phase, including the final card (Task 2). +4. ASCII mode rendering a non-ASCII flavor message, banner or particle glyph — the ASCII guarantee has to cover text, not just blocks (Task 3). +5. A resize below the minimum while boot, help or the collapse is on screen — the too-small notice replaces the overlay without panicking (Task 3). + +## Plan Set + +Run in this order. A ruling that changes a name, signature, or value a later plan consumes is applied to that plan's file before the next task starts. + +1. `plans/2026-09-18-cosmic-tetris-1-engine.md` — headless deterministic engine in `internal/game`. Consumes: nothing. +2. `plans/2026-09-18-cosmic-tetris-2-playable-terminal.md` — Bubble Tea app, canvas renderer, layout, HUD, hold/next/ghost, CLI flags, pause/help/game-over card, golden tests. Consumes Plan 1's `game.Game`, `Advance`, `Event`, `Board`, `Piece`, `GhostY`. +3. `plans/2026-09-18-cosmic-tetris-3-cosmic-foundation.md` — `internal/fx` (particles, starfield), animated border, piece trails, `internal/flavor` mission control. Consumes Plan 1's `Event`/`Cell` and Plan 2's `render.Canvas`, `render.Layout`, `render.Snapshot`, `app.Model`. +4. `plans/2026-09-18-cosmic-tetris-4-violence.md` — hard-drop impact, screen shake, line supernova, shockwaves, hyperdrive, four-line sequence, combo/level overlays. Consumes Plan 3's `fx.World` and the render FX layer. +5. `plans/2026-09-18-cosmic-tetris-5-polish.md` — boot sequence, game-over black hole, ASCII/no-FX guarantees, §45 details, README, definition-of-done sweep. Consumes everything above. + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `internal/render/boot.go` | the §29 boot screen and its reveal timeline | +| `internal/fx/collapse.go` | the §28 game-over collapse timeline and cell physics | +| `internal/render/collapse.go` | SIGNAL LOST, inward fall, black-hole art | +| `internal/flavor/messages.go` (modify) | the §45 occasional lines | +| `internal/fx/shootingstar.go` | the §45 idle shooting star | +| `README.md` | what it is, how to run it, flags, keys | + +--- + +### Task 1: Boot sequence + +**Files:** +- Create: `internal/render/boot.go` +- Modify: `internal/app/model.go` (start in `PhaseBoot`, add the spinner), `internal/app/update.go` (boot timing and skip), `internal/render/render.go` (boot branch), `internal/render/golden_test.go` +- Test: `internal/render/boot_test.go`, `internal/app/boot_test.go` + +**Interfaces:** +- Consumes: Plan 2's `Canvas`, `Layout`, `Snapshot.Elapsed`, `PhaseBoot`. +- Produces: +```go +const BootDuration = 1100 * time.Millisecond +func DrawBoot(c *Canvas, l Layout, elapsed time.Duration, spinner string, o Options) +``` + +Reveal timeline, all text verbatim from §29: + +| Elapsed | Revealed | +|---|---| +| 0ms | `✦`, `C O S M I C`, `T E T R I S` | +| 150ms | `INITIALIZING LOCAL UNIVERSE...` + spinner | +| 350ms | `gravity ........ OK` | +| 550ms | `spacetime ...... OK` | +| 750ms | `tetrominoes .... QUESTIONABLE` | +| 950ms | `UNIVERSE ONLINE` | +| 1100ms | boot ends, play starts | + +App rules: `New` sets `Phase = PhaseBoot`. While booting, `FrameMsg` accumulates `Elapsed` and steps the FX world (stars drift behind the boot text) but never calls `Advance`. Any `tea.KeyPressMsg` during boot switches to `PhasePlaying` and is consumed — except `q`/`esc`, which still quit. Reaching `BootDuration` switches to `PhasePlaying` and resets `Elapsed` to 0 so gameplay chrome starts from zero. The spinner is `bubbles/v2/spinner` (§3: use Bubbles only where it helps — boot spinner is the named case). + +- [ ] **Step 1: Write the failing tests** + +```go +func TestBootRevealTimeline(t *testing.T) +// DrawBoot at 0ms contains "C O S M I C" and "T E T R I S" but not "gravity" +// at 400ms contains "gravity ........ OK" but not "spacetime" +// at 800ms contains "tetrominoes .... QUESTIONABLE" but not "UNIVERSE ONLINE" +// at 1000ms contains "UNIVERSE ONLINE" + +func TestBootFitsSmallTerminals(t *testing.T) +// at 40x24 and 100x40, every line of the boot output is within the terminal width +// and the line count is within the height + +func TestModelStartsInBoot(t *testing.T) +// New(...).Phase == PhaseBoot; the game does not advance: two FrameMsgs 900ms apart +// leave Active.Y unchanged + +func TestAnyKeySkipsBoot(t *testing.T) +// a single tea.KeyPressMsg for "c" during boot => Phase == PhasePlaying, +// Elapsed == 0, and the hold was NOT used (the key is consumed by the skip) + +func TestKeyInTheVeryFirstBootFrameSkipsCleanly(t *testing.T) +// key press before any FrameMsg => PhasePlaying, game intact, View() non-empty, +// and a following FrameMsg advances gravity normally + +func TestQuitStillWorksDuringBoot(t *testing.T) +// "q" during boot returns a cmd producing tea.QuitMsg + +func TestBootEndsOnItsOwn(t *testing.T) +// frames totalling 1.2s with no key => Phase == PhasePlaying and Elapsed restarted at ~100ms + +func TestGoldenBoot(t *testing.T) // 100x40 at 800ms, FX off => testdata/boot.txt +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/render/ -run TestBoot -v && go test ./internal/app/ -run 'TestModelStarts|TestAnyKey|TestKeyInThe|TestQuitStill|TestBootEnds' -v` +Expected: FAIL — undefined `DrawBoot`. + +- [ ] **Step 3: Implement `boot.go` and the app changes, then migrate the earlier app tests** + +Plans 2–4's app tests assume the model starts playing. Add a test helper `skipBoot(t *testing.T, m *Model)` in `internal/app/helpers_test.go` that sets `Phase = PhasePlaying`, and call it in every existing app test that drives gameplay. Do not change production behaviour to keep old tests green. + +- [ ] **Step 4: Create the golden and run everything** + +Run: `go test ./internal/render/ -run TestGolden -update && go test ./... -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/boot.go internal/render/render.go internal/render/boot_test.go internal/render/golden_test.go internal/render/testdata internal/app +git commit -m "feat(render): one second of excessive boot drama, skippable" +``` + +--- + +### Task 2: Game-over black hole collapse + +**Files:** +- Create: `internal/fx/collapse.go`, `internal/render/collapse.go` +- Modify: `internal/fx/world.go` (`Observe` triggers on `GameOver`), `internal/app/update.go` (game-over input rules), `internal/render/render.go` +- Test: `internal/fx/collapse_test.go`, `internal/render/collapse_test.go`, `internal/app/gameover_test.go` + +**Interfaces:** +- Consumes: Plan 1's `GameOver` event and `Board`; Plan 3's `World`. +- Produces: +```go +type CollapseCell struct { + X, Y float64 + VX, VY float64 + Kind game.PieceKind +} +type Collapse struct { + Age float64 + Cells []CollapseCell + Active bool +} +const ( + CollapseFreezeEnd = 0.30 // §28: 0–300ms everything freezes, "SIGNAL LOST" + CollapseFallEnd = 0.90 // 300–900ms blocks fall inward + CollapseHoleEnd = 1.30 // 900–1300ms board collapses into a black hole +) +func (w *World) TriggerCollapse(b *game.Board) +func (w *World) CollapseStage() int // 0 freeze, 1 falling, 2 black hole, 3 finished, -1 inactive + +// render +func DrawCollapse(c *Canvas, l Layout, w *fx.World, o Options) +var BlackHoleArt = []string{ // §28, verbatim + " · ", + " ˚ ", + " \\ | / ", + "--- ● --- ", + " / | \\ ", + " * ", +} +``` + +`TriggerCollapse` snapshots every occupied board cell into a `CollapseCell` whose velocity points at the board centre, with speed proportional to distance, so stage 1 looks like the stack falling inward. Stage 2 replaces the board with the black-hole art plus a swirl of `ClassEmber` particles. Stage 3 hands over to Plan 2's `DrawGameOver` card, which gains the `CAUSE: EXCESSIVE GEOMETRY` subtitle (§28). + +App rules: entering `PhaseGameOver` calls `TriggerCollapse(&m.Game.Board)` exactly once. During the collapse, `r` restarts and `q`/`esc` quit; every other key is ignored. FX keeps stepping so the collapse animates; the engine is not advanced. The collapse runs to completion even if a banner, hyperdrive or supernova was live — those keep decaying independently. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestCollapseStageTimeline(t *testing.T) +// TriggerCollapse(board): CollapseStage() is 0 at Age 0 and 0.29; 1 at 0.31 and 0.89; +// 2 at 0.91 and 1.29; 3 at 1.31 + +func TestCollapseSnapshotsEveryOccupiedCell(t *testing.T) +// a board with 37 occupied cells => len(Cells) == 37, each carrying its Kind + +func TestCollapseCellsFallInward(t *testing.T) +// cells left of centre have VX > 0, right have VX < 0, above the centre row have VY > 0 + +func TestCollapseIgnoresFXDisabled(t *testing.T) +// Options{Enabled:false}: TriggerCollapse leaves CollapseStage() == -1 so the app +// falls straight through to the game-over card + +func TestCollapseCompletesWhileOtherEffectsAreLive(t *testing.T) +// trigger a four-line bundle, then TriggerCollapse in the same frame: +// stepping 1.4s reaches stage 3 and no collection exceeds its cap + +func TestSignalLostThenBlackHole(t *testing.T) // render side +// stage 0 output contains "SIGNAL LOST"; stage 2 output contains "●" and the +// diagonal rays; stage 3 output contains "UNIVERSE EXPIRED" and "CAUSE: EXCESSIVE GEOMETRY" + +func TestRestartWorksFromEveryCollapseStage(t *testing.T) // app level +// for each stage 0..3: reach game over, step into that stage, press "r" +// => Phase == PhasePlaying, Score 0, empty board, CollapseStage() == -1 + +func TestOnlyRestartAndQuitDuringCollapse(t *testing.T) +// during stage 1, pressing left/right/space/c/p changes nothing about the game + +func TestCollapseTriggersOnce(t *testing.T) +// several frames after game over => len(Collapse.Cells) never grows +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -run TestCollapse -v` +Expected: FAIL — undefined `TriggerCollapse`. + +- [ ] **Step 3: Implement `collapse.go`, `DrawCollapse` and the app rules** + +- [ ] **Step 4: Create the golden and run everything** + +Add `TestGoldenBlackHole` (100×40 at collapse Age 1.0, fixed fx seed) to the golden set, then: + +Run: `go test ./internal/render/ -run TestGolden -update && go test ./... -v` +Expected: PASS; read `testdata/blackhole.txt` and confirm the universe looks properly dead. + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/collapse.go internal/fx/world.go internal/fx/collapse_test.go internal/render/collapse.go internal/render/render.go internal/render/collapse_test.go internal/render/testdata internal/app +git commit -m "feat(fx): game-over collapse into a simulated black hole" +``` + +--- + +### Task 3: ASCII and no-FX guarantees + +**Files:** +- Modify: `internal/render/fx.go` (route every glyph through `ASCIISafe` in `ModeASCII`), `internal/render/spectacle.go`, `internal/render/collapse.go`, `internal/render/boot.go`, `internal/flavor/messages.go` (ASCII-safe variants), `internal/render/golden_test.go` +- Test: `internal/render/ascii_test.go` + +**Interfaces:** +- Consumes: Plan 3's `ASCIISafe`. +- Produces: +```go +func ASCIIText(s string) string // maps every non-ASCII rune in a string via ASCIISafe +``` + +Every text-emitting path in `ModeASCII` passes through `ASCIIText`: mission control lines, banners (`✦ EVENT HORIZON ✦`), the boot `✦`, the black-hole art, the level card, the HUD title. Glyph paths already use `ASCIISafe`. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestEveryViewIsPureASCIIInASCIIMode(t *testing.T) +// For each state — boot, playing, paused, help, four-line clear mid-banner, each +// collapse stage, game-over card, too-small notice — and at 40x24 and 100x40: +// every rune of ansi.Strip(Render(..., Options{Mode: ModeASCII, FXEnabled: true})) is < 128 + +func TestASCIIModeKeepsTheBoardGeometry(t *testing.T) +// in ModeASCII the board rows are still BoardCols wide and blocks render "[]", ghosts ".." + +func TestNoFXModeIsStillAGoodGame(t *testing.T) +// Options{FXEnabled:false} across the same states: output is non-empty, contains the +// board, HUD, next queue and controls, and contains no star, particle or trail glyph + +func TestResizeBelowMinimumReplacesEveryOverlay(t *testing.T) +// for boot, help, paused, each collapse stage and the game-over card: +// Render at 34x19 shows "THIS UNIVERSE IS TOO SMALL" and nothing else, with no panic + +func TestGoldenASCIIWide(t *testing.T) // 100x40, ModeASCII, FX on, fixed fx seed +func TestGoldenASCIISmall(t *testing.T) // 40x24, ModeASCII +func TestGoldenNoFXWide(t *testing.T) // 100x40, ModeFull, FXEnabled false +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/render/ -run 'TestEveryView|TestASCIIMode|TestNoFXMode|TestResizeBelow' -v` +Expected: FAIL — non-ASCII runes leak through banners and flavor text. + +- [ ] **Step 3: Add `ASCIIText` and route every text path through it** + +- [ ] **Step 4: Create the goldens and run everything** + +Run: `go test ./internal/render/ -run TestGolden -update && go test ./... -v && go run ./cmd/cosmic-tetris --ascii --seed 1234 && go run ./cmd/cosmic-tetris --no-fx --seed 1234` +Expected: PASS; both boring modes play well and look intentional rather than broken. + +- [ ] **Step 5: Commit** + +```bash +git add internal/render internal/flavor +git commit -m "feat(render): guaranteed ASCII and no-FX modes" +``` + +--- + +### Task 4: The tiny details + +**Files:** +- Create: `internal/fx/shootingstar.go` +- Modify: `internal/flavor/messages.go`, `internal/flavor/channel.go` (score-rollover detection), `internal/fx/world.go` (idle tracking) +- Test: `internal/fx/shootingstar_test.go`, `internal/flavor/details_test.go` + +**Interfaces:** +- Consumes: Plan 3's `Channel`, `Messages`, `ForEvent`. +- Produces: +```go +// fx +const (ShootingStarChancePerSecond = 0.15; ShootingStarSpeed = 30.0) +func (w *World) maybeShootingStar(dt float64) // called from Step; one ClassStar streak + // crossing diagonally, life ~0.5s + +// flavor — new single-line categories (§45) +const ( + CatKineticRod Category = iota + 100 // "KINETIC ROD DEPLOYED" + CatCubeSecured // "CUBE ADJACENT OBJECT SECURED" + CatRollover // "NUMBER BECAME BIGGER" +) +func (c *Channel) noteScore(score int) bool // true when the score crossed a power of ten >= 10000 +``` + +`ForEvent` gains three cases, each ranking above `CatLock` and below `CatTetris`: +- `PieceHardDropped` whose `Piece.Kind == game.KindI` and `Piece.Rotation` is 1 or 3 → `CatKineticRod`. +- `HoldUsed` whose `Piece.Kind == game.KindO` → `CatCubeSecured`. +- a `GameView.Score` that crossed a power of ten at or above 10000 since the last observation → `CatRollover`. + +`CatRare` (`DID YOU KNOW YOU'RE IN A TERMINAL?`) and the idle `CAPTAIN?` already exist from Plan 3. Plan 3's `TestEveryCategoryHasMessages` exempts `CatBoot`; extend that exemption to these three single-line categories. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestShootingStarIsRareAndShortLived(t *testing.T) +// 60 seconds of 16ms steps with a fixed fx seed: between 3 and 25 shooting stars spawn; +// each lives under 1s; none exists after a further 2s of stepping + +func TestShootingStarTravelsDiagonally(t *testing.T) +// a spawned shooting star has both VX != 0 and VY != 0 and Class == ClassStar + +func TestNoShootingStarWhenFXDisabled(t *testing.T) + +func TestKineticRodOnVerticalIHardDrop(t *testing.T) +// ForEvent(PieceHardDropped with Piece{KindI, Rotation:1}) => CatKineticRod +// Rotation 0 (horizontal) => not CatKineticRod +// a KindT vertical hard drop => not CatKineticRod + +func TestCubeSecuredOnHoldingAnO(t *testing.T) +// ForEvent(HoldUsed with Piece{KindO}) => CatCubeSecured; KindS => CatHold + +func TestScoreRolloverFiresOncePerDecade(t *testing.T) +// noteScore: 9_999 → false; 10_000 → true; 10_500 → false; 99_999 → false; +// 100_000 → true; 1_000 → false (below the 10_000 threshold) +// and the channel's Text() then contains "NUMBER BECAME BIGGER" + +func TestRareLinesStayRare(t *testing.T) +// 1000 clear events with a fixed seed: the CatRare line appears at least once and +// fewer than 60 times (RareChance 0.02) + +func TestDetailMessagesAreOccasionalNotConstant(t *testing.T) +// across a 3-minute simulated session, no single message occupies more than 25% +// of the frames in which the mission line was non-empty +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -run TestShooting -v && go test ./internal/flavor/ -v` +Expected: FAIL — undefined `CatKineticRod`. + +- [ ] **Step 3: Implement the shooting star and the three flavor cases** + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./... -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx internal/flavor +git commit -m "feat(flavor): shooting stars, kinetic rods, cube adjacency and number growth" +``` + +--- + +### Task 5: README and the definition-of-done sweep + +**Files:** +- Create: `README.md` +- Test: `internal/render/flicker_test.go`, `internal/app/done_test.go` + +**Interfaces:** +- Consumes: everything. +- Produces: the §47 gate. No new production API. + +`README.md` covers: what Cosmic Tetris is (one paragraph, §48's framing), `go run ./cmd/cosmic-tetris`, `go build`, the five flags with one line each, the §8 key table, the three rendering modes, the architecture map from §33 with one line per package, and how to run the tests (`go test ./...`, `-update` for goldens). + +- [ ] **Step 1: Write the failing tests** + +```go +func TestRenderIsStableForIdenticalState(t *testing.T) +// the same Snapshot and Options rendered twice is byte-identical, for playing, paused, +// boot, help, collapse and game-over states — the renderer contributes no flicker (§47) + +func TestNoFilesystemOrLoggingDuringGameplay(t *testing.T) +// a 600-frame session with heavy FX writes nothing to a redirected log.Default() output +// and opens no files (assert log output buffer stays empty) (§38) + +func TestDefinitionOfDoneChecklist(t *testing.T) +// one table-driven test, one subtest per §47 bullet that can be asserted in code: +// playable start → game over; hold; ghost; next queue; deterministic piece generation; +// isolated game and FX RNGs; correct line clearing; gravity increases; pause; restart; +// ASCII fallback; no-FX mode; resize; effects never modify game state; +// animations never block input. +// Each subtest drives the app model and asserts the behaviour; none may be skipped. +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/render/ -run TestRenderIsStable -v && go test ./internal/app/ -run 'TestNoFilesystem|TestDefinitionOfDone' -v` +Expected: FAIL — undefined tests / unmet assertions + +- [ ] **Step 3: Fix whatever the checklist surfaces, then write `README.md`** + +- [ ] **Step 4: Run the full gate** + +Run: +```bash +gofmt -l . && go vet ./... && go test ./... && go test -race ./internal/game/ ./internal/fx/ && go build ./... +``` +Expected: `gofmt` prints nothing, vet is clean, all tests pass, race detector clean, build succeeds. + +- [ ] **Step 5: Run the §43 coolness acceptance test by hand** + +Launch `go run ./cmd/cosmic-tetris --seed 8675309` and confirm, writing the result into the commit message: +- within 30 seconds of normal play: moving starfield, animated board border, piece trails, hard-drop impact, particles, mission-control commentary; +- on the first completed line: supernova clear animation, debris, border reaction; +- a four-line clear produces the §43 reaction; +- game over collapses the universe into a black hole; +- resizing the terminal mid-game never breaks or crashes it; +- `--ascii`, `--no-fx` and `--reduced-motion` each still play well. + +- [ ] **Step 6: Commit** + +```bash +git add README.md internal/render/flicker_test.go internal/app/done_test.go +git commit -m "docs: README and definition-of-done sweep" +``` diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/C3-planset-cosmic-92/2026-09-18-cosmic-tetris-effects.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/C3-planset-cosmic-92/2026-09-18-cosmic-tetris-effects.md new file mode 100644 index 000000000..31125f28e --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/C3-planset-cosmic-92/2026-09-18-cosmic-tetris-effects.md @@ -0,0 +1,1562 @@ +# Cosmic Tetris — Cosmic Effects Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give the working game an irresponsible special-effects budget — starfield, animated border, piece trails, particle physics, hard-drop impact, supernova line clears, shockwaves, hyperdrive, the four-line spectacle, mission-control commentary, the boot sequence and the game-over black hole — without any of it touching game state or delaying input. + +**Architecture:** Two new packages sit between the engine and the renderer. `internal/flavor` is pure text selection: it turns a `game.Event` plus a `game.Snapshot` into a string. `internal/fx` is an independent simulation: `World.Handle(events, snapshot)` observes, `World.Advance(dt)` steps, and it holds its own `*rand.Rand` that never touches the game's. `World.Handle` takes a `game.Snapshot` **by value**, which is what structurally guarantees §14's rule that effects can never modify `GameState`. `internal/render` grows a set of draw functions that read `*fx.World` and composite onto the same `lipgloss.Canvas`. Dependency direction stays `app → render → fx → flavor → game`. + +**Tech Stack:** Go 1.26, `charm.land/lipgloss/v2` (canvas cell writes, colour blending), `charm.land/bubbles/v2/spinner` (boot), `math/rand` (a second, independent generator). + +**Spec:** `design.md` (§14–§25, §27, §28, §29, §42 Phases 3–5, §43, §44, §45, §49.5) + +## Global Constraints + +- `internal/fx` must not import `internal/render` or `internal/app`, and must not hold a `*game.Game` — only `game.Event` values and `game.Snapshot` values (§14). +- The FX generator is created as `rand.New(rand.NewSource(seed ^ 0x5DEECE66D))` so it is reproducible for tests yet provably distinct from the game's `rand.NewSource(seed)`. It drives particles, star placement and flavor selection and nothing else (§35, §49.6). +- Effects never change piece order, score, gravity or collision. The engine's public surface is read-only from `fx` and `render`. +- Screen shake never exceeds one terminal cell in any direction (§44). +- FX writes into the board interior only where the destination cell is blank, so locked blocks, the ghost and the active piece are never obscured (§44). The single exception is the line-clear band, which by design draws over the rows it is destroying. +- `--reduced-motion` (§49.5) suppresses screen shake, hyperdrive acceleration and shockwaves. Colour, trails, particles, banners and mission control are untouched. +- `--no-fx` sets `render.Input.FX` to nil. A nil `FX` must produce output byte-identical to the goldens recorded in the terminal plan. +- Particle count is capped at `MaxParticles = 400` (§38). No goroutine per particle or per frame. +- Test command for this plan: `go test ./...`. + +## Review Focus + +1. A particle whose float position rounds outside the board rectangle must be culled, never written out of bounds — Task 3 and Task 4. +2. The worst-case simultaneous load (four-line clear, combo 7, shockwave, trails) must stay under the particle cap and still render — Task 9. +3. Every effect at the 40 × 24 minimum must reduce outside the board and leave the board fully readable — Task 12. +4. `--reduced-motion` during a four-line clear must produce zero shake offset, a hyperdrive multiplier of exactly 1, and no rings — Task 12. +5. Pausing mid-animation must freeze gameplay particles while stars keep drifting, and resuming must not replay the frozen interval as a burst — Task 12. + +## Plan Set + +Run in this order. A ruling that changes a name, signature, or value that a later plan consumes must be applied to that plan's document before the next task starts. + +1. `plans/2026-09-18-cosmic-tetris-engine.md` — headless deterministic game engine. Consumes: nothing. +2. `plans/2026-09-18-cosmic-tetris-terminal.md` — Bubble Tea app, renderer, layout, HUD, overlays, CLI. Consumes: the `internal/game` public API. +3. `plans/2026-09-18-cosmic-tetris-effects.md` (this plan) — all effects. Consumes: `render.Input`, `render.Theme`, `render.Layout`, `render.Frame`, `render.BoardPanel`, `app.Model`, `app.FrameMsg`, `app.Options`, `game.Event`, `game.Snapshot`. + +--- + +### Task 1: Flavor text + +**Files:** +- Create: `internal/flavor/messages.go` +- Test: `internal/flavor/messages_test.go` + +**Interfaces:** +- Consumes: `game.Event`, `game.EventKind`, `game.Snapshot`, `game.Piece`, `game.PieceKind`. +- Produces: + ```go + // Priority levels; a higher-priority message preempts a lower one on screen. + const ( + PriAmbient = 0 // idle chatter + PriRoutine = 1 // locks, spawns + PriSpecial = 2 // the §45 easter eggs + PriEvent = 3 // level change, combo + PriMajor = 4 // four-line clear, game over + ) + + // Mission picks a mission-control line for an event. ok is false when the + // event deserves no commentary. + func Mission(e game.Event, s game.Snapshot, rng *rand.Rand) (text string, priority int, ok bool) + + func FourLineBanner(rng *rand.Rand) string // one of the four §20 banners + func ComboLine(combo int, rng *rand.Rand) string // "COMBO 5 // ..." for combo >= 5, else "" + func LevelSubtitle(rng *rand.Rand) string // one of the three §22 subtitles + func Idle(rng *rand.Rand) string // "CAPTAIN?" and friends + func Rare(rng *rand.Rand) (string, bool) // 1-in-200: "DID YOU KNOW YOU'RE IN A TERMINAL?" + ``` + +All copy comes verbatim from §20, §21, §22, §27 and §45 — the nine mission-control lines, the four four-line banners, the three combo lines, the three level subtitles, and the §45 specials. Pinned special cases, each at `PriSpecial`: + +```text +PieceHardDropped with a vertical I (Kind KindI, Rotation 1 or 3) -> "KINETIC ROD DEPLOYED" +HoldUsed with an O piece -> "CUBE ADJACENT OBJECT SECURED" +LinesCleared where Snapshot.Score crossed a power of ten -> "NUMBER BECAME BIGGER" +``` + +- [ ] **Step 1: Write the failing tests** + +```go +func TestMissionReturnsCommentaryForEveryEventKind(t *testing.T) { + // for each game.EventKind: Mission never panics; when ok is true the text is + // non-empty and the priority is in PriAmbient..PriMajor +} + +func TestMissionIsSilentForMovementAndRotation(t *testing.T) { + // PieceMoved and PieceRotated return ok == false + // (§27: "Do not rotate messages constantly") +} + +func TestKineticRodForVerticalI(t *testing.T) { + // Event{Kind: PieceHardDropped, Piece: Piece{Kind: KindI, Rotation: 1}} + // -> "KINETIC ROD DEPLOYED" at PriSpecial + // Rotation 0 (horizontal I) does not produce it + // a KindT at Rotation 1 does not produce it +} + +func TestCubeAdjacentForHeldO(t *testing.T) { + // Event{Kind: HoldUsed, Piece: Piece{Kind: KindO}} -> "CUBE ADJACENT OBJECT SECURED" +} + +func TestNumberBecameBiggerOnPowerOfTenCrossing(t *testing.T) { + // LinesCleared with Snapshot{Score: 10000} -> "NUMBER BECAME BIGGER" + // Snapshot{Score: 10345} -> not that message +} + +func TestFourLineBannerIsAlwaysOneOfTheSpecBanners(t *testing.T) { + // 200 draws all land in the §20 set of four +} + +func TestComboLineStartsAtFive(t *testing.T) { + // ComboLine(4, rng) == "" + // ComboLine(5, rng) contains "COMBO 5" + // ComboLine(9, rng) contains "COMBO 9" (wrapping the three-line pool) +} + +func TestLevelSubtitleAndIdleAreNonEmpty(t *testing.T) { + // 100 draws of each are non-empty +} + +func TestRareFiresRoughlyOneInTwoHundred(t *testing.T) { + // over 20000 draws from a seeded rng, the hit count is between 40 and 160 +} + +func TestSelectionIsDeterministicPerSeed(t *testing.T) { + // two rngs from the same seed produce identical 100-draw sequences for + // FourLineBanner, LevelSubtitle and Idle +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/flavor/ -v` +Expected: FAIL — package does not exist. + +- [ ] **Step 3: Implement `internal/flavor/messages.go`** + +Copy every string from the spec exactly, including punctuation, the `*` footnote on `LOCAL UNIVERSE STABLE*`, and the `//` in the combo lines. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/flavor/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/flavor/ +git commit -m "feat: mission-control, banner and combo flavor text" +``` + +--- + +### Task 2: FX world, starfield, and the app clock hookup + +**Files:** +- Create: `internal/fx/world.go`, `internal/fx/starfield.go`, `internal/fx/events.go` +- Modify: `internal/app/model.go`, `internal/app/update.go` (own a `*fx.World`, drive it from `FrameMsg`) +- Test: `internal/fx/world_test.go`, `internal/fx/starfield_test.go`, `internal/app/fxclock_test.go` + +**Interfaces:** +- Consumes: `game.Event`, `game.Snapshot` (Task 1's package is consumed from Task 6 onward). +- Produces: + ```go + type Options struct { + Seed int64 + ReducedMotion bool + } + + type World struct { + Stars []Star + Elapsed time.Duration + // later tasks add Particles, Trails, Rings, Clears, Shake, Hyper, + // Banner, Level, Mission, Collapse + } + + func NewWorld(o Options) *World + + // Resize tells the world the terminal size (stars fill it) and the board's + // interior rectangle in terminal cells (gameplay effects live inside it). + func (w *World) Resize(termW, termH int, board image.Rectangle) + + func (w *World) Handle(evs []game.Event, s game.Snapshot) + + // Advance steps the world. When gameplay is false (paused, help open), + // only the background starfield and Elapsed move — §30 pins that background + // stars keep drifting while gameplay-related effects freeze. Task 12 relies on + // this parameter; every test in this plan passes true unless it is testing + // pause behaviour. + func (w *World) Advance(dt time.Duration, gameplay bool) + + func (w *World) ReducedMotion() bool + + type Star struct { + X, Y float64 + VY float64 + VX float64 // non-zero only for the §45 shooting star + Layer int // 0 far, 1 mid, 2 near, 3 shooting star + Glyph rune + Life float64 // seconds; only the shooting-star layer expires + } + ``` + +`app.Model` gains `FX *fx.World`, nil when `Options.NoFX`. `app.New` builds it with `fx.Options{Seed: opts.Seed, ReducedMotion: opts.ReducedMotion}`. `tea.WindowSizeMsg` calls `FX.Resize(w, h, m.Layout.BoardRect())` — `BoardRect` arrives in Task 4, so until then pass `image.Rectangle{}`. `FrameMsg` calls, in this order: `evs := m.Game.Advance(dt)` (only when playing), `m.FX.Handle(evs, m.Game.Snapshot())`, `m.FX.Advance(dt, m.State == StatePlaying)`. + +Pinned starfield values (§15): + +```text +density per layer (stars = termW*termH / divisor): + far divisor 40 glyph pool ['.'] base VY 0.6 cells/sec + mid divisor 80 glyph pool ['·', '˚'] base VY 1.5 cells/sec + near divisor 160 glyph pool ['✦', '✧', '*'] base VY 3.5 cells/sec + +level speed scale: 1 + 0.05*(level-1), clamped to 2.5 +stars drift downward; a star past the bottom wraps to a new random X at y = -1 +``` + +The §45 shooting star is a fourth, transient layer: roughly once every 12 seconds of `Elapsed`, spawn one star at a random position on the top or left edge with `VX` of 14–22 and `VY` of 7–11 cells/sec and a `Life` of 0.6s, glyph cycling `✦ · ·`. Only one may exist at a time, so it stays occasional rather than becoming weather. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestNewWorldUsesAnIndependentGenerator(t *testing.T) { + // two NewWorld(Options{Seed: 5}) produce identical star slices after Resize + // a World with Seed 5 and a game rng from rand.NewSource(5) drawing 100 + // Float64s produce different first values (the streams are not the same) +} + +func TestHandleTakesASnapshotByValue(t *testing.T) { + // compile-level guarantee, asserted as behaviour: mutating the Snapshot after + // Handle returns changes nothing observable in the World + // (this test exists to pin the signature; if Handle ever takes *game.Game it + // will not compile) +} + +func TestStarDensityScalesWithArea(t *testing.T) { + // Resize(80, 30, board): len(Stars) == 80*30/40 + 80*30/80 + 80*30/160 + // Resize(40, 24, board): a smaller count, and all three layers present +} + +func TestStarsStayInsideTheTerminal(t *testing.T) { + // Resize(80, 30, ...), then 600 Advance(16ms) calls: + // every star has 0 <= X < 80 and -1 <= Y < 30 +} + +func TestStarsDriftDownward(t *testing.T) { + // record every star's Y, Advance(500ms), and assert that stars which did not + // wrap have a larger Y +} + +func TestHigherLevelsMoveStarsFaster(t *testing.T) { + // two worlds from the same seed; feed one Handle with Snapshot{Level: 1} and + // the other Snapshot{Level: 10}; after Advance(1s) the level-10 world's mean + // star Y displacement is larger, and less than 2.5x the level-1 world's +} + +func TestLevelSpeedScaleIsClamped(t *testing.T) { + // Snapshot{Level: 100} produces the same displacement as Snapshot{Level: 31} + // (both at the 2.5 clamp) +} + +func TestAdvanceWithZeroDtChangesNothing(t *testing.T) { + // Advance(0, true) leaves Stars and Elapsed untouched +} + +func TestResizeToZeroDoesNotPanic(t *testing.T) { + // Resize(0, 0, image.Rect(0,0,0,0)) then Advance(16ms, true): + // no panic, no stars +} + +func TestResizePreservesNothingButDoesNotLeak(t *testing.T) { + // Resize(80,30,...) then Resize(40,24,...) yields the 40x24 count exactly +} + +func TestShootingStarIsOccasionalAndSingular(t *testing.T) { + // §45: over 120 seconds of Advance(16ms, true) the shooting-star layer is + // populated between 5 and 20 times, never holds more than one star at once, + // and every shooting star disappears within 1 second of appearing +} + +func TestAppFrameMsgDrivesTheWorld(t *testing.T) { + // app-level: after a WindowSizeMsg and two FrameMsgs 16ms apart, + // m.FX.Elapsed is 16ms and the stars have moved +} + +func TestAppPassesEventsToTheWorld(t *testing.T) { + // app-level: a hard-drop key press followed by a FrameMsg reaches the world — + // assert via m.FX.Elapsed advancing and, once Task 5 lands, via len(Trails) > 0 +} + +func TestNoFXModelHasNoWorld(t *testing.T) { + // app.New(Options{NoFX: true}).FX == nil, and a FrameMsg does not panic +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ -v` +Expected: FAIL — package does not exist. + +- [ ] **Step 3: Implement `world.go`, `starfield.go`, `events.go` and the app hookup** + +`events.go` holds the small `handle` dispatch table keyed on `game.EventKind` so later tasks add cases in one place. `World` keeps the last `game.Snapshot` it saw so `Advance` can scale star speed by level without re-reading the game. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/fx/ ./internal/app/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/ internal/app/ +git commit -m "feat: fx world with an independent RNG, starfield and app clock hookup" +``` + +--- + +### Task 3: Particle simulation + +**Files:** +- Create: `internal/fx/particle.go` +- Modify: `internal/fx/world.go` (add `Particles`, cap enforcement) +- Test: `internal/fx/particle_test.go` + +**Interfaces:** +- Consumes: Task 2's `World`. +- Produces: + ```go + const ( + MaxParticles = 400 + Gravity = 18.0 // cells/sec^2, downward + Drag = 0.35 // velocity multiplier per second + ) + + type Particle struct { + X, Y float64 + VX, VY float64 + Life float64 // seconds remaining + MaxLife float64 + Glyph rune + Brightness float64 // 0..1, derived as Life/MaxLife on each step + } + + // Emit adds n particles at (x, y) with speeds in [minSpeed, maxSpeed] spread + // over the given angle range, dropping the request when the cap is reached. + func (w *World) Emit(spec EmitSpec) + + type EmitSpec struct { + X, Y float64 + Count int + MinSpeed, MaxSpeed float64 + AngleFrom, AngleTo float64 // radians; 0 is +X, pi/2 is +Y (downward) + Glyphs []rune + MinLife, MaxLife float64 // seconds + } + + func (w *World) ParticleCount() int + ``` + +Integration step, exactly §23's order: + +```text +X += VX*dt; Y += VY*dt +VY += Gravity*dt +VX *= pow(Drag, dt); VY *= pow(Drag, dt) +Life -= dt +Brightness = Life / MaxLife +die when Life <= 0 or the position rounds outside the board rectangle +``` + +Default glyph pool (§18): `· * ✦ +`. + +Task 12 scales every emitted count by `World.Intensity()`, which is 1.0 at 80 × 30 and above. Any test in this plan that asserts an exact or bounded particle count must `Resize(80, 30, ...)` first, so it keeps passing once that scaling lands. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestEmitCreatesTheRequestedCount(t *testing.T) { + // Emit with Count 12 -> ParticleCount() == 12 + // every particle's Life is within [MinLife, MaxLife] and Glyph is in Glyphs +} + +func TestEmitRespectsTheAngleRange(t *testing.T) { + // AngleFrom -pi, AngleTo 0 (upward half): every particle has VY <= 0 +} + +func TestParticlesDieWhenLifeRunsOut(t *testing.T) { + // Emit with MaxLife 0.2; Advance(250ms) -> ParticleCount() == 0 +} + +func TestParticlesAreCulledOutsideTheBoard(t *testing.T) { + // Review Focus #1 + // Resize(80, 30, image.Rect(10, 2, 30, 22)); Emit at the board's right edge + // with a large +X velocity and MaxLife 5s; after Advance(500ms) every + // surviving particle's rounded position is inside image.Rect(10,2,30,22) +} + +func TestParticleCapIsEnforced(t *testing.T) { + // ten Emit calls of Count 80 each -> ParticleCount() == MaxParticles + // and Advance does not panic +} + +func TestGravityAndDragActInTheSpecOrder(t *testing.T) { + // a single particle at (5,5) with VX 10, VY 0, Life 1, stepped by Advance(1s): + // X is greater than 5 but less than 15 (drag applied after the move) + // VY is positive but less than Gravity (drag applied after gravity) +} + +func TestBrightnessTracksRemainingLife(t *testing.T) { + // MaxLife 1.0; after Advance(500ms) every Brightness is within 0.01 of 0.5 +} + +func TestEmitWithZeroCountIsANoOp(t *testing.T) { + // Count 0 and Count -1 both leave ParticleCount() at 0 and do not panic +} + +func TestEmitIsDeterministicPerSeed(t *testing.T) { + // two worlds from the same seed, same Resize and same EmitSpec produce + // identical Particles slices +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ -run 'TestEmit|TestParticle|TestGravity|TestBrightness' -v` +Expected: FAIL — undefined `Emit`. + +- [ ] **Step 3: Implement `internal/fx/particle.go` and wire `Particles` into `Advance`** + +Compact the slice in place with a write index rather than allocating a new slice each frame (§38: reusable slices). + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/fx/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/ +git commit -m "feat: capped terminal-space particle simulation" +``` + +--- + +### Task 4: FX compositing in the renderer + +**Files:** +- Create: `internal/render/canvas.go`, `internal/render/fxdraw.go` +- Modify: `internal/render/render.go` (add `Input.FX`, call the draw functions at §37 steps 2, 6 and 9) +- Create: `internal/render/testdata/fx.golden` +- Test: `internal/render/canvas_test.go`, `internal/render/fxdraw_test.go` + +**Interfaces:** +- Consumes: `fx.World`, `fx.Star`, `fx.Particle`, `render.Theme`, `render.Layout`. +- Produces: + ```go + // PlaceSparse writes one cell only when the destination is blank and inside + // the canvas. It returns false when it declined, which is how §44's + // "never obscure the active piece" rule is enforced mechanically. + func PlaceSparse(c *lipgloss.Canvas, x, y int, glyph rune, fg color.Color) bool + + func drawStars(c *lipgloss.Canvas, w *fx.World, th Theme) + func drawParticles(c *lipgloss.Canvas, w *fx.World, th Theme, board image.Rectangle) + + // BoardRect returns the board interior rectangle in terminal cells for a layout. + func (l Layout) BoardRect() image.Rectangle + ``` + +`Input` gains `FX *fx.World`. When it is nil, `Frame` skips every draw function and produces exactly what the terminal plan's goldens recorded. Star brightness maps to `th.Dim` (far), a blend of `th.Dim` and `th.Text` (mid), and `th.Text` (near); particle colour interpolates from `th.Accent` to `th.Dim` by `Brightness` using `lipgloss.Alpha`. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestPlaceSparseWritesIntoBlankCells(t *testing.T) { + // a fresh 10x5 canvas: PlaceSparse(c, 3, 2, '✦', red) returns true and + // c.CellAt(3,2).Content == "✦" +} + +func TestPlaceSparseRefusesOccupiedCells(t *testing.T) { + // compose a layer containing "██" at (0,0), then PlaceSparse(c, 0, 0, '✦', red) + // returns false and the cell content is unchanged +} + +func TestPlaceSparseRefusesOutOfBounds(t *testing.T) { + // Review Focus #1: (-1,0), (0,-1), (10,0), (0,5) on a 10x5 canvas all return + // false and do not panic +} + +func TestBoardRectMatchesTheBoardPanelInterior(t *testing.T) { + // for Compute(80,30) and Compute(40,24): BoardRect().Dx() == game.Width*2 + // and BoardRect().Dy() == game.VisibleRows, positioned one cell inside the border +} + +func TestParticlesNeverOverwriteBoardContent(t *testing.T) { + // build a fixture with a partial stack and 400 particles saturating the board + // rect; the stripped frame's board rows, sliced to the board columns, are + // identical to ansi.Strip(BoardPanel(...)) for every cell that BoardPanel + // filled with a block or ghost glyph +} + +func TestNilFXReproducesTheTerminalGoldens(t *testing.T) { + // Frame with FX nil at 80x30 equals testdata/wide.golden byte for byte + // (the same assertion for medium, small and ascii) +} + +func TestFrameWithFXGolden(t *testing.T) { + // a world seeded at 8675309, Resize(80,30,board), advanced by 40 frames of + // 16ms, composed into the standard fixture, compared to testdata/fx.golden +} + +func TestFrameWithFXKeepsItsDimensions(t *testing.T) { + // the FX frame at 80x30 is exactly 30 lines, none wider than 80 +} + +func TestStarsDoNotDrawInsideTheBoard(t *testing.T) { + // §15 "there should always be space behind/around the game" plus §44: + // stars whose position falls inside BoardRect() are skipped, so the board's + // empty interior in the stripped frame contains no star glyphs +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/render/ -run 'TestPlaceSparse|TestBoardRect|TestParticlesNever|TestNilFX|TestFrameWithFX|TestStarsDoNot' -v` +Expected: FAIL — undefined `PlaceSparse`. + +- [ ] **Step 3: Implement `canvas.go`, `fxdraw.go` and the `Frame` integration** + +Draw order inside `Frame`: stars first (behind everything, outside `BoardRect`), then the board panel and HUD, then particles into the board rect via `PlaceSparse` (§37 step 6), then global FX outside the board (§37 step 9). + +- [ ] **Step 4: Record the new golden and confirm the old ones still hold** + +Run: `go test ./internal/render/ -run TestFrameWithFXGolden -update` +Then run the whole package without `-update`. `TestNilFXReproducesTheTerminalGoldens` must pass without regenerating anything — if it fails, the FX integration changed the base frame and that is a bug, not a golden to re-record. + +- [ ] **Step 5: Run the suite** + +Run: `go test ./... -v` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add internal/render/ +git commit -m "feat: composite starfield and particles without touching board cells" +``` + +--- + +### Task 5: Animated board border, piece trails, quantum storage + +**Files:** +- Create: `internal/fx/trail.go` +- Modify: `internal/fx/world.go`, `internal/render/board.go`, `internal/render/fxdraw.go`, `internal/render/render.go` +- Test: `internal/fx/trail_test.go`, `internal/fx/hold_test.go`, `internal/render/border_test.go` + +**Interfaces:** +- Consumes: Tasks 2–4. +- Produces: + ```go + // fx + const ( + TrailLife = 140 * time.Millisecond // §17: ~100-160ms + TrailSteps = 4 + ) + + type Trail struct { + Cells []game.Point // board cells the piece occupied, in board coordinates + Kind game.PieceKind + Elapsed time.Duration + } + + func (t Trail) Step() int // 0..TrailSteps-1, or TrailSteps when expired + + // render + // BorderColor cycles th.Border over CycleNormal, shortening toward CycleHot + // and brightening as energy rises. energy is 0..1. + func BorderColor(th Theme, elapsed time.Duration, energy float64) color.Color + + const ( + CycleNormal = 12 * time.Second + CycleHot = 1500 * time.Millisecond + ) + + // Energy maps the §25 "energy state" from the snapshot: combo and level. + func Energy(s game.Snapshot) float64 + + func drawTrails(c *lipgloss.Canvas, w *fx.World, th Theme, board image.Rectangle) + + // TrailGlyphs returns the per-step glyph for a mode. + // full/reduced: "██" "▓▓" "▒▒" "░░" ASCII: "[]" "::" ".." " " + func (g Glyphs) Trail(step int) string + + // QUANTUM STORAGE (§9): the outgoing held piece compresses, streaks sideways + // and vanishes while the incoming piece flashes in. Gameplay never waits on it. + const HoldStreakLife = 120 * time.Millisecond + + type HoldStreak struct { + Out game.PieceKind // the piece leaving for the hold slot + In game.PieceKind // the piece arriving on the board + Cells []game.Point // where the outgoing piece was, board coordinates + Elapsed time.Duration + } + + // Squeeze returns the horizontal compression (1 at the start, 0 at the end) + // and the sideways streak offset in cells, toward the HOLD panel. + func (h HoldStreak) Squeeze() (scale float64, offset int) + func (h HoldStreak) InFlash() float64 // 0..1 brightness boost on the incoming piece + + func drawHoldStreak(c *lipgloss.Canvas, w *fx.World, th Theme, board image.Rectangle) + ``` + +`World.Handle` pushes a `Trail` for every `PieceMoved`, `PieceRotated` and `PieceHardDropped` event, recording the cells the piece left. A `PieceHardDropped` event pushes one trail per row crossed, which is §17's "stronger vertical trail". A `HoldUsed` event pushes a `HoldStreak` for `HoldStreakLife`. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestTrailStepAdvancesAndExpires(t *testing.T) { + // Trail{Elapsed: 0}.Step() == 0 + // Elapsed at TrailLife/4 -> 1; at TrailLife/2 -> 2; at 3*TrailLife/4 -> 3 + // Elapsed at TrailLife -> TrailSteps (expired) +} + +func TestMovementPushesATrail(t *testing.T) { + // Handle([]game.Event{{Kind: PieceMoved, Piece: p}}, snap) -> one Trail + // whose Cells match p.Cells() translated into board-interior coordinates +} + +func TestExpiredTrailsAreRemoved(t *testing.T) { + // after Handle and Advance(TrailLife + 10ms), len(w.Trails) == 0 +} + +func TestHardDropPushesOneTrailPerRowCrossed(t *testing.T) { + // Event{Kind: PieceHardDropped, Value: 7} -> 7 trails + // Value 0 -> no trails +} + +func TestTrailGlyphsPerMode(t *testing.T) { + // ModeFull: steps 0..3 are "██" "▓▓" "▒▒" "░░" + // ModeASCII: steps 0..3 are "[]" "::" ".." " " + // every glyph is exactly 2 columns wide + // step TrailSteps and step -1 return the blank glyph, not a panic +} + +func TestTrailsDoNotOverwriteTheActivePiece(t *testing.T) { + // a trail placed on the active piece's current cells leaves the stripped + // board rows identical to BoardPanel's (PlaceSparse declines) +} + +func TestBorderColorCyclesThroughTheStops(t *testing.T) { + // BorderColor(th, 0, 0) equals BorderColor(th, CycleNormal, 0) + // sampling 60 points across one cycle visits at least 4 distinct colours +} + +func TestBorderColorSpeedsUpWithEnergy(t *testing.T) { + // the number of distinct colours sampled over a fixed 1.5s window is + // strictly greater at energy 1.0 than at energy 0.0 +} + +func TestEnergyRisesWithComboAndLevel(t *testing.T) { + // Energy(Snapshot{}) == 0 + // Energy(Snapshot{Combo: 7, Level: 20}) == 1 + // Energy is monotonically non-decreasing in both Combo and Level +} + +func TestASCIIBorderDoesNotAnimateColors(t *testing.T) { + // with a single-stop Border slice, BorderColor returns that stop for every + // elapsed value (no index-out-of-range on a one-element palette) +} + +func TestHoldUsedPushesAQuantumStorageStreak(t *testing.T) { + // §9: Handle([]game.Event{{Kind: HoldUsed, Piece: p}}, snap) yields one + // HoldStreak whose Out is p.Kind and whose Cells match p.Cells() +} + +func TestHoldStreakCompressesAndStreaksSideways(t *testing.T) { + // §9 "compressed -> streaked sideways -> disappear": + // Squeeze() at Elapsed 0 is (1.0, 0) + // scale decreases monotonically to 0 across HoldStreakLife + // |offset| increases monotonically and is negative (toward the HOLD panel) +} + +func TestHoldStreakExpiresInOneHundredTwentyMilliseconds(t *testing.T) { + // after Advance(HoldStreakLife + 10ms, true), len(w.HoldStreaks) == 0 +} + +func TestIncomingPieceFlashes(t *testing.T) { + // InFlash() is near 1 at Elapsed 0 and 0 at HoldStreakLife +} + +func TestHoldDoesNotStallGameplay(t *testing.T) { + // §9 "Gameplay does not wait for the animation": app-level, press c then + // immediately press left in the same batch of Updates — the new active piece + // moves without waiting out HoldStreakLife +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ ./internal/render/ -run 'TestTrail|TestMovementPushes|TestExpired|TestHardDropPushes|TestBorderColor|TestEnergy|TestASCIIBorder' -v` +Expected: FAIL + +- [ ] **Step 3: Implement the trail and hold-streak types, the border gradient, and the draw wiring** + +Use `lipgloss.Blend1D` over `th.Border` to build a lookup table once per theme rather than blending every frame (§38). Both trails and hold streaks draw through `PlaceSparse`, so neither can cover the active piece. + +- [ ] **Step 4: Refresh the FX golden and read it** + +Run: `go test ./internal/render/ -run TestFrameWithFXGolden -update` +Read `testdata/fx.golden` and confirm trails appear as a short fading tail and the board border is intact. Confirm `TestNilFXReproducesTheTerminalGoldens` still passes. + +- [ ] **Step 5: Run the suite** + +Run: `go test ./... -v` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add internal/fx/ internal/render/ +git commit -m "feat: energy-reactive border gradient and ion piece trails" +``` + +--- + +### Task 6: Mission control channel + +**Files:** +- Create: `internal/fx/mission.go` +- Modify: `internal/fx/world.go`, `internal/app/update.go` (feed `Mission` into `render.Input`) +- Test: `internal/fx/mission_test.go` + +**Interfaces:** +- Consumes: `flavor.Mission`, `flavor.Idle`, `flavor.Rare` (Task 1). +- Produces: + ```go + const ( + MissionHold = 2500 * time.Millisecond // §27: give them time to breathe + IdlePrompt = 20 * time.Second // §45: long idle before first move + ) + + type Mission struct { + Text string + Priority int + Hold time.Duration // time remaining before an equal-priority line may replace this one + Idle time.Duration // time since the last player input + } + + func (w *World) MissionText() string + func (w *World) NoteInput() // called by app on any gameplay key press, resets Idle + ``` + +Replacement rule: a candidate line replaces the current one when `Hold <= 0`, or when its priority is strictly higher than the current priority. Otherwise it is dropped — messages are not queued (§27: "Do not rotate messages constantly"). + +- [ ] **Step 1: Write the failing tests** + +```go +func TestMissionStartsWithADefaultLine(t *testing.T) { + // a fresh world's MissionText() is non-empty (the §27 "NOMINALISH" opener) +} + +func TestEqualPriorityLineDoesNotPreemptWithinTheHold(t *testing.T) { + // Handle a PriRoutine event; text := MissionText() + // Advance(1s); Handle another PriRoutine event -> MissionText() == text + // Advance(1.6s) (past MissionHold); Handle again -> the text may change +} + +func TestHigherPriorityLinePreemptsImmediately(t *testing.T) { + // Handle a PriRoutine event; Advance(100ms) + // Handle a LinesCleared-of-four event (PriMajor) -> MissionText() changed +} + +func TestLowerPriorityLineIsDroppedNotQueued(t *testing.T) { + // Handle PriMajor; Advance(100ms); Handle PriRoutine -> text unchanged + // Advance(3s) -> the text is still the PriMajor line (nothing was queued) +} + +func TestIdlePromptAfterTwentySeconds(t *testing.T) { + // a fresh world advanced by 21s in 100ms steps eventually shows a line from + // flavor.Idle; NoteInput() before 20s resets the timer so it does not fire +} + +func TestNoteInputResetsIdle(t *testing.T) { + // Advance(19s); NoteInput(); Advance(19s) -> the idle line has not fired +} + +func TestMissionTextIsNeverEmptyAfterAnyEventSequence(t *testing.T) { + // feed one of every game.EventKind in turn with Advance(3s) between: + // MissionText() is non-empty at every point +} + +func TestMissionIsDeterministicPerSeed(t *testing.T) { + // two worlds from the same seed fed the same event/dt script produce the + // same MissionText() at every step +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ -run TestMission -v` +Expected: FAIL + +- [ ] **Step 3: Implement `mission.go` and wire it into `app`** + +`app.Model.View` reads `m.FX.MissionText()` into `render.Input.Mission`; the key handler calls `m.FX.NoteInput()` for gameplay keys only (not pause, help or quit). + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./... -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/ internal/app/ +git commit -m "feat: mission-control channel with hold time and priority preemption" +``` + +--- + +### Task 7: Hard-drop impact — shake, debris, border flash + +**Files:** +- Create: `internal/fx/shake.go` +- Modify: `internal/fx/world.go`, `internal/render/render.go`, `internal/render/fxdraw.go` +- Test: `internal/fx/shake_test.go`, `internal/render/shake_test.go` + +**Interfaces:** +- Consumes: Tasks 2–5. +- Produces: + ```go + const ( + ShakeDuration = 80 * time.Millisecond // §18 + ShakeSteps = 5 + FlashDuration = 120 * time.Millisecond + ) + + type Shake struct { + Elapsed, Total time.Duration + Amp int // 1 for a hard drop, 2 requested for a four-line clear but clamped to 1 + } + + // Offset returns the deterministic §18 pattern, or (0,0) when the world is in + // reduced-motion mode or the shake has expired. Neither component ever + // exceeds 1 in absolute value (§44). + func (w *World) ShakeOffset() (dx, dy int) + + func (w *World) Flash() float64 // 0..1 border-flash intensity, decaying over FlashDuration + ``` + +Pinned pattern (§18), one step per `ShakeDuration/ShakeSteps` = 16ms: + +```text +step 0: ( 0, +1) +step 1: (-1, 0) +step 2: (+1, 0) +step 3: ( 0, -1) +step 4: ( 0, 0) +``` + +`World.Handle` on `PieceHardDropped` starts the shake, emits 10–18 debris particles upward from the contact cells (`AngleFrom -2.6, AngleTo -0.5, MinSpeed 6, MaxSpeed 16`), and starts the border flash. `Frame` applies `ShakeOffset` to the board layer's X and Y only — the HUD, mission line and controls do not move, so the screen stays readable (§18: "Do not make the entire terminal unreadable"). + +- [ ] **Step 1: Write the failing tests** + +```go +func TestShakeOffsetFollowsTheSpecPattern(t *testing.T) { + // start a shake, then sample ShakeOffset() after 0, 16, 32, 48, 64 and 80ms: + // (0,1), (-1,0), (1,0), (0,-1), (0,0), (0,0) +} + +func TestShakeNeverExceedsOneCell(t *testing.T) { + // §44: for a shake with Amp 5 requested, every sampled offset has + // abs(dx) <= 1 and abs(dy) <= 1 +} + +func TestShakeExpires(t *testing.T) { + // after Advance(ShakeDuration + 20ms), ShakeOffset() == (0, 0) +} + +func TestReducedMotionSuppressesShake(t *testing.T) { + // Review Focus #4: a world with ReducedMotion true, given a hard drop, + // returns (0,0) at every sample point +} + +func TestHardDropEmitsDebrisUpward(t *testing.T) { + // Handle a PieceHardDropped event: ParticleCount() is between 10 and 18 + // and every new particle has VY < 0 +} + +func TestHardDropStartsTheBorderFlash(t *testing.T) { + // Flash() is > 0.9 immediately after the event and 0 after FlashDuration +} + +func TestReducedMotionKeepsDebrisAndFlash(t *testing.T) { + // §49.5: reduced motion leaves particles and the flash alone — + // ParticleCount() > 0 and Flash() > 0 after a hard drop +} + +func TestShakeMovesOnlyTheBoard(t *testing.T) { + // render-side: two frames, one with an active shake at step 0 and one with + // none; the controls line and mission line are at the same row in both, + // and the board's first border row differs by exactly one row +} + +func TestShakenBoardStaysInsideTheTerminal(t *testing.T) { + // at 40x24 with every shake step: the frame is 24 lines and no line exceeds + // 40 columns (the offset must be clipped, not allowed to push content off) +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ ./internal/render/ -run 'TestShake|TestHardDropEmits|TestHardDropStarts|TestReducedMotion' -v` +Expected: FAIL + +- [ ] **Step 3: Implement `shake.go`, the `Handle` case, and the `Frame` offset** + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./... -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/ internal/render/ +git commit -m "feat: hard-drop impact with deterministic shake, debris and border flash" +``` + +--- + +### Task 8: Line-clear supernova and shockwaves + +**Files:** +- Create: `internal/fx/clear.go`, `internal/fx/shockwave.go` +- Modify: `internal/fx/world.go`, `internal/render/fxdraw.go` +- Test: `internal/fx/clear_test.go`, `internal/fx/shockwave_test.go` + +**Interfaces:** +- Consumes: Tasks 2–7. +- Produces: + ```go + const ( + ClearDuration = 220 * time.Millisecond // §19 + ClearPhaseA = 70 * time.Millisecond // critical mass + ClearPhaseB = 150 * time.Millisecond // supernova (cumulative) + RingDuration = 300 * time.Millisecond // §24 + ) + + type ClearPhase uint8 + + const ( + PhaseCriticalMass ClearPhase = iota + PhaseSupernova + PhaseCollapse + PhaseDone + ) + + type ClearAnim struct { + Rows []int // board-interior row indices + Elapsed time.Duration + } + + func (c ClearAnim) Phase() ClearPhase + // Glyph returns the glyph for column col (0..width-1) at the current phase, + // or 0 when that column should be left alone. + func (c ClearAnim) Glyph(col, width int) rune + + type Shockwave struct { + X, Y float64 + Elapsed time.Duration + } + + func (s Shockwave) Radius() float64 // 0 .. maxRadius over RingDuration + func (s Shockwave) Glyph() rune // · ○ ◌ ◯ by radius band (§24) + ``` + +`World.Handle` on `LinesCleared` pushes a `ClearAnim` for the rows, and emits debris whose horizontal velocity scales with each particle's distance from the row centre (§19: "Particles should inherit some horizontal velocity from their location relative to center"). A clear of three or more rows also pushes a `Shockwave` at the centre of the cleared band — unless reduced motion is on (§49.5). + +Phase glyphs (§19): phase A brightens the row centre to `█` with `▓` at the edges; phase B is a centre-out explosion reading `░░░▓▓██✦✦██▓▓░░░`; phase C leaves only sparse debris glyphs. The clear band is the one effect allowed to draw over locked cells, since it is drawing the destruction of those cells. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestClearPhaseBoundaries(t *testing.T) { + // Elapsed 0 and 69ms -> PhaseCriticalMass + // 70ms and 149ms -> PhaseSupernova + // 150ms and 219ms -> PhaseCollapse + // 220ms and beyond -> PhaseDone +} + +func TestClearAnimIsRemovedWhenDone(t *testing.T) { + // Handle a LinesCleared event, Advance(ClearDuration + 10ms): + // len(w.Clears) == 0 +} + +func TestClearGlyphIsSymmetricAboutTheCentre(t *testing.T) { + // for each phase and width 20: Glyph(col, 20) == Glyph(19-col, 20) +} + +func TestPhaseBIsBrightestAtTheCentre(t *testing.T) { + // at PhaseSupernova, Glyph(9, 20) and Glyph(10, 20) are '✦' + // and Glyph(0, 20) is a dimmer glyph +} + +func TestPhaseCLeavesMostColumnsAlone(t *testing.T) { + // at PhaseCollapse, at least half the columns return 0 (sparse debris) +} + +func TestClearEmitsCentreBiasedDebris(t *testing.T) { + // Handle LinesCleared for a row: particles left of centre have VX < 0 and + // particles right of centre have VX > 0 +} + +func TestThreeOrMoreRowsPushAShockwave(t *testing.T) { + // LinesCleared with Value 1 or 2 -> len(w.Rings) == 0 + // Value 3 or 4 -> len(w.Rings) == 1, centred on the cleared band +} + +func TestReducedMotionSuppressesShockwaves(t *testing.T) { + // Review Focus #4: reduced motion + LinesCleared Value 4 -> len(w.Rings) == 0 + // but len(w.Clears) == 1 and ParticleCount() > 0 +} + +func TestShockwaveRadiusGrowsMonotonically(t *testing.T) { + // sampling Radius() over 0..RingDuration is non-decreasing and starts at 0 + // Glyph() changes at least three times across that range +} + +func TestShockwaveExpires(t *testing.T) { + // after Advance(RingDuration + 10ms), len(w.Rings) == 0 +} + +func TestClearBandStaysWithinTheBoardRows(t *testing.T) { + // Review Focus #1: a LinesCleared event naming rows 0 and 21 draws only + // inside BoardRect() — assert the rendered frame's height is unchanged and + // no rows outside the board changed +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ -run 'TestClear|TestPhase|TestThreeOrMore|TestShockwave' -v` +Expected: FAIL + +- [ ] **Step 3: Implement `clear.go`, `shockwave.go`, the `Handle` case, and `drawClears` / `drawRings`** + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./... -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/ internal/render/ +git commit -m "feat: three-phase supernova line clear and radial shockwaves" +``` + +--- + +### Task 9: Hyperdrive, banners, combo escalation, level-up card + +**Files:** +- Create: `internal/fx/hyperdrive.go`, `internal/fx/banner.go` +- Modify: `internal/fx/world.go`, `internal/fx/starfield.go`, `internal/render/fxdraw.go`, `internal/render/render.go` +- Test: `internal/fx/hyperdrive_test.go`, `internal/fx/banner_test.go` + +**Interfaces:** +- Consumes: Tasks 1–8. +- Produces: + ```go + const ( + HyperDuration = 1100 * time.Millisecond // §16 + BannerDuration = 700 * time.Millisecond // §20 + LevelDuration = 900 * time.Millisecond // §22 + ) + + type Hyperdrive struct { + Elapsed, Total time.Duration + } + + // SpeedMultiplier interpolates the §16 keyframes; it returns exactly 1 when + // inactive or when the world is in reduced-motion mode. + func (w *World) SpeedMultiplier() float64 + + type Banner struct { + Text string + Elapsed time.Duration + } + + func (w *World) Banner() (text string, alpha float64, ok bool) + + type Levelup struct { + Level int + Subtitle string + Elapsed time.Duration + } + + func (w *World) Levelup() (level int, subtitle string, slide float64, ok bool) + + func (w *World) ComboIntensity() float64 // 0..1, drives the §21 HUD pulse + + func drawBanner(c *lipgloss.Canvas, w *fx.World, th Theme, l Layout) + func drawLevelup(c *lipgloss.Canvas, w *fx.World, th Theme, l Layout) + ``` + +Pinned hyperdrive keyframes (§16), linearly interpolated: + +```text + 0ms -> 0.0 stars pause + 50ms -> 0.3 stars stretch + 100ms -> 3.0 accelerate violently + 500ms -> 9.0 peak speed + 800ms -> 3.0 decay +1100ms -> 1.0 normal +``` + +Triggers (§16): a four-line clear, a combo reaching 5 or more, and a new high score (the world tracks its own best-score watermark, since the engine has no notion of one). Combo escalation (§21), driven from `ComboChanged`: + +```text +combo 2 -> 8 spark particles +combo 3 -> 16 meteor particles with strong horizontal velocity +combo 4 -> ComboIntensity() >= 0.5, which makes the HUD pulse +combo 5+ -> hyperdrive plus a flavor.ComboLine at PriEvent +``` + +The four-line sequence (§20) fires all of it at once: hyperdrive, shake, border pulse via `Energy`, a particle eruption, HUD flash, a temporary star-density increase, and a `flavor.FourLineBanner` for `BannerDuration`. `LevelChanged` pushes a `Levelup` with a `flavor.LevelSubtitle`, sliding out over `LevelDuration`. Neither the banner nor the level card is allowed to change `app.State` — input keeps flowing (§20). + +- [ ] **Step 1: Write the failing tests** + +```go +func TestSpeedMultiplierMatchesTheKeyframes(t *testing.T) { + // start hyperdrive, then sample at 0, 50, 100, 500, 800 and 1100ms: + // within 0.01 of 0.0, 0.3, 3.0, 9.0, 3.0, 1.0 + // a sample at 300ms lies strictly between 3.0 and 9.0 +} + +func TestSpeedMultiplierIsOneWhenInactive(t *testing.T) { + // a fresh world and a world advanced past HyperDuration both return 1.0 +} + +func TestReducedMotionPinsSpeedMultiplierToOne(t *testing.T) { + // Review Focus #4: reduced motion plus a four-line clear returns exactly 1.0 + // at every sample point +} + +func TestHyperdriveAcceleratesStars(t *testing.T) { + // two worlds from the same seed; trigger hyperdrive in one; after + // Advance(500ms) the triggered world's mean star displacement is larger +} + +func TestFourLineClearTriggersEverything(t *testing.T) { + // Handle LinesCleared with Value 4: + // SpeedMultiplier() != 1, ShakeOffset() != (0,0), ParticleCount() > 0, + // Banner() ok with a §20 text, len(w.Clears) == 1, len(w.Rings) == 1, + // and MissionText() is a PriMajor line +} + +func TestBannerExpiresAfterSevenHundredMilliseconds(t *testing.T) { + // ok is true at 699ms and false at 700ms; alpha decreases monotonically + // over the last 200ms and is 1.0 for the first 500ms +} + +func TestComboEscalationThresholds(t *testing.T) { + // ComboChanged Value 2 -> ParticleCount() == 8 + // Value 3 -> 16 more particles, and their |VX| exceeds the combo-2 particles' + // Value 4 -> ComboIntensity() >= 0.5 + // Value 5 -> SpeedMultiplier() != 1 and MissionText() contains "COMBO 5" + // Value 0 -> ComboIntensity() == 0 +} + +func TestNewHighScoreTriggersHyperdriveOnce(t *testing.T) { + // Handle LinesCleared with Snapshot{Score: 1000} -> hyperdrive starts + // Advance past HyperDuration, Handle again with Snapshot{Score: 900} + // -> hyperdrive does not restart +} + +func TestLevelChangedPushesTheCard(t *testing.T) { + // LevelChanged Value 8 -> Levelup() ok with level 8 and a §22 subtitle + // slide goes from 0 to 1 across LevelDuration, then ok is false +} + +func TestWorstCaseLoadStaysUnderTheCap(t *testing.T) { + // Review Focus #2: Handle a single batch containing LinesCleared Value 4, + // ComboChanged Value 7, PieceHardDropped Value 20 and PieceLocked, + // then Advance 60 frames of 16ms: + // ParticleCount() <= MaxParticles at every frame, and the frame renders + // at 40x24 and 80x30 without panicking +} + +func TestBannerDoesNotBlockInput(t *testing.T) { + // app-level: with a banner active, a left key press still moves the piece + // in the same Update call +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ ./internal/app/ -run 'TestSpeedMultiplier|TestReducedMotionPins|TestHyperdrive|TestFourLine|TestBanner|TestCombo|TestNewHighScore|TestLevelChanged|TestWorstCase' -v` +Expected: FAIL + +- [ ] **Step 3: Implement `hyperdrive.go`, `banner.go`, the star-speed hook, and the draw functions** + +`drawBanner` centres the banner over the board using `lipgloss.Place` and fades it with `lipgloss.Alpha`; `drawLevelup` slides the §22 card in from the right of the board using the `slide` value. + +- [ ] **Step 4: Refresh the FX golden and read it** + +Run: `go test ./internal/render/ -run TestFrameWithFXGolden -update` +Confirm `TestNilFXReproducesTheTerminalGoldens` still passes. + +- [ ] **Step 5: Run the suite** + +Run: `go test ./... -v` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add internal/fx/ internal/render/ +git commit -m "feat: hyperdrive, four-line spectacle, combo escalation and level-up card" +``` + +--- + +### Task 10: Boot sequence + +**Files:** +- Modify: `internal/app/model.go`, `internal/app/update.go`, `internal/render/overlay.go`, `internal/render/render.go` +- Modify: `internal/render/testdata/boot.golden` +- Test: `internal/app/boot_test.go`, `internal/render/overlay_test.go` + +**Interfaces:** +- Consumes: `render.BootBox` (terminal plan Task 6), `spinner.Model`. +- Produces: + ```go + // app.Model gains: + // Spinner spinner.Model // shown beside the §29 checklist while booting + // Init returns tea.Batch(Frame(), m.Spinner.Tick) + ``` + +`BootBox` gains a spinner frame parameter: `BootBox(progress float64, spin string, th Theme) string`. The boot overlay renders over a live starfield — `Frame` draws stars before the boot box, so the terminal is already alive during the one second of drama (§29). Any key press ends the sequence immediately. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestBootShowsSpinnerAndChecklist(t *testing.T) { + // a model in StateBoot at progress 0.6: the stripped view contains + // "INITIALIZING LOCAL UNIVERSE", "gravity", "spacetime" and the spinner frame +} + +func TestBootRunsStarfieldBehindTheCard(t *testing.T) { + // the boot frame at 80x30 contains at least one star glyph outside the box +} + +func TestAnyKeySkipsBoot(t *testing.T) { + // §29: for each of "a", "space", "?", "p", the model leaves StateBoot + // (and "q" still quits rather than skipping into play) +} + +func TestBootEndsOnItsOwnAtOneSecond(t *testing.T) { + // FrameMsgs totalling BootDuration leave StatePlaying, and the game has + // not advanced during boot (Active.Y is still the spawn Y) +} + +func TestSpinnerTickDoesNotAdvanceTheGame(t *testing.T) { + // feeding spinner.TickMsg while in StatePlaying changes neither Score + // nor Active +} + +func TestBootGolden(t *testing.T) { + // the boot frame with a fixed spinner frame and progress 0.6 at 80x30 + // matches testdata/boot.golden +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/app/ ./internal/render/ -run 'TestBoot|TestAnyKey|TestSpinner' -v` +Expected: FAIL + +- [ ] **Step 3: Implement the spinner wiring and the updated `BootBox`** + +Use `spinner.New(spinner.WithSpinner(spinner.Dot))`. Forward `spinner.TickMsg` to `m.Spinner.Update` and nothing else. + +- [ ] **Step 4: Refresh the boot golden and read it** + +Run: `go test ./internal/render/ -run 'TestBootGolden|TestOverlayGoldens' -update` + +- [ ] **Step 5: Run the suite** + +Run: `go test ./... -v` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add internal/app/ internal/render/ +git commit -m "feat: boot sequence with spinner over a live starfield" +``` + +--- + +### Task 11: Game-over black hole + +**Files:** +- Create: `internal/fx/collapse.go` +- Modify: `internal/fx/world.go`, `internal/render/fxdraw.go`, `internal/render/render.go`, `internal/app/update.go` +- Create: `internal/render/testdata/collapse.golden` +- Test: `internal/fx/collapse_test.go`, `internal/render/collapse_test.go` + +**Interfaces:** +- Consumes: Tasks 2–9; `render.GameOverBox`. +- Produces: + ```go + const ( + CollapseFreeze = 300 * time.Millisecond // §28: SIGNAL LOST + CollapseFall = 900 * time.Millisecond // cumulative: blocks fall inward + CollapseSingularity = 1300 * time.Millisecond // cumulative: black hole art + ) + + type CollapsePhase uint8 + + const ( + CollapseSignalLost CollapsePhase = iota + CollapseFalling + CollapseBlackHole + CollapseCard + ) + + type Collapse struct { + Cells []CollapseCell + Elapsed time.Duration + } + + type CollapseCell struct { + X, Y float64 + Kind game.PieceKind + originX, originY float64 + } + + func (w *World) Collapse() (*Collapse, bool) + func (c *Collapse) Phase() CollapsePhase + + func drawCollapse(c *lipgloss.Canvas, w *fx.World, th Theme, l Layout) + + // BlackHoleArt returns the §28 singularity drawing for a mode. + func BlackHoleArt(th Theme) string + ``` + +`World.Handle` on `GameOver` snapshots every filled board cell into `Collapse.Cells`. During `CollapseFalling` each cell eases from its origin toward the board centre; during `CollapseBlackHole` the cells are gone and `BlackHoleArt` renders. `render.Frame` shows `OverlayGameOver`'s card only once `Phase() == CollapseCard`, so the §28 sequence plays before the final panel. Because the engine is already in `StateOver`, the collapse is purely visual and `r` and `q` work throughout (§20's "must not block gameplay input" applied to game over). + +The board snapshot requires the filled cells at game-over time. `game.Event{Kind: GameOver}` does not carry them, so `app` passes the board through: `World.Handle` receives the events and the snapshot, and `app` calls `w.NoteBoard(&m.Game.Board)` immediately before `Handle` on the frame that produced the `GameOver` event. `NoteBoard` copies the cells it needs and keeps no pointer. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestCollapsePhaseBoundaries(t *testing.T) { + // 0 and 299ms -> CollapseSignalLost + // 300 and 899ms -> CollapseFalling + // 900 and 1299ms-> CollapseBlackHole + // 1300ms+ -> CollapseCard +} + +func TestCollapseSnapshotsTheFilledCells(t *testing.T) { + // a board with 17 filled cells, NoteBoard then Handle(GameOver): + // len(Collapse().Cells) == 17 and each carries the right Kind +} + +func TestNoteBoardKeepsNoPointer(t *testing.T) { + // NoteBoard(&b); Handle(GameOver); then clear b entirely: + // Collapse().Cells is unchanged (§14: fx cannot reach game state) +} + +func TestCellsFallTowardTheCentre(t *testing.T) { + // at CollapseFalling's start every cell is at its origin; + // at 890ms every cell's distance to the board centre is smaller than at 310ms +} + +func TestSignalLostFreezesEverything(t *testing.T) { + // during CollapseSignalLost no cell has moved from its origin, + // and the rendered frame contains "SIGNAL LOST" +} + +func TestBlackHoleArtAppearsThenTheCard(t *testing.T) { + // the frame at 1000ms contains the §28 singularity ("●" and "---") and not + // "UNIVERSE EXPIRED" + // the frame at 1400ms contains "UNIVERSE EXPIRED" and the final counters +} + +func TestASCIIBlackHoleUsesASCIIGlyphs(t *testing.T) { + // in ModeASCII the art contains no "●" +} + +func TestRestartAndQuitWorkDuringTheCollapse(t *testing.T) { + // app-level: with the collapse at 500ms, "r" returns to StatePlaying with a + // fresh board, and "q" returns a tea.QuitMsg +} + +func TestCollapseGolden(t *testing.T) { + // the frame at 1000ms with a fixed board matches testdata/collapse.golden +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ ./internal/render/ ./internal/app/ -run 'TestCollapse|TestNoteBoard|TestCellsFall|TestSignalLost|TestBlackHole|TestASCIIBlackHole|TestRestartAndQuit' -v` +Expected: FAIL + +- [ ] **Step 3: Implement `collapse.go`, `NoteBoard`, `drawCollapse`, `BlackHoleArt` and the `Frame` gating** + +- [ ] **Step 4: Record the golden and read it** + +Run: `go test ./internal/render/ -run TestCollapseGolden -update` +Read `testdata/collapse.golden` and confirm the singularity is centred on the board and the HUD is still legible around it. + +- [ ] **Step 5: Run the suite** + +Run: `go test ./... -v` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add internal/fx/ internal/render/ internal/app/ +git commit -m "feat: game-over collapse into a simulated black hole" +``` + +--- + +### Task 12: Mode audit, responsive FX, coolness acceptance + +**Files:** +- Modify: `internal/fx/world.go` (`Intensity` scaling by terminal size), `internal/app/model.go` (`--no-fx` and `--reduced-motion` wiring), `README.md` +- Test: `internal/app/modes_test.go`, `internal/fx/intensity_test.go` +- Create: `internal/render/testdata/small-fx.golden` + +**Interfaces:** +- Consumes: everything. +- Produces: + ```go + // Intensity scales effect volume down on small terminals (§31: "Effects + // automatically reduce outside the board"). 1.0 at 80x30 and above, + // 0.4 at the 40x24 minimum, linear between. + func (w *World) Intensity() float64 + ``` + +`app.New` sets `Options.NoFX` to mean `Model.FX == nil`, which makes `render.Input.FX` nil. `Options.ReducedMotion` flows into `fx.Options.ReducedMotion`. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestIntensityScalesWithTerminalSize(t *testing.T) { + // Review Focus #3 + // Resize(80,30,...) -> 1.0 + // Resize(40,24,...) -> within 0.01 of 0.4 + // Resize(60,27,...) -> strictly between + // Resize(0,0,...) -> 0.4 (floor, no division by zero) +} + +func TestSmallTerminalEmitsFewerParticles(t *testing.T) { + // Review Focus #3: two worlds, one at 80x30 and one at 40x24, both given a + // four-line clear: the 40x24 world's ParticleCount() is lower +} + +func TestSmallTerminalBoardStaysReadable(t *testing.T) { + // Review Focus #3: at 40x24 with a saturated FX state, the stripped frame's + // board rows sliced to the board columns still equal BoardPanel's output for + // every cell BoardPanel filled, and the frame is exactly 24 lines + // (recorded as testdata/small-fx.golden) +} + +func TestNoFXProducesTheBaseGame(t *testing.T) { + // app.New(Options{NoFX: true}): m.FX == nil, View() does not panic, + // and after a hard drop the stripped view contains no particle glyphs + // from the §18 pool +} + +func TestNoFXStillPlays(t *testing.T) { + // §47 "the game is fun even with effects disabled": with NoFX, moves, + // rotations, hard drops, hold, line clears, pause and restart all still work + // (drive a short scripted session through Update and assert Score > 0 and + // Lines > 0) +} + +func TestReducedMotionSuppressesOnlyTheThreeEffects(t *testing.T) { + // Review Focus #4: app.New(Options{ReducedMotion: true}), four-line clear: + // ShakeOffset() == (0,0), SpeedMultiplier() == 1.0, len(Rings) == 0 + // AND ParticleCount() > 0, len(Clears) == 1, Banner() ok, Flash() > 0 +} + +func TestPauseFreezesGameplayFXButNotStars(t *testing.T) { + // Review Focus #5: press p mid-animation (an active ClearAnim and Shake), + // feed 30 FrameMsgs: + // the ClearAnim's Elapsed and the Shake's Elapsed are unchanged, + // ParticleCount() is unchanged, but the stars have moved +} + +func TestResumeDoesNotBurstAfterAPause(t *testing.T) { + // Review Focus #5: pause, feed a FrameMsg 30 seconds later, unpause, feed + // another FrameMsg 16ms later: the piece descends at most one cell +} + +func TestASCIIModeRunsEveryEffect(t *testing.T) { + // with ModeASCII and a saturated FX state, the stripped frame contains no + // glyph outside the ASCII/Latin-1 set used by the ASCII theme +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ ./internal/app/ -run 'TestIntensity|TestSmallTerminal|TestNoFX|TestReducedMotion|TestPauseFreezes|TestResume|TestASCIIModeRuns' -v` +Expected: FAIL + +- [ ] **Step 3: Implement `Intensity`, the pause gating for gameplay FX, and the flag wiring** + +`Advance`'s `gameplay` parameter (pinned in Task 2) is what gates this: stars, the shooting star and `Elapsed` always step, while particles, trails, hold streaks, clears, rings, shake, hyperdrive, banners and the collapse step only when `gameplay` is true. Confirm every effect added since Task 2 respects it. + +`Emit` and every `Handle` case scale their particle counts by `Intensity()`, rounded down, with a floor of 1 so an effect never vanishes entirely on a small terminal. + +- [ ] **Step 4: Record the small-FX golden and read it** + +Run: `go test ./internal/render/ -run TestSmallTerminalBoardStaysReadable -update` + +- [ ] **Step 5: Run the full suite with race detection** + +Run: `go build ./... && go vet ./... && go test ./... -race -cover` +Expected: PASS + +- [ ] **Step 6: The §43 coolness acceptance pass** + +Run: `go run ./cmd/cosmic-tetris --seed 8675309` + +Within the first 30 seconds of normal play, confirm by eye that all six are present (§43): moving starfield, animated board border, piece trails, hard-drop impact, particles, mission-control commentary. On the first completed line, confirm the supernova animation, debris and a border reaction. Then engineer a four-line clear (hold an `I`, build a well) and confirm the banner, hyperdrive, shake, eruption and HUD flash all fire together. Then die and confirm the §28 sequence: freeze with `SIGNAL LOST`, blocks falling inward, the black hole, then the final card with the right numbers. + +Run each of these and confirm the stated behaviour: +- `go run ./cmd/cosmic-tetris --no-fx` — still a good game, nothing moving in the background. +- `go run ./cmd/cosmic-tetris --ascii` — every effect present, no `██`, readable. +- `go run ./cmd/cosmic-tetris --reduced-motion` — a four-line clear produces no shake, no star acceleration and no rings, but still the banner, particles and colour. +- Resize the window repeatedly during play, including below 40×24 and back — no panic, no flicker, the board recovers. + +Anything that fails gets fixed inside this task before committing. + +- [ ] **Step 7: Update the README and commit** + +Add an "Effects" section covering the flag behaviours, the three rendering modes, and the fact that `internal/fx` cannot reach game state. Then: + +```bash +git add internal/fx/ internal/app/ internal/render/ README.md +git commit -m "feat: responsive effect intensity, no-fx and reduced-motion audit" +``` + +--- + +## Done when + +Every item in §47's definition of done holds, and §43's acceptance pass in Task 12 Step 6 has been walked end to end by hand: the game is playable start to game over, controls are immediate, resizing works, `--ascii`, `--no-fx` and `--reduced-motion` all work, the engine's RNG and the FX RNG are isolated, effects never modify game state, four-line clears are gloriously excessive, and the universe collapses into a black hole on death. diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/C3-planset-cosmic-92/2026-09-18-cosmic-tetris-engine.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/C3-planset-cosmic-92/2026-09-18-cosmic-tetris-engine.md new file mode 100644 index 000000000..fe33092fb --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/C3-planset-cosmic-92/2026-09-18-cosmic-tetris-engine.md @@ -0,0 +1,1134 @@ +# Cosmic Tetris — Engine Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the headless, deterministic Cosmic Tetris game engine — pieces, board, 7-bag, movement, wall kicks, gravity, lock delay, line clearing, hold, scoring, game over — with comprehensive unit tests and a replay determinism test, before any terminal code exists. + +**Architecture:** One package, `internal/game`, with no dependencies outside the standard library. It never reads a clock: all time enters through `(*Game).Advance(dt time.Duration) []Event`. Every mutating method returns a `[]Event` slice describing what happened, which later plans feed to the effects system. The package owns a single `*rand.Rand` that drives the 7-bag and nothing else. + +**Tech Stack:** Go 1.26, standard library only (`math`, `math/rand`, `time`, `strings`). + +**Spec:** `design.md` (§5–§13, §34, §35, §40, §42 Phase 1, §49.1, §49.2, §49.6) + +## Global Constraints + +- Module path: `cosmic-tetris`. Go directive: `go 1.26`. +- Nothing under `internal/game` may import `charm.land/*`, and nothing under `internal/game` may call `time.Now()` (§49.2). Task 11 enforces this with a test. +- Board: width 10, height 22, visible rows 20, hidden spawn rows 2 (§5). +- `y` grows downward. `Board.Cells[0]` is the top hidden row; `Board.Cells[21]` is the bottom visible row. +- Game RNG is `*rand.Rand` owned by `Game` and used only by the 7-bag (§49.6). FX RNG is a separate generator created in a later plan; they never share. +- Combo indexing and bonus follow §49.1 exactly: first clearing placement sets combo to 1; bonus is `50 × (combo - 1) × level`. +- Test command for this plan: `go test ./internal/game/...`. +- Commit style: `feat:`, `test:`, `chore:` prefixes; one commit per task. + +## Review Focus + +These are behaviors the spec implies but never states. Each has a test added to the task that owns the code. + +1. `Advance` called with a `dt` far larger than the drop interval (laptop sleep, Ctrl-Z resume) must not cascade through many pieces in one call — Task 6. +2. A wall kick offset that would place cells above row 0 must be handled consistently and must never write outside the `Cells` array — Task 5. +3. `HardDrop` on a piece already resting on the stack must score 0 and lock without a negative drop distance — Task 7. +4. `UseHold` when the swapped-in piece cannot fit at spawn must end the game, not overlap locked cells — Task 9. +5. `DropInterval` at very high levels must stay at or above 60 ms and never reach 0 — a zero interval divides by zero in the gravity loop — Task 8. + +## Plan Set + +Run in this order. A ruling that changes a name, signature, or value that a later plan consumes must be applied to that plan's document before the next task starts. + +1. `plans/2026-09-18-cosmic-tetris-engine.md` (this plan) — headless deterministic game engine. Consumes: nothing. +2. `plans/2026-09-18-cosmic-tetris-terminal.md` — Bubble Tea app, renderer, layout, HUD, overlays, CLI. Consumes: the entire `internal/game` public API produced here, especially `Advance`, `Event`, `Snapshot`, `GhostY`, and the `Board`/`Piece` accessors. +3. `plans/2026-09-18-cosmic-tetris-effects.md` — starfield, particles, trails, shake, supernova, hyperdrive, boot, black hole, mission control. Consumes: `game.Event`, `game.Snapshot` from here, and `render.Input`, `render.Theme`, `app.Model` from plan 2. + +--- + +### Task 1: Module scaffold, board geometry, collision + +**Files:** +- Create: `go.mod`, `LICENSE`, `.gitignore` +- Create: `internal/game/board.go` +- Test: `internal/game/board_test.go` + +**Interfaces:** +- Consumes: nothing. +- Produces: + ```go + const ( + Width = 10 + Height = 22 + VisibleRows = 20 + HiddenRows = 2 + ) + + type Point struct{ X, Y int } + + type Cell struct { + Filled bool + Kind PieceKind + } + + type Board struct { + Cells [Height][Width]Cell + } + + func (b *Board) InBounds(x, y int) bool // 0<=x= 0 and does not panic +} + +func TestCompleteRowsReturnsAscendingIndices(t *testing.T) { + // board with rows 19 and 21 full -> []int{19, 21} + // empty board -> nil + // a row with one gap is not reported +} + +func TestClearRowsCollapsesFromAbove(t *testing.T) { + // picture: row 19 = "X........." + // row 20 = "XXXXXXXXXX" + // row 21 = "XXXXXXXXXX" + // ClearRows([]int{20, 21}) leaves row 21 = "X........." and rows 0..20 empty +} + +func TestClearRowsHandlesNonAdjacentRows(t *testing.T) { + // rows 18 and 20 full, row 19 = ".X........", row 21 = "..X......."; + // after ClearRows([]int{18, 20}): row 20 = ".X........", row 21 = "..X......." +} + +func TestClearRowsWithEmptyInputIsANoOp(t *testing.T) { + // ClearRows(nil) leaves String() unchanged +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/game/ -run 'TestFits|TestLock|TestCompleteRows|TestClearRows' -v` +Expected: FAIL — undefined methods. + +- [ ] **Step 3: Implement `Fits`, `Lock`, `CompleteRows`, `ClearRows` in `internal/game/board.go`** + +`ClearRows` should copy surviving rows downward in a single bottom-up pass rather than shifting once per cleared row. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/ +git commit -m "feat: piece fit, locking, row completion and collapse" +``` + +--- + +### Task 4: 7-bag piece generation + +**Files:** +- Create: `internal/game/bag.go` +- Test: `internal/game/bag_test.go` + +**Interfaces:** +- Consumes: `PieceKind`, `KindCount` (Task 2). +- Produces: + ```go + type Bag struct { + remaining []PieceKind + } + + func (b *Bag) Next(rng *rand.Rand) PieceKind // refills and shuffles when empty, then pops + ``` + +`Bag` deliberately does not hold an RNG — `Game` owns the only game RNG (§49.6) and passes it in. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestEveryBagContainsAllSevenKindsExactlyOnce(t *testing.T) { + var b Bag + rng := rand.New(rand.NewSource(1)) + for round := 0; round < 20; round++ { + var seen [KindCount]int + for i := 0; i < KindCount; i++ { seen[b.Next(rng)]++ } + // every count is exactly 1 + } +} + +func TestSameSeedProducesSameSequence(t *testing.T) { + // two Bags driven by rand.New(rand.NewSource(8675309)) produce identical + // 50-element sequences +} + +func TestDifferentSeedsDiverge(t *testing.T) { + // seeds 1 and 2 produce different 50-element sequences +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/game/ -run 'TestEveryBag|TestSameSeed|TestDifferentSeeds' -v` +Expected: FAIL — undefined `Bag`. + +- [ ] **Step 3: Implement `Bag.Next` in `internal/game/bag.go`** + +Use `rng.Shuffle` on the refilled slice. Reuse the backing array across refills. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/ +git commit -m "feat: seeded 7-bag piece generation" +``` + +--- + +### Task 5: Game state, events, movement, wall-kick rotation + +**Files:** +- Create: `internal/game/events.go`, `internal/game/game.go`, `internal/game/rules.go` +- Test: `internal/game/game_test.go`, `internal/game/rotation_test.go` + +**Interfaces:** +- Consumes: everything from Tasks 1–4. +- Produces: + ```go + type EventKind uint8 + + const ( + PieceMoved EventKind = iota + PieceRotated + PieceHardDropped + PieceLocked + HoldUsed + LinesCleared + ComboChanged + LevelChanged + GameOver + ) + + func (k EventKind) String() string + + type Event struct { + Kind EventKind + Piece Piece // the piece involved, in its post-event position + Rows []int // LinesCleared: the cleared row indices, ascending + Value int // PieceHardDropped: cells fallen. ComboChanged: new combo. LevelChanged: new level. LinesCleared: number of rows. + } + + type State uint8 + + const ( + StatePlaying State = iota + StateOver + ) + + type Game struct { + Board Board + Active Piece + Hold *PieceKind + CanHold bool + + Next []PieceKind // always exactly NextCount entries + Bag Bag + + Score int + Lines int + Level int + Combo int + + GravityAccumulator time.Duration + LockAccumulator time.Duration + + State State + Seed int64 + // unexported: rng *rand.Rand, grounded bool, lockResets int + } + + const NextCount = 5 + + func New(seed int64) *Game + func (g *Game) MoveLeft() []Event + func (g *Game) MoveRight() []Event + func (g *Game) RotateCW() []Event + func (g *Game) RotateCCW() []Event + + // kickOffsets is tried in order; the first offset that Fits wins (§7). + var kickOffsets = [8]Point{{0, 0}, {-1, 0}, {1, 0}, {-2, 0}, {2, 0}, {0, -1}, {-1, -1}, {1, -1}} + ``` + +- [ ] **Step 1: Write the failing tests** + +```go +func TestNewGameStartsPlayingWithAFullQueue(t *testing.T) { + g := New(42) + // g.State == StatePlaying, g.Level == 1, g.Score == 0, g.Lines == 0, g.Combo == 0 + // len(g.Next) == NextCount, g.Hold == nil, g.CanHold == true + // g.Seed == 42, g.Board.String() has no filled cells + // g.Active equals Spawn() — assert Active.Rotation == 0 and all cells Y < HiddenRows +} + +func TestMoveLeftAndRightShiftTheActivePiece(t *testing.T) { + // MoveRight returns one Event{Kind: PieceMoved} and X increases by 1 + // MoveLeft returns it back +} + +func TestMoveIsBlockedByTheWall(t *testing.T) { + // move left until it stops; the last call returns nil and X is unchanged + // the leftmost cell X is 0 +} + +func TestMoveIsBlockedByLockedCells(t *testing.T) { + // with a filled column to the piece's right, MoveRight returns nil +} + +func TestRotateCWAdvancesRotationAndEmitsEvent(t *testing.T) { + // Rotation goes 0 -> 1 and one Event{Kind: PieceRotated} is returned +} + +func TestRotateCCWWrapsFromZeroToThree(t *testing.T) { + // Rotation 0 -> 3 +} + +func TestRotationKicksOffTheLeftWall(t *testing.T) { + // place an I piece at X such that rotation 1 would put cells at X < 0; + // RotateCW succeeds and the resulting cells are all in bounds +} + +func TestRotationKicksOffTheRightWall(t *testing.T) { + // symmetric case against column 9 +} + +func TestRotationFailsWhenNoOffsetFits(t *testing.T) { + // build a board where the active piece is sealed in a one-cell-wide well; + // RotateCW returns nil and Rotation is unchanged +} + +func TestKickAboveTheTopRowStaysWithinTheBoardArray(t *testing.T) { + // Review Focus #2: put the active piece at Y 0 against a floor that forces + // the (0,-1) kick; RotateCW succeeds, resulting Y is -1, cells with Y < 0 + // are allowed by Fits, and a following Lock writes nothing out of range + // (assert no panic and Board.String() length is unchanged) +} + +func TestInputsAreIgnoredAfterGameOver(t *testing.T) { + // with State forced to StateOver, MoveLeft/MoveRight/RotateCW/RotateCCW all return nil + // and leave Active untouched +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/game/ -run 'TestNewGame|TestMove|TestRotat|TestKick|TestInputsAreIgnored' -v` +Expected: FAIL — undefined `New`, `Game`, `Event`. + +- [ ] **Step 3: Implement `events.go`, `game.go` (state, `New`, `refillNext`, `spawnNext`) and `rules.go` (`MoveLeft`, `MoveRight`, `RotateCW`, `RotateCCW`, `kickOffsets`)** + +`New` seeds `rng` with `rand.NewSource(seed)`, fills `Next` to `NextCount`, and sets `Active` from the first draw. Rotation applies `(Rotation ± 1 + 4) % 4` to a trial piece, then tries `kickOffsets` in order. Movement and rotation must return `nil` (not an empty slice) when nothing happened, so callers can test with a plain length check. + +Grounded bookkeeping is Task 6's job; for now leave the `grounded` and `lockResets` fields declared and unused. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/ +git commit -m "feat: game state, event stream, movement and wall-kick rotation" +``` + +--- + +### Task 6: Gravity, lock delay, lock resets — `Advance(dt)` + +**Files:** +- Modify: `internal/game/rules.go`, `internal/game/game.go` +- Test: `internal/game/advance_test.go` + +**Interfaces:** +- Consumes: Task 5's `Game`, `Event`. +- Produces: + ```go + const ( + LockDelay = 500 * time.Millisecond + MaxLockResets = 15 + BaseInterval = 800 * time.Millisecond + MinInterval = 60 * time.Millisecond + ) + + func DropInterval(level int) time.Duration // BaseInterval * 0.86^(level-1), floored at MinInterval; level < 1 treated as 1 + func (g *Game) Advance(dt time.Duration) []Event + func (g *Game) Grounded() bool + ``` + +Pinned `Advance` semantics — the whole timing contract lives here: + +```text +if State != StatePlaying: return nil +GravityAccumulator += dt +loop (at most VisibleRows+Height times): + interval := DropInterval(Level) + if GravityAccumulator < interval: break + GravityAccumulator -= interval + if the active piece can step down: step it, emit PieceMoved, grounded = false + else: grounded = true; break +if grounded: + LockAccumulator += dt + if LockAccumulator >= LockDelay || lockResets >= MaxLockResets: + lock the piece (Task 7's lockPiece), then return — at most one lock per Advance call, + and both accumulators reset to 0 so leftover time never cascades into the next piece +else: + LockAccumulator = 0 +``` + +A successful `MoveLeft`/`MoveRight`/`RotateCW`/`RotateCCW` while `grounded` sets `LockAccumulator = 0` and increments `lockResets`, but only while `lockResets < MaxLockResets`. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestDropIntervalFollowsTheLevelCurve(t *testing.T) { + // level 1 == 800ms + // level 2 is within 1ms of 688ms + // level 5 is within 1ms of 800ms * math.Pow(0.86, 4) + // DropInterval is monotonically non-increasing for levels 1..40 +} + +func TestDropIntervalIsFlooredAndNeverZero(t *testing.T) { + // Review Focus #5 + // DropInterval(40) == MinInterval + // DropInterval(1000) == MinInterval + // DropInterval(0) == DropInterval(1) + // for level 1..1000: DropInterval(level) >= MinInterval +} + +func TestPieceDoesNotDescendBeforeTheInterval(t *testing.T) { + // Advance(799ms) on a fresh level-1 game returns no PieceMoved and Active.Y is unchanged +} + +func TestPieceDescendsOncePerInterval(t *testing.T) { + // Advance(800ms) yields exactly one PieceMoved and Y increases by 1 + // Advance(1600ms) yields exactly two PieceMoved and Y increases by 2 +} + +func TestGroundedPieceLocksAfterLockDelay(t *testing.T) { + // hard-drop-free setup: Advance in 800ms steps until Grounded() is true + // Advance(499ms) does not emit PieceLocked + // one more Advance(1ms) emits PieceLocked +} + +func TestMovementWhileGroundedResetsTheLockTimer(t *testing.T) { + // once Grounded(): Advance(400ms), MoveLeft(), Advance(400ms) -> no PieceLocked yet +} + +func TestLockResetsAreCappedAtFifteen(t *testing.T) { + // once Grounded(): 15 rounds of {Advance(400ms), successful MoveLeft/MoveRight} + // the 16th successful move does not reset the timer, and the next Advance locks +} + +func TestLargeDtLocksAtMostOncePerCall(t *testing.T) { + // Review Focus #1 + // g := New(7); evs := g.Advance(10 * time.Second) + // exactly one PieceLocked event + // the active piece after the call is at spawn height (all cells Y < HiddenRows) + // GravityAccumulator == 0 && LockAccumulator == 0 + // at most one row of the board is occupied by the locked piece (no cascade of pieces) +} + +func TestAdvanceWithZeroDtIsANoOp(t *testing.T) { + // Advance(0) on a fresh game returns nil and leaves Active, Score and accumulators unchanged +} + +func TestAdvanceAfterGameOverReturnsNil(t *testing.T) { + // State forced to StateOver: Advance(5 * time.Second) returns nil +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/game/ -run 'TestDropInterval|TestPieceDo|TestPieceDesc|TestGrounded|TestMovementWhile|TestLockResets|TestLargeDt|TestAdvance' -v` +Expected: FAIL — undefined `Advance`, `DropInterval`. + +- [ ] **Step 3: Implement `DropInterval`, `Advance`, `Grounded`, and the lock-reset hook in the four input methods** + +`DropInterval` uses `math.Pow(0.86, float64(level-1))`. Task 7 supplies `lockPiece`; until then `Advance` may call a stub `lockPiece` that commits the piece and spawns the next without scoring — Task 7's tests will drive the full version. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/ +git commit -m "feat: clock-free gravity, lock delay and lock-reset cap" +``` + +--- + +### Task 7: Soft drop, hard drop, ghost landing, lock sequence + +**Files:** +- Modify: `internal/game/rules.go` +- Test: `internal/game/drop_test.go` + +**Interfaces:** +- Consumes: Tasks 5–6. +- Produces: + ```go + func (g *Game) SoftDrop() []Event // steps down one cell, +1 point per cell (§11) + func (g *Game) HardDrop() []Event // drops to GhostY, +2 points per cell, then locks immediately + func (g *Game) GhostY() int // Y the active piece would rest at; equals Active.Y when already resting + ``` + +Lock sequence, in the order §12 pins: + +```text +1. Board.Lock(Active) +2. rows := Board.CompleteRows() +3. Board.ClearRows(rows) +4. score the clear (Task 8) +5. emit PieceLocked, then LinesCleared / ComboChanged / LevelChanged +6. spawn the next piece; CanHold = true; grounded = false; lockResets = 0 +``` + +- [ ] **Step 1: Write the failing tests** + +```go +func TestGhostYFindsTheRestingRow(t *testing.T) { + // empty board, KindO at X 4: GhostY puts its lowest cells on row 21 + // with a two-cell tower in column 4, GhostY is two rows higher +} + +func TestGhostYOverUnevenStack(t *testing.T) { + // board picture with a step under a KindI piece: the landing row is set by + // the highest column the piece covers +} + +func TestGhostYEqualsCurrentYWhenResting(t *testing.T) { + // after HardDrop of a first piece, re-check a piece placed directly on it +} + +func TestSoftDropScoresOnePointPerCell(t *testing.T) { + // SoftDrop returns one PieceMoved and Score increases by 1 + // SoftDrop against the floor returns nil and does not change Score +} + +func TestHardDropScoresTwoPointsPerCell(t *testing.T) { + // fresh game; distance := g.GhostY() - g.Active.Y + // evs := g.HardDrop() + // Score == 2*distance + // evs contains PieceHardDropped with Value == distance, followed by PieceLocked +} + +func TestHardDropOnARestingPieceScoresZero(t *testing.T) { + // Review Focus #3 + // drive the piece down with Advance until Grounded() + // score := g.Score; evs := g.HardDrop() + // g.Score == score, the PieceHardDropped Value == 0, and evs contains PieceLocked +} + +func TestHardDropCommitsToTheBoardAndSpawnsTheNextPiece(t *testing.T) { + // next := g.Next[0] + // HardDrop(): four cells are filled on the board, g.Active.Kind == next, + // len(g.Next) == NextCount, g.CanHold == true +} + +func TestLockEmitsEventsInSpecOrder(t *testing.T) { + // with a board one row short of a clear, HardDrop's events are + // PieceHardDropped, PieceLocked, LinesCleared, ComboChanged, (LevelChanged if applicable) + // in that order +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/game/ -run 'TestGhostY|TestSoftDrop|TestHardDrop|TestLockEmits' -v` +Expected: FAIL + +- [ ] **Step 3: Implement `SoftDrop`, `HardDrop`, `GhostY`, and the full `lockPiece` in `internal/game/rules.go`** + +Scoring calls land here but their arithmetic comes from Task 8's `scoring.go` — implement `lockPiece` to call `LineScore` / `ComboBonus` / `LevelFor` and let Task 8 define them; add temporary local stubs if needed so this task compiles, and delete them in Task 8. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/ +git commit -m "feat: soft drop, hard drop, ghost landing and lock sequence" +``` + +--- + +### Task 8: Scoring, combo, level progression + +**Files:** +- Create: `internal/game/scoring.go` +- Modify: `internal/game/rules.go` (remove the Task 7 stubs) +- Test: `internal/game/scoring_test.go` + +**Interfaces:** +- Consumes: Tasks 5–7. +- Produces: + ```go + func LineScore(lines, level int) int // {0,100,300,500,800}[lines] * level; lines outside 0..4 returns 0 + func ComboBonus(combo, level int) int // 50 * (combo-1) * level, clamped at 0 (§49.1) + func LevelFor(lines int) int // lines/10 + 1 + ``` + +- [ ] **Step 1: Write the failing tests** + +```go +func TestLineScoreValues(t *testing.T) { + // level 1: 0,100,300,500,800 for 0..4 lines + // level 7: 700,2100,3500,5600 for 1..4 lines + // lines 5 and -1 return 0 +} + +func TestComboBonusStartsAtComboTwo(t *testing.T) { + // ComboBonus(0, 5) == 0 + // ComboBonus(1, 5) == 0 + // ComboBonus(2, 5) == 250 + // ComboBonus(4, 3) == 450 +} + +func TestLevelForRisesEveryTenLines(t *testing.T) { + // 0..9 -> 1, 10..19 -> 2, 100 -> 11 +} + +func TestSingleClearSetsComboToOneAndPaysNoBonus(t *testing.T) { + // board one row short of a clear; HardDrop into it + // g.Combo == 1, g.Lines == 1, g.Score gain == 2*distance + LineScore(1, 1) + // the ComboChanged event Value == 1 +} + +func TestConsecutiveClearsPayTheComboBonus(t *testing.T) { + // two clearing placements in a row: after the second, g.Combo == 2 and the + // score gain for that placement includes ComboBonus(2, g.Level) +} + +func TestPlacementWithoutAClearResetsCombo(t *testing.T) { + // after a clear, a non-clearing HardDrop sets g.Combo == 0 and emits + // ComboChanged with Value 0 +} + +func TestLevelUpEmitsLevelChanged(t *testing.T) { + // drive Lines from 9 to 10 with a clear; a LevelChanged event with Value 2 + // is emitted and g.Level == 2 +} + +func TestFourLineClearScoresEightHundredTimesLevel(t *testing.T) { + // board with four full-but-for-one-column rows; drop a vertical I into the gap + // g.Lines == 4 and the clear portion of the score is 800 * level +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/game/ -run 'TestLineScore|TestComboBonus|TestLevelFor|TestSingleClear|TestConsecutive|TestPlacementWithout|TestLevelUp|TestFourLine' -v` +Expected: FAIL + +- [ ] **Step 3: Implement `scoring.go` and finish `lockPiece`'s scoring path** + +Combo update order: increment or reset `Combo` *before* computing `ComboBonus`, so the first clear is combo 1 with no bonus. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/ +git commit -m "feat: line scoring, combo bonus and level progression" +``` + +--- + +### Task 9: Hold + +**Files:** +- Modify: `internal/game/rules.go` +- Test: `internal/game/hold_test.go` + +**Interfaces:** +- Consumes: Tasks 5–8. +- Produces: + ```go + func (g *Game) UseHold() []Event // emits HoldUsed, or GameOver if the incoming piece cannot spawn + ``` + +Rules (§9): `Hold` empty → store `Active.Kind`, spawn from `Next`. `Hold` set → swap, and the incoming piece enters at `Spawn(kind)` (rotation 0). Either way `CanHold` becomes false until the next lock; a call with `CanHold == false` returns `nil`. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestFirstHoldStoresActiveAndSpawnsFromQueue(t *testing.T) { + // active := g.Active.Kind; next := g.Next[0] + // UseHold() returns one HoldUsed event + // *g.Hold == active, g.Active.Kind == next, g.CanHold == false, len(g.Next) == NextCount +} + +func TestSecondHoldBeforeLockIsBlocked(t *testing.T) { + // UseHold(); a second UseHold() returns nil and leaves Hold and Active unchanged +} + +func TestHoldSwapsWithTheStoredPiece(t *testing.T) { + // UseHold(); HardDrop() to re-enable hold; UseHold() again + // the previously held kind is now Active.Kind and the pre-swap Active.Kind is held +} + +func TestHoldIsRestoredAfterLock(t *testing.T) { + // UseHold(); HardDrop(); g.CanHold == true +} + +func TestHeldPieceReturnsAtSpawnRotationAndPosition(t *testing.T) { + // rotate the active piece twice and move it right, then UseHold() twice + // (with a HardDrop between) so it comes back: + // the returning piece equals Spawn(kind) exactly +} + +func TestHoldThatCannotSpawnEndsTheGame(t *testing.T) { + // Review Focus #4 + // build a board whose hidden rows are occupied where the incoming piece would spawn + // UseHold() emits GameOver, g.State == StateOver, + // and Board.String() is unchanged (no overlapping write) +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/game/ -run 'TestFirstHold|TestSecondHold|TestHold|TestHeldPiece' -v` +Expected: FAIL — undefined `UseHold`. + +- [ ] **Step 3: Implement `UseHold` in `internal/game/rules.go`** + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/ +git commit -m "feat: hold with once-per-piece limit and spawn-rotation reset" +``` + +--- + +### Task 10: Game over and `Snapshot` + +**Files:** +- Modify: `internal/game/game.go` +- Test: `internal/game/gameover_test.go` + +**Interfaces:** +- Consumes: Tasks 5–9. +- Produces: + ```go + type Snapshot struct { + Score, Lines, Level, Combo int + State State + } + + func (g *Game) Snapshot() Snapshot + ``` + +`Snapshot` is the read-only view the FX and flavor packages receive; it is a value type precisely so those packages cannot reach game state (§14). Plan 3 consumes it. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestBlockedSpawnEndsTheGame(t *testing.T) { + // fill rows 0..3 of the board except one column, then HardDrop + // the returned events end with GameOver and g.State == StateOver +} + +func TestGameOverIsEmittedExactlyOnce(t *testing.T) { + // after game over, further Advance/HardDrop/UseHold calls return nil + // (no second GameOver event) +} + +func TestSnapshotMirrorsGameCounters(t *testing.T) { + // g.Score = 1234; g.Lines = 42; g.Level = 7; g.Combo = 3 + // Snapshot() == Snapshot{1234, 42, 7, 3, StatePlaying} +} + +func TestSnapshotIsAValueCopy(t *testing.T) { + // s := g.Snapshot(); g.Score = 999; s.Score is still the old value +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/game/ -run 'TestBlockedSpawn|TestGameOverIs|TestSnapshot' -v` +Expected: FAIL + +- [ ] **Step 3: Implement the blocked-spawn transition and `Snapshot` in `internal/game/game.go`** + +The spawn helper returns a `bool`; when it is false, set `State = StateOver` and append `Event{Kind: GameOver}`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/ +git commit -m "feat: blocked-spawn game over and read-only Snapshot" +``` + +--- + +### Task 11: Determinism replay test and the no-clock guard + +**Files:** +- Create: `internal/game/determinism_test.go` +- Create: `internal/game/testdata/replay.golden` +- Test: same files + +**Interfaces:** +- Consumes: the whole package. +- Produces: no production code — this task is the §35 / §49.2 guarantee, expressed as tests. + +- [ ] **Step 1: Write the failing tests** + +```go +// replay drives a fixed 400-step (input, dt) script derived from the step index, +// so the script is reproducible without being written out longhand. +func replay(seed int64) (*Game, string) { + g := New(seed) + for i := 0; i < 400; i++ { + switch i % 7 { + case 0: g.MoveLeft() + case 1: g.RotateCW() + case 2: g.MoveRight() + case 3: g.SoftDrop() + case 4: g.RotateCCW() + case 5: g.UseHold() + case 6: g.HardDrop() + } + g.Advance(time.Duration(17+i%5) * time.Millisecond) + } + return g, fmt.Sprintf("score=%d lines=%d level=%d combo=%d state=%d\n%s", + g.Score, g.Lines, g.Level, g.Combo, g.State, g.Board.String()) +} + +func TestReplayIsReproducibleWithinAProcess(t *testing.T) { + // _, a := replay(8675309); _, b := replay(8675309); a == b +} + +func TestReplayMatchesGolden(t *testing.T) { + // compare replay(8675309)'s fingerprint to testdata/replay.golden, + // regenerating it when -update is passed +} + +func TestDifferentSeedsProduceDifferentReplays(t *testing.T) { + // replay(1) and replay(2) fingerprints differ +} + +func TestFXRandomnessCannotAffectPieceOrder(t *testing.T) { + // draw 200 pieces from a Bag using the game rng for seed 5; + // repeat, but interleave 1000 draws from a *separate* rand.Rand between bag draws; + // both sequences are identical +} + +func TestEngineNeverReadsTheClock(t *testing.T) { + // §49.2: walk every non-test .go file in this directory and fail if it + // contains "time.Now(" or "time.Since(" or imports charm.land/ +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/game/ -run 'TestReplay|TestDifferentSeedsProduceDifferent|TestFXRandomness|TestEngineNever' -v` +Expected: FAIL — `testdata/replay.golden` missing; the clock guard fails if any `time.Now()` slipped in. + +- [ ] **Step 3: Record the golden and fix any clock reads** + +Run: `go test ./internal/game/ -run TestReplayMatchesGolden -update` +Then inspect `testdata/replay.golden` and confirm the board picture is plausible (some filled rows, not all empty and not all full). + +- [ ] **Step 4: Run the full suite with race detection and coverage** + +Run: `go test ./internal/game/... -race -cover` +Expected: PASS, coverage above 85% for the package. + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/ +git commit -m "test: replay determinism golden and no-clock guard for the engine" +``` + +--- + +## Done when + +`go test ./internal/game/... -race` passes, coverage is above 85%, `testdata/replay.golden` is committed, and §42's gate is met: "Tests must pass before proceeding." The engine has no `charm.land` imports and no clock reads. Proceed to `plans/2026-09-18-cosmic-tetris-terminal.md`. diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/C3-planset-cosmic-92/2026-09-18-cosmic-tetris-terminal.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/C3-planset-cosmic-92/2026-09-18-cosmic-tetris-terminal.md new file mode 100644 index 000000000..a04d69993 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/C3-planset-cosmic-92/2026-09-18-cosmic-tetris-terminal.md @@ -0,0 +1,1079 @@ +# Cosmic Tetris — Playable Terminal Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Turn the headless engine into a genuinely good playable terminal game — Bubble Tea app loop, board and HUD rendering, ghost piece, next queue, hold, adaptive layout, pause/help/game-over overlays, resize handling, and the full CLI — with ANSI-stripped golden tests as the binding layout contract. + +**Architecture:** Three new packages. `internal/render` is pure: `Frame(Input) string` composes styled panel strings onto a `lipgloss.Canvas` and never touches game state. `internal/app` is the Bubble Tea model: it owns the single animation clock, converts key presses straight into engine calls, and hands `render` an `Input`. `cmd/cosmic-tetris` parses flags and starts the program. Dependency direction is strictly `app → render → game`. + +**Tech Stack:** Go 1.26, `charm.land/bubbletea/v2` v2.0.9, `charm.land/lipgloss/v2` v2.0.6, `charm.land/bubbles/v2` v2.2.1, `github.com/charmbracelet/x/ansi` (ANSI stripping in tests), `github.com/charmbracelet/colorprofile` (capability detection). + +**Spec:** `design.md` (§4, §8, §10, §26, §27 line only, §28 final card, §29 skip path, §30, §31, §32, §33, §34, §36, §37, §39, §41, §42 Phase 2, §44, §46, §49.3, §49.4, §49.5, §49.7) + +## Global Constraints + +- Module path `cosmic-tetris`; imports are `cosmic-tetris/internal/...`. +- Exact dependency versions: `charm.land/bubbletea/v2 v2.0.9`, `charm.land/lipgloss/v2 v2.0.6`, `charm.land/bubbles/v2 v2.2.1`. No other direct dependencies. +- Bubble Tea **v2** API — this differs from v1 and is a common source of wrong code: + - `Init() tea.Cmd` (no model returned) + - `Update(tea.Msg) (tea.Model, tea.Cmd)` + - `View() tea.View`, built with `tea.NewView(s)` + - **Alt screen is a `View` field**, not a program option: set `v.AltScreen = true` in `View()`. There is no `tea.WithAltScreen`. + - Key presses arrive as `tea.KeyPressMsg`; `msg.String()` yields `"space"`, `"esc"`, `"left"`, `"?"` and so on. Match with `key.Matches(msg, binding)`. +- Lip Gloss **v2** takes `color.Color` (from `image/color`), not strings: `lipgloss.Color("#22E4F7")` returns a `color.Color`. +- `internal/render` must not import `internal/app`, and neither `render` nor `app` may mutate anything under `internal/game` except by calling the engine's own documented mutating methods from `app`. +- One animation clock only (§36): a single `tea.Tick` producing `FrameMsg`. No separate gravity timer. +- Glyphs are pinned by §49.4: block `██` (`[]` in ASCII), ghost `░░` (`··` in ASCII). Pieces use a bright foreground on filled glyphs, never a foreground/background pair. Active piece renders one step brighter than locked cells. +- Minimum usable terminal is 40 × 24 (§31). Below that, only the too-small notice renders. +- The §4 mockup is mood, not geometry (§49.7). The golden files in `internal/render/testdata/` are the layout contract. +- Test command for this plan: `go test ./...`. + +## Review Focus + +1. A resize to 0 or 1 columns/rows mid-game must show the too-small notice and must never build a zero- or negative-sized canvas — Task 2 and Task 5. +2. A `FrameMsg` whose `dt` spans a suspend/resume or a laptop sleep must be clamped, not replayed as a multi-second gravity burst — Task 7. +3. `--seed` with a non-numeric, negative, or int64-overflowing value must exit with a clear message and status 2, not panic and not silently become 0 — Task 8. +4. Score, lines, or level exceeding their HUD field widths must leave every panel's width unchanged so the board does not shift — Task 4. +5. Output piped to a file, `NO_COLOR=1`, or `TERM=dumb` must produce readable output with no truecolor escapes and no crash — Task 1. + +## Plan Set + +Run in this order. A ruling that changes a name, signature, or value that a later plan consumes must be applied to that plan's document before the next task starts. + +1. `plans/2026-09-18-cosmic-tetris-engine.md` — headless deterministic game engine. Consumes: nothing. +2. `plans/2026-09-18-cosmic-tetris-terminal.md` (this plan) — Bubble Tea app, renderer, layout, HUD, overlays, CLI. Consumes: the entire `internal/game` public API, especially `Advance`, `Event`, `Snapshot`, `GhostY`, `Board`, `Piece`. +3. `plans/2026-09-18-cosmic-tetris-effects.md` — starfield, particles, trails, shake, supernova, hyperdrive, boot, black hole, mission control. Consumes: `render.Input`, `render.Theme`, `render.Layout`, `render.Frame`, `app.Model`, `app.FrameMsg`, `app.Options` from this plan. + +--- + +### Task 1: Rendering modes, glyph sets, palette + +**Files:** +- Create: `internal/render/palette.go` +- Modify: `go.mod` (add the three charm dependencies) +- Test: `internal/render/palette_test.go` + +**Interfaces:** +- Consumes: `game.PieceKind`, `game.KindCount`. +- Produces: + ```go + type Mode uint8 + + const ( + ModeFull Mode = iota // Unicode + truecolor + ModeReduced // Unicode + 256 color + ModeASCII // ASCII glyphs, 8 colors + ) + + func ModeFor(p colorprofile.Profile, forceASCII bool) Mode + + type Glyphs struct { + Block string // "██" full/reduced, "[]" ASCII + Ghost string // "░░" full/reduced, "··" ASCII + Empty string // " " in every mode + Board lipgloss.Border // DoubleBorder() full/reduced, ASCIIBorder() ASCII + Panel lipgloss.Border // RoundedBorder() full/reduced, ASCIIBorder() ASCII + } + + func GlyphsFor(m Mode) Glyphs + + type PieceColors struct{ Active, Locked color.Color } + + type Theme struct { + Mode Mode + Glyphs Glyphs + Piece [game.KindCount]PieceColors + Border []color.Color // gradient stops for the board border (§25) + Ghost color.Color + Text color.Color + Dim color.Color + Accent color.Color + Alert color.Color + } + + func NewTheme(m Mode) Theme + ``` + +Pinned palette (§26, §25) — full and reduced modes both use these hex values and let `colorprofile` downsample: + +```go +// piece colours: bright active, dimmer locked +KindI: {Active: "#22E4F7", Locked: "#1499A8"} // plasma cyan +KindJ: {Active: "#3A6BFF", Locked: "#2447A8"} // deep electric blue +KindL: {Active: "#FF8A2B", Locked: "#B85C14"} // solar orange +KindO: {Active: "#FFD24A", Locked: "#B8932B"} // stellar gold +KindS: {Active: "#4CE66B", Locked: "#2E9945"} // alien green +KindT: {Active: "#B45CFF", Locked: "#7A33B8"} // ultraviolet +KindZ: {Active: "#FF3C6E", Locked: "#B32048"} // supernova pink + +Ghost: "#4A4A6A" +Text: "#E2E8F0" +Dim: "#5A6478" +Accent: "#22D3EE" +Alert: "#FF3C6E" + +Border stops (§25, in cycle order): + "#5B21B6" deep violet + "#22D3EE" electric cyan + "#E935C1" magenta + "#3B82F6" stellar blue + "#F8FAFC" hot white +``` + +In `ModeASCII`, every colour above is replaced by a basic ANSI colour so nothing depends on 256-colour or truecolor support: pieces cycle `lipgloss.Cyan, Blue, Yellow, BrightYellow, Green, Magenta, Red` for `KindI..KindZ` with `Locked` equal to `Active`, `Ghost` is `lipgloss.BrightBlack`, `Text` is `lipgloss.White`, `Dim` is `lipgloss.BrightBlack`, `Accent` is `lipgloss.BrightCyan`, `Alert` is `lipgloss.BrightRed`, and `Border` is the single stop `lipgloss.BrightBlack`. + +- [ ] **Step 1: Add the dependencies** + +```bash +go get charm.land/bubbletea/v2@v2.0.9 charm.land/lipgloss/v2@v2.0.6 charm.land/bubbles/v2@v2.2.1 +go mod tidy +``` + +- [ ] **Step 2: Write the failing tests** + +```go +func TestModeForMapsProfiles(t *testing.T) { + // TrueColor -> ModeFull + // ANSI256 -> ModeReduced + // ANSI -> ModeASCII + // Ascii -> ModeASCII + // NoTTY -> ModeASCII + // Unknown -> ModeASCII +} + +func TestForceASCIIOverridesEveryProfile(t *testing.T) { + // ModeFor(colorprofile.TrueColor, true) == ModeASCII +} + +func TestGlyphsAreTwoColumnsWide(t *testing.T) { + // Review Focus #5 partly: for each mode, lipgloss.Width of Block, Ghost and + // Empty is exactly 2 +} + +func TestASCIIGlyphsUseOnlyASCII(t *testing.T) { + // Review Focus #5: in ModeASCII, Block == "[]", Ghost == "··" is NOT used — + // assert Ghost == "··"? No: §49.4 pins ASCII ghost as "··", which is non-ASCII. + // Assert Block == "[]" and Ghost == "··", and that every rune in Block is < 128. +} + +func TestASCIIThemeUsesOnlyBasicColors(t *testing.T) { + // Review Focus #5: in ModeASCII every colour in the theme (piece Active and + // Locked, Ghost, Text, Dim, Accent, Alert, Border) type-asserts to + // ansi.BasicColor, so nothing can emit a truecolor escape +} + +func TestPieceColorsAreDistinctPerKind(t *testing.T) { + // in ModeFull, the seven Active colours are seven distinct values +} + +func TestActiveIsBrighterThanLocked(t *testing.T) { + // §49.4: in ModeFull, for each kind the Active colour's luminance exceeds + // the Locked colour's (convert with colorful.MakeColor and compare .Luminance()) +} + +func TestBorderStopsMatchTheSpecPalette(t *testing.T) { + // ModeFull Border has exactly 5 stops in the §25 order +} +``` + +Note on `TestASCIIGlyphsUseOnlyASCII`: §49.4 pins the ASCII ghost as `··` (U+00B7), which is not strictly ASCII. Keep the spec's glyph — assert `Block == "[]"`, `Ghost == "··"`, and that the ASCII border sets come from `lipgloss.ASCIIBorder()`. The mode's real promise is "no special Unicode assumptions" (§32), and `·` is Latin-1 and near-universal; record that reading in a comment. + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `go test ./internal/render/ -v` +Expected: FAIL — undefined `ModeFor`, `Theme`. + +- [ ] **Step 4: Implement `internal/render/palette.go`** + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add go.mod go.sum internal/render/ +git commit -m "feat: rendering modes, glyph sets and the neon space palette" +``` + +--- + +### Task 2: Adaptive layout + +**Files:** +- Create: `internal/render/layout.go` +- Test: `internal/render/layout_test.go` + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: + ```go + type Size uint8 + + const ( + SizeTooSmall Size = iota + SizeSmall + SizeMedium + SizeLarge + ) + + const ( + MinWidth = 40 + MinHeight = 24 + BoardCols = 22 // 10 cells * 2 columns + 2 border columns + BoardRows = 22 // 20 visible rows + 2 border rows + SidePanelW = 12 + ) + + type Layout struct { + Size Size + TermWidth, TermHeight int + ShowFrame bool // outer rounded frame + title line (§4) + ShowMission bool + ShowStatLabels bool + ShowHold bool + NextCount int + BoardX, BoardY int // top-left of the board panel's border corner + LeftX, LeftY int // HOLD + stats column; valid when ShowHold + RightX, RightY int // NEXT column (plus stats when !ShowHold) + RightW int + MissionY int // -1 when !ShowMission + ControlsY int + } + + func Compute(width, height int) Layout + ``` + +Pinned thresholds and geometry — these, not the §4 mockup, decide the frame: + +```text +SizeTooSmall width < 40 || height < 24 +SizeLarge width >= 56 && height >= 27 +SizeMedium width >= 46 && height >= 25 +SizeSmall everything else at or above the minimum + +Large: ShowFrame true, ShowMission true, ShowStatLabels true, ShowHold true, NextCount 5 + content block is 50 wide (12 + 2 + 22 + 2 + 12) + contentX = 1 + (width-2-50)/2 + LeftX = contentX, BoardX = contentX+14, RightX = contentX+38, RightW = 12 + BoardY = 1, LeftY = 2, RightY = 2, MissionY = height-3, ControlsY = height-2 + +Medium: ShowFrame false, ShowMission true, ShowStatLabels true, ShowHold true, NextCount 5 + BoardX = 0, BoardY = 0 + RightX = 23, RightY = 0, RightW = min(width-23, 20) + LeftX/LeftY unused (HOLD stacks into the right column) + MissionY = height-2, ControlsY = height-1 + +Small: ShowFrame false, ShowMission false, ShowStatLabels false, ShowHold false, NextCount 3 + BoardX = 0, BoardY = 0 + RightX = 23, RightY = 0, RightW = min(width-23, 14) + MissionY = -1, ControlsY = height-1 + +TooSmall: every geometry field is 0 and MissionY is -1. +``` + +Drop order matches §49.3 exactly — title frame goes first, then mission control, then stat labels. HOLD is dropped at `SizeSmall` because §31's small-terminal priority list is board, next, score, controls, and HOLD is not on it. NEXT never stacks above or below the board in any size. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestComputeTooSmallBelowMinimum(t *testing.T) { + // 39x24, 40x23, 0x0, 1x1, 34x19 all give SizeTooSmall + // and MissionY == -1 +} + +func TestComputeNeverReturnsNegativeGeometry(t *testing.T) { + // Review Focus #1: for every width 0..80 and height 0..40, + // no field of Compute(w,h) is negative except MissionY, which is only ever -1 +} + +func TestComputeSmallAtExactMinimum(t *testing.T) { + // Compute(40, 24): SizeSmall, ShowFrame false, ShowMission false, + // ShowStatLabels false, ShowHold false, NextCount 3, + // BoardX 0, BoardY 0, RightX 23, RightW 14, MissionY -1, ControlsY 23 +} + +func TestComputeMediumThresholds(t *testing.T) { + // Compute(46, 25): SizeMedium, ShowStatLabels true, ShowHold true, NextCount 5, + // ShowFrame false, MissionY 23, ControlsY 24 + // Compute(45, 25) and Compute(46, 24) are not SizeMedium +} + +func TestComputeLargeThresholds(t *testing.T) { + // Compute(56, 27): SizeLarge, ShowFrame true, MissionY 24, ControlsY 25 + // Compute(55, 27) is SizeMedium; Compute(56, 26) is SizeMedium +} + +func TestBoardFitsInsideTheTerminal(t *testing.T) { + // for every size at or above the minimum: + // BoardX + BoardCols <= TermWidth and BoardY + BoardRows <= TermHeight +} + +func TestSidePanelsDoNotOverlapTheBoard(t *testing.T) { + // for every size at or above the minimum: + // when ShowHold, LeftX + SidePanelW <= BoardX + // RightX >= BoardX + BoardCols and RightX + RightW <= TermWidth +} + +func TestMissionAndControlsRowsDoNotOverlapTheBoard(t *testing.T) { + // ControlsY >= BoardY + BoardRows and ControlsY < TermHeight + // when ShowMission: BoardY+BoardRows <= MissionY < ControlsY +} + +func TestLargeContentBlockIsCentered(t *testing.T) { + // Compute(80, 30): BoardX is within 1 of (80-22)/2 +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/render/ -run TestCompute -v` +Expected: FAIL — undefined `Compute`. + +- [ ] **Step 3: Implement `Compute` in `internal/render/layout.go`** + +The invariant tests above are the real specification; the pinned table is the values that satisfy them. If a threshold needs nudging to satisfy `TestSidePanelsDoNotOverlapTheBoard` or `TestMissionAndControlsRowsDoNotOverlapTheBoard`, adjust the constant and record the new value in this plan and in `plans/2026-09-18-cosmic-tetris-effects.md` if it consumes it. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/ +git commit -m "feat: adaptive layout with pinned size thresholds and drop order" +``` + +--- + +### Task 3: Board panel with ghost and active piece + +**Files:** +- Create: `internal/render/board.go` +- Test: `internal/render/board_test.go` + +**Interfaces:** +- Consumes: `Theme`, `Glyphs` (Task 1); `game.Board`, `game.Piece`, `game.Game`, `game.GhostY`. +- Produces: + ```go + // BoardPanel renders the 20 visible rows with border, locked cells, ghost and + // active piece. borderColor lets later plans drive the §25 gradient; pass + // th.Border[0] for a static border. + func BoardPanel(g *game.Game, th Theme, borderColor color.Color) string + ``` + +Draw order inside the panel follows §37 steps 3–5: locked cells, then ghost, then active piece. Ghost cells are written only where the cell is empty, so ghost can never obscure a locked block (§10). The active piece is written last and uses `PieceColors.Active`; locked cells use `PieceColors.Locked`. + +- [ ] **Step 1: Write the failing tests** + +Use `ansi.Strip` on the panel before asserting on geometry. + +```go +func TestBoardPanelDimensions(t *testing.T) { + // for each mode: the stripped panel is exactly BoardRows lines, + // each exactly BoardCols columns wide (lipgloss.Width) +} + +func TestBoardPanelShowsOnlyVisibleRows(t *testing.T) { + // fill board row 1 (hidden) and row 2 (first visible) with different kinds; + // the stripped panel's first interior line shows the row-2 content and never + // the row-1 content +} + +func TestGhostAppearsBelowTheActivePiece(t *testing.T) { + // fresh game, ModeFull: the stripped panel contains "░░" and the ghost's + // interior row index equals g.GhostY() - game.HiddenRows offset by 1 for the border +} + +func TestGhostDoesNotOverwriteLockedCells(t *testing.T) { + // place locked cells directly under the active piece so the ghost lands on them; + // every stripped cell that is locked still shows the block glyph, not "░░" +} + +func TestGhostIsSuppressedWhenItCoincidesWithTheActivePiece(t *testing.T) { + // with the active piece already resting, the panel contains no "░░" +} + +func TestASCIIModeUsesBracketsAndMiddots(t *testing.T) { + // ModeASCII panel contains "[]" and "··" and contains neither "██" nor "░░" +} + +func TestActiveAndLockedUseDifferentStyles(t *testing.T) { + // the *unstripped* panel contains both the Active and the Locked hex for the + // active piece's kind after a piece has been locked below the active one +} + +func TestEmptyBoardPanelHasNoBlockGlyphsInsideTheBorder(t *testing.T) { + // with State forced past spawn (no active piece drawn), interior is blank — + // or, simpler: assert the number of "██" occurrences equals 4 (the active + // piece) plus 4 ghost cells rendered as "░░" on a fresh game +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/render/ -run 'TestBoardPanel|TestGhost|TestASCIIMode|TestActiveAndLocked|TestEmptyBoard' -v` +Expected: FAIL — undefined `BoardPanel`. + +- [ ] **Step 3: Implement `BoardPanel` in `internal/render/board.go`** + +Build the interior as a `[game.VisibleRows][game.Width]` array of `(glyph, color)` pairs, then style each run and wrap with `lipgloss.NewStyle().Border(th.Glyphs.Board).BorderForeground(borderColor)`. Styling per run rather than per cell keeps the escape-sequence count down (§38). + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/ +git commit -m "feat: board panel with ghost piece and brighter active piece" +``` + +--- + +### Task 4: HUD panels + +**Files:** +- Create: `internal/render/hud.go` +- Test: `internal/render/hud_test.go` + +**Interfaces:** +- Consumes: `Theme`, `Layout` (Tasks 1–2); `game.PieceKind`, `game.Snapshot`. +- Produces: + ```go + func HoldPanel(hold *game.PieceKind, width int, th Theme, labeled bool) string + func NextPanel(next []game.PieceKind, count, width int, th Theme, labeled bool) string + func StatsPanel(s game.Snapshot, width int, th Theme, labeled bool) string + func ControlsLine(width int, th Theme) string + func MissionLine(msg string, width int, th Theme) string + func TitleLine(universe string, level, width int, th Theme) string + + func FormatScore(n int) string // "%08d" below 1e8, plain decimal above, never longer than 11 runes + func FormatLines(n int) string // "%03d", never longer than 6 runes + func FormatLevel(n int) string // "%02d", never longer than 4 runes + ``` + +Every panel is rendered through `lipgloss.NewStyle().Width(width).MaxWidth(width)` so no value can widen it (Review Focus #4). Panel heights are fixed: `HoldPanel` is 4 lines (label + 2 piece rows + blank), `NextPanel` is `1 + 3*count` lines, `StatsPanel` is 6 lines when `labeled` and 3 when not (§49.3: values stay, labels go). + +`ControlsLine` copy (§4), truncated by width: +- full: `←→ move ↑ rotate ↓ descend SPACE YEET C hold ? help` +- short (when full does not fit): `←→ ↑ ↓ SPACE YEET C hold ? help` +- ASCII mode replaces the arrows with `<>`, `^`, `v`. + +`TitleLine` copy (§4): `✦ COSMIC TETRIS` on the left, `LOCAL UNIVERSE ` on the right, `✦ VELOCITY: NN` for the level. ASCII mode drops `✦`. + +`MissionLine` copy (§27): `☄ MISSION CONTROL: ` followed by `msg`, truncated with an ellipsis when it does not fit. ASCII mode uses `> MISSION CONTROL: `. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestPanelWidthsAreFixedRegardlessOfContent(t *testing.T) { + // Review Focus #4 + // for width 12 and each of StatsPanel with Snapshot{Score: 0}, + // Snapshot{Score: math.MaxInt, Lines: math.MaxInt, Level: math.MaxInt}: + // every stripped line has lipgloss.Width exactly 12 +} + +func TestFormatterLengthCaps(t *testing.T) { + // FormatScore: 0 -> "00000000"; 129340 -> "00129340"; + // len([]rune(FormatScore(n))) <= 11 for n in {0, 99999999, 100000000, math.MaxInt} + // FormatLines(42) == "042"; FormatLevel(7) == "07" + // length caps hold for math.MaxInt for all three +} + +func TestStatsPanelDropsLabelsWhenUnlabeled(t *testing.T) { + // labeled=true output contains "SCORE", "LINES", "LEVEL" + // labeled=false output contains none of them but still contains "042" + // labeled=false is 3 lines; labeled=true is 6 lines +} + +func TestNextPanelHeightIsFixedForAnyQueueLength(t *testing.T) { + // NextPanel with 5 kinds and count 3 is 1+3*3 lines + // NextPanel with 2 kinds and count 5 is still 1+3*5 lines (missing slots blank) + // NextPanel with nil and count 3 does not panic +} + +func TestHoldPanelShowsEmptyStateWithoutPanicking(t *testing.T) { + // HoldPanel(nil, 12, th, true) is 4 lines, width 12, contains "HOLD", + // and contains no block glyph +} + +func TestControlsLineFitsItsWidth(t *testing.T) { + // for width in {20, 30, 40, 56, 80}: lipgloss.Width(ansi.Strip(...)) <= width + // at width 80 it contains "YEET"; at width 20 it still contains "? help" +} + +func TestControlsLineIsASCIIInASCIIMode(t *testing.T) { + // ModeASCII output contains "<>" and "^" and no "←" +} + +func TestMissionLineTruncatesLongMessages(t *testing.T) { + // a 200-character message at width 40 yields width exactly 40 + // an empty message yields a line of width 40 with no "MISSION CONTROL" text +} + +func TestTitleLineShowsUniverseAndVelocity(t *testing.T) { + // TitleLine("7F3A", 3, 64, th) contains "COSMIC TETRIS", "7F3A" and "03" + // and has width exactly 64 +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/render/ -run 'TestPanel|TestFormatter|TestStats|TestNext|TestHold|TestControls|TestMission|TestTitle' -v` +Expected: FAIL + +- [ ] **Step 3: Implement `internal/render/hud.go`** + +Piece previews inside `HoldPanel` and `NextPanel` draw `Spawn(kind).Cells()` normalised to the top-left of its own bounding box, so previews sit flush rather than floating at the spawn offset. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/ +git commit -m "feat: HUD panels with width-clamped stat fields" +``` + +--- + +### Task 5: Frame assembly and layout golden tests + +**Files:** +- Create: `internal/render/render.go` +- Create: `internal/render/testdata/{wide,medium,small,toosmall,ascii}.golden` +- Test: `internal/render/render_test.go`, `internal/render/golden_test.go` + +**Interfaces:** +- Consumes: Tasks 1–4. +- Produces: + ```go + type Overlay uint8 + + const ( + OverlayNone Overlay = iota + OverlayBoot + OverlayPaused + OverlayHelp + OverlayGameOver + OverlayTooSmall + ) + + type Input struct { + Game *game.Game + Layout Layout + Theme Theme + Overlay Overlay + HelpBody string // pre-rendered by bubbles/help in the app layer + Mission string + Universe string // e.g. "7F3A" + Elapsed time.Duration // wall time since start, for border cycling + } + + func Frame(in Input) string + ``` + +`Frame` follows §37: build a `lipgloss.Canvas` of `max(Layout.TermWidth,1) × max(Layout.TermHeight,1)`, compose the board panel, then HOLD/NEXT/stats, then the title, mission and controls lines, then the overlay last. `Layout.Size == SizeTooSmall` skips everything but the notice. `Frame` never reads or writes `Game` other than through the engine's accessors, and it must not call `time.Now()`. + +- [ ] **Step 1: Write the failing tests** + +`golden_test.go` holds a `-update` flag and a `fixture()` helper that builds a deterministic `Input`: `game.New(8675309)`, then a fixed script of engine calls (three hard drops and two rotations) so a partial stack exists, `Elapsed` of `2500ms`, `Mission` of `"NOMINALISH"`, `Universe` of `"7F3A"`. + +```go +func TestFrameGoldens(t *testing.T) { + // table: {"wide", 80, 30, ModeFull, OverlayNone} + // {"medium", 50, 26, ModeFull, OverlayNone} + // {"small", 40, 24, ModeFull, OverlayNone} + // {"toosmall", 34, 19, ModeFull, OverlayTooSmall} + // {"ascii", 80, 30, ModeASCII, OverlayNone} + // compare ansi.Strip(Frame(in)) against testdata/.golden, -update rewrites +} + +func TestFrameFillsExactlyTheTerminal(t *testing.T) { + // for every golden case: the stripped frame has exactly TermHeight lines + // and no line exceeds TermWidth columns +} + +func TestBoardRowsSurviveHUDComposition(t *testing.T) { + // for the wide and medium cases: the BoardRows lines starting at BoardY, + // sliced to columns BoardX..BoardX+BoardCols, equal ansi.Strip(BoardPanel(...)) + // This is §41's "HUD doesn't corrupt board" requirement. +} + +func TestFrameNeverPanicsAcrossSizes(t *testing.T) { + // Review Focus #1: for width 0..90 and height 0..40, Frame(fixture at that + // size) returns without panicking and produces at most max(height,1) lines +} + +func TestTooSmallNoticeReportsBothSizes(t *testing.T) { + // §31 copy: the 34x19 frame contains "THIS UNIVERSE IS TOO SMALL", + // "resize terminal to continue", "current: 34 × 19" and "needed:" +} + +func TestTooSmallNoticeFitsAOneByOneTerminal(t *testing.T) { + // Review Focus #1: Frame at 1x1 yields exactly 1 line of at most 1 column +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/render/ -run TestFrame -v` +Expected: FAIL — undefined `Frame`. + +- [ ] **Step 3: Implement `Frame` in `internal/render/render.go`** + +Border colour for this plan is `th.Border[0]`; the gradient cycle arrives in plan 3. + +- [ ] **Step 4: Record the goldens and read them** + +Run: `go test ./internal/render/ -run TestFrameGoldens -update` +Then read each file in `internal/render/testdata/` and confirm by eye: the board border is intact, HOLD/NEXT/stats do not overlap it, the controls line is the bottom row, and the ASCII golden has no `██`. Re-run without `-update` to confirm they are stable. + +- [ ] **Step 5: Run the suite** + +Run: `go test ./internal/render/ -v` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add internal/render/ +git commit -m "feat: frame composition with ANSI-stripped layout goldens" +``` + +--- + +### Task 6: Overlays — pause, help, game over, boot card + +**Files:** +- Create: `internal/render/overlay.go` +- Modify: `internal/render/render.go` (dispatch on `Input.Overlay`) +- Create: `internal/render/testdata/{pause,gameover,help,boot}.golden` +- Test: `internal/render/overlay_test.go` + +**Interfaces:** +- Consumes: Tasks 1–5. +- Produces: + ```go + func PauseBox(th Theme) string + func GameOverBox(s game.Snapshot, th Theme) string + func HelpBox(body string, th Theme) string + func BootBox(progress float64, th Theme) string // 0..1 through the §29 checklist + func TooSmallNotice(width, height int, th Theme) string + ``` + +Exact copy, from §30, §28, §39, §29 — reproduce the text verbatim; `GameOverBox` fills in the snapshot's score, lines and level and carries the subtitle `CAUSE: EXCESSIVE GEOMETRY`. `BootBox` reveals the three checklist lines (`gravity ........ OK`, `spacetime ...... OK`, `tetrominoes .... QUESTIONABLE`) as `progress` crosses 0.3, 0.55 and 0.8, and shows `UNIVERSE ONLINE` at 1.0. Boxes are centred with `lipgloss.Place` over the frame. `HelpBox` wraps the body string the app builds from `bubbles/help`. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestPauseBoxCopy(t *testing.T) { + // contains "TEMPORAL SUSPENSION", "SPACE IS PAUSED", "p resume" +} + +func TestGameOverBoxShowsFinalCounters(t *testing.T) { + // Snapshot{Score: 483200, Lines: 127, Level: 13}: + // contains "UNIVERSE EXPIRED", "483,200", "127", "13", + // "r REBOOT UNIVERSE", "q ACCEPT COSMIC DEATH", "CAUSE: EXCESSIVE GEOMETRY" +} + +func TestGameOverBoxWidthIsStableForHugeScores(t *testing.T) { + // Review Focus #4: the box width for Snapshot{Score: math.MaxInt} equals + // the box width for Snapshot{Score: 0} +} + +func TestHelpBoxCopy(t *testing.T) { + // contains "FLIGHT MANUAL" and the supplied body verbatim +} + +func TestBootBoxRevealsChecklistWithProgress(t *testing.T) { + // progress 0.0 -> contains "INITIALIZING LOCAL UNIVERSE" and none of the OK lines + // progress 0.6 -> contains "gravity" and "spacetime", not "tetrominoes" + // progress 1.0 -> contains "tetrominoes" and "UNIVERSE ONLINE" +} + +func TestBoxesAreRectangular(t *testing.T) { + // for each box in each mode: every stripped line has the same lipgloss.Width +} + +func TestOverlayGoldens(t *testing.T) { + // Frame with OverlayPaused / OverlayGameOver / OverlayHelp / OverlayBoot at + // 80x30 ModeFull, compared to testdata/{pause,gameover,help,boot}.golden +} + +func TestOverlaysDoNotChangeFrameDimensions(t *testing.T) { + // each overlay frame at 80x30 is exactly 30 lines, none wider than 80 +} + +func TestOverlaysFitTheMinimumTerminal(t *testing.T) { + // at 40x24 each overlay frame is 24 lines and no line exceeds 40 columns +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/render/ -run 'TestPauseBox|TestGameOver|TestHelpBox|TestBootBox|TestBoxes|TestOverlay' -v` +Expected: FAIL + +- [ ] **Step 3: Implement `internal/render/overlay.go` and the `Frame` dispatch** + +- [ ] **Step 4: Record the goldens and read them** + +Run: `go test ./internal/render/ -run TestOverlayGoldens -update` +Read all four files and confirm each box is centred and the board is still visible around it where the spec expects (pause and help float over the board; game over replaces the play area's content but the frame stays). + +- [ ] **Step 5: Run the suite** + +Run: `go test ./internal/render/ -v` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add internal/render/ +git commit -m "feat: pause, help, game-over, boot and too-small overlays" +``` + +--- + +### Task 7: Bubble Tea model, key map, single animation clock + +**Files:** +- Create: `internal/app/keys.go`, `internal/app/messages.go`, `internal/app/model.go`, `internal/app/update.go` +- Test: `internal/app/update_test.go`, `internal/app/keys_test.go` + +**Interfaces:** +- Consumes: all of `internal/render`, all of `internal/game`. +- Produces: + ```go + // messages.go + type FrameMsg struct{ Now time.Time } + + const ( + FrameInterval = 16 * time.Millisecond // ~60 Hz (§36) + MaxFrameDelta = 100 * time.Millisecond // clamp across suspend/sleep + BootDuration = 1000 * time.Millisecond + ) + + func Frame() tea.Cmd // tea.Tick(FrameInterval, ...) -> FrameMsg + + // keys.go + type KeyMap struct { + Left, Right, SoftDrop, RotateCW, RotateCCW, HardDrop, Hold, + Pause, Restart, Help, Quit key.Binding + } + + func DefaultKeyMap() KeyMap + func (k KeyMap) ShortHelp() []key.Binding + func (k KeyMap) FullHelp() [][]key.Binding + + // model.go + type Options struct { + Seed int64 + SeedExplicit bool // r restarts with the same seed when true, a fresh one when false + ASCII bool + NoFX bool + ReducedMotion bool + } + + type AppState uint8 + + const ( + StateBoot AppState = iota + StatePlaying + StatePaused + StateHelp + StateOver + ) + + type Model struct { + Game *game.Game + Opts Options + Keys KeyMap + Help help.Model + + Width, Height int + State AppState + Theme render.Theme + Layout render.Layout + Mission string + + Elapsed time.Duration + Boot time.Duration + LastFrame time.Time + } + + func New(opts Options) *Model + func (m *Model) Init() tea.Cmd + func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) + func (m *Model) View() tea.View + func (m *Model) Universe() string // fmt.Sprintf("%04X", uint16(m.Opts.Seed)) + ``` + +Key bindings, from §8 plus the WASD aliases: + +```text +Left left, h, a "←→ / h l" "move spacecraft" +Right right, l, d +SoftDrop down, j, s "↓ / j" "accelerate doom" +RotateCW up, k, x, w "↑ / k / x" "rotate geometry" +RotateCCW z "z" "rotate other way" +HardDrop space "SPACE" "YEET" +Hold c "c" "quantum storage" +Pause p "p" "suspend spacetime" +Restart r "r" "reboot universe" +Help ? "?" "close this nonsense" +Quit q, esc, ctrl+c "q" "abandon mission" +``` + +`tea.KeyPressMsg.String()` yields `"space"`, `"esc"`, `"left"` — bind those strings, not `" "`. + +Pinned `Update` behaviour: + +```text +tea.WindowSizeMsg -> Width, Height, Layout = render.Compute(w, h) + State stays as it is; SizeTooSmall only changes what renders +tea.ColorProfileMsg -> Theme = render.NewTheme(render.ModeFor(profile, Opts.ASCII)) +FrameMsg -> dt := min(msg.Now.Sub(LastFrame), MaxFrameDelta), floored at 0 + LastFrame = msg.Now; Elapsed += dt + StateBoot: Boot += dt; at BootDuration switch to StatePlaying + StatePlaying: Game.Advance(dt); a GameOver event switches to StateOver + StatePaused / StateHelp / StateOver: no Advance + always return Frame() to reschedule +tea.KeyPressMsg -> handled immediately, before any tick, and never queued: + StateBoot: any key ends the boot sequence (§29) + Quit -> tea.Quit from every state + Restart -> new game; seed = Opts.Seed when SeedExplicit, + else time.Now().UnixNano(); State = StatePlaying + Help -> toggles StateHelp <-> StatePlaying + Pause -> toggles StatePaused <-> StatePlaying + movement/rotation/drop/hold -> only in StatePlaying +``` + +`View()` builds a `render.Input` from the model, calls `render.Frame`, and returns a `tea.View` with `AltScreen = true`. `Overlay` is derived from `State` plus `Layout.Size`: `SizeTooSmall` wins over everything. + +- [ ] **Step 1: Write the failing tests** + +These run without a terminal — construct the model and feed it messages directly. + +```go +func TestEveryBindingIsReachable(t *testing.T) { + // for each of the key strings in the §8 table, exactly one binding in + // DefaultKeyMap matches a tea.KeyPressMsg with that String() +} + +func TestWindowSizeUpdatesLayout(t *testing.T) { + // Update(tea.WindowSizeMsg{80, 30}) -> m.Layout.Size == render.SizeLarge + // Update(tea.WindowSizeMsg{34, 19}) -> m.Layout.Size == render.SizeTooSmall +} + +func TestZeroWindowSizeDoesNotPanic(t *testing.T) { + // Review Focus #1: Update(tea.WindowSizeMsg{0, 0}) then View() returns + // without panicking and the view content is non-empty +} + +func TestKeyPressMovesPieceImmediately(t *testing.T) { + // after StatePlaying and a WindowSizeMsg: x := m.Game.Active.X + // Update(tea.KeyPressMsg{Code: 'l', Text: "l"}) -> m.Game.Active.X == x+1 + // with no FrameMsg in between (§44 "never make controls lag") +} + +func TestHardDropBindsToSpace(t *testing.T) { + // Update(tea.KeyPressMsg{Code: ' ', Text: " "}) locks a piece: + // m.Game.Board has 4 filled cells and Score > 0 +} + +func TestPauseStopsAdvancing(t *testing.T) { + // press p, then feed a FrameMsg 5 seconds later: + // m.Game.Active is unchanged and m.Game.Score is unchanged + // press p again, feed another FrameMsg: the piece moves +} + +func TestPausedStarsStillAdvanceElapsed(t *testing.T) { + // §30: while paused, m.Elapsed still grows with each FrameMsg + // (the background keeps drifting even though gameplay is frozen) +} + +func TestHelpTogglesAndFreezesGameplay(t *testing.T) { + // "?" -> StateHelp; a FrameMsg does not Advance; "?" -> StatePlaying +} + +func TestFrameDeltaIsClamped(t *testing.T) { + // Review Focus #2: send FrameMsg at t0, then FrameMsg at t0+10s + // the piece descends by at most the number of cells MaxFrameDelta allows + // (with level 1 at 800ms, at most 1 cell) and no PieceLocked cascade occurred: + // the board has at most 4 filled cells +} + +func TestNegativeFrameDeltaIsTreatedAsZero(t *testing.T) { + // FrameMsg at t0+1s then FrameMsg at t0 (a clock step backwards): + // no panic, no state change +} + +func TestBootEndsAfterOneSecondOrAnyKey(t *testing.T) { + // fresh model is StateBoot + // feeding FrameMsgs totalling BootDuration switches to StatePlaying + // a fresh model plus one arbitrary key press also switches to StatePlaying +} + +func TestGameOverStopsAdvancingAndKeepsRestartWorking(t *testing.T) { + // force m.Game into StateOver, feed a FrameMsg -> m.State == StateOver + // press "r" -> m.State == StatePlaying with a fresh empty board and Score 0 +} + +func TestRestartReusesTheSeedOnlyWhenExplicit(t *testing.T) { + // Opts{Seed: 99, SeedExplicit: true}: after "r", m.Game.Seed == 99 + // Opts{Seed: 99, SeedExplicit: false}: after "r", m.Game.Seed != 99 +} + +func TestQuitReturnsTeaQuit(t *testing.T) { + // "q" and "esc" each return a non-nil Cmd whose Msg is tea.QuitMsg +} + +func TestViewRequestsAltScreen(t *testing.T) { + // View().AltScreen == true and View().Content is non-empty +} + +func TestColorProfileMsgSelectsTheMode(t *testing.T) { + // ColorProfileMsg{colorprofile.TrueColor} -> m.Theme.Mode == render.ModeFull + // with Opts.ASCII true, the same message still yields render.ModeASCII +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/app/ -v` +Expected: FAIL — undefined `New`, `Model`. + +- [ ] **Step 3: Implement `keys.go`, `messages.go`, `model.go`, `update.go`** + +`Update` uses a pointer receiver and returns `m` as the `tea.Model`. Key handling is a single `switch` of `key.Matches` calls placed before any other message handling, so input never waits on a tick (§44). Held left/right relies on the terminal's own key repeat — no custom repeat timer. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/app/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/app/ +git commit -m "feat: Bubble Tea model with one animation clock and immediate input" +``` + +--- + +### Task 8: CLI, program wiring, README + +**Files:** +- Create: `cmd/cosmic-tetris/main.go`, `internal/app/options.go`, `README.md` +- Test: `internal/app/options_test.go` + +**Interfaces:** +- Consumes: `app.New`, `app.Options`. +- Produces: + ```go + // ParseArgs parses the CLI surface pinned in §49.5. usage is written to out + // for --help. err is non-nil for a bad flag or a bad seed; main exits 2. + func ParseArgs(args []string, out io.Writer) (Options, bool /*exit*/, error) + ``` + +Final CLI surface (§49.5) — nothing else: + +```text +cosmic-tetris +cosmic-tetris --seed 1234 +cosmic-tetris --ascii +cosmic-tetris --no-fx +cosmic-tetris --reduced-motion +cosmic-tetris --help +``` + +`--seed` is parsed with `strconv.ParseInt(v, 10, 64)`; when absent, `Seed` is `time.Now().UnixNano()` and `SeedExplicit` is false. `main` builds the program with `tea.NewProgram(app.New(opts), tea.WithFPS(60))` and exits 1 on a run error. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestParseArgsDefaults(t *testing.T) { + // no args: ASCII, NoFX, ReducedMotion all false; SeedExplicit false; + // Seed is non-zero; exit false; err nil +} + +func TestParseArgsFlags(t *testing.T) { + // --seed 1234 -> Seed 1234, SeedExplicit true + // --ascii -> ASCII true + // --no-fx -> NoFX true + // --reduced-motion -> ReducedMotion true + // all four together -> all set +} + +func TestParseArgsHelpExitsCleanly(t *testing.T) { + // --help: exit true, err nil, and out contains every flag name plus + // "cosmic-tetris" +} + +func TestParseArgsRejectsBadSeeds(t *testing.T) { + // Review Focus #3 + // --seed abc -> err non-nil + // --seed 99999999999999999999 -> err non-nil (int64 overflow) + // --seed "" -> err non-nil + // --seed 1.5 -> err non-nil + // in every case the returned Options must not be used: err is what main checks +} + +func TestParseArgsAcceptsNegativeAndZeroSeeds(t *testing.T) { + // Review Focus #3 + // --seed 0 -> Seed 0, SeedExplicit true, err nil (0 is a legitimate seed) + // --seed -7 -> Seed -7, SeedExplicit true, err nil + // game.New with each of those seeds produces a full Next queue +} + +func TestParseArgsRejectsUnknownFlags(t *testing.T) { + // --turbo -> err non-nil and out mentions the flag +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/app/ -run TestParseArgs -v` +Expected: FAIL — undefined `ParseArgs`. + +- [ ] **Step 3: Implement `ParseArgs` and `cmd/cosmic-tetris/main.go`** + +Use `flag.NewFlagSet` with `flag.ContinueOnError` and `SetOutput(out)` so the tests can capture usage. Detect `SeedExplicit` with `fs.Visit`. `--seed -7` must survive Go's flag parsing — use `-seed=-7` form in the test for the negative case if the bare form is ambiguous, and accept both in the implementation. + +- [ ] **Step 4: Write the README** + +`README.md` covers: what it is (one paragraph from §1 and §48), `go build ./cmd/cosmic-tetris`, the six CLI invocations above, the §8 control table, the three rendering modes and when each is picked, the 40×24 minimum, and a "Design" section pointing at `design.md` and the three plan files. + +- [ ] **Step 5: Run the full suite and build** + +Run: `go build ./... && go vet ./... && go test ./... -race` +Expected: PASS + +- [ ] **Step 6: Play it** + +Run: `go run ./cmd/cosmic-tetris --seed 8675309` +Confirm by hand: the boot card appears and any key skips it; arrows move and rotate; the ghost tracks the active piece; space hard-drops; `c` holds; `p` pauses; `?` shows the flight manual; a completed line clears; resizing the terminal down past 40×24 shows the too-small notice and resizing back recovers; `q` exits cleanly and leaves the terminal usable. Then run `go run ./cmd/cosmic-tetris --ascii` and confirm no `██` appears. Record anything that misbehaves as a fix inside this task before committing. + +- [ ] **Step 7: Commit** + +```bash +git add cmd/ internal/app/ README.md +git commit -m "feat: CLI surface, program wiring and README" +``` + +--- + +## Done when + +`go test ./... -race` passes; the goldens in `internal/render/testdata/` are committed and stable; §42's Phase 2 bar is met — "At this point it should already be a genuinely good game" — verified by the hands-on pass in Task 8 Step 6. `--no-fx` and `--reduced-motion` parse and are carried in `Options` but have nothing to suppress yet; plan 3 gives them meaning. Proceed to `plans/2026-09-18-cosmic-tetris-effects.md`. diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/F1-first-plan-cosmic-41/2026-09-17-cosmic-tetris-engine.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/F1-first-plan-cosmic-41/2026-09-17-cosmic-tetris-engine.md new file mode 100644 index 000000000..bf8cc875b --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/F1-first-plan-cosmic-41/2026-09-17-cosmic-tetris-engine.md @@ -0,0 +1,3120 @@ +# Cosmic Tetris — Phase 1: Deterministic Game Engine Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the complete headless `internal/game` engine for Cosmic Tetris — pieces, board, 7-bag, movement, wall kicks, gravity, lock delay, line clearing, hold, scoring, game over, and the game-event stream — with the comprehensive test suite design.md §40 requires, so that Phase 2 can put a Bubble Tea UI on top of a proven engine. + +**Architecture:** A single Go package, `internal/game`, depending only on the standard library. The engine is a pure state machine: it never reads a clock (design.md §49.2 — Bubble Tea passes elapsed time in via `Advance(dt)`), it owns one `*rand.Rand` that drives only the 7-bag (§49.6), and every state transition returns a `[]Event` slice that the future FX world observes without being able to write back (§14). Board and piece geometry are plain value types so tests can construct exact positions and compare whole boards. + +**Tech Stack:** Go 1.26, standard library only (`math`, `math/rand`, `time`, plus `os`/`strings`/`fmt`/`reflect` in tests). No third-party dependencies in this phase — Bubble Tea, Lip Gloss, and Bubbles arrive in Phase 2. + +**Spec:** `design.md` (this repository root). Section references below (`§7`, `§49.2`, …) are to that document. Where §1–48 and §49 disagree, §49 wins. + +## Global Constraints + +- Language: Go. Module path: `cosmic-tetris`. `go.mod` declares `go 1.26`. +- `internal/game` imports **only** the Go standard library. No third-party dependency may be added in this phase. +- Nothing under `internal/game` may call `time.Now()` or otherwise read a clock (§49.2). Timing is an input: `func (g *Game) Advance(dt time.Duration) []Event`. +- Board geometry is fixed: width 10, height 22, 20 visible rows, 2 hidden spawn rows (§5). +- The game RNG drives the 7-bag and nothing else. FX gets a separate, independent `*rand.Rand` in a later phase; the two never share (§49.6). +- Combo indexing and bonus are §49.1: the first clearing placement sets combo to 1; a placement clearing nothing resets combo to 0; `bonus = 50 × (combo - 1) × level`. +- Wall-kick offsets are exactly §7's eight offsets, tried in that order; first valid position wins; otherwise the rotation fails. +- Gravity: `interval = 800ms × 0.86^(level-1)`, clamped to a floor of 60ms; level rises every 10 cleared lines (§11). +- Lock delay 500ms; a successful move or rotation while grounded resets it; at most 15 resets per piece (§12). +- File layout follows §33 exactly. The one addition is `internal/game/events.go`, because §14 requires nine event types and keeping them out of `game.go` is what §33's "keep the repository obvious" is asking for. +- Effects never modify game state (§14, §44). This phase enforces that structurally: the engine has no reference to any FX type, and events are values. +- Commit style: conventional commits (`feat:`, `test:`, `chore:`). Commit at the end of every task. + +## Review Focus + +These are the input classes design.md implies but never names, ordered by how likely they are to bite a real player. Each one's test is assigned to the task that owns the code. + +1. **A huge `dt`** — the player backgrounds the terminal, closes the laptop, or the process is SIGSTOPped, and the next frame reports 30 seconds of elapsed time. The piece must not teleport down the board or skip its lock delay; catch-up is bounded. (Task 7) +2. **A non-positive `dt`** — two frames land inside the same clock tick, or the wall clock steps backwards during an NTP correction. `Advance` must be a no-op, never rewinding state or spinning in the gravity loop. (Task 7) +3. **Very high levels** — a strong player reaching level 40+ must not hit a zero or negative gravity interval, which would make the gravity loop spin forever and hang the UI thread. (Task 4) +4. **Rotation against the ceiling** — §7's kick list includes `(0,-1)`, `(-1,-1)`, `(1,-1)`, which push cells above row 0 while a piece is in the hidden spawn rows. That must be rejected by a bounds check, not panic on a negative slice index. (Tasks 2 and 6) +5. **Spawning into a full stack** — when the stack reaches the spawn rows, both a normal spawn and a hold-swap must end the game rather than leave an active piece embedded inside locked cells. (Tasks 5 and 9) + +--- + +### Task 1: Module bootstrap and piece geometry + +Creates the module and the piece tables everything else is built on: seven kinds, four rotations each, and the absolute-cell computation. Also installs the architecture test that keeps the engine clock-free for the rest of the project. + +**Files:** +- Create: `go.mod` +- Create: `README.md` +- Create: `internal/game/rules.go` +- Create: `internal/game/piece.go` +- Test: `internal/game/piece_test.go` +- Test: `internal/game/arch_test.go` + +**Interfaces:** +- Consumes: nothing. +- Produces: + - `type PieceKind int`, constants `I, J, L, O, S, T, Z PieceKind`, `const KindCount = 7`, `func (k PieceKind) String() string` + - `type Point struct{ X, Y int }` + - `type Piece struct{ Kind PieceKind; Rotation, X, Y int }` + - `func (p Piece) Cells() [4]Point`, `func (p Piece) Moved(dx, dy int) Piece`, `func (p Piece) Rotated(dir int) Piece` + - `func SpawnPiece(k PieceKind) Piece` + - constants `Width, Height, HiddenRows, VisibleRows, SpawnX, SpawnY, NextCount, LinesPerLevel, SoftDropPoints, HardDropPoints`, `BaseGravity, GravityFactor, MinGravity, LockDelay, MaxLockResets, MaxAdvanceStep`, and `var kickOffsets [8]Point` + +- [ ] **Step 1: Create the module and README** + +```bash +cd "$(git rev-parse --show-toplevel)" +mkdir -p cmd/cosmic-tetris internal/game +cat > go.mod <<'EOF' +module cosmic-tetris + +go 1.26 +EOF +``` + +`README.md`: + +```markdown +# Cosmic Tetris + +A falling-block puzzle game occurring during a completely unnecessary +cosmological emergency. See `design.md` for the build spec. + +## Status + +Phase 1: headless game engine (`internal/game`). Not yet playable. + +## Development + + go test ./... + go vet ./... + +The engine in `internal/game` never reads a clock: it advances via +`Advance(dt)` so that a seed plus an input-and-timing stream reproduces a +game exactly (design.md §35, §49.2). +``` + +- [ ] **Step 2: Write the rules constants** + +These are data, not behavior — the numbers every later task cites, in one place. Create `internal/game/rules.go`: + +```go +// Package game is the headless Cosmic Tetris engine. It is deterministic and +// clock-free: callers pass elapsed time in through Advance (design.md §49.2). +package game + +import "time" + +// Board geometry (design.md §5). +const ( + Width = 10 + Height = 22 + HiddenRows = 2 + VisibleRows = Height - HiddenRows +) + +// Spawn position: the top-left corner of a piece's 4x4 rotation box, placed so +// the piece is horizontally centred and starts in the hidden rows. +const ( + SpawnX = 3 + SpawnY = 0 +) + +// NextCount is how many upcoming pieces stay visible (design.md §6). +const NextCount = 5 + +// LinesPerLevel is how many cleared lines advance the level (design.md §11). +const LinesPerLevel = 10 + +// Drop scoring, in points per cell descended (design.md §11). +const ( + SoftDropPoints = 1 + HardDropPoints = 2 +) + +// Timing (design.md §11, §12). +const ( + BaseGravity = 800 * time.Millisecond + GravityFactor = 0.86 + MinGravity = 60 * time.Millisecond + + LockDelay = 500 * time.Millisecond + MaxLockResets = 15 + + // MaxAdvanceStep bounds how much elapsed time one Advance call may apply. + // A backgrounded terminal can report multi-second frames; without this the + // piece would fall the whole board in a single frame. + MaxAdvanceStep = 250 * time.Millisecond +) + +// kickOffsets are tried in order when a rotation is blocked (design.md §7). +// The first offset that yields a valid position wins; if none do, the rotation +// fails. +var kickOffsets = [8]Point{ + {0, 0}, {-1, 0}, {1, 0}, {-2, 0}, {2, 0}, {0, -1}, {-1, -1}, {1, -1}, +} +``` + +- [ ] **Step 3: Write the failing piece tests** + +Create `internal/game/piece_test.go`: + +```go +package game + +import "testing" + +func TestEveryRotationHasFourCellsInsideItsBox(t *testing.T) { + for k := PieceKind(0); k < KindCount; k++ { + for r := 0; r < 4; r++ { + p := Piece{Kind: k, Rotation: r, X: 0, Y: 0} + cells := p.Cells() + seen := map[Point]bool{} + for _, c := range cells { + if c.X < 0 || c.X > 3 || c.Y < 0 || c.Y > 3 { + t.Errorf("%v rotation %d: cell %v outside the 4x4 box", k, r, c) + } + if seen[c] { + t.Errorf("%v rotation %d: duplicate cell %v", k, r, c) + } + seen[c] = true + } + if len(seen) != 4 { + t.Errorf("%v rotation %d: got %d distinct cells, want 4", k, r, len(seen)) + } + } + } +} + +func TestKindNames(t *testing.T) { + want := []struct { + kind PieceKind + name string + }{{I, "I"}, {J, "J"}, {L, "L"}, {O, "O"}, {S, "S"}, {T, "T"}, {Z, "Z"}} + for _, w := range want { + if got := w.kind.String(); got != w.name { + t.Errorf("PieceKind(%d).String() = %q, want %q", w.kind, got, w.name) + } + } + if got := PieceKind(99).String(); got != "?" { + t.Errorf("out-of-range kind printed %q, want %q", got, "?") + } +} + +func TestIPieceRotationsAreHorizontalThenVertical(t *testing.T) { + flat := Piece{Kind: I, Rotation: 0}.Cells() + for _, c := range flat { + if c.Y != 1 { + t.Errorf("I rotation 0 cell %v: want every cell on row 1", c) + } + } + tall := Piece{Kind: I, Rotation: 1}.Cells() + for _, c := range tall { + if c.X != 2 { + t.Errorf("I rotation 1 cell %v: want every cell in column 2", c) + } + } +} + +// I, S and Z are two-state pieces: rotations 2 and 3 repeat 0 and 1. O is +// identical in all four (design.md §6). This test documents that choice so a +// later change to the tables is deliberate rather than accidental. +func TestTwoStateAndSymmetricPieces(t *testing.T) { + for _, k := range []PieceKind{I, S, Z} { + if (Piece{Kind: k, Rotation: 2}).Cells() != (Piece{Kind: k, Rotation: 0}).Cells() { + t.Errorf("%v: rotation 2 should repeat rotation 0", k) + } + if (Piece{Kind: k, Rotation: 3}).Cells() != (Piece{Kind: k, Rotation: 1}).Cells() { + t.Errorf("%v: rotation 3 should repeat rotation 1", k) + } + } + base := Piece{Kind: O, Rotation: 0}.Cells() + for r := 1; r < 4; r++ { + if (Piece{Kind: O, Rotation: r}).Cells() != base { + t.Errorf("O: rotation %d differs from rotation 0", r) + } + } +} + +func TestSpawnPiecePosition(t *testing.T) { + p := SpawnPiece(T) + if p.Kind != T || p.Rotation != 0 || p.X != SpawnX || p.Y != SpawnY { + t.Fatalf("SpawnPiece(T) = %+v, want kind T rotation 0 at (%d,%d)", p, SpawnX, SpawnY) + } + for _, c := range p.Cells() { + if c.Y >= HiddenRows { + t.Errorf("spawned cell %v is already visible; spawn belongs in the hidden rows", c) + } + if c.X < 0 || c.X >= Width { + t.Errorf("spawned cell %v is outside the board width", c) + } + } +} + +func TestMovedAndRotatedReturnCopies(t *testing.T) { + p := Piece{Kind: J, Rotation: 0, X: 4, Y: 5} + if got := p.Moved(-1, 2); got.X != 3 || got.Y != 7 { + t.Errorf("Moved(-1,2) = (%d,%d), want (3,7)", got.X, got.Y) + } + if p.X != 4 || p.Y != 5 { + t.Errorf("Moved mutated the receiver: %+v", p) + } + if got := p.Rotated(1); got.Rotation != 1 { + t.Errorf("Rotated(1).Rotation = %d, want 1", got.Rotation) + } + if got := p.Rotated(-1); got.Rotation != 3 { + t.Errorf("Rotated(-1).Rotation = %d, want 3 (wrap)", got.Rotation) + } + if got := (Piece{Kind: J, Rotation: 3}).Rotated(1); got.Rotation != 0 { + t.Errorf("rotation 3 clockwise = %d, want 0 (wrap)", got.Rotation) + } +} +``` + +- [ ] **Step 4: Run the tests to verify they fail** + +Run: `go test ./internal/game/ -run 'Piece|Kind|Spawn|Moved|TwoState' -v` +Expected: FAIL — the package does not compile, with errors like `undefined: PieceKind`, `undefined: SpawnPiece`. + +- [ ] **Step 5: Implement piece geometry** + +Create `internal/game/piece.go`: + +```go +package game + +// PieceKind identifies one of the seven tetromino families (design.md §6). +type PieceKind int + +const ( + I PieceKind = iota + J + L + O + S + T + Z +) + +// KindCount is how many tetromino families exist. +const KindCount = 7 + +func (k PieceKind) String() string { + if k < 0 || k >= KindCount { + return "?" + } + return "IJLOSTZ"[k : k+1] +} + +// Point is a board coordinate. Y grows downward: row 0 is the top hidden row, +// row Height-1 is the floor. +type Point struct { + X, Y int +} + +// Piece is the active tetromino: a kind, a rotation index 0-3, and the board +// position of the top-left corner of its 4x4 rotation box. +type Piece struct { + Kind PieceKind + Rotation int + X int + Y int +} + +// shapes[kind][rotation] is a 4x4 mask, one string per row, 'X' for a filled +// cell. Four predefined rotations per piece (design.md §6, §7): I, S and Z +// repeat rotations 0 and 1 in slots 2 and 3, and O is identical throughout. +var shapes = [KindCount][4][4]string{ + { // I + {"....", "XXXX", "....", "...."}, + {"..X.", "..X.", "..X.", "..X."}, + {"....", "XXXX", "....", "...."}, + {"..X.", "..X.", "..X.", "..X."}, + }, + { // J + {"X...", "XXX.", "....", "...."}, + {".XX.", ".X..", ".X..", "...."}, + {"....", "XXX.", "..X.", "...."}, + {".X..", ".X..", "XX..", "...."}, + }, + { // L + {"..X.", "XXX.", "....", "...."}, + {".X..", ".X..", ".XX.", "...."}, + {"....", "XXX.", "X...", "...."}, + {"XX..", ".X..", ".X..", "...."}, + }, + { // O + {".XX.", ".XX.", "....", "...."}, + {".XX.", ".XX.", "....", "...."}, + {".XX.", ".XX.", "....", "...."}, + {".XX.", ".XX.", "....", "...."}, + }, + { // S + {".XX.", "XX..", "....", "...."}, + {"X...", "XX..", ".X..", "...."}, + {".XX.", "XX..", "....", "...."}, + {"X...", "XX..", ".X..", "...."}, + }, + { // T + {".X..", "XXX.", "....", "...."}, + {".X..", ".XX.", ".X..", "...."}, + {"....", "XXX.", ".X..", "...."}, + {".X..", "XX..", ".X..", "...."}, + }, + { // Z + {"XX..", ".XX.", "....", "...."}, + {"..X.", ".XX.", ".X..", "...."}, + {"XX..", ".XX.", "....", "...."}, + {"..X.", ".XX.", ".X..", "...."}, + }, +} + +// Cells returns the four absolute board coordinates the piece occupies. +// Returning an array keeps this allocation-free: it runs inside collision +// checks on every frame (design.md §38). +func (p Piece) Cells() [4]Point { + var out [4]Point + n := 0 + for dy, row := range shapes[p.Kind][p.Rotation&3] { + for dx := 0; dx < 4; dx++ { + if row[dx] == 'X' { + out[n] = Point{X: p.X + dx, Y: p.Y + dy} + n++ + } + } + } + return out +} + +// Moved returns the piece translated by (dx, dy). +func (p Piece) Moved(dx, dy int) Piece { + p.X += dx + p.Y += dy + return p +} + +// Rotated returns the piece rotated by dir: +1 clockwise, -1 counter-clockwise. +func (p Piece) Rotated(dir int) Piece { + p.Rotation = ((p.Rotation+dir)%4 + 4) % 4 + return p +} + +// SpawnPiece returns a fresh piece at the spawn position in spawn rotation. +func SpawnPiece(k PieceKind) Piece { + return Piece{Kind: k, Rotation: 0, X: SpawnX, Y: SpawnY} +} +``` + +- [ ] **Step 6: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS for all six tests. + +- [ ] **Step 7: Add the clock-free architecture test** + +This one passes on arrival — it exists to fail later, if someone reaches for `time.Now()` inside the engine. Note it skips its own filename, because its message contains the very string it searches for. Create `internal/game/arch_test.go`: + +```go +package game + +import ( + "os" + "strings" + "testing" +) + +// The engine takes elapsed time as an input and must never read a clock +// (design.md §49.2). That is what makes the determinism promise in §35 +// testable, so it is worth guarding mechanically. +func TestEngineNeverReadsTheClock(t *testing.T) { + entries, err := os.ReadDir(".") + if err != nil { + t.Fatalf("reading package directory: %v", err) + } + for _, e := range entries { + name := e.Name() + if e.IsDir() || !strings.HasSuffix(name, ".go") || name == "arch_test.go" { + continue + } + src, err := os.ReadFile(name) + if err != nil { + t.Fatalf("reading %s: %v", name, err) + } + if strings.Contains(string(src), "time.Now") { + t.Errorf("%s reads the clock; the engine must take dt as an input (design.md §49.2)", name) + } + } +} +``` + +- [ ] **Step 8: Run the full package tests and vet** + +Run: `go test ./... && go vet ./...` +Expected: PASS, no vet diagnostics. + +- [ ] **Step 9: Commit** + +```bash +git add go.mod README.md internal/game/rules.go internal/game/piece.go internal/game/piece_test.go internal/game/arch_test.go +git commit -m "feat(game): tetromino geometry, rules constants, clock-free guard" +``` + +--- + +### Task 2: Board — collision, locking, row completion, collapse + +The board is the only mutable spatial structure in the engine. Everything about legality lives here, including the bounds check that stops §7's upward kicks from indexing above row 0. + +**Files:** +- Create: `internal/game/board.go` +- Test: `internal/game/board_test.go` + +**Interfaces:** +- Consumes: `Piece`, `Point`, `Width`, `Height`, `PieceKind` (Task 1). +- Produces: + - `type Cell uint8`, `const Empty Cell = 0`, `func cellFor(k PieceKind) Cell`, `func (c Cell) Kind() PieceKind` + - `type Board struct{ Cells [Height][Width]Cell }` + - `func (b *Board) InBounds(x, y int) bool`, `At(x, y int) Cell`, `Set(x, y int, c Cell)`, `Occupied(x, y int) bool` + - `func (b *Board) Collides(p Piece) bool` + - `func (b *Board) Lock(p Piece)` + - `func (b *Board) CompleteRows() []int` + - `func (b *Board) ClearRows(rows []int)` + - test helper `func fillRow(b *Board, y int, holes ...int)` — later tasks reuse it from this file + +- [ ] **Step 1: Write the failing board tests** + +Create `internal/game/board_test.go`: + +```go +package game + +import ( + "reflect" + "testing" +) + +// fillRow fills row y with locked cells, leaving the listed columns empty. +// Later tasks build stacks with it. +func fillRow(b *Board, y int, holes ...int) { + hole := map[int]bool{} + for _, x := range holes { + hole[x] = true + } + for x := 0; x < Width; x++ { + if !hole[x] { + b.Set(x, y, cellFor(O)) + } + } +} + +func TestInBoundsAndAccessors(t *testing.T) { + var b Board + cases := []struct { + x, y int + want bool + }{ + {0, 0, true}, {Width - 1, Height - 1, true}, + {-1, 0, false}, {0, -1, false}, + {Width, 0, false}, {0, Height, false}, + } + for _, c := range cases { + if got := b.InBounds(c.x, c.y); got != c.want { + t.Errorf("InBounds(%d,%d) = %v, want %v", c.x, c.y, got, c.want) + } + } + b.Set(3, 4, cellFor(T)) + if !b.Occupied(3, 4) { + t.Error("Occupied(3,4) = false after Set") + } + if got := b.At(3, 4).Kind(); got != T { + t.Errorf("At(3,4).Kind() = %v, want T", got) + } + if b.Occupied(3, 5) { + t.Error("Occupied(3,5) = true, want false") + } + // Out-of-range access must be a safe miss, not a panic. + if b.Occupied(-5, -5) || b.At(Width+2, Height+2) != Empty { + t.Error("out-of-range access should read as Empty") + } + b.Set(-1, -1, cellFor(T)) // must not panic +} + +func TestCollidesWithWallsFloorAndCeiling(t *testing.T) { + var b Board + // O occupies columns X+1,X+2 of a 4-wide box, so X=-2 puts a cell at -1. + if !b.Collides(Piece{Kind: O, X: -2, Y: 5}) { + t.Error("piece off the left edge should collide") + } + if !b.Collides(Piece{Kind: O, X: Width - 1, Y: 5}) { + t.Error("piece off the right edge should collide") + } + if !b.Collides(Piece{Kind: O, X: 3, Y: Height - 1}) { + t.Error("piece through the floor should collide") + } + // A rotation kick can push cells above row 0 (design.md §7 offsets + // (0,-1),(-1,-1),(1,-1)). That must read as a collision, not panic. + if !b.Collides(Piece{Kind: O, X: 3, Y: -1}) { + t.Error("piece above the ceiling should collide") + } + if b.Collides(Piece{Kind: O, X: 3, Y: 0}) { + t.Error("piece inside an empty board should not collide") + } +} + +func TestCollidesWithLockedCells(t *testing.T) { + var b Board + b.Set(4, 6, cellFor(Z)) + if !b.Collides(Piece{Kind: O, X: 3, Y: 5}) { + t.Error("piece overlapping a locked cell should collide") + } + if b.Collides(Piece{Kind: O, X: 6, Y: 5}) { + t.Error("piece clear of the locked cell should not collide") + } +} + +func TestLockWritesPieceIdentity(t *testing.T) { + var b Board + p := Piece{Kind: S, Rotation: 0, X: 3, Y: 8} + b.Lock(p) + for _, c := range p.Cells() { + if !b.Occupied(c.X, c.Y) { + t.Errorf("cell %v not filled after Lock", c) + } + if got := b.At(c.X, c.Y).Kind(); got != S { + t.Errorf("cell %v has kind %v, want S", c, got) + } + } +} + +func TestCompleteRowsFindsFullRowsTopToBottom(t *testing.T) { + var b Board + fillRow(&b, 21) + fillRow(&b, 19) + fillRow(&b, 20, 7) // one hole: not complete + got := b.CompleteRows() + if want := []int{19, 21}; !reflect.DeepEqual(got, want) { + t.Fatalf("CompleteRows() = %v, want %v", got, want) + } + var empty Board + if rows := empty.CompleteRows(); len(rows) != 0 { + t.Errorf("empty board reported complete rows %v", rows) + } +} + +func TestClearRowsCollapsesEverythingAbove(t *testing.T) { + var b Board + b.Set(2, 18, cellFor(J)) // lone marker high up + fillRow(&b, 20) + fillRow(&b, 21) + b.ClearRows([]int{20, 21}) + for y := 0; y < Height; y++ { + for x := 0; x < Width; x++ { + want := x == 2 && y == 20 // marker fell two rows + if b.Occupied(x, y) != want { + t.Fatalf("cell (%d,%d) occupied=%v, want %v", x, y, b.Occupied(x, y), want) + } + } + } +} + +func TestClearRowsMovesCellsOutOfTheHiddenRows(t *testing.T) { + var b Board + b.Set(0, 0, cellFor(L)) // sitting in a hidden spawn row + fillRow(&b, 21) + b.ClearRows([]int{21}) + if b.Occupied(0, 0) { + t.Error("hidden-row cell did not move down") + } + if !b.Occupied(0, 1) { + t.Error("hidden-row cell should have fallen to row 1") + } +} + +func TestClearRowsToleratesDuplicateUnsortedAndBogusIndices(t *testing.T) { + var b Board + fillRow(&b, 21) + b.Set(5, 20, cellFor(T)) + b.ClearRows([]int{21, 21, -3, Height + 4}) + if !b.Occupied(5, 21) { + t.Error("row 21 should have been cleared exactly once, dropping row 20 into it") + } + if b.Occupied(5, 20) { + t.Error("row 20 should be empty after the collapse") + } + var untouched Board + before := untouched + untouched.ClearRows(nil) + if untouched != before { + t.Error("ClearRows(nil) modified the board") + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/game/ -run Board -v` +Expected: FAIL to compile — `undefined: Board`, `undefined: cellFor`, `undefined: Empty`. + +- [ ] **Step 3: Implement the board** + +Create `internal/game/board.go`: + +```go +package game + +// Cell is one locked board square. Empty means unoccupied; any other value +// records which piece family filled it, so the renderer can colour locked +// cells (design.md §26). +type Cell uint8 + +// Empty is the zero value of Cell, so a zeroed Board is an empty board. +const Empty Cell = 0 + +func cellFor(k PieceKind) Cell { return Cell(k) + 1 } + +// Kind reports the piece family that filled the cell. Only meaningful when the +// cell is not Empty. +func (c Cell) Kind() PieceKind { return PieceKind(c - 1) } + +// Board is the locked playfield: Height rows of Width cells, row 0 at the top. +// It is a value type, so tests and snapshots can copy and compare whole boards. +type Board struct { + Cells [Height][Width]Cell +} + +// InBounds reports whether (x, y) names a real board square. +func (b *Board) InBounds(x, y int) bool { + return x >= 0 && x < Width && y >= 0 && y < Height +} + +// At returns the cell at (x, y), or Empty when out of bounds. +func (b *Board) At(x, y int) Cell { + if !b.InBounds(x, y) { + return Empty + } + return b.Cells[y][x] +} + +// Set writes a cell, ignoring out-of-bounds coordinates. +func (b *Board) Set(x, y int, c Cell) { + if b.InBounds(x, y) { + b.Cells[y][x] = c + } +} + +// Occupied reports whether (x, y) holds a locked cell. +func (b *Board) Occupied(x, y int) bool { return b.At(x, y) != Empty } + +// Collides reports whether the piece overlaps a wall, the floor, the space +// above the board, or a locked cell. Out-of-bounds counts as a collision, +// which is what makes the upward wall kicks in design.md §7 safe. +func (b *Board) Collides(p Piece) bool { + for _, c := range p.Cells() { + if !b.InBounds(c.X, c.Y) || b.Cells[c.Y][c.X] != Empty { + return true + } + } + return false +} + +// Lock commits the piece's cells to the board. +func (b *Board) Lock(p Piece) { + for _, c := range p.Cells() { + b.Set(c.X, c.Y, cellFor(p.Kind)) + } +} + +// CompleteRows returns the indices of the fully filled rows, top to bottom. +func (b *Board) CompleteRows() []int { + var rows []int + for y := 0; y < Height; y++ { + full := true + for x := 0; x < Width; x++ { + if b.Cells[y][x] == Empty { + full = false + break + } + } + if full { + rows = append(rows, y) + } + } + return rows +} + +// ClearRows removes the named rows and collapses everything above them down. +// Duplicate, unsorted, and out-of-range indices are tolerated. +func (b *Board) ClearRows(rows []int) { + if len(rows) == 0 { + return + } + var doomed [Height]bool + for _, y := range rows { + if y >= 0 && y < Height { + doomed[y] = true + } + } + dst := Height - 1 + for src := Height - 1; src >= 0; src-- { + if doomed[src] { + continue + } + b.Cells[dst] = b.Cells[src] + dst-- + } + for ; dst >= 0; dst-- { + b.Cells[dst] = [Width]Cell{} + } +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS, including all Task 1 tests. + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/board.go internal/game/board_test.go +git commit -m "feat(game): board collision, locking, row completion and collapse" +``` + +--- + +### Task 3: The 7-bag + +Piece generation is the only consumer of the game RNG (§49.6). Its determinism is what makes the whole replay guarantee possible, so it gets its own task. + +**Files:** +- Create: `internal/game/bag.go` +- Test: `internal/game/bag_test.go` + +**Interfaces:** +- Consumes: `PieceKind`, `KindCount` (Task 1). +- Produces: + - `type Bag struct{ rng *rand.Rand; queue []PieceKind }` + - `func NewBag(rng *rand.Rand) Bag` + - `func (b *Bag) Next() PieceKind` + - `func (b *Bag) Remaining() int` + +- [ ] **Step 1: Write the failing bag tests** + +Create `internal/game/bag_test.go`: + +```go +package game + +import ( + "math/rand" + "testing" +) + +func TestEveryBagHoldsAllSevenKindsExactlyOnce(t *testing.T) { + b := NewBag(rand.New(rand.NewSource(42))) + for round := 0; round < 5; round++ { + counts := map[PieceKind]int{} + for i := 0; i < KindCount; i++ { + counts[b.Next()]++ + } + for k := PieceKind(0); k < KindCount; k++ { + if counts[k] != 1 { + t.Fatalf("round %d: kind %v drawn %d times, want 1 (draws: %v)", round, k, counts[k], counts) + } + } + } +} + +func TestSeededBagsAreReproducible(t *testing.T) { + draw := func(seed int64, n int) []PieceKind { + b := NewBag(rand.New(rand.NewSource(seed))) + out := make([]PieceKind, n) + for i := range out { + out[i] = b.Next() + } + return out + } + a, b := draw(8675309, 100), draw(8675309, 100) + for i := range a { + if a[i] != b[i] { + t.Fatalf("draw %d differs between identical seeds: %v vs %v", i, a[i], b[i]) + } + } + if same := draw(1, 20); equalKinds(same, draw(2, 20)) { + t.Error("different seeds produced identical piece order") + } +} + +func equalKinds(a, b []PieceKind) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func TestBagIsShuffledNotFixedOrder(t *testing.T) { + // Across many bags, at least one must not be in I,J,L,O,S,T,Z order. + // A bag that always returns the same order would pass the count test above. + b := NewBag(rand.New(rand.NewSource(7))) + inOrder := []PieceKind{I, J, L, O, S, T, Z} + shuffledSomewhere := false + for round := 0; round < 10; round++ { + got := make([]PieceKind, KindCount) + for i := range got { + got[i] = b.Next() + } + if !equalKinds(got, inOrder) { + shuffledSomewhere = true + } + } + if !shuffledSomewhere { + t.Error("ten consecutive bags all came out in declaration order; the bag is not shuffling") + } +} + +func TestRemainingTracksTheCurrentBag(t *testing.T) { + b := NewBag(rand.New(rand.NewSource(3))) + if got := b.Remaining(); got != 0 { + t.Errorf("Remaining() before the first draw = %d, want 0", got) + } + b.Next() + if got := b.Remaining(); got != KindCount-1 { + t.Errorf("Remaining() after one draw = %d, want %d", got, KindCount-1) + } + for i := 0; i < KindCount-1; i++ { + b.Next() + } + if got := b.Remaining(); got != 0 { + t.Errorf("Remaining() after a full bag = %d, want 0", got) + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/game/ -run Bag -v` +Expected: FAIL to compile — `undefined: NewBag`. + +- [ ] **Step 3: Implement the bag** + +Create `internal/game/bag.go`: + +```go +package game + +import "math/rand" + +// Bag is the 7-bag piece generator (design.md §6): every family appears once +// per bag, in shuffled order, and the bag refills when it runs dry. +// +// Its rng is the game RNG and drives nothing else. FX randomness lives in a +// separate generator so that particle counts can never shift piece order +// (design.md §49.6). +type Bag struct { + rng *rand.Rand + queue []PieceKind +} + +// NewBag returns a bag drawing from rng. +func NewBag(rng *rand.Rand) Bag { return Bag{rng: rng} } + +// Next takes the next piece, refilling the bag when empty. +func (b *Bag) Next() PieceKind { + if len(b.queue) == 0 { + b.refill() + } + k := b.queue[len(b.queue)-1] + b.queue = b.queue[:len(b.queue)-1] + return k +} + +// Remaining is how many pieces are left in the current bag. +func (b *Bag) Remaining() int { return len(b.queue) } + +func (b *Bag) refill() { + b.queue = append(b.queue, I, J, L, O, S, T, Z) + b.rng.Shuffle(len(b.queue), func(i, j int) { + b.queue[i], b.queue[j] = b.queue[j], b.queue[i] + }) +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/bag.go internal/game/bag_test.go +git commit -m "feat(game): seeded 7-bag piece generator" +``` + +--- + +### Task 4: Scoring and the gravity curve + +Pure functions, no state. Worth its own task because the numbers are exactly what a reviewer can check against §11, §13 and §49.1 without reading any other code — and because the gravity clamp is the difference between a hard level and a hung process. + +**Files:** +- Create: `internal/game/scoring.go` +- Modify: `internal/game/rules.go` (append `GravityInterval`) +- Test: `internal/game/scoring_test.go` + +**Interfaces:** +- Consumes: `BaseGravity`, `GravityFactor`, `MinGravity`, `LinesPerLevel` (Task 1). +- Produces: + - `func LineScore(lines, level int) int` + - `func ComboBonus(combo, level int) int` + - `func LevelFor(lines int) int` + - `func GravityInterval(level int) time.Duration` + +- [ ] **Step 1: Write the failing scoring and gravity tests** + +Create `internal/game/scoring_test.go`: + +```go +package game + +import ( + "testing" + "time" +) + +func TestLineScoreBaseValues(t *testing.T) { + cases := []struct { + lines, level, want int + }{ + {0, 1, 0}, + {1, 1, 100}, {2, 1, 300}, {3, 1, 500}, {4, 1, 800}, + {1, 7, 700}, {4, 3, 2400}, + {5, 3, 0}, {-1, 3, 0}, // impossible clears score nothing rather than panicking + } + for _, c := range cases { + if got := LineScore(c.lines, c.level); got != c.want { + t.Errorf("LineScore(%d, %d) = %d, want %d", c.lines, c.level, got, c.want) + } + } +} + +// design.md §49.1: combo 1 is a lone clear and earns no bonus; the bonus first +// appears at combo 2, which is where §21 starts escalating the effects. +func TestComboBonus(t *testing.T) { + cases := []struct { + combo, level, want int + }{ + {0, 5, 0}, {1, 5, 0}, + {2, 1, 50}, {2, 3, 150}, + {5, 2, 400}, {7, 1, 300}, + {-2, 4, 0}, + } + for _, c := range cases { + if got := ComboBonus(c.combo, c.level); got != c.want { + t.Errorf("ComboBonus(%d, %d) = %d, want %d", c.combo, c.level, got, c.want) + } + } +} + +func TestLevelForRisesEveryTenLines(t *testing.T) { + cases := []struct { + lines, want int + }{ + {0, 1}, {1, 1}, {9, 1}, + {10, 2}, {19, 2}, {20, 3}, + {127, 13}, + {-4, 1}, + } + for _, c := range cases { + if got := LevelFor(c.lines); got != c.want { + t.Errorf("LevelFor(%d) = %d, want %d", c.lines, got, c.want) + } + } +} + +func TestGravityIntervalCurve(t *testing.T) { + if got := GravityInterval(1); got != BaseGravity { + t.Errorf("GravityInterval(1) = %v, want %v", got, BaseGravity) + } + near := func(got, want, tol time.Duration) bool { + d := got - want + if d < 0 { + d = -d + } + return d <= tol + } + if got := GravityInterval(2); !near(got, 688*time.Millisecond, time.Millisecond) { + t.Errorf("GravityInterval(2) = %v, want ~688ms", got) + } + if got := GravityInterval(5); !near(got, 437*time.Millisecond, 2*time.Millisecond) { + t.Errorf("GravityInterval(5) = %v, want ~437ms", got) + } + for level := 2; level <= 25; level++ { + if GravityInterval(level) > GravityInterval(level-1) { + t.Fatalf("gravity got slower from level %d to %d", level-1, level) + } + } +} + +// A zero or negative interval would make the gravity loop in Advance spin +// forever and hang the UI thread, so the clamp is load-bearing, not cosmetic. +func TestGravityIntervalNeverDropsBelowTheFloor(t *testing.T) { + for _, level := range []int{19, 30, 100, 1000, 1 << 20} { + got := GravityInterval(level) + if got < MinGravity { + t.Errorf("GravityInterval(%d) = %v, want >= %v", level, got, MinGravity) + } + if got <= 0 { + t.Errorf("GravityInterval(%d) = %v, want a positive duration", level, got) + } + } + if got := GravityInterval(1000); got != MinGravity { + t.Errorf("GravityInterval(1000) = %v, want the floor %v", got, MinGravity) + } + if got := GravityInterval(18); got <= MinGravity { + t.Errorf("GravityInterval(18) = %v, want above the floor (the clamp should bite around level 19)", got) + } +} + +func TestGravityIntervalTreatsBogusLevelsAsLevelOne(t *testing.T) { + for _, level := range []int{0, -1, -999} { + if got := GravityInterval(level); got != BaseGravity { + t.Errorf("GravityInterval(%d) = %v, want %v", level, got, BaseGravity) + } + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/game/ -run 'Score|Combo|Level|Gravity' -v` +Expected: FAIL to compile — `undefined: LineScore`, `undefined: GravityInterval`. + +- [ ] **Step 3: Implement scoring** + +Create `internal/game/scoring.go`: + +```go +package game + +// lineValues are the base clear values, indexed by lines cleared, before the +// level multiplier (design.md §13). +var lineValues = [5]int{0, 100, 300, 500, 800} + +// LineScore is the points awarded for clearing lines at the given level. +func LineScore(lines, level int) int { + if lines < 0 || lines >= len(lineValues) { + return 0 + } + return lineValues[lines] * level +} + +// ComboBonus is the points awarded for a combo (design.md §49.1): +// 50 x (combo - 1) x level, so a lone clear earns nothing extra. +func ComboBonus(combo, level int) int { + if combo < 2 { + return 0 + } + return 50 * (combo - 1) * level +} + +// LevelFor is the level reached after clearing the given number of lines. +// Play starts at level 1 and gains a level every LinesPerLevel lines. +func LevelFor(lines int) int { + if lines < 0 { + return 1 + } + return lines/LinesPerLevel + 1 +} +``` + +- [ ] **Step 4: Append the gravity curve to `internal/game/rules.go`** + +Add `"math"` to the import block, making it: + +```go +import ( + "math" + "time" +) +``` + +and append at the end of the file: + +```go +// GravityInterval is how long the active piece waits between gravity steps at +// the given level: 800ms x 0.86^(level-1), clamped to MinGravity +// (design.md §11). The clamp also guarantees a positive result, which the +// gravity loop in Advance depends on to terminate. +func GravityInterval(level int) time.Duration { + if level < 1 { + level = 1 + } + d := time.Duration(float64(BaseGravity) * math.Pow(GravityFactor, float64(level-1))) + if d < MinGravity { + return MinGravity + } + return d +} +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add internal/game/scoring.go internal/game/rules.go internal/game/scoring_test.go +git commit -m "feat(game): clear scoring, combo bonus, level and gravity curve" +``` + +--- + +### Task 5: Game events, game construction, next queue, ghost piece + +Introduces the `Game` value and the event vocabulary the FX world will observe. No gameplay verbs yet — this task delivers a constructed, inspectable game with a five-deep next queue, a working ghost projection, and the spawn path that ends the game when the stack reaches orbit. + +**Files:** +- Create: `internal/game/events.go` +- Create: `internal/game/game.go` +- Test: `internal/game/game_test.go` + +**Interfaces:** +- Consumes: `Board`, `Piece`, `PieceKind`, `Bag`, `NewBag`, `SpawnPiece`, `NextCount`, `LevelFor` (Tasks 1–4); test helper `fillRow` (Task 2). +- Produces: + - `type Event interface{ event() }` and the nine event structs: `PieceMoved{Piece Piece; DX, DY int}`, `PieceRotated{Piece Piece; Dir int}`, `PieceHardDropped{Piece Piece; FromY, ToY int}`, `PieceLocked{Piece Piece; Cells [4]Point}`, `HoldUsed{Stored, Spawned PieceKind}`, `LinesCleared{Rows []int; Count, Level int}`, `ComboChanged{Combo int}`, `LevelChanged{Level int}`, `GameOver{Score, Lines, Level int}` + - `type Game struct{ ... }` with exported fields `Board, Active, Hold, CanHold, Next, Bag, Score, Lines, Level, Combo, GravityAccumulator, LockAccumulator, LockResets, Over, Seed` and unexported `rng` + - `func New(seed int64) *Game` + - `func (g *Game) GhostPiece() Piece` + - `func (g *Game) popNext() PieceKind`, `func (g *Game) spawn(k PieceKind) []Event`, `func (g *Game) grounded() bool` + +- [ ] **Step 1: Write the event types** + +Events are values with a sealed interface: only this package can implement `Event`, so the FX world can observe the stream by type switch but cannot inject into it or write back (design.md §14, §44). Create `internal/game/events.go`: + +```go +package game + +// Event is something the engine did. The FX world observes events to decide +// what to make explode; it can never modify game state (design.md §14). +// The unexported method seals the interface to this package. +type Event interface { + event() +} + +// PieceMoved reports a successful translation, from gravity or from the player. +type PieceMoved struct { + Piece Piece + DX, DY int +} + +// PieceRotated reports a successful rotation. Dir is +1 clockwise, -1 counter. +type PieceRotated struct { + Piece Piece + Dir int +} + +// PieceHardDropped reports a hard drop, from row FromY to resting row ToY. +// The cells crossed between the two rows are what the ion trail is drawn +// through (design.md §18). +type PieceHardDropped struct { + Piece Piece + FromY, ToY int +} + +// PieceLocked reports a piece committed to the board. +type PieceLocked struct { + Piece Piece + Cells [4]Point +} + +// HoldUsed reports a hold swap: Stored went into the hold slot, Spawned became +// the active piece. +type HoldUsed struct { + Stored, Spawned PieceKind +} + +// LinesCleared reports completed rows, top to bottom. Level is the level in +// effect when the clear scored. +type LinesCleared struct { + Rows []int + Count int + Level int +} + +// ComboChanged reports a new combo count, including the reset to zero. +type ComboChanged struct { + Combo int +} + +// LevelChanged reports that gravity got worse. +type LevelChanged struct { + Level int +} + +// GameOver reports the final tally. +type GameOver struct { + Score, Lines, Level int +} + +func (PieceMoved) event() {} +func (PieceRotated) event() {} +func (PieceHardDropped) event() {} +func (PieceLocked) event() {} +func (HoldUsed) event() {} +func (LinesCleared) event() {} +func (ComboChanged) event() {} +func (LevelChanged) event() {} +func (GameOver) event() {} +``` + +- [ ] **Step 2: Write the failing game-construction tests** + +Create `internal/game/game_test.go`: + +```go +package game + +import ( + "testing" +) + +func TestNewGameStartsClean(t *testing.T) { + g := New(8675309) + if g.Score != 0 || g.Lines != 0 || g.Combo != 0 { + t.Errorf("new game has score=%d lines=%d combo=%d, want zeros", g.Score, g.Lines, g.Combo) + } + if g.Level != 1 { + t.Errorf("new game level = %d, want 1", g.Level) + } + if g.Over { + t.Error("new game is already over") + } + if !g.CanHold { + t.Error("new game should allow a hold") + } + if g.Hold != nil { + t.Errorf("new game hold = %v, want nil", *g.Hold) + } + if g.Seed != 8675309 { + t.Errorf("new game seed = %d, want 8675309", g.Seed) + } + var empty Board + if g.Board != empty { + t.Error("new game board is not empty") + } + if g.Board.Collides(g.Active) { + t.Error("the first active piece starts inside a collision") + } + if g.Active.Rotation != 0 || g.Active.X != SpawnX || g.Active.Y != SpawnY { + t.Errorf("first active piece = %+v, want spawn position", g.Active) + } +} + +func TestNextQueueStaysFiveDeep(t *testing.T) { + g := New(1) + if len(g.Next) != NextCount { + t.Fatalf("len(Next) = %d, want %d", len(g.Next), NextCount) + } + for i := 0; i < 30; i++ { + head := g.Next[0] + got := g.popNext() + if got != head { + t.Fatalf("popNext() = %v, want the queue head %v", got, head) + } + if len(g.Next) != NextCount { + t.Fatalf("after %d pops len(Next) = %d, want %d", i+1, len(g.Next), NextCount) + } + } +} + +func TestNewGameDrawsOnlyWhatTheQueueNeeds(t *testing.T) { + // One active piece plus NextCount queued = 6 draws from the first bag of 7. + // This pins the game RNG's usage: an extra draw here would silently change + // every seeded replay (design.md §49.6). + g := New(99) + if got, want := g.Bag.Remaining(), KindCount-(NextCount+1); got != want { + t.Errorf("bag has %d pieces left after New, want %d", got, want) + } +} + +func TestGhostPieceLandsOnTheStack(t *testing.T) { + g := New(5) + g.Active = Piece{Kind: O, Rotation: 0, X: 3, Y: 0} + + ghost := g.GhostPiece() + if ghost.Y != Height-2 { + t.Errorf("ghost on an empty board rests at Y=%d, want %d", ghost.Y, Height-2) + } + if ghost.Kind != O || ghost.Rotation != 0 || ghost.X != 3 { + t.Errorf("ghost = %+v, want the active piece's kind, rotation and column", ghost) + } + if g.Active.Y != 0 { + t.Error("GhostPiece moved the active piece") + } + + fillRow(&g.Board, Height-1) + if got := g.GhostPiece().Y; got != Height-3 { + t.Errorf("ghost above a filled floor row rests at Y=%d, want %d", got, Height-3) + } +} + +func TestGhostPieceOfAGroundedPieceIsItself(t *testing.T) { + g := New(5) + g.Active = Piece{Kind: O, Rotation: 0, X: 3, Y: Height - 2} + if got := g.GhostPiece(); got != g.Active { + t.Errorf("ghost = %+v, want the active piece %+v", got, g.Active) + } +} + +func TestSpawnIntoABlockedBoardEndsTheGame(t *testing.T) { + g := New(11) + // Fill the top hidden row, leaving column 9 open so the row is not + // complete and will not be cleared out from under the test. + fillRow(&g.Board, 0, Width-1) + + evs := g.spawn(O) + + if !g.Over { + t.Fatal("spawning into an occupied row should end the game") + } + if len(evs) != 1 { + t.Fatalf("spawn returned %d events, want 1 GameOver: %+v", len(evs), evs) + } + over, ok := evs[0].(GameOver) + if !ok { + t.Fatalf("spawn returned %T, want GameOver", evs[0]) + } + if over.Score != g.Score || over.Lines != g.Lines || over.Level != g.Level { + t.Errorf("GameOver%+v does not match the final tally score=%d lines=%d level=%d", + over, g.Score, g.Lines, g.Level) + } +} + +func TestSpawnResetsPerPieceTimers(t *testing.T) { + g := New(12) + g.GravityAccumulator = 400 + g.LockAccumulator = 400 + g.LockResets = 9 + + if evs := g.spawn(T); len(evs) != 0 { + t.Fatalf("clean spawn returned events %+v, want none", evs) + } + if g.GravityAccumulator != 0 || g.LockAccumulator != 0 || g.LockResets != 0 { + t.Errorf("after spawn gravity=%v lock=%v resets=%d, want all zero", + g.GravityAccumulator, g.LockAccumulator, g.LockResets) + } + if g.Active.Kind != T { + t.Errorf("active kind = %v, want T", g.Active.Kind) + } +} + +func TestGroundedReportsContactWithFloorAndStack(t *testing.T) { + g := New(13) + g.Active = Piece{Kind: O, Rotation: 0, X: 3, Y: 0} + if g.grounded() { + t.Error("a piece in mid-air reported grounded") + } + g.Active.Y = Height - 2 + if !g.grounded() { + t.Error("a piece on the floor reported airborne") + } + g.Active.Y = 0 + fillRow(&g.Board, 2) + if !g.grounded() { + t.Error("a piece resting on the stack reported airborne") + } +} +``` + +- [ ] **Step 3: Run the tests to verify they fail** + +Run: `go test ./internal/game/ -run 'New|Next|Ghost|Spawn|Grounded' -v` +Expected: FAIL to compile — `undefined: New`, `undefined: Game`. + +- [ ] **Step 4: Implement the game value, spawn path and ghost** + +Create `internal/game/game.go`: + +```go +package game + +import ( + "math/rand" + "time" +) + +// Game is the whole logical state of one session. It is deterministic: given a +// seed, the same sequence of player actions and Advance durations always +// produces the same Game (design.md §35). +// +// Pause lives in the application layer, not here: a paused app simply stops +// calling Advance. +type Game struct { + Board Board + Active Piece + Hold *PieceKind + CanHold bool + + Next []PieceKind + Bag Bag + + Score int + Lines int + Level int + Combo int + + // GravityAccumulator and LockAccumulator hold elapsed time handed in + // through Advance. Nothing here reads a clock (design.md §49.2). + GravityAccumulator time.Duration + LockAccumulator time.Duration + + // LockResets counts lock-timer resets spent on the current piece, capped + // at MaxLockResets so a player cannot stall forever (design.md §12). + LockResets int + + Over bool + + // Seed is recorded for display and restart; rng is the game RNG and drives + // only the 7-bag (design.md §49.6). + Seed int64 + rng *rand.Rand +} + +// New starts a game from the given seed. +func New(seed int64) *Game { + g := &Game{ + Level: 1, + CanHold: true, + Seed: seed, + rng: rand.New(rand.NewSource(seed)), + } + g.Bag = NewBag(g.rng) + g.Next = make([]PieceKind, 0, NextCount) + for len(g.Next) < NextCount { + g.Next = append(g.Next, g.Bag.Next()) + } + g.Active = SpawnPiece(g.popNext()) + return g +} + +// GhostPiece is the active piece projected down to where it would land +// (design.md §10). It is a copy: the active piece does not move. +func (g *Game) GhostPiece() Piece { + p := g.Active + for !g.Board.Collides(p.Moved(0, 1)) { + p = p.Moved(0, 1) + } + return p +} + +// popNext takes the head of the next queue and tops the queue back up. +func (g *Game) popNext() PieceKind { + k := g.Next[0] + g.Next = append(g.Next[:0], g.Next[1:]...) + g.Next = append(g.Next, g.Bag.Next()) + return k +} + +// spawn makes a fresh piece of the given kind active and resets the per-piece +// timers. If the piece cannot fit, the stack has reached orbit: the game ends +// and a GameOver event is returned. +func (g *Game) spawn(k PieceKind) []Event { + g.Active = SpawnPiece(k) + g.GravityAccumulator = 0 + g.LockAccumulator = 0 + g.LockResets = 0 + if g.Board.Collides(g.Active) { + g.Over = true + return []Event{GameOver{Score: g.Score, Lines: g.Lines, Level: g.Level}} + } + return nil +} + +// grounded reports whether the active piece is resting on the floor or stack. +func (g *Game) grounded() bool { + return g.Board.Collides(g.Active.Moved(0, 1)) +} +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add internal/game/events.go internal/game/game.go internal/game/game_test.go +git commit -m "feat(game): game state, event vocabulary, next queue and ghost piece" +``` + +--- + +### Task 6: Movement, rotation with wall kicks, lock-timer resets + +The player's verbs, minus the drops. This is where §7's kick order and §12's reset accounting land. + +**Files:** +- Modify: `internal/game/game.go` (append the movement methods) +- Test: `internal/game/move_test.go` + +**Interfaces:** +- Consumes: `Game`, `grounded`, `Board.Collides`, `kickOffsets`, `SoftDropPoints`, event types (Tasks 1–5). +- Produces: + - `func (g *Game) MoveLeft() []Event` + - `func (g *Game) MoveRight() []Event` + - `func (g *Game) SoftDrop() []Event` + - `func (g *Game) Rotate(dir int) []Event` + - `func (g *Game) noteLockReset()` + +- [ ] **Step 1: Write the failing movement and rotation tests** + +Create `internal/game/move_test.go`: + +```go +package game + +import ( + "testing" + "time" +) + +// activeAt puts a known piece under test control, bypassing the bag. +func activeAt(g *Game, k PieceKind, rotation, x, y int) { + g.Active = Piece{Kind: k, Rotation: rotation, X: x, Y: y} +} + +func TestMoveLeftAndRight(t *testing.T) { + g := New(1) + activeAt(g, O, 0, 3, 5) + + evs := g.MoveRight() + if g.Active.X != 4 { + t.Errorf("after MoveRight X = %d, want 4", g.Active.X) + } + if len(evs) != 1 { + t.Fatalf("MoveRight returned %d events, want 1: %+v", len(evs), evs) + } + moved, ok := evs[0].(PieceMoved) + if !ok { + t.Fatalf("MoveRight returned %T, want PieceMoved", evs[0]) + } + if moved.DX != 1 || moved.DY != 0 || moved.Piece != g.Active { + t.Errorf("PieceMoved%+v, want DX=1 DY=0 and the moved piece", moved) + } + + if evs := g.MoveLeft(); len(evs) != 1 || g.Active.X != 3 { + t.Errorf("after MoveLeft X = %d with %d events, want 3 and 1", g.Active.X, len(evs)) + } +} + +func TestMovementBlockedByWallsAndStack(t *testing.T) { + g := New(1) + // O fills columns X+1 and X+2, so X=-1 puts it against the left wall. + activeAt(g, O, 0, -1, 5) + if evs := g.MoveLeft(); len(evs) != 0 { + t.Errorf("blocked MoveLeft returned events %+v, want none", evs) + } + if g.Active.X != -1 { + t.Errorf("blocked MoveLeft moved the piece to X = %d", g.Active.X) + } + + activeAt(g, O, 0, Width-3, 5) + if evs := g.MoveRight(); len(evs) != 0 || g.Active.X != Width-3 { + t.Errorf("blocked MoveRight: X = %d, events %+v", g.Active.X, evs) + } + + activeAt(g, O, 0, 3, 5) + g.Board.Set(6, 5, cellFor(Z)) // directly right of the O's right column + if evs := g.MoveRight(); len(evs) != 0 || g.Active.X != 3 { + t.Errorf("move into a locked cell: X = %d, events %+v", g.Active.X, evs) + } +} + +func TestSoftDropScoresAndDescends(t *testing.T) { + g := New(1) + activeAt(g, O, 0, 3, 5) + + evs := g.SoftDrop() + if g.Active.Y != 6 { + t.Errorf("after SoftDrop Y = %d, want 6", g.Active.Y) + } + if g.Score != SoftDropPoints { + t.Errorf("score = %d, want %d", g.Score, SoftDropPoints) + } + if len(evs) != 1 { + t.Fatalf("SoftDrop returned %d events, want 1", len(evs)) + } + if moved, ok := evs[0].(PieceMoved); !ok || moved.DY != 1 { + t.Errorf("SoftDrop returned %+v, want PieceMoved with DY=1", evs[0]) + } +} + +func TestSoftDropOnTheFloorDoesNothingAndDoesNotScore(t *testing.T) { + g := New(1) + activeAt(g, O, 0, 3, Height-2) + before := g.Active + + if evs := g.SoftDrop(); len(evs) != 0 { + t.Errorf("grounded SoftDrop returned events %+v, want none", evs) + } + if g.Active != before { + t.Errorf("grounded SoftDrop moved the piece to %+v", g.Active) + } + if g.Score != 0 { + t.Errorf("grounded SoftDrop scored %d, want 0", g.Score) + } +} + +func TestRotateClockwiseAndCounter(t *testing.T) { + g := New(1) + activeAt(g, T, 0, 3, 5) + + evs := g.Rotate(1) + if g.Active.Rotation != 1 { + t.Errorf("rotation = %d, want 1", g.Active.Rotation) + } + if len(evs) != 1 { + t.Fatalf("Rotate returned %d events, want 1", len(evs)) + } + if rot, ok := evs[0].(PieceRotated); !ok || rot.Dir != 1 || rot.Piece != g.Active { + t.Errorf("Rotate returned %+v, want PieceRotated{Dir:1}", evs[0]) + } + + if evs := g.Rotate(-1); len(evs) != 1 || g.Active.Rotation != 0 { + t.Errorf("counter-rotation: rotation = %d, events %d", g.Active.Rotation, len(evs)) + } +} + +// design.md §7: offsets are tried (0,0), (-1,0), (1,0), (-2,0), (2,0), +// (0,-1), (-1,-1), (1,-1) and the first valid one wins. +func TestRotationWallKicksOffTheLeftWall(t *testing.T) { + g := New(1) + // Vertical I against the left wall: cells sit in column X+2, so X=-2 is + // column 0. Rotating to horizontal needs cells at X..X+3 = -2..1, which + // is off-board, so the kick must shift it right. + activeAt(g, I, 1, -2, 10) + + if evs := g.Rotate(1); len(evs) != 1 { + t.Fatalf("rotation off the left wall failed, events %+v", evs) + } + if g.Active.Rotation != 2 { + t.Errorf("rotation = %d, want 2", g.Active.Rotation) + } + if g.Active.X != 0 { + t.Errorf("kicked to X = %d, want 0 (the first valid offset in §7 order)", g.Active.X) + } + if g.Board.Collides(g.Active) { + t.Error("kicked into a colliding position") + } +} + +func TestRotationPrefersTheEarliestValidOffset(t *testing.T) { + g := New(1) + // A vertical I with its column against the right wall: cells in column + // Width-1 means X = Width-3. Horizontal needs X..X+3 in bounds, so the + // (-1,0) kick is the first that fits. + activeAt(g, I, 1, Width-3, 10) + if evs := g.Rotate(1); len(evs) != 1 { + t.Fatalf("rotation off the right wall failed, events %+v", evs) + } + if g.Active.X != Width-4 { + t.Errorf("kicked to X = %d, want %d", g.Active.X, Width-4) + } +} + +func TestRotationFailsWhenNoOffsetFits(t *testing.T) { + g := New(1) + // Bury a vertical I in a one-column shaft: every horizontal placement and + // every kick offset is blocked. + for y := 8; y < Height; y++ { + fillRow(&g.Board, y, 2) + } + activeAt(g, I, 1, 0, Height-4) // column 2, rows Height-4..Height-1 + before := g.Active + + if evs := g.Rotate(1); len(evs) != 0 { + t.Errorf("impossible rotation returned events %+v, want none", evs) + } + if g.Active != before { + t.Errorf("failed rotation changed the piece to %+v, want %+v", g.Active, before) + } +} + +// The (0,-1) family of kicks pushes cells above row 0 while a piece is still +// in the hidden spawn rows. Bounds checking must reject that, not panic. +func TestRotationAgainstTheCeilingDoesNotPanic(t *testing.T) { + g := New(1) + activeAt(g, I, 0, 3, 0) // horizontal I, cells on row 1 + _ = g.Rotate(1) + if g.Board.Collides(g.Active) { + t.Errorf("ceiling rotation left the piece colliding at %+v", g.Active) + } + for _, c := range g.Active.Cells() { + if c.Y < 0 { + t.Errorf("cell %v ended up above the board", c) + } + } +} + +func TestGroundedMoveResetsTheLockTimer(t *testing.T) { + g := New(1) + activeAt(g, O, 0, 3, Height-2) + g.LockAccumulator = 400 * time.Millisecond + + g.MoveLeft() + + if g.LockAccumulator != 0 { + t.Errorf("lock accumulator = %v after a grounded move, want 0", g.LockAccumulator) + } + if g.LockResets != 1 { + t.Errorf("lock resets = %d, want 1", g.LockResets) + } +} + +func TestAirborneMoveDoesNotSpendALockReset(t *testing.T) { + g := New(1) + activeAt(g, O, 0, 3, 5) + g.MoveLeft() + if g.LockResets != 0 { + t.Errorf("lock resets = %d after a mid-air move, want 0", g.LockResets) + } +} + +func TestLockResetsAreCappedSoStallingEnds(t *testing.T) { + g := New(1) + activeAt(g, O, 0, 3, Height-2) + + for i := 0; i < MaxLockResets+10; i++ { + g.LockAccumulator = 400 * time.Millisecond + if i%2 == 0 { + g.MoveLeft() + } else { + g.MoveRight() + } + } + + if g.LockResets != MaxLockResets { + t.Errorf("lock resets = %d, want the cap %d", g.LockResets, MaxLockResets) + } + if g.LockAccumulator == 0 { + t.Error("moves past the reset cap still cleared the lock timer; the piece could stall forever") + } +} + +func TestGroundedRotationAlsoResetsTheLockTimer(t *testing.T) { + g := New(1) + activeAt(g, T, 0, 3, Height-2) + g.LockAccumulator = 300 * time.Millisecond + + if evs := g.Rotate(1); len(evs) != 1 { + t.Fatalf("grounded rotation failed, events %+v", evs) + } + if g.LockAccumulator != 0 || g.LockResets != 1 { + t.Errorf("lock accumulator = %v, resets = %d; want 0 and 1", g.LockAccumulator, g.LockResets) + } +} + +func TestActionsDoNothingOnceTheGameIsOver(t *testing.T) { + g := New(1) + activeAt(g, O, 0, 3, 5) + g.Over = true + before := *g + + for name, action := range map[string]func() []Event{ + "MoveLeft": g.MoveLeft, + "MoveRight": g.MoveRight, + "SoftDrop": g.SoftDrop, + "Rotate": func() []Event { return g.Rotate(1) }, + } { + if evs := action(); len(evs) != 0 { + t.Errorf("%s after game over returned events %+v", name, evs) + } + } + if g.Active != before.Active || g.Score != before.Score { + t.Errorf("actions after game over changed state: %+v", g.Active) + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/game/ -run 'Move|SoftDrop|Rotat|Lock|Actions' -v` +Expected: FAIL to compile — `g.MoveLeft undefined`, `g.Rotate undefined`. + +- [ ] **Step 3: Append the movement methods to `internal/game/game.go`** + +```go +// MoveLeft shifts the active piece one column left, if it fits. +func (g *Game) MoveLeft() []Event { return g.shift(-1) } + +// MoveRight shifts the active piece one column right, if it fits. +func (g *Game) MoveRight() []Event { return g.shift(1) } + +func (g *Game) shift(dx int) []Event { + if g.Over { + return nil + } + moved := g.Active.Moved(dx, 0) + if g.Board.Collides(moved) { + return nil + } + g.Active = moved + g.noteLockReset() + return []Event{PieceMoved{Piece: g.Active, DX: dx, DY: 0}} +} + +// SoftDrop descends one row and scores a point per cell (design.md §11). On the +// floor it does nothing: the lock delay is what ends the piece, not the key. +func (g *Game) SoftDrop() []Event { + if g.Over { + return nil + } + moved := g.Active.Moved(0, 1) + if g.Board.Collides(moved) { + return nil + } + g.Active = moved + g.Score += SoftDropPoints + g.GravityAccumulator = 0 + return []Event{PieceMoved{Piece: g.Active, DX: 0, DY: 1}} +} + +// Rotate turns the active piece: dir +1 clockwise, -1 counter-clockwise. The +// kick offsets in design.md §7 are tried in order and the first position that +// fits wins; if none do, the rotation fails and nothing changes. +func (g *Game) Rotate(dir int) []Event { + if g.Over { + return nil + } + target := g.Active.Rotated(dir) + for _, k := range kickOffsets { + candidate := target.Moved(k.X, k.Y) + if !g.Board.Collides(candidate) { + g.Active = candidate + g.noteLockReset() + return []Event{PieceRotated{Piece: g.Active, Dir: dir}} + } + } + return nil +} + +// noteLockReset restarts the lock countdown after a successful grounded move or +// rotation, up to MaxLockResets times per piece (design.md §12). +func (g *Game) noteLockReset() { + if !g.grounded() || g.LockResets >= MaxLockResets { + return + } + g.LockResets++ + g.LockAccumulator = 0 +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/game.go internal/game/move_test.go +git commit -m "feat(game): movement, wall-kick rotation and lock-timer resets" +``` + +--- + +### Task 7: Advance — gravity, locking, line clearing, combo and level + +The engine's heartbeat. `Advance(dt)` is the only way time enters the engine, and locking is where scoring, clearing and the next spawn all happen in the order design.md §12 specifies. + +**Files:** +- Modify: `internal/game/game.go` (append `Advance` and `lock`) +- Test: `internal/game/advance_test.go` + +**Interfaces:** +- Consumes: everything from Tasks 1–6. +- Produces: + - `func (g *Game) Advance(dt time.Duration) []Event` + - `func (g *Game) lock() []Event` + - test helpers `func firstEvent[T Event](t *testing.T, evs []Event) T` and `func eventKinds(evs []Event) []string`, reused by Tasks 8–10 + +- [ ] **Step 1: Write the failing gravity and locking tests** + +Create `internal/game/advance_test.go`: + +```go +package game + +import ( + "fmt" + "reflect" + "testing" + "time" +) + +// firstEvent returns the first event of type T, failing the test if absent. +func firstEvent[T Event](t *testing.T, evs []Event) T { + t.Helper() + for _, e := range evs { + if got, ok := any(e).(T); ok { + return got + } + } + var zero T + t.Fatalf("no %T among events %s", zero, eventKinds(evs)) + return zero +} + +// eventKinds names the events in order, for readable failure messages and for +// asserting the order design.md §12 requires. +func eventKinds(evs []Event) []string { + out := make([]string, len(evs)) + for i, e := range evs { + out[i] = fmt.Sprintf("%T", e) + } + return out +} + +func TestGravityDescendsAfterTheInterval(t *testing.T) { + g := New(1) + activeAt(g, O, 0, 3, 5) + + if evs := g.Advance(GravityInterval(1) - time.Millisecond); len(evs) != 0 { + t.Fatalf("piece moved before the interval elapsed: %s", eventKinds(evs)) + } + if g.Active.Y != 5 { + t.Fatalf("Y = %d before the interval elapsed, want 5", g.Active.Y) + } + + evs := g.Advance(time.Millisecond) + if g.Active.Y != 6 { + t.Errorf("Y = %d after the interval elapsed, want 6", g.Active.Y) + } + moved := firstEvent[PieceMoved](t, evs) + if moved.DY != 1 || moved.DX != 0 { + t.Errorf("PieceMoved%+v, want DX=0 DY=1", moved) + } +} + +func TestGravityRemainderCarriesToTheNextFrame(t *testing.T) { + g := New(1) + activeAt(g, O, 0, 3, 5) + interval := GravityInterval(1) + + g.Advance(interval + 100*time.Millisecond) + if g.Active.Y != 6 { + t.Fatalf("Y = %d, want 6", g.Active.Y) + } + if g.GravityAccumulator != 100*time.Millisecond { + t.Errorf("accumulator = %v, want the 100ms remainder", g.GravityAccumulator) + } +} + +// A backgrounded terminal or a sleeping laptop reports an enormous dt. The +// piece must not fall the whole board in one frame. +func TestAdvanceClampsAnEnormousDT(t *testing.T) { + g := New(1) + activeAt(g, O, 0, 3, 5) + + evs := g.Advance(30 * time.Second) + + if g.Active.Y != 5 { + t.Errorf("Y = %d after a 30s frame, want 5: catch-up is capped at %v, below the %v interval", + g.Active.Y, MaxAdvanceStep, GravityInterval(1)) + } + if g.GravityAccumulator != MaxAdvanceStep { + t.Errorf("accumulator = %v, want the clamp %v", g.GravityAccumulator, MaxAdvanceStep) + } + if len(evs) != 0 { + t.Errorf("events %s, want none", eventKinds(evs)) + } +} + +func TestAdvanceAppliesEveryStepInsideTheClamp(t *testing.T) { + g := New(1) + g.Level = 30 // gravity floor: 60ms per step + activeAt(g, O, 0, 3, 2) + + g.Advance(MaxAdvanceStep) // 250ms / 60ms = 4 steps, 10ms left over + + if g.Active.Y != 6 { + t.Errorf("Y = %d, want 6 (four gravity steps)", g.Active.Y) + } + if g.GravityAccumulator != 10*time.Millisecond { + t.Errorf("accumulator = %v, want 10ms", g.GravityAccumulator) + } +} + +func TestAdvanceIgnoresNonPositiveDT(t *testing.T) { + g := New(1) + activeAt(g, O, 0, 3, 5) + g.GravityAccumulator = 300 * time.Millisecond + before := *g + + for _, dt := range []time.Duration{0, -time.Millisecond, -5 * time.Second} { + if evs := g.Advance(dt); len(evs) != 0 { + t.Errorf("Advance(%v) returned events %s, want none", dt, eventKinds(evs)) + } + } + if g.Active != before.Active || g.GravityAccumulator != before.GravityAccumulator { + t.Errorf("a non-positive dt changed state: piece %+v accumulator %v", + g.Active, g.GravityAccumulator) + } +} + +func TestGravityStopsAtTheStackWithoutLockingImmediately(t *testing.T) { + g := New(1) + activeAt(g, O, 0, 3, Height-3) + g.Advance(GravityInterval(1)) // descends onto the floor + + if g.Active.Y != Height-2 { + t.Fatalf("Y = %d, want %d", g.Active.Y, Height-2) + } + if g.Board.Occupied(4, Height-1) { + t.Error("the piece locked as soon as it landed; design.md §12 gives it a 500ms lock delay") + } +} + +func TestLockDelayElapsesThenTheBoardTakesThePiece(t *testing.T) { + g := New(1) + activeAt(g, O, 0, 3, Height-2) + + if evs := g.Advance(LockDelay - time.Millisecond); len(evs) != 0 { + t.Fatalf("locked early: %s", eventKinds(evs)) + } + if g.LockAccumulator != LockDelay-time.Millisecond { + t.Errorf("lock accumulator = %v, want %v", g.LockAccumulator, LockDelay-time.Millisecond) + } + + evs := g.Advance(time.Millisecond) + + locked := firstEvent[PieceLocked](t, evs) + if locked.Piece.Kind != O { + t.Errorf("PieceLocked reported kind %v, want O", locked.Piece.Kind) + } + for _, c := range []Point{{4, Height - 2}, {5, Height - 2}, {4, Height - 1}, {5, Height - 1}} { + if !g.Board.Occupied(c.X, c.Y) { + t.Errorf("cell %v not committed to the board", c) + } + } + if g.Active.Y != SpawnY || g.Active.X != SpawnX { + t.Errorf("after locking the active piece is %+v, want a fresh spawn", g.Active) + } + if g.LockAccumulator != 0 || g.LockResets != 0 { + t.Errorf("per-piece timers not reset: lock=%v resets=%d", g.LockAccumulator, g.LockResets) + } +} + +func TestLeavingTheGroundClearsTheLockTimer(t *testing.T) { + g := New(1) + // Rest on a one-cell ledge, then slide off it into open space. + g.Board.Set(4, Height-1, cellFor(J)) + activeAt(g, O, 0, 3, Height-3) + g.Advance(200 * time.Millisecond) + if g.LockAccumulator == 0 { + t.Fatal("expected the lock timer to be running while grounded") + } + + g.MoveRight() // columns 5,6: nothing below now + g.Advance(time.Millisecond) + + if g.LockAccumulator != 0 { + t.Errorf("lock accumulator = %v after leaving the ground, want 0", g.LockAccumulator) + } +} + +func TestLockingACompletedRowClearsAndScoresIt(t *testing.T) { + g := New(1) + fillRow(&g.Board, Height-1, 4, 5) // bottom row needs exactly the O's columns + activeAt(g, O, 0, 3, Height-2) + + evs := g.Advance(LockDelay) + + if g.Lines != 1 { + t.Errorf("lines = %d, want 1", g.Lines) + } + if want := LineScore(1, 1); g.Score != want { + t.Errorf("score = %d, want %d (no combo bonus for a lone clear, design.md §49.1)", g.Score, want) + } + if g.Combo != 1 { + t.Errorf("combo = %d, want 1", g.Combo) + } + cleared := firstEvent[LinesCleared](t, evs) + if !reflect.DeepEqual(cleared, (LinesCleared{Rows: []int{Height - 1}, Count: 1, Level: 1})) { + t.Errorf("LinesCleared%+v, want rows [%d] count 1 level 1", cleared, Height-1) + } + if combo := firstEvent[ComboChanged](t, evs); combo.Combo != 1 { + t.Errorf("ComboChanged%+v, want 1", combo) + } + // The O's upper half survives and falls into the emptied row. + if !g.Board.Occupied(4, Height-1) || !g.Board.Occupied(5, Height-1) { + t.Error("the surviving half of the piece did not collapse into the cleared row") + } + if g.Board.Occupied(0, Height-1) { + t.Error("the cleared row kept its old contents") + } + if g.Board.Occupied(4, Height-2) { + t.Error("row above the clear should be empty after the collapse") + } +} + +func TestEventOrderAtLockTime(t *testing.T) { + g := New(1) + fillRow(&g.Board, Height-1, 4, 5) + activeAt(g, O, 0, 3, Height-2) + + got := eventKinds(g.Advance(LockDelay)) + want := []string{"game.PieceLocked", "game.LinesCleared", "game.ComboChanged"} + if !reflect.DeepEqual(got, want) { + t.Errorf("event order = %v, want %v (design.md §12: commit, clear, score, then FX)", got, want) + } +} + +func TestFourLineClearScoresAndEmptiesTheBoard(t *testing.T) { + g := New(1) + for y := Height - 4; y < Height; y++ { + fillRow(&g.Board, y, 0) // four rows, all missing column 0 + } + // A vertical I fills column 0: cells sit in column X+2, so X=-2. + activeAt(g, I, 1, -2, Height-4) + + evs := g.Advance(LockDelay) + + if g.Lines != 4 { + t.Errorf("lines = %d, want 4", g.Lines) + } + if want := LineScore(4, 1); g.Score != want { + t.Errorf("score = %d, want %d", g.Score, want) + } + cleared := firstEvent[LinesCleared](t, evs) + if want := (LinesCleared{Rows: []int{18, 19, 20, 21}, Count: 4, Level: 1}); !reflect.DeepEqual(cleared, want) { + t.Errorf("LinesCleared%+v, want %+v", cleared, want) + } + var empty Board + if g.Board != empty { + t.Error("board should be empty after clearing all four occupied rows") + } +} + +func TestConsecutiveClearsBuildACombo(t *testing.T) { + g := New(1) + // First clear: bottom row missing column 0, filled by a vertical I. + fillRow(&g.Board, Height-1, 0) + activeAt(g, I, 1, -2, Height-4) + g.Advance(LockDelay) + + if g.Combo != 1 || g.Score != LineScore(1, 1) { + t.Fatalf("after the first clear combo = %d score = %d, want 1 and %d", g.Combo, g.Score, LineScore(1, 1)) + } + + // Second clear: rebuild the bottom row missing column 1 this time. + fillRow(&g.Board, Height-1, 1) + activeAt(g, I, 1, -1, Height-4) // column 1 + evs := g.Advance(LockDelay) + + if g.Combo != 2 { + t.Fatalf("combo = %d, want 2", g.Combo) + } + want := LineScore(1, 1)*2 + ComboBonus(2, 1) + if g.Score != want { + t.Errorf("score = %d, want %d (two clears plus a combo-2 bonus)", g.Score, want) + } + if combo := firstEvent[ComboChanged](t, evs); combo.Combo != 2 { + t.Errorf("ComboChanged%+v, want 2", combo) + } +} + +func TestAPlacementWithoutAClearResetsTheCombo(t *testing.T) { + g := New(1) + g.Combo = 4 + activeAt(g, O, 0, 3, Height-2) + + evs := g.Advance(LockDelay) + + if g.Combo != 0 { + t.Errorf("combo = %d, want 0", g.Combo) + } + if combo := firstEvent[ComboChanged](t, evs); combo.Combo != 0 { + t.Errorf("ComboChanged%+v, want 0", combo) + } +} + +func TestNoComboEventWhenThereWasNoCombo(t *testing.T) { + g := New(1) + activeAt(g, O, 0, 3, Height-2) + + got := eventKinds(g.Advance(LockDelay)) + for _, k := range got { + if k == "game.ComboChanged" { + t.Errorf("events %v include a redundant ComboChanged; combo was already 0", got) + } + } +} + +func TestTenLinesRaisesTheLevel(t *testing.T) { + g := New(1) + g.Lines = 9 + fillRow(&g.Board, Height-1, 4, 5) + activeAt(g, O, 0, 3, Height-2) + + evs := g.Advance(LockDelay) + + if g.Level != 2 { + t.Errorf("level = %d, want 2", g.Level) + } + if lv := firstEvent[LevelChanged](t, evs); lv.Level != 2 { + t.Errorf("LevelChanged%+v, want 2", lv) + } + // The clear is scored at the level in force when the piece locked. + if want := LineScore(1, 1); g.Score != want { + t.Errorf("score = %d, want %d (scored at the pre-level-up level)", g.Score, want) + } + if cleared := firstEvent[LinesCleared](t, evs); cleared.Level != 1 { + t.Errorf("LinesCleared reported level %d, want 1", cleared.Level) + } + if got, want := GravityInterval(g.Level), GravityInterval(2); got != want { + t.Errorf("gravity interval = %v, want %v", got, want) + } +} + +func TestLockingIntoAFullSpawnRowEndsTheGame(t *testing.T) { + g := New(1) + // Occupy the spawn cells, leaving column 9 open so the rows are not + // complete and survive the clear check. + fillRow(&g.Board, 0, Width-1) + fillRow(&g.Board, 1, Width-1) + activeAt(g, O, 0, 3, Height-2) + + evs := g.Advance(LockDelay) + + if !g.Over { + t.Fatal("game should be over: the next piece cannot spawn") + } + over := firstEvent[GameOver](t, evs) + if over.Score != g.Score || over.Lines != g.Lines || over.Level != g.Level { + t.Errorf("GameOver%+v does not match score=%d lines=%d level=%d", over, g.Score, g.Lines, g.Level) + } + if kinds := eventKinds(evs); kinds[len(kinds)-1] != "game.GameOver" { + t.Errorf("GameOver should be the last event, got %v", kinds) + } +} + +func TestAdvanceIsInertAfterGameOver(t *testing.T) { + g := New(1) + g.Over = true + before := *g + + if evs := g.Advance(10 * time.Second); len(evs) != 0 { + t.Errorf("Advance after game over returned %s, want no events", eventKinds(evs)) + } + if g.Active != before.Active || g.Score != before.Score || g.Board != before.Board { + t.Error("Advance after game over changed state") + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/game/ -run 'Gravity|Advance|Lock|Combo|Four|Ten|Event' -v` +Expected: FAIL to compile — `g.Advance undefined`. + +- [ ] **Step 3: Append `Advance` and `lock` to `internal/game/game.go`** + +```go +// Advance applies dt of elapsed time: gravity while the piece is falling, the +// lock countdown while it is grounded. This is the only way time enters the +// engine (design.md §49.2). +// +// dt is clamped to MaxAdvanceStep, so a frame delayed by a backgrounded +// terminal cannot drop the piece down the whole board, and non-positive dt is +// ignored, so a stalled or backwards clock cannot rewind the game. +func (g *Game) Advance(dt time.Duration) []Event { + if g.Over || dt <= 0 { + return nil + } + if dt > MaxAdvanceStep { + dt = MaxAdvanceStep + } + + if g.grounded() { + g.GravityAccumulator = 0 + g.LockAccumulator += dt + if g.LockAccumulator >= LockDelay { + return g.lock() + } + return nil + } + + g.LockAccumulator = 0 + g.GravityAccumulator += dt + interval := GravityInterval(g.Level) + var evs []Event + for g.GravityAccumulator >= interval { + g.GravityAccumulator -= interval + if g.Board.Collides(g.Active.Moved(0, 1)) { + break // grounded: the lock countdown starts on the next Advance + } + g.Active = g.Active.Moved(0, 1) + evs = append(evs, PieceMoved{Piece: g.Active, DX: 0, DY: 1}) + } + return evs +} + +// lock commits the active piece and runs the post-lock sequence from +// design.md §12: commit, detect complete rows, clear them, update the score, +// emit the FX events, spawn the next piece. +func (g *Game) lock() []Event { + p := g.Active + g.Board.Lock(p) + evs := []Event{PieceLocked{Piece: p, Cells: p.Cells()}} + + if rows := g.Board.CompleteRows(); len(rows) > 0 { + // Clears score at the level in force when the piece locked; the level + // bump takes effect afterwards. + level := g.Level + g.Board.ClearRows(rows) + g.Lines += len(rows) + g.Combo++ + g.Score += LineScore(len(rows), level) + ComboBonus(g.Combo, level) + evs = append(evs, + LinesCleared{Rows: rows, Count: len(rows), Level: level}, + ComboChanged{Combo: g.Combo}, + ) + if lv := LevelFor(g.Lines); lv != g.Level { + g.Level = lv + evs = append(evs, LevelChanged{Level: g.Level}) + } + } else if g.Combo != 0 { + g.Combo = 0 + evs = append(evs, ComboChanged{Combo: 0}) + } + + g.CanHold = true + return append(evs, g.spawn(g.popNext())...) +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. If `TestEventOrderAtLockTime` fails on the type names, confirm the package is named `game` — the assertion expects `game.PieceLocked`. + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/game.go internal/game/advance_test.go +git commit -m "feat(game): gravity, lock delay, line clearing, combo and level progression" +``` + +--- + +### Task 8: Hard drop + +Hard drop is the engine half of design.md §18's refrigerator-from-orbit moment: it must report the rows crossed so the FX layer can draw the ion trail, and it locks immediately rather than waiting out the lock delay. + +**Files:** +- Modify: `internal/game/game.go` (append `HardDrop`) +- Test: `internal/game/harddrop_test.go` + +**Interfaces:** +- Consumes: `Game`, `GhostPiece`, `lock`, `HardDropPoints`, `PieceHardDropped` (Tasks 1–7). +- Produces: `func (g *Game) HardDrop() []Event` + +- [ ] **Step 1: Write the failing hard-drop tests** + +Create `internal/game/harddrop_test.go`: + +```go +package game + +import ( + "reflect" + "testing" +) + +func TestHardDropLandsScoresAndLocks(t *testing.T) { + g := New(1) + activeAt(g, O, 0, 3, 0) + + evs := g.HardDrop() + + dropped := firstEvent[PieceHardDropped](t, evs) + if dropped.FromY != 0 || dropped.ToY != Height-2 { + t.Errorf("PieceHardDropped%+v, want FromY 0 ToY %d", dropped, Height-2) + } + if dropped.Piece.Y != Height-2 || dropped.Piece.Kind != O { + t.Errorf("PieceHardDropped carried %+v, want the landed O", dropped.Piece) + } + cells := Height - 2 // rows descended + if want := cells * HardDropPoints; g.Score != want { + t.Errorf("score = %d, want %d (%d cells x %d points)", g.Score, want, cells, HardDropPoints) + } + for _, c := range []Point{{4, Height - 2}, {5, Height - 2}, {4, Height - 1}, {5, Height - 1}} { + if !g.Board.Occupied(c.X, c.Y) { + t.Errorf("cell %v not locked; hard drop must not wait out the lock delay", c) + } + } + if got := eventKinds(evs); got[0] != "game.PieceHardDropped" || got[1] != "game.PieceLocked" { + t.Errorf("event order = %v, want the drop then the lock", got) + } + if g.Active.Y != SpawnY { + t.Errorf("active piece = %+v, want a fresh spawn", g.Active) + } +} + +func TestHardDropLandsOnTheStack(t *testing.T) { + g := New(1) + fillRow(&g.Board, Height-1) + fillRow(&g.Board, Height-2) + activeAt(g, O, 0, 3, 3) + + evs := g.HardDrop() + + dropped := firstEvent[PieceHardDropped](t, evs) + if dropped.ToY != Height-4 { + t.Errorf("landed at ToY = %d, want %d (on top of two filled rows)", dropped.ToY, Height-4) + } + if want := (Height - 4 - 3) * HardDropPoints; g.Score != want { + t.Errorf("score = %d, want %d", g.Score, want) + } +} + +func TestHardDroppingAGroundedPieceScoresNothingAndStillLocks(t *testing.T) { + g := New(1) + activeAt(g, O, 0, 3, Height-2) + + evs := g.HardDrop() + + if g.Score != 0 { + t.Errorf("score = %d, want 0: the piece had nowhere to fall", g.Score) + } + dropped := firstEvent[PieceHardDropped](t, evs) + if dropped.FromY != dropped.ToY { + t.Errorf("PieceHardDropped%+v, want FromY == ToY", dropped) + } + if !g.Board.Occupied(4, Height-1) { + t.Error("the piece did not lock") + } +} + +func TestHardDropThatCompletesARowClearsIt(t *testing.T) { + g := New(1) + fillRow(&g.Board, Height-1, 4, 5) + fillRow(&g.Board, Height-2, 4, 5) + activeAt(g, O, 0, 3, 0) + + evs := g.HardDrop() + + if g.Lines != 2 { + t.Errorf("lines = %d, want 2", g.Lines) + } + cleared := firstEvent[LinesCleared](t, evs) + if want := (LinesCleared{Rows: []int{Height - 2, Height - 1}, Count: 2, Level: 1}); !reflect.DeepEqual(cleared, want) { + t.Errorf("LinesCleared%+v, want %+v", cleared, want) + } + var empty Board + if g.Board != empty { + t.Error("board should be empty after both rows cleared") + } + dropRows := Height - 2 + if want := dropRows*HardDropPoints + LineScore(2, 1); g.Score != want { + t.Errorf("score = %d, want %d (drop distance plus the double clear)", g.Score, want) + } +} + +func TestHardDropIsInertAfterGameOver(t *testing.T) { + g := New(1) + activeAt(g, O, 0, 3, 0) + g.Over = true + before := *g + + if evs := g.HardDrop(); len(evs) != 0 { + t.Errorf("HardDrop after game over returned %s, want none", eventKinds(evs)) + } + if g.Board != before.Board || g.Score != before.Score { + t.Error("HardDrop after game over changed state") + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/game/ -run HardDrop -v` +Expected: FAIL to compile — `g.HardDrop undefined`. + +- [ ] **Step 3: Append `HardDrop` to `internal/game/game.go`** + +```go +// HardDrop slams the active piece to its landing position, scores two points +// per cell crossed, and locks it immediately (design.md §11, §18). The +// PieceHardDropped event carries the rows crossed so the FX layer can draw the +// ion trail through them. +func (g *Game) HardDrop() []Event { + if g.Over { + return nil + } + from := g.Active.Y + landed := g.GhostPiece() + g.Active = landed + g.Score += (landed.Y - from) * HardDropPoints + evs := []Event{PieceHardDropped{Piece: landed, FromY: from, ToY: landed.Y}} + return append(evs, g.lock()...) +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/game.go internal/game/harddrop_test.go +git commit -m "feat(game): hard drop with distance scoring and immediate lock" +``` + +--- + +### Task 9: Hold + +One held piece, one hold per active piece, spawn rotation restored (design.md §9). The engine does not wait for the quantum-storage animation — that is entirely an FX concern in a later phase. + +**Files:** +- Modify: `internal/game/game.go` (append `HoldPiece`) +- Test: `internal/game/hold_test.go` + +**Interfaces:** +- Consumes: `Game`, `spawn`, `popNext`, `HoldUsed` (Tasks 1–7). +- Produces: `func (g *Game) HoldPiece() []Event` + +- [ ] **Step 1: Write the failing hold tests** + +Create `internal/game/hold_test.go`: + +```go +package game + +import ( + "testing" + "time" +) + +func TestFirstHoldStoresTheActivePieceAndPullsFromTheQueue(t *testing.T) { + g := New(7) + stored := g.Active.Kind + incoming := g.Next[0] + + evs := g.HoldPiece() + + if g.Hold == nil { + t.Fatal("hold slot is still empty") + } + if *g.Hold != stored { + t.Errorf("hold = %v, want %v", *g.Hold, stored) + } + if g.Active.Kind != incoming { + t.Errorf("active kind = %v, want the queue head %v", g.Active.Kind, incoming) + } + if len(g.Next) != NextCount { + t.Errorf("len(Next) = %d, want %d", len(g.Next), NextCount) + } + if g.CanHold { + t.Error("CanHold should be false until the active piece locks") + } + used := firstEvent[HoldUsed](t, evs) + if used.Stored != stored || used.Spawned != incoming { + t.Errorf("HoldUsed%+v, want stored %v spawned %v", used, stored, incoming) + } +} + +func TestSecondHoldBeforeALockIsRefused(t *testing.T) { + g := New(7) + g.HoldPiece() + held, active := *g.Hold, g.Active + + if evs := g.HoldPiece(); len(evs) != 0 { + t.Errorf("second hold returned events %s, want none", eventKinds(evs)) + } + if *g.Hold != held || g.Active != active { + t.Errorf("second hold changed state: hold %v active %+v", *g.Hold, g.Active) + } +} + +func TestHoldSwapsWithTheStoredPiece(t *testing.T) { + g := New(7) + first := g.Active.Kind + g.HoldPiece() + second := g.Active.Kind + g.CanHold = true // as a lock would do + + evs := g.HoldPiece() + + if *g.Hold != second { + t.Errorf("hold = %v, want the piece that was active (%v)", *g.Hold, second) + } + if g.Active.Kind != first { + t.Errorf("active kind = %v, want the previously held %v", g.Active.Kind, first) + } + used := firstEvent[HoldUsed](t, evs) + if used.Stored != second || used.Spawned != first { + t.Errorf("HoldUsed%+v, want stored %v spawned %v", used, second, first) + } +} + +func TestHeldPieceReturnsAtSpawnRotationAndPosition(t *testing.T) { + g := New(7) + g.Active.Rotation = 2 + g.Active.X = 6 + g.Active.Y = 9 + g.GravityAccumulator = 300 * time.Millisecond + g.LockAccumulator = 300 * time.Millisecond + g.LockResets = 4 + + g.HoldPiece() + + if g.Active.Rotation != 0 || g.Active.X != SpawnX || g.Active.Y != SpawnY { + t.Errorf("incoming piece = %+v, want spawn rotation and position", g.Active) + } + if g.GravityAccumulator != 0 || g.LockAccumulator != 0 || g.LockResets != 0 { + t.Errorf("timers not reset: gravity %v lock %v resets %d", + g.GravityAccumulator, g.LockAccumulator, g.LockResets) + } + // The stored piece forgets its rotation too: it comes back upright. + g.CanHold = true + g.Active.Rotation = 3 + g.HoldPiece() + if g.Active.Rotation != 0 { + t.Errorf("returning held piece rotation = %d, want 0", g.Active.Rotation) + } +} + +func TestHoldBecomesAvailableAgainAfterALock(t *testing.T) { + g := New(7) + g.HoldPiece() + if g.CanHold { + t.Fatal("CanHold should be false right after a hold") + } + + activeAt(g, O, 0, 3, Height-2) + g.Advance(LockDelay) + + if !g.CanHold { + t.Error("CanHold should be restored once the piece locks") + } + if evs := g.HoldPiece(); len(evs) == 0 { + t.Error("hold after a lock was refused") + } +} + +func TestHoldIsAlsoRestoredAfterAHardDrop(t *testing.T) { + g := New(7) + g.HoldPiece() + g.HardDrop() + if !g.CanHold { + t.Error("CanHold should be restored after a hard drop locks the piece") + } +} + +func TestHoldIntoAFullSpawnAreaEndsTheGame(t *testing.T) { + g := New(7) + // Column 9 stays open so these rows are not complete rows. + fillRow(&g.Board, 0, Width-1) + fillRow(&g.Board, 1, Width-1) + + evs := g.HoldPiece() + + if !g.Over { + t.Fatal("holding into an occupied spawn area should end the game") + } + over := firstEvent[GameOver](t, evs) + if over.Score != g.Score { + t.Errorf("GameOver%+v does not match score %d", over, g.Score) + } + if kinds := eventKinds(evs); kinds[0] != "game.HoldUsed" || kinds[len(kinds)-1] != "game.GameOver" { + t.Errorf("event order = %v, want the hold then the game over", kinds) + } +} + +func TestHoldIsInertAfterGameOver(t *testing.T) { + g := New(7) + g.Over = true + before := *g + + if evs := g.HoldPiece(); len(evs) != 0 { + t.Errorf("hold after game over returned %s, want none", eventKinds(evs)) + } + if g.Hold != nil || g.Active != before.Active { + t.Error("hold after game over changed state") + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/game/ -run Hold -v` +Expected: FAIL to compile — `g.HoldPiece undefined`. + +- [ ] **Step 3: Append `HoldPiece` to `internal/game/game.go`** + +```go +// HoldPiece swaps the active piece with the hold slot (design.md §9). If the +// slot is empty the active piece goes in and the queue head comes out. Only one +// hold is allowed per active piece. The incoming piece always arrives at spawn +// rotation and position. +func (g *Game) HoldPiece() []Event { + if g.Over || !g.CanHold { + return nil + } + stored := g.Active.Kind + var incoming PieceKind + if g.Hold == nil { + incoming = g.popNext() + } else { + incoming = *g.Hold + } + g.Hold = &stored + g.CanHold = false + evs := []Event{HoldUsed{Stored: stored, Spawned: incoming}} + return append(evs, g.spawn(incoming)...) +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/game.go internal/game/hold_test.go +git commit -m "feat(game): hold slot with one swap per piece" +``` + +--- + +### Task 10: Restart and the determinism replay test + +The engine's headline promise (§35): the same seed plus the same input-and-timing stream reproduces the same game. This task adds `Restart` and the replay harness that proves it, then closes the phase. + +**Files:** +- Modify: `internal/game/game.go` (append `Restart`) +- Modify: `README.md` +- Test: `internal/game/determinism_test.go` + +**Interfaces:** +- Consumes: everything from Tasks 1–9. +- Produces: + - `func (g *Game) Restart()` + - test helpers `type scriptStep struct{ action string; dt time.Duration }`, `func runScript(g *Game, script []scriptStep) []string`, `func fingerprint(g *Game) string` + +- [ ] **Step 1: Write the failing determinism and restart tests** + +The script mixes every input with varied `dt` values, including a huge frame and a non-positive one, so the replay covers the clamping paths too. Create `internal/game/determinism_test.go`: + +```go +package game + +import ( + "fmt" + "strings" + "testing" + "time" +) + +type scriptStep struct { + action string + dt time.Duration +} + +// canned is a fixed input-and-timing stream: exactly what design.md §40's +// replay test calls for. Each cycle walks the piece a varying distance sideways +// before dropping it, so the stack spreads across the board instead of piling +// up in the spawn columns. The dt series deliberately includes zero, a negative +// value and an 8-second frame, so the replay covers the clamping paths in +// Advance as well as ordinary frames. +var canned = func() []scriptStep { + dts := []time.Duration{ + 16 * time.Millisecond, 33 * time.Millisecond, 0, + -5 * time.Millisecond, 120 * time.Millisecond, 8 * time.Second, + } + var script []scriptStep + step := func(action string) { + script = append(script, scriptStep{action: action, dt: dts[len(script)%len(dts)]}) + } + for cycle := 0; cycle < 60; cycle++ { + side := "left" + if cycle%2 == 1 { + side = "right" + } + for i := 0; i <= cycle%5; i++ { + step(side) + } + if cycle%3 == 0 { + step("cw") + } + if cycle%7 == 0 { + step("ccw") + } + if cycle%11 == 0 { + step("hold") + } + step("soft") + step("tick") + step("hard") + step("tick") + } + return script +}() + +// runScript applies the stream and returns the flattened event log, so the test +// compares the observable output as well as the final state. +func runScript(g *Game, script []scriptStep) []string { + var log []string + record := func(evs []Event) { + for _, e := range evs { + log = append(log, fmt.Sprintf("%T%+v", e, e)) + } + } + for _, s := range script { + switch s.action { + case "left": + record(g.MoveLeft()) + case "right": + record(g.MoveRight()) + case "cw": + record(g.Rotate(1)) + case "ccw": + record(g.Rotate(-1)) + case "soft": + record(g.SoftDrop()) + case "hard": + record(g.HardDrop()) + case "hold": + record(g.HoldPiece()) + case "tick": + // no input, just time + default: + panic("unknown script action " + s.action) + } + record(g.Advance(s.dt)) + } + return log +} + +// fingerprint is every piece of observable state, as text. It avoids the +// unexported rng field on purpose: determinism is a claim about the game, not +// about generator internals. +func fingerprint(g *Game) string { + var sb strings.Builder + fmt.Fprintf(&sb, "score=%d lines=%d level=%d combo=%d over=%v canHold=%v\n", + g.Score, g.Lines, g.Level, g.Combo, g.Over, g.CanHold) + fmt.Fprintf(&sb, "active=%+v hold=%s next=%v\n", g.Active, holdName(g), g.Next) + fmt.Fprintf(&sb, "gravity=%v lock=%v resets=%d bag=%d\n", + g.GravityAccumulator, g.LockAccumulator, g.LockResets, g.Bag.Remaining()) + for y := 0; y < Height; y++ { + for x := 0; x < Width; x++ { + if g.Board.Occupied(x, y) { + sb.WriteString(g.Board.At(x, y).Kind().String()) + } else { + sb.WriteByte('.') + } + } + sb.WriteByte('\n') + } + return sb.String() +} + +func holdName(g *Game) string { + if g.Hold == nil { + return "-" + } + return g.Hold.String() +} + +func TestReplayingTheSameSeedAndTimingReproducesTheGame(t *testing.T) { + a, b := New(8675309), New(8675309) + + logA, logB := runScript(a, canned), runScript(b, canned) + + if len(logA) == 0 { + t.Fatal("the canned script produced no events; it is not exercising the engine") + } + if len(logA) != len(logB) { + t.Fatalf("event counts differ: %d vs %d", len(logA), len(logB)) + } + for i := range logA { + if logA[i] != logB[i] { + t.Fatalf("event %d differs:\n %s\n %s", i, logA[i], logB[i]) + } + } + if fa, fb := fingerprint(a), fingerprint(b); fa != fb { + t.Errorf("final state differs:\n--- run A ---\n%s\n--- run B ---\n%s", fa, fb) + } +} + +func TestReplayVisitsInterestingStates(t *testing.T) { + // A determinism test over a script that barely touches the board proves + // very little, so check the replay actually plays. Line clears depend on + // where the stack happens to land and are not asserted here; the clear, + // combo and level paths are pinned by advance_test.go and harddrop_test.go. + g := New(8675309) + log := runScript(g, canned) + var locks, clears int + for _, e := range log { + if strings.HasPrefix(e, "game.PieceLocked") { + locks++ + } + if strings.HasPrefix(e, "game.LinesCleared") { + clears++ + } + } + if locks < 10 { + t.Errorf("only %d pieces locked during the replay; want at least 10", locks) + } + if g.Score == 0 { + t.Error("the replay scored nothing; hard drops alone should score") + } + t.Logf("replay: %d locks, %d clears, score %d, lines %d, level %d, over=%v", + locks, clears, g.Score, g.Lines, g.Level, g.Over) +} + +func TestDifferentSeedsDivergeButStayInternallyConsistent(t *testing.T) { + a, b := New(1), New(2) + runScript(a, canned) + runScript(b, canned) + if fingerprint(a) == fingerprint(b) { + t.Error("two different seeds produced identical games; the seed is not reaching the bag") + } + + // Same seed, same script, but a second game constructed later: still equal. + c := New(1) + runScript(c, canned) + if fingerprint(a) != fingerprint(c) { + t.Error("two runs of seed 1 diverged") + } +} + +func TestRestartRewindsToAFreshGameOnTheSameSeed(t *testing.T) { + g := New(4242) + runScript(g, canned[:200]) + if g.Score == 0 { + t.Fatal("the script did not score anything; the restart check would be vacuous") + } + + g.Restart() + + if want := fingerprint(New(4242)); fingerprint(g) != want { + t.Errorf("after Restart the game is not a fresh New(4242):\n--- got ---\n%s\n--- want ---\n%s", + fingerprint(g), want) + } + if g.Seed != 4242 { + t.Errorf("seed = %d after Restart, want 4242 preserved so --seed stays reproducible", g.Seed) + } + if g.Over { + t.Error("game is still over after Restart") + } +} + +func TestRestartWorksAfterGameOver(t *testing.T) { + g := New(9) + fillRow(&g.Board, 0, Width-1) + fillRow(&g.Board, 1, Width-1) + g.HardDrop() // locks, then fails to spawn + if !g.Over { + t.Fatal("expected the game to be over") + } + + g.Restart() + + if g.Over || g.Score != 0 { + t.Errorf("after Restart over=%v score=%d, want false and 0", g.Over, g.Score) + } + var empty Board + if g.Board != empty { + t.Error("board not cleared by Restart") + } + if g.Board.Collides(g.Active) { + t.Error("the restarted game starts in a collision") + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/game/ -run 'Replay|Restart|Seeds' -v` +Expected: FAIL to compile — `g.Restart undefined`. + +- [ ] **Step 3: Append `Restart` to `internal/game/game.go`** + +```go +// Restart begins a new game on the same seed, so a --seed run stays +// reproducible across restarts (design.md §35). +func (g *Game) Restart() { + *g = *New(g.Seed) +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. If `TestReplayVisitsInterestingStates` reports fewer than 10 locks, the script is not reaching the board — do not weaken the threshold; check that the `hard` steps are being applied and that `HardDrop` locks immediately. + +- [ ] **Step 5: Run the whole suite with the race detector and vet** + +Run: + +```bash +gofmt -l . && go vet ./... && go test ./... -count=1 && go test ./internal/game/ -race -count=1 +``` + +Expected: `gofmt -l` prints nothing, vet is silent, all tests PASS twice over. + +- [ ] **Step 6: Update the README to record what Phase 1 delivered** + +Replace the `## Status` section of `README.md` with: + +```markdown +## Status + +Phase 1 complete: the headless game engine (`internal/game`) is implemented and +tested — pieces, board, 7-bag, movement, wall kicks, gravity, lock delay, line +clearing, hold, scoring, game over, and the game-event stream. Not yet +playable: Phase 2 adds the Bubble Tea terminal UI. + +The engine is deterministic. `game.New(seed)` plus a fixed stream of inputs and +`Advance(dt)` durations always reproduces the same game, which is what +`internal/game/determinism_test.go` asserts. +``` + +- [ ] **Step 7: Commit** + +```bash +git add internal/game/game.go internal/game/determinism_test.go README.md +git commit -m "feat(game): restart and the seeded replay determinism test" +``` + +--- + +## What this plan does not build + +Deliberately out of scope for Phase 1, each becoming its own plan once this engine is running (design.md §42): + +- **Phase 2 — playable terminal:** `internal/app` (Bubble Tea model, update, messages, keys), `internal/render` (layout, board, HUD, palette), `cmd/cosmic-tetris/main.go` with the §49.5 CLI surface, resize handling, and the §41 snapshot tests. Key repeat, pause, and help live here — the engine deliberately has no notion of pause, because a paused app simply stops calling `Advance`. +- **Phase 3 — cosmic foundation:** `internal/fx` (starfield, trails, its own `*rand.Rand`), animated border, `internal/flavor` mission-control messages. +- **Phase 4 — violence:** hard-drop impact, particles, line supernova, screen shake, shockwaves, hyperdrive, the four-line sequence. +- **Phase 5 — absurd polish:** boot sequence, the game-over black hole, responsive FX degradation, ASCII fallback, flavor tuning. + +One open question for the human: `design.md` §33 lists a `LICENSE` file. Phase 1 does not add one, because picking a licence is your call, not the implementer's. Say the word and it goes in the Phase 2 plan. + + + diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/F1-first-plan-cosmic-42/2026-09-17-cosmic-tetris-engine.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/F1-first-plan-cosmic-42/2026-09-17-cosmic-tetris-engine.md new file mode 100644 index 000000000..736293b0b --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/F1-first-plan-cosmic-42/2026-09-17-cosmic-tetris-engine.md @@ -0,0 +1,3256 @@ +# Cosmic Tetris — Game Engine Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the headless, deterministic Cosmic Tetris game engine (`internal/game`) plus the CLI flag surface, fully unit-tested, so that later plans can bolt a Bubble Tea renderer and a cosmic effects layer onto a game that is already provably correct. + +**Architecture:** One Go package, `internal/game`, owns all rules: pieces, board, 7-bag, movement, wall kicks, gravity, locking, line clears, scoring, hold, game over. It exposes a single time entry point — `Advance(dt time.Duration) []Event` — and never reads a clock, so the caller owns time and a canned `(input, dt)` stream reproduces any game exactly. The engine emits `Event` values describing what happened; it has no knowledge of rendering or effects. `cmd/cosmic-tetris/main.go` parses the flag surface into a `Config` and, for now, prints the resolved universe; Phase 2 replaces that body with the Bubble Tea program. + +**Tech Stack:** Go 1.26, standard library only (`math/rand/v2`, `time`, `flag`, `go/ast` for one architectural test). No third-party dependencies in this plan — Bubble Tea / Lip Gloss / Bubbles arrive in the next plan. + +**Spec:** `design.md` (this repo root). Section references below (`§7`, `§49.2`, …) point into it. + +## Global Constraints + +- Module path: `cosmic-tetris`. Engine imports as `cosmic-tetris/internal/game`. +- `go.mod` declares `go 1.26`. No third-party dependencies in this plan. +- Board geometry is fixed: width `10`, height `22`, hidden spawn rows `2`, visible rows `20` (§5). +- Nothing under `internal/game` may call `time.Now()` or `time.Since()`. `dt` is the only time source (§49.2). Task 7 adds a test that enforces this. +- The game RNG (`Game.rng`) drives the 7-bag and nothing else. FX gets a separate generator in a later plan. The two never share (§49.6, §35). +- Combo bonus is exactly `50 × (combo - 1) × level`; the first clearing placement sets combo to 1 and earns no bonus (§49.1). +- Gravity interval is `800ms × 0.86^(level-1)`, clamped at a `60ms` floor; level is `lines/10 + 1` (§11). +- Lock delay `500ms`, at most `15` move-resets per piece (§12). +- Final CLI surface, exactly: bare, `--seed N`, `--ascii`, `--no-fx`, `--reduced-motion`, `--help` (§49.5). Nothing else. +- Y grows downward everywhere: row `0` is the top hidden spawn row, row `21` is the floor row. +- Before every commit: `gofmt -l .` must print nothing, `go vet ./...` must pass, `go test ./...` must pass. + +## Review Focus + +These are input classes the spec implies but never names. Each has a test assigned to the task that owns the code. + +1. **A suspended terminal delivers one enormous `dt`.** Laptop sleep, a long `SIGSTOP`, or a stalled render loop hands the engine seconds at once; the piece must not teleport to the floor or spin the gravity loop for thousands of iterations. → Task 7, `TestAdvanceClampsHugeDt`. +2. **Keys pressed during the game-over sequence.** §28's collapse runs for 1300ms and §20 says animation never blocks input, so a player will absolutely hammer keys while dead; every input must be an inert no-op, not a mutation of a finished game. → Task 9, `TestInputsAfterGameOverAreNoOps`. +3. **Deep play drives the level past the interval clamp.** `0.86^(level-1)` underflows toward zero; if `DropInterval` ever returned `0` the gravity loop in `Advance` would never terminate and the game would hang mid-play. → Task 7, `TestDropIntervalNeverReachesZero`. +4. **`--seed` given something that is not a number.** A typo (`--seed abc`, `--seed 1e9`) must produce a readable one-line complaint and exit code 2, not a panic or a silent seed of 0. → Task 10, `TestParseFlagsRejectsBadSeed`. +5. **Rotation attempted while wedged against a wall, the floor, or a high stack.** §7's kick list includes `(0,-1)` and `±2` shifts, which can carry a piece off the board or into locked cells if the candidate is not re-validated; a piece rendered outside the playfield is an unrecoverable visual bug. → Task 5, `TestRotationNeverProducesIllegalPosition`. + +## File Structure + +| File | Responsibility | Task | +|---|---|---| +| `go.mod` | module + Go version | 1 | +| `internal/game/piece.go` | `PieceKind`, `Piece`, the four rotation tables, `Cells()` | 1 | +| `internal/game/piece_test.go` | rotation table integrity, spawn shapes | 1 | +| `internal/game/board.go` | geometry, bounds, collision, lock, row detect/clear/collapse, debug `String()` | 2 | +| `internal/game/board_test.go` | collision, bounds, completion, collapse | 2 | +| `internal/game/bag.go` | 7-bag randomizer, `newRNG` | 3 | +| `internal/game/bag_test.go` | bag completeness, reproducibility | 3 | +| `internal/game/events.go` | `EventKind`, `Event` | 4 | +| `internal/game/game.go` | `Game` state, `New`, `Restart`, spawn/queue, lock pipeline, `Advance`, drops, hold | 4, 6, 7, 8, 9 | +| `internal/game/rules.go` | movement, rotation + wall kicks, ghost, lock-timer touch | 4, 5 | +| `internal/game/scoring.go` | `LineScore`, `ComboBonus`, `LevelFor`, `DropInterval` | 6, 7 | +| `internal/game/game_test.go` | spawn, queue, ghost, lock pipeline, scoring, gravity, hold, game over, replay | 4–9 | +| `internal/game/rules_test.go` | movement, rotation, kicks | 5 | +| `cmd/cosmic-tetris/main.go` | `Config`, `parseFlags`, entry point | 10 | +| `cmd/cosmic-tetris/main_test.go` | flag parsing | 10 | +| `README.md` | what it is, how to run, how to test | 10 | + +## Not in this plan + +Deliberately deferred to later plans, one per §42 phase: Bubble Tea app + renderer + layout + resize (Phase 2), palette / starfield / animated border / trails / mission control (Phase 3), particles / impacts / supernova / shake / hyperdrive (Phase 4), boot sequence / black hole / help / ASCII fallback (Phase 5). The `--ascii`, `--no-fx`, and `--reduced-motion` flags are parsed here and consumed there. + +--- + +### Task 1: Module scaffold and tetromino geometry + +**Files:** +- Create: `go.mod` +- Create: `internal/game/piece.go` +- Test: `internal/game/piece_test.go` + +**Interfaces:** +- Consumes: nothing. +- Produces: `type PieceKind int` with constants `KindI, KindJ, KindL, KindO, KindS, KindT, KindZ` and `const KindCount = 7`; `func (k PieceKind) String() string`; `type Offset struct{ X, Y int }`; `type Piece struct { Kind PieceKind; Rotation int; X int; Y int }`; `func (p Piece) Cells() [4]Offset`. + +- [ ] **Step 1: Create the module** + +```bash +cd "$(git rev-parse --show-toplevel)" +go mod init cosmic-tetris +``` + +Confirm `go.mod` says `go 1.26` (Go 1.26.1 is installed); if it wrote a `toolchain` line, leave it. + +- [ ] **Step 2: Write the failing test** + +Create `internal/game/piece_test.go`: + +```go +package game + +import "testing" + +// allKinds is the iteration order used throughout the tests. +var allKinds = []PieceKind{KindI, KindJ, KindL, KindO, KindS, KindT, KindZ} + +func TestKindString(t *testing.T) { + want := map[PieceKind]string{ + KindI: "I", KindJ: "J", KindL: "L", KindO: "O", + KindS: "S", KindT: "T", KindZ: "Z", + } + for k, s := range want { + if got := k.String(); got != s { + t.Errorf("PieceKind(%d).String() = %q, want %q", int(k), got, s) + } + } +} + +// Every rotation of every piece must occupy exactly four distinct cells inside +// a 4x4 bounding box. A duplicated or out-of-box offset means a typo in the +// shape table, which would silently corrupt collision detection. +func TestEveryRotationHasFourDistinctCellsInBox(t *testing.T) { + for _, k := range allKinds { + for r := 0; r < 4; r++ { + p := Piece{Kind: k, Rotation: r} + seen := map[Offset]bool{} + for _, c := range p.Cells() { + if seen[c] { + t.Errorf("%s rotation %d: duplicate cell %+v", k, r, c) + } + seen[c] = true + if c.X < 0 || c.X > 3 || c.Y < 0 || c.Y > 3 { + t.Errorf("%s rotation %d: cell %+v outside 4x4 box", k, r, c) + } + } + if len(seen) != 4 { + t.Errorf("%s rotation %d: %d distinct cells, want 4", k, r, len(seen)) + } + } + } +} + +// O is the one piece §6 allows to be visually identical through rotation. +func TestOIsRotationInvariant(t *testing.T) { + base := Piece{Kind: KindO}.Cells() + for r := 1; r < 4; r++ { + if got := (Piece{Kind: KindO, Rotation: r}).Cells(); got != base { + t.Errorf("O rotation %d = %v, want %v", r, got, base) + } + } +} + +// T rotation 0 pins the table's orientation convention: the nub is up and the +// bar sits on the row below it. +func TestTSpawnShape(t *testing.T) { + want := [4]Offset{{X: 1, Y: 0}, {X: 0, Y: 1}, {X: 1, Y: 1}, {X: 2, Y: 1}} + if got := (Piece{Kind: KindT}).Cells(); got != want { + t.Errorf("T rotation 0 = %v, want %v", got, want) + } +} + +func TestCellsTranslateByPiecePosition(t *testing.T) { + p := Piece{Kind: KindT, X: 4, Y: 7} + want := [4]Offset{{X: 5, Y: 7}, {X: 4, Y: 8}, {X: 5, Y: 8}, {X: 6, Y: 8}} + if got := p.Cells(); got != want { + t.Errorf("translated cells = %v, want %v", got, want) + } +} + +// Rotation is normalised, so callers may hand in any integer. +func TestCellsNormaliseRotation(t *testing.T) { + base := Piece{Kind: KindJ, Rotation: 1}.Cells() + for _, r := range []int{5, 9, -3, -7} { + if got := (Piece{Kind: KindJ, Rotation: r}).Cells(); got != base { + t.Errorf("J rotation %d = %v, want %v", r, got, base) + } + } +} +``` + +- [ ] **Step 3: Run the test to verify it fails** + +Run: `go test ./internal/game/` +Expected: FAIL — build error, `undefined: PieceKind`, `undefined: Piece`. + +- [ ] **Step 4: Write the implementation** + +Create `internal/game/piece.go`: + +```go +// Package game is the Cosmic Tetris rules engine. It is headless and +// deterministic: it never reads a clock (see Advance) and never touches the +// terminal. Y grows downward; row 0 is the top hidden spawn row. +package game + +// PieceKind identifies one of the seven tetromino families (§6). +type PieceKind int + +const ( + KindI PieceKind = iota + KindJ + KindL + KindO + KindS + KindT + KindZ +) + +// KindCount is how many tetromino families exist. +const KindCount = 7 + +var kindNames = [KindCount]string{"I", "J", "L", "O", "S", "T", "Z"} + +func (k PieceKind) String() string { + if k < 0 || int(k) >= KindCount { + return "?" + } + return kindNames[k] +} + +// Offset is a cell position, either relative to a piece's bounding-box origin +// or absolute on the board depending on context. +type Offset struct{ X, Y int } + +// Piece is a tetromino in play: a family, a rotation index, and the board +// position of its bounding box's top-left corner. +type Piece struct { + Kind PieceKind + Rotation int + X int + Y int +} + +// shapes[kind][rotation] holds the four occupied cells of that rotation, +// relative to the bounding-box origin. Four explicit rotations per piece +// beats a rotation algorithm here: it is trivially inspectable and cannot +// drift (§6). +var shapes = [KindCount][4][4]Offset{ + // .... / XXXX / .... / .... + KindI: { + {{0, 1}, {1, 1}, {2, 1}, {3, 1}}, + {{2, 0}, {2, 1}, {2, 2}, {2, 3}}, + {{0, 2}, {1, 2}, {2, 2}, {3, 2}}, + {{1, 0}, {1, 1}, {1, 2}, {1, 3}}, + }, + // X.. / XXX / ... + KindJ: { + {{0, 0}, {0, 1}, {1, 1}, {2, 1}}, + {{1, 0}, {2, 0}, {1, 1}, {1, 2}}, + {{0, 1}, {1, 1}, {2, 1}, {2, 2}}, + {{1, 0}, {1, 1}, {0, 2}, {1, 2}}, + }, + // ..X / XXX / ... + KindL: { + {{2, 0}, {0, 1}, {1, 1}, {2, 1}}, + {{1, 0}, {1, 1}, {1, 2}, {2, 2}}, + {{0, 1}, {1, 1}, {2, 1}, {0, 2}}, + {{0, 0}, {1, 0}, {1, 1}, {1, 2}}, + }, + // .XX / .XX / ... + KindO: { + {{1, 0}, {2, 0}, {1, 1}, {2, 1}}, + {{1, 0}, {2, 0}, {1, 1}, {2, 1}}, + {{1, 0}, {2, 0}, {1, 1}, {2, 1}}, + {{1, 0}, {2, 0}, {1, 1}, {2, 1}}, + }, + // .XX / XX. / ... + KindS: { + {{1, 0}, {2, 0}, {0, 1}, {1, 1}}, + {{1, 0}, {1, 1}, {2, 1}, {2, 2}}, + {{1, 1}, {2, 1}, {0, 2}, {1, 2}}, + {{0, 0}, {0, 1}, {1, 1}, {1, 2}}, + }, + // .X. / XXX / ... + KindT: { + {{1, 0}, {0, 1}, {1, 1}, {2, 1}}, + {{1, 0}, {1, 1}, {2, 1}, {1, 2}}, + {{0, 1}, {1, 1}, {2, 1}, {1, 2}}, + {{1, 0}, {0, 1}, {1, 1}, {1, 2}}, + }, + // XX. / .XX / ... + KindZ: { + {{0, 0}, {1, 0}, {1, 1}, {2, 1}}, + {{2, 0}, {1, 1}, {2, 1}, {1, 2}}, + {{0, 1}, {1, 1}, {1, 2}, {2, 2}}, + {{1, 0}, {0, 1}, {1, 1}, {0, 2}}, + }, +} + +// Cells returns the four board cells this piece occupies. Rotation is +// normalised, so any integer is a legal Rotation value. +func (p Piece) Cells() [4]Offset { + r := ((p.Rotation % 4) + 4) % 4 + cells := shapes[p.Kind][r] // array copy: safe to mutate + for i := range cells { + cells[i].X += p.X + cells[i].Y += p.Y + } + return cells +} +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `go test ./internal/game/ -v` +Expected: PASS — all six tests. + +- [ ] **Step 6: Commit** + +```bash +gofmt -l . && go vet ./... && go test ./... +git add go.mod internal/game/piece.go internal/game/piece_test.go +git commit -m "feat(game): tetromino kinds and rotation tables" +``` + +--- + +### Task 2: Board — collision, row completion, collapse + +**Files:** +- Create: `internal/game/board.go` +- Test: `internal/game/board_test.go` + +**Interfaces:** +- Consumes: `Piece`, `PieceKind`, `Offset` (Task 1). +- Produces: consts `BoardWidth = 10`, `BoardHeight = 22`, `HiddenRows = 2`, `VisibleRows = 20`; `type Cell struct { Filled bool; Kind PieceKind }`; `type Board struct { Cells [BoardHeight][BoardWidth]Cell }`; methods `InBounds(x, y int) bool`, `Occupied(x, y int) bool`, `Collides(p Piece) bool`, `Lock(p Piece)`, `CompleteRows() []int`, `ClearRows(rows []int)`, `String() string`. + +- [ ] **Step 1: Write the failing test** + +Create `internal/game/board_test.go`: + +```go +package game + +import ( + "reflect" + "testing" +) + +// fill marks row y as occupied except at the listed hole columns. Tests build +// boards with this rather than literal 22-row arrays so the interesting row is +// the only thing on screen. +func fill(b *Board, y int, holes ...int) { + hole := map[int]bool{} + for _, x := range holes { + hole[x] = true + } + for x := 0; x < BoardWidth; x++ { + if !hole[x] { + b.Cells[y][x] = Cell{Filled: true, Kind: KindT} + } + } +} + +// markRow fills row y entirely with a distinguishable kind so collapse tests +// can tell rows apart after they move. +func markRow(b *Board, y int, k PieceKind, holes ...int) { + fill(b, y, holes...) + for x := 0; x < BoardWidth; x++ { + if b.Cells[y][x].Filled { + b.Cells[y][x].Kind = k + } + } +} + +func TestGeometryMatchesSpec(t *testing.T) { + if BoardWidth != 10 || BoardHeight != 22 || HiddenRows != 2 || VisibleRows != 20 { + t.Fatalf("geometry drifted: %d x %d, hidden %d, visible %d", + BoardWidth, BoardHeight, HiddenRows, VisibleRows) + } +} + +func TestInBounds(t *testing.T) { + var b Board + cases := []struct { + x, y int + want bool + }{ + {0, 0, true}, + {BoardWidth - 1, BoardHeight - 1, true}, + {-1, 0, false}, + {BoardWidth, 0, false}, + {0, -1, false}, + {0, BoardHeight, false}, + } + for _, c := range cases { + if got := b.InBounds(c.x, c.y); got != c.want { + t.Errorf("InBounds(%d,%d) = %v, want %v", c.x, c.y, got, c.want) + } + } +} + +// Everything off the board counts as occupied, which is what makes Collides a +// single loop with no special cases. +func TestOccupiedTreatsOutOfBoundsAsSolid(t *testing.T) { + var b Board + if !b.Occupied(-1, 5) || !b.Occupied(BoardWidth, 5) || !b.Occupied(3, BoardHeight) { + t.Error("out-of-bounds cells must report occupied") + } + if b.Occupied(3, 5) { + t.Error("empty in-bounds cell must report free") + } + b.Cells[5][3] = Cell{Filled: true, Kind: KindZ} + if !b.Occupied(3, 5) { + t.Error("filled cell must report occupied") + } +} + +func TestCollides(t *testing.T) { + var b Board + // O piece occupies bounding-box columns 1-2, rows 0-1. + free := Piece{Kind: KindO, X: 4, Y: 10} + if b.Collides(free) { + t.Error("piece in open space must not collide") + } + if !b.Collides(Piece{Kind: KindO, X: -2, Y: 10}) { + t.Error("piece through the left wall must collide") + } + if !b.Collides(Piece{Kind: KindO, X: BoardWidth - 1, Y: 10}) { + t.Error("piece through the right wall must collide") + } + if !b.Collides(Piece{Kind: KindO, X: 4, Y: BoardHeight - 1}) { + t.Error("piece through the floor must collide") + } + b.Cells[11][5] = Cell{Filled: true, Kind: KindI} + if !b.Collides(free) { + t.Error("piece overlapping a locked cell must collide") + } +} + +func TestLockWritesCellsWithKind(t *testing.T) { + var b Board + p := Piece{Kind: KindS, X: 2, Y: 9} + b.Lock(p) + for _, c := range p.Cells() { + got := b.Cells[c.Y][c.X] + if !got.Filled || got.Kind != KindS { + t.Errorf("cell %+v = %+v, want filled S", c, got) + } + } + if n := filledCount(&b); n != 4 { + t.Errorf("locked %d cells, want 4", n) + } +} + +func filledCount(b *Board) int { + n := 0 + for y := 0; y < BoardHeight; y++ { + for x := 0; x < BoardWidth; x++ { + if b.Cells[y][x].Filled { + n++ + } + } + } + return n +} + +func TestCompleteRows(t *testing.T) { + var b Board + if rows := b.CompleteRows(); len(rows) != 0 { + t.Errorf("empty board reported rows %v", rows) + } + fill(&b, 21) + fill(&b, 19, 4) // one hole: not complete + fill(&b, 18) + got := b.CompleteRows() + want := []int{18, 21} + if !reflect.DeepEqual(got, want) { + t.Errorf("CompleteRows() = %v, want %v (ascending)", got, want) + } +} + +// A row can complete inside the hidden spawn rows when the stack reaches +// orbit; it must clear like any other row. +func TestCompleteRowsIncludesHiddenRows(t *testing.T) { + var b Board + fill(&b, 1) + if got := b.CompleteRows(); !reflect.DeepEqual(got, []int{1}) { + t.Errorf("CompleteRows() = %v, want [1]", got) + } +} + +func TestClearRowsCollapsesFromAbove(t *testing.T) { + var b Board + markRow(&b, 19, KindI) // complete, will clear + markRow(&b, 20, KindJ, 0, 1) // survivor with holes + markRow(&b, 21, KindL) // complete, will clear + b.ClearRows([]int{19, 21}) + + // The survivor should now be the bottom row, holes intact. + for x := 0; x < BoardWidth; x++ { + want := x >= 2 + got := b.Cells[BoardHeight-1][x] + if got.Filled != want { + t.Errorf("bottom row col %d filled = %v, want %v", x, got.Filled, want) + } + if want && got.Kind != KindJ { + t.Errorf("bottom row col %d kind = %v, want J", x, got.Kind) + } + } + if n := filledCount(&b); n != BoardWidth-2 { + t.Errorf("%d cells remain, want %d", n, BoardWidth-2) + } +} + +func TestClearRowsFourAtOnce(t *testing.T) { + var b Board + for y := 18; y <= 21; y++ { + fill(&b, y) + } + b.ClearRows([]int{18, 19, 20, 21}) + if n := filledCount(&b); n != 0 { + t.Errorf("%d cells remain after a four-line clear, want 0", n) + } +} + +func TestClearRowsEmptyIsNoOp(t *testing.T) { + var b Board + fill(&b, 21, 3) + before := b + b.ClearRows(nil) + if b != before { + t.Error("ClearRows(nil) modified the board") + } +} + +func TestStringRendersOneCharPerCell(t *testing.T) { + var b Board + fill(&b, 21) + lines := splitLines(b.String()) + if len(lines) != BoardHeight { + t.Fatalf("String() has %d rows, want %d", len(lines), BoardHeight) + } + if lines[0] != ".........." { + t.Errorf("top row = %q, want all dots", lines[0]) + } + if lines[BoardHeight-1] != "##########" { + t.Errorf("bottom row = %q, want all hashes", lines[BoardHeight-1]) + } +} + +func splitLines(s string) []string { + var out []string + start := 0 + for i := 0; i < len(s); i++ { + if s[i] == '\n' { + out = append(out, s[start:i]) + start = i + 1 + } + } + return out +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/game/` +Expected: FAIL — `undefined: BoardWidth`, `undefined: Board`. + +- [ ] **Step 3: Write the implementation** + +Create `internal/game/board.go`: + +```go +package game + +import "strings" + +// Board geometry (§5). The top HiddenRows rows are the spawn area and are not +// drawn; the remaining VisibleRows are what the player sees. +const ( + BoardWidth = 10 + BoardHeight = 22 + HiddenRows = 2 + VisibleRows = BoardHeight - HiddenRows +) + +// Cell is one playfield square. Kind is only meaningful when Filled. +type Cell struct { + Filled bool + Kind PieceKind +} + +// Board holds locked blocks only; the active piece lives on Game. Row 0 is the +// top hidden row and row BoardHeight-1 is the floor row. +type Board struct { + Cells [BoardHeight][BoardWidth]Cell +} + +// InBounds reports whether (x, y) is a real board cell. +func (b *Board) InBounds(x, y int) bool { + return x >= 0 && x < BoardWidth && y >= 0 && y < BoardHeight +} + +// Occupied reports whether (x, y) blocks a piece. Anything off the board is +// solid, so walls and floor need no special handling in Collides. +func (b *Board) Occupied(x, y int) bool { + if !b.InBounds(x, y) { + return true + } + return b.Cells[y][x].Filled +} + +// Collides reports whether the piece overlaps a locked cell or leaves the +// board. This is the single source of truth for legality: visual effects are +// never consulted (§5). +func (b *Board) Collides(p Piece) bool { + for _, c := range p.Cells() { + if b.Occupied(c.X, c.Y) { + return true + } + } + return false +} + +// Lock commits a piece's cells to the board. +func (b *Board) Lock(p Piece) { + for _, c := range p.Cells() { + if b.InBounds(c.X, c.Y) { + b.Cells[c.Y][c.X] = Cell{Filled: true, Kind: p.Kind} + } + } +} + +// CompleteRows returns the indices of fully filled rows, top to bottom. +func (b *Board) CompleteRows() []int { + var rows []int + for y := 0; y < BoardHeight; y++ { + full := true + for x := 0; x < BoardWidth; x++ { + if !b.Cells[y][x].Filled { + full = false + break + } + } + if full { + rows = append(rows, y) + } + } + return rows +} + +// ClearRows removes the given rows and collapses everything above them down, +// leaving empty rows at the top. +func (b *Board) ClearRows(rows []int) { + if len(rows) == 0 { + return + } + var remove [BoardHeight]bool + for _, y := range rows { + if y >= 0 && y < BoardHeight { + remove[y] = true + } + } + dst := BoardHeight - 1 + for src := BoardHeight - 1; src >= 0; src-- { + if remove[src] { + continue + } + b.Cells[dst] = b.Cells[src] + dst-- + } + for ; dst >= 0; dst-- { + b.Cells[dst] = [BoardWidth]Cell{} + } +} + +// String renders the board as plain text, one character per cell. This exists +// for test failure messages and debugging, not for the game display. +func (b *Board) String() string { + var sb strings.Builder + sb.Grow(BoardHeight * (BoardWidth + 1)) + for y := 0; y < BoardHeight; y++ { + for x := 0; x < BoardWidth; x++ { + if b.Cells[y][x].Filled { + sb.WriteByte('#') + } else { + sb.WriteByte('.') + } + } + sb.WriteByte('\n') + } + return sb.String() +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `go test ./internal/game/ -run 'Board|Bounds|Occupied|Collides|Lock|Complete|Clear|Geometry|String' -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . && go vet ./... && go test ./... +git add internal/game/board.go internal/game/board_test.go +git commit -m "feat(game): board collision, row completion and collapse" +``` + +--- + +### Task 3: 7-bag piece generation + +**Files:** +- Create: `internal/game/bag.go` +- Test: `internal/game/bag_test.go` + +**Interfaces:** +- Consumes: `PieceKind`, `KindCount` (Task 1). +- Produces: `type Bag struct { remaining []PieceKind }`; `func (b *Bag) Next(rng *rand.Rand) PieceKind`; `func newRNG(seed int64) *rand.Rand`. + +- [ ] **Step 1: Write the failing test** + +Create `internal/game/bag_test.go`: + +```go +package game + +import "testing" + +// Draw n kinds from a fresh bag seeded with seed. +func draw(seed int64, n int) []PieceKind { + rng := newRNG(seed) + var bag Bag + out := make([]PieceKind, 0, n) + for i := 0; i < n; i++ { + out = append(out, bag.Next(rng)) + } + return out +} + +// §6/§40: every bag holds all seven kinds exactly once. +func TestEveryBagContainsAllSevenKindsOnce(t *testing.T) { + got := draw(12345, 7*4) + for b := 0; b < 4; b++ { + var count [KindCount]int + for _, k := range got[b*7 : b*7+7] { + count[k]++ + } + for k, n := range count { + if n != 1 { + t.Errorf("bag %d: %v appears %d times, want 1 (bag: %v)", + b, PieceKind(k), n, got[b*7:b*7+7]) + } + } + } +} + +// §35: seeded generation is reproducible. +func TestSeededBagIsReproducible(t *testing.T) { + a := draw(8675309, 21) + b := draw(8675309, 21) + for i := range a { + if a[i] != b[i] { + t.Fatalf("draw %d: %v vs %v (sequences diverged)\n%v\n%v", i, a[i], b[i], a, b) + } + } +} + +func TestDifferentSeedsProduceDifferentOrder(t *testing.T) { + a := draw(1, 21) + b := draw(2, 21) + same := true + for i := range a { + if a[i] != b[i] { + same = false + break + } + } + if same { + t.Errorf("seeds 1 and 2 produced identical sequences: %v", a) + } +} + +// The bag must not be handed out in a fixed order: over four bags, at least +// one bag should differ from the canonical I J L O S T Z ordering. +func TestBagIsShuffled(t *testing.T) { + got := draw(99, 7*4) + canonical := []PieceKind{KindI, KindJ, KindL, KindO, KindS, KindT, KindZ} + shuffledSomewhere := false + for b := 0; b < 4; b++ { + for i, k := range got[b*7 : b*7+7] { + if k != canonical[i] { + shuffledSomewhere = true + } + } + } + if !shuffledSomewhere { + t.Errorf("four consecutive bags all came out in canonical order: %v", got) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/game/ -run Bag` +Expected: FAIL — `undefined: newRNG`, `undefined: Bag`. + +- [ ] **Step 3: Write the implementation** + +Create `internal/game/bag.go`: + +```go +package game + +import "math/rand/v2" + +// newRNG builds the deterministic generator that drives piece order. The FX +// layer must construct its own generator: sharing one would make piece order +// depend on particle counts and break replay (§49.6). +func newRNG(seed int64) *rand.Rand { + return rand.New(rand.NewPCG(uint64(seed), uint64(seed)+0x9E3779B97F4A7C15)) +} + +// Bag is the 7-bag randomiser (§6): deal each kind once in shuffled order, +// then refill. +type Bag struct { + remaining []PieceKind +} + +// Next deals the next kind, refilling and shuffling when the bag runs dry. +func (b *Bag) Next(rng *rand.Rand) PieceKind { + if len(b.remaining) == 0 { + b.refill(rng) + } + last := len(b.remaining) - 1 + k := b.remaining[last] + b.remaining = b.remaining[:last] + return k +} + +func (b *Bag) refill(rng *rand.Rand) { + b.remaining = b.remaining[:0] + for k := PieceKind(0); int(k) < KindCount; k++ { + b.remaining = append(b.remaining, k) + } + // Fisher-Yates. + for i := len(b.remaining) - 1; i > 0; i-- { + j := rng.IntN(i + 1) + b.remaining[i], b.remaining[j] = b.remaining[j], b.remaining[i] + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `go test ./internal/game/ -run Bag -v` +Expected: PASS — four tests. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . && go vet ./... && go test ./... +git add internal/game/bag.go internal/game/bag_test.go +git commit -m "feat(game): seeded 7-bag piece generation" +``` + +--- + +### Task 4: Game state, events, spawn, next queue, ghost + +**Files:** +- Create: `internal/game/events.go` +- Create: `internal/game/game.go` +- Create: `internal/game/rules.go` +- Test: `internal/game/game_test.go` + +**Interfaces:** +- Consumes: `Piece`, `Board`, `Bag`, `newRNG` (Tasks 1–3). +- Produces: `type EventKind int` with `EventPieceMoved, EventPieceRotated, EventPieceHardDropped, EventPieceLocked, EventHoldUsed, EventLinesCleared, EventComboChanged, EventLevelChanged, EventGameOver` and `func (k EventKind) String() string`; `type Event struct { Kind EventKind; Piece Piece; Rows []int; Value int; Points int }`; `type Game struct { … }` with exported fields `Board Board`, `Active Piece`, `Hold *PieceKind`, `CanHold bool`, `Next []PieceKind`, `Bag Bag`, `Score, Lines, Level, Combo int`, `GravityAccumulator, LockAccumulator time.Duration`, `LockResets int`, `Over bool`, `Seed int64`; consts `NextQueueLen = 5`, `SpawnX = 3`, `SpawnY = 0`; `func New(seed int64) *Game`; `func (g *Game) Restart()`; `func (g *Game) GhostY() int`; unexported `takeNext()`, `spawn(k PieceKind) []Event`, `grounded() bool`, `tryMove(dx, dy int) bool`. + +- [ ] **Step 1: Write the failing test** + +Create `internal/game/game_test.go`: + +```go +package game + +import ( + "testing" +) + +// kinds extracts the kinds from an event slice for readable assertions. +func kindsOf(events []Event) []EventKind { + out := make([]EventKind, 0, len(events)) + for _, e := range events { + out = append(out, e.Kind) + } + return out +} + +// hasEvent reports whether any event has the given kind. +func hasEvent(events []Event, k EventKind) bool { + for _, e := range events { + if e.Kind == k { + return true + } + } + return false +} + +// findEvent returns the first event of a kind, and whether it was found. +func findEvent(events []Event, k EventKind) (Event, bool) { + for _, e := range events { + if e.Kind == k { + return e, true + } + } + return Event{}, false +} + +func TestEventKindString(t *testing.T) { + if got := EventLinesCleared.String(); got != "LinesCleared" { + t.Errorf("EventLinesCleared.String() = %q, want %q", got, "LinesCleared") + } + if got := EventKind(99).String(); got == "" { + t.Error("unknown EventKind must still render something") + } +} + +func TestNewStartsAtLevelOneWithFullQueue(t *testing.T) { + g := New(42) + if g.Level != 1 { + t.Errorf("Level = %d, want 1", g.Level) + } + if g.Score != 0 || g.Lines != 0 || g.Combo != 0 { + t.Errorf("Score/Lines/Combo = %d/%d/%d, want 0/0/0", g.Score, g.Lines, g.Combo) + } + if !g.CanHold { + t.Error("CanHold must start true") + } + if g.Hold != nil { + t.Errorf("Hold = %v, want nil", g.Hold) + } + if len(g.Next) != NextQueueLen { + t.Errorf("len(Next) = %d, want %d", len(g.Next), NextQueueLen) + } + if g.Over { + t.Error("a new game must not be over") + } + if g.Seed != 42 { + t.Errorf("Seed = %d, want 42", g.Seed) + } +} + +// §6: the spawn position puts the whole piece in the hidden rows so it slides +// into view under gravity. +func TestSpawnSitsInHiddenRows(t *testing.T) { + for _, k := range allKinds { + g := New(1) + g.Active = Piece{Kind: k, X: SpawnX, Y: SpawnY} + for _, c := range g.Active.Cells() { + if c.Y >= HiddenRows { + t.Errorf("%s spawns with cell at row %d, want < %d", k, c.Y, HiddenRows) + } + if c.X < 0 || c.X >= BoardWidth { + t.Errorf("%s spawns with cell at column %d, out of board", k, c.X) + } + } + } +} + +// The queue is topped up as it is consumed, so the HUD always has five to draw. +func TestTakeNextKeepsQueueFull(t *testing.T) { + g := New(7) + first := g.Next[0] + got := g.takeNext() + if got != first { + t.Errorf("takeNext() = %v, want front of queue %v", got, first) + } + if len(g.Next) != NextQueueLen { + t.Errorf("len(Next) = %d after takeNext, want %d", len(g.Next), NextQueueLen) + } +} + +func TestSpawnResetsTimers(t *testing.T) { + g := New(3) + g.GravityAccumulator = 500 + g.LockAccumulator = 400 + g.LockResets = 9 + if events := g.spawn(KindT); len(events) != 0 { + t.Fatalf("spawn on an empty board returned %v", kindsOf(events)) + } + if g.GravityAccumulator != 0 || g.LockAccumulator != 0 || g.LockResets != 0 { + t.Errorf("timers after spawn = %v/%v/%d, want zeroes", + g.GravityAccumulator, g.LockAccumulator, g.LockResets) + } + if g.Active.Kind != KindT || g.Active.Rotation != 0 || + g.Active.X != SpawnX || g.Active.Y != SpawnY { + t.Errorf("Active = %+v, want T at spawn", g.Active) + } +} + +// §10: the ghost is the landing position — one cell further must collide. +func TestGhostYIsTheLandingPosition(t *testing.T) { + for _, k := range allKinds { + g := New(5) + g.Active = Piece{Kind: k, X: 3, Y: 0} + rest := g.GhostY() + landed := g.Active + landed.Y = rest + if g.Board.Collides(landed) { + t.Errorf("%s: ghost position %d collides", k, rest) + } + below := landed + below.Y++ + if !g.Board.Collides(below) { + t.Errorf("%s: ghost position %d is not the lowest legal row", k, rest) + } + } +} + +func TestGhostYRestsOnTheStack(t *testing.T) { + g := New(5) + g.Active = Piece{Kind: KindO, X: 4, Y: 0} + fill(&g.Board, 15) // a solid floor at row 15 + rest := g.GhostY() + // O occupies bounding-box rows 0 and 1, so its box top rests at 13. + if rest != 13 { + t.Errorf("GhostY() = %d, want 13 (resting on row 15)", rest) + } + if g.Active.Y != 0 { + t.Error("GhostY must not move the active piece") + } +} + +func TestRestartRebuildsTheSameUniverse(t *testing.T) { + g := New(4242) + openingQueue := append([]PieceKind{g.Active.Kind}, g.Next...) + g.Score = 999 + g.Lines = 33 + g.Level = 5 + g.Combo = 4 + g.Over = true + fill(&g.Board, 21) + + g.Restart() + + if g.Score != 0 || g.Lines != 0 || g.Level != 1 || g.Combo != 0 || g.Over { + t.Errorf("after Restart: score %d lines %d level %d combo %d over %v", + g.Score, g.Lines, g.Level, g.Combo, g.Over) + } + if n := filledCount(&g.Board); n != 0 { + t.Errorf("after Restart the board still holds %d cells", n) + } + if g.Seed != 4242 { + t.Errorf("Seed = %d, want 4242", g.Seed) + } + got := append([]PieceKind{g.Active.Kind}, g.Next...) + for i := range openingQueue { + if got[i] != openingQueue[i] { + t.Fatalf("restart changed piece order: %v vs %v", got, openingQueue) + } + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/game/ -run 'Event|New|Spawn|TakeNext|Ghost|Restart'` +Expected: FAIL — `undefined: New`, `undefined: EventKind`. + +- [ ] **Step 3: Write the event type** + +Create `internal/game/events.go`: + +```go +package game + +// EventKind says what happened during a state change (§14). Events are the +// only channel from the engine to the effects layer, and the traffic is +// one-way: FX may observe events, never modify game state. +type EventKind int + +const ( + EventPieceMoved EventKind = iota + EventPieceRotated + EventPieceHardDropped + EventPieceLocked + EventHoldUsed + EventLinesCleared + EventComboChanged + EventLevelChanged + EventGameOver +) + +var eventNames = map[EventKind]string{ + EventPieceMoved: "PieceMoved", + EventPieceRotated: "PieceRotated", + EventPieceHardDropped: "PieceHardDropped", + EventPieceLocked: "PieceLocked", + EventHoldUsed: "HoldUsed", + EventLinesCleared: "LinesCleared", + EventComboChanged: "ComboChanged", + EventLevelChanged: "LevelChanged", + EventGameOver: "GameOver", +} + +func (k EventKind) String() string { + if name, ok := eventNames[k]; ok { + return name + } + return "EventKind(?)" +} + +// Event describes one thing that happened. One flat struct beats an interface +// hierarchy here: the FX layer switches on Kind and reads the fields it cares +// about. Which fields are populated depends on Kind: +// +// EventPieceMoved Piece: the piece after moving. Points: soft-drop points, if any. +// EventPieceRotated Piece: the piece after rotating. +// EventPieceHardDropped Piece: the resting piece. Value: cells fallen. Points: points awarded. +// EventPieceLocked Piece: the piece as committed to the board. +// EventHoldUsed Piece: the piece that entered play. +// EventLinesCleared Rows: cleared rows in board coordinates, before collapse. +// Value: how many. Points: points awarded including combo bonus. +// EventComboChanged Value: the new combo count (0 when broken). +// EventLevelChanged Value: the new level. +// EventGameOver Piece: the piece that could not spawn. +type Event struct { + Kind EventKind + Piece Piece + Rows []int + Value int + Points int +} +``` + +- [ ] **Step 4: Write the game state** + +Create `internal/game/game.go`: + +```go +package game + +import ( + "math/rand/v2" + "time" +) + +const ( + // NextQueueLen is how many upcoming pieces stay visible (§6). + NextQueueLen = 5 + // SpawnX and SpawnY place a new piece's bounding box: horizontally + // centred, entirely inside the hidden rows. + SpawnX = 3 + SpawnY = 0 +) + +// Game is the whole logical game. Exported fields are the renderer's read-only +// view; the rng is private because sharing it would break replay (§49.6). +type Game struct { + Board Board + Active Piece + Hold *PieceKind + CanHold bool + + Next []PieceKind + Bag Bag + + Score int + Lines int + Level int + Combo int + + GravityAccumulator time.Duration + LockAccumulator time.Duration + LockResets int + + Over bool + + Seed int64 // recorded for display and restart + + rng *rand.Rand // game RNG: drives the 7-bag, nothing else +} + +// New starts a game from a seed. The same seed always deals the same pieces. +func New(seed int64) *Game { + g := &Game{ + Level: 1, + CanHold: true, + Seed: seed, + rng: newRNG(seed), + } + for len(g.Next) < NextQueueLen { + g.Next = append(g.Next, g.Bag.Next(g.rng)) + } + g.spawn(g.takeNext()) // cannot fail on an empty board + return g +} + +// Restart rebuilds the game from the same seed, replaying the same pieces. +func (g *Game) Restart() { + *g = *New(g.Seed) +} + +// takeNext pops the front of the queue and tops it back up from the bag. +func (g *Game) takeNext() PieceKind { + k := g.Next[0] + g.Next = g.Next[1:] + for len(g.Next) < NextQueueLen { + g.Next = append(g.Next, g.Bag.Next(g.rng)) + } + return k +} + +// spawn puts a new piece at the spawn position in spawn rotation and resets the +// per-piece timers. If it does not fit, the stack has reached orbit and the +// game is over (§28, §40). +func (g *Game) spawn(k PieceKind) []Event { + p := Piece{Kind: k, Rotation: 0, X: SpawnX, Y: SpawnY} + g.Active = p + g.GravityAccumulator = 0 + g.LockAccumulator = 0 + g.LockResets = 0 + if g.Board.Collides(p) { + g.Over = true + return []Event{{Kind: EventGameOver, Piece: p}} + } + return nil +} +``` + +- [ ] **Step 5: Write the movement primitives the ghost needs** + +Create `internal/game/rules.go`: + +```go +package game + +// tryMove shifts the active piece by (dx, dy) if the destination is legal, and +// reports whether it moved. +func (g *Game) tryMove(dx, dy int) bool { + p := g.Active + p.X += dx + p.Y += dy + if g.Board.Collides(p) { + return false + } + g.Active = p + return true +} + +// grounded reports whether the active piece is resting on the floor or stack. +func (g *Game) grounded() bool { + p := g.Active + p.Y++ + return g.Board.Collides(p) +} + +// GhostY is the Y the active piece would come to rest at if dropped now (§10). +// It does not move the piece. +func (g *Game) GhostY() int { + p := g.Active + for { + next := p + next.Y++ + if g.Board.Collides(next) { + return p.Y + } + p = next + } +} +``` + +- [ ] **Step 6: Run the test to verify it passes** + +Run: `go test ./internal/game/ -run 'Event|New|Spawn|TakeNext|Ghost|Restart' -v` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +gofmt -l . && go vet ./... && go test ./... +git add internal/game/events.go internal/game/game.go internal/game/rules.go internal/game/game_test.go +git commit -m "feat(game): game state, events, spawn queue and ghost" +``` + +--- + +### Task 5: Movement, rotation and wall kicks + +**Files:** +- Modify: `internal/game/rules.go` (append) +- Test: `internal/game/rules_test.go` + +**Interfaces:** +- Consumes: `Game`, `tryMove`, `grounded`, `Event` (Task 4). +- Produces: `func (g *Game) MoveLeft() []Event`, `func (g *Game) MoveRight() []Event`, `func (g *Game) RotateCW() []Event`, `func (g *Game) RotateCCW() []Event`; consts `LockDelay = 500 * time.Millisecond` and `MaxLockResets = 15` (declared in this task's Step 3, in `rules.go`; Task 7's `Advance` is the other user); unexported `touchLockTimer()`; `var kickOffsets [8]Offset`. + +- [ ] **Step 1: Write the failing test** + +Create `internal/game/rules_test.go`: + +```go +package game + +import "testing" + +// pieceAt builds a game whose active piece is exactly what the test wants. +func pieceAt(k PieceKind, rotation, x, y int) *Game { + g := New(1) + g.Active = Piece{Kind: k, Rotation: rotation, X: x, Y: y} + return g +} + +func TestMoveLeftAndRight(t *testing.T) { + g := pieceAt(KindO, 0, 4, 10) + if events := g.MoveLeft(); !hasEvent(events, EventPieceMoved) { + t.Fatalf("MoveLeft returned %v, want a PieceMoved event", kindsOf(events)) + } + if g.Active.X != 3 { + t.Errorf("X = %d after MoveLeft, want 3", g.Active.X) + } + if events := g.MoveRight(); !hasEvent(events, EventPieceMoved) { + t.Fatalf("MoveRight returned %v, want a PieceMoved event", kindsOf(events)) + } + if g.Active.X != 4 { + t.Errorf("X = %d after MoveRight, want 4", g.Active.X) + } +} + +// A blocked move is a silent no-op: no state change, no event for the FX layer +// to react to. +func TestBlockedMoveIsSilent(t *testing.T) { + // O occupies box columns 1-2, so X = -1 puts it against the left wall. + g := pieceAt(KindO, 0, -1, 10) + if events := g.MoveLeft(); len(events) != 0 { + t.Errorf("blocked MoveLeft returned %v, want nothing", kindsOf(events)) + } + if g.Active.X != -1 { + t.Errorf("blocked MoveLeft moved the piece to X = %d", g.Active.X) + } + + g = pieceAt(KindO, 0, BoardWidth-3, 10) + if events := g.MoveRight(); len(events) != 0 { + t.Errorf("blocked MoveRight returned %v, want nothing", kindsOf(events)) + } +} + +func TestMoveBlockedByLockedCells(t *testing.T) { + g := pieceAt(KindO, 0, 4, 10) + g.Board.Cells[10][4] = Cell{Filled: true, Kind: KindI} + if events := g.MoveLeft(); len(events) != 0 { + t.Errorf("move into a locked cell returned %v, want nothing", kindsOf(events)) + } +} + +func TestRotateCyclesThroughFourRotations(t *testing.T) { + g := pieceAt(KindT, 0, 4, 10) + for want := 1; want <= 4; want++ { + events := g.RotateCW() + if !hasEvent(events, EventPieceRotated) { + t.Fatalf("RotateCW returned %v, want a PieceRotated event", kindsOf(events)) + } + if got := g.Active.Rotation; got != want%4 { + t.Fatalf("Rotation = %d, want %d", got, want%4) + } + } + if g.Active.X != 4 || g.Active.Y != 10 { + t.Errorf("open-space rotation drifted to (%d,%d), want (4,10)", g.Active.X, g.Active.Y) + } +} + +func TestRotateCCW(t *testing.T) { + g := pieceAt(KindT, 0, 4, 10) + if events := g.RotateCCW(); !hasEvent(events, EventPieceRotated) { + t.Fatalf("RotateCCW returned %v, want a PieceRotated event", kindsOf(events)) + } + if g.Active.Rotation != 3 { + t.Errorf("Rotation = %d after RotateCCW from 0, want 3", g.Active.Rotation) + } +} + +// §7: a rotation that does not fit in place is nudged by the kick list. A +// vertical I flush against the right wall needs a leftward kick to lie down. +func TestWallKickPushesPieceInFromTheWall(t *testing.T) { + // I rotation 1 occupies box column 2; X = 7 puts its cells in column 9. + g := pieceAt(KindI, 1, 7, 10) + if g.Board.Collides(g.Active) { + t.Fatal("test setup: starting position already collides") + } + events := g.RotateCW() + if !hasEvent(events, EventPieceRotated) { + t.Fatalf("rotation against the wall returned %v, want a PieceRotated event", kindsOf(events)) + } + if g.Board.Collides(g.Active) { + t.Errorf("kicked position %+v collides", g.Active) + } + if g.Active.X == 7 { + t.Error("rotation should have been kicked sideways, but X did not change") + } +} + +// §7: when no offset works, rotation fails and nothing changes. +func TestRotationFailsWhenBoxedIn(t *testing.T) { + g := pieceAt(KindI, 1, 3, 18) + // Wall in every cell around the piece's column so no kick can succeed. + for y := 0; y < BoardHeight; y++ { + for x := 0; x < BoardWidth; x++ { + if x != 5 { + g.Board.Cells[y][x] = Cell{Filled: true, Kind: KindZ} + } + } + } + g.Active = Piece{Kind: KindI, Rotation: 1, X: 3, Y: 18} + if g.Board.Collides(g.Active) { + t.Fatal("test setup: starting position already collides") + } + before := g.Active + if events := g.RotateCW(); len(events) != 0 { + t.Errorf("boxed-in rotation returned %v, want nothing", kindsOf(events)) + } + if g.Active != before { + t.Errorf("failed rotation changed the piece: %+v, want %+v", g.Active, before) + } +} + +// Review Focus 5: whatever the kick list does, the result is always a legal +// position. Sweep every kind, rotation and position on an empty board and on a +// board with a tall stack. +func TestRotationNeverProducesIllegalPosition(t *testing.T) { + stack := func(g *Game) { + for y := 12; y < BoardHeight; y++ { + fill(&g.Board, y, 3, 4) + } + } + for _, setup := range []func(*Game){func(*Game) {}, stack} { + for _, k := range allKinds { + for r := 0; r < 4; r++ { + for x := -3; x <= BoardWidth+1; x++ { + for y := 0; y < BoardHeight; y++ { + g := New(1) + setup(g) + g.Active = Piece{Kind: k, Rotation: r, X: x, Y: y} + if g.Board.Collides(g.Active) { + continue // not a reachable state + } + for _, rotate := range []func() []Event{g.RotateCW, g.RotateCCW} { + before := g.Active + rotate() + if g.Board.Collides(g.Active) { + t.Fatalf("%s r%d at (%d,%d): rotation produced illegal %+v", + k, r, x, y, g.Active) + } + g.Active = before + } + } + } + } + } + } +} + +// §12: a successful grounded move resets the lock timer, up to the cap. +func TestGroundedMoveResetsLockTimer(t *testing.T) { + g := pieceAt(KindO, 0, 4, BoardHeight-2) + if !g.grounded() { + t.Fatal("test setup: piece is not grounded") + } + g.LockAccumulator = LockDelay - 1 + g.MoveLeft() + if g.LockAccumulator != 0 { + t.Errorf("LockAccumulator = %v after a grounded move, want 0", g.LockAccumulator) + } + if g.LockResets != 1 { + t.Errorf("LockResets = %d, want 1", g.LockResets) + } +} + +func TestLockResetsAreCapped(t *testing.T) { + g := pieceAt(KindO, 0, 4, BoardHeight-2) + for i := 0; i < MaxLockResets+5; i++ { + g.LockAccumulator = LockDelay - 1 + if i%2 == 0 { + g.MoveLeft() + } else { + g.MoveRight() + } + } + if g.LockResets != MaxLockResets { + t.Errorf("LockResets = %d, want the cap %d", g.LockResets, MaxLockResets) + } + if g.LockAccumulator != LockDelay-1 { + t.Errorf("LockAccumulator = %v, want the timer left running past the cap", + g.LockAccumulator) + } +} + +// A move that lifts the piece off the stack is not a lock reset. +func TestUngroundedMoveDoesNotConsumeALockReset(t *testing.T) { + g := pieceAt(KindO, 0, 4, 10) + g.MoveLeft() + if g.LockResets != 0 { + t.Errorf("LockResets = %d after a mid-air move, want 0", g.LockResets) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/game/ -run 'Move|Rotat|Lock'` +Expected: FAIL — `undefined: MoveLeft`, `undefined: LockDelay`. + +- [ ] **Step 3: Append the implementation to `internal/game/rules.go`** + +```go +// Lock timing (§12). Declared here because touchLockTimer needs them; Advance +// in game.go is the other user. +const ( + // LockDelay is how long a grounded piece waits before committing. + LockDelay = 500 * time.Millisecond + // MaxLockResets caps how many times move-resetting can postpone a lock, + // so a player cannot stall forever. + MaxLockResets = 15 +) + +// kickOffsets are tried in order when a rotation does not fit where it is +// (§7). This short deliberate list replaces the full SRS ruleset: it is +// forgiving, predictable, and not a subsystem. +var kickOffsets = [8]Offset{ + {X: 0, Y: 0}, + {X: -1, Y: 0}, + {X: 1, Y: 0}, + {X: -2, Y: 0}, + {X: 2, Y: 0}, + {X: 0, Y: -1}, + {X: -1, Y: -1}, + {X: 1, Y: -1}, +} + +// MoveLeft shifts the active piece one column left. +func (g *Game) MoveLeft() []Event { return g.shift(-1) } + +// MoveRight shifts the active piece one column right. +func (g *Game) MoveRight() []Event { return g.shift(1) } + +func (g *Game) shift(dx int) []Event { + if g.Over { + return nil + } + if !g.tryMove(dx, 0) { + return nil + } + g.touchLockTimer() + return []Event{{Kind: EventPieceMoved, Piece: g.Active}} +} + +// RotateCW rotates the active piece clockwise, kicking if needed. +func (g *Game) RotateCW() []Event { return g.rotate(1) } + +// RotateCCW rotates the active piece counter-clockwise, kicking if needed. +func (g *Game) RotateCCW() []Event { return g.rotate(-1) } + +func (g *Game) rotate(delta int) []Event { + if g.Over { + return nil + } + target := g.Active + target.Rotation = ((target.Rotation+delta)%4 + 4) % 4 + for _, k := range kickOffsets { + candidate := target + candidate.X += k.X + candidate.Y += k.Y + if g.Board.Collides(candidate) { + continue + } + g.Active = candidate + g.touchLockTimer() + return []Event{{Kind: EventPieceRotated, Piece: g.Active}} + } + return nil // rotation fails; nothing changes +} + +// touchLockTimer restarts the lock delay after a successful move or rotation +// while grounded, up to MaxLockResets times for this piece (§12). +func (g *Game) touchLockTimer() { + if !g.grounded() { + return + } + if g.LockResets >= MaxLockResets { + return + } + g.LockResets++ + g.LockAccumulator = 0 +} +``` + +Add the import at the top of `internal/game/rules.go`: + +```go +import "time" +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `go test ./internal/game/ -run 'Move|Rotat|Lock' -v` +Expected: PASS — including `TestRotationNeverProducesIllegalPosition`, which sweeps every kind and position. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . && go vet ./... && go test ./... +git add internal/game/rules.go internal/game/rules_test.go +git commit -m "feat(game): movement, rotation and forgiving wall kicks" +``` + +--- + +### Task 6: Lock pipeline — clear, score, combo, level + +**Files:** +- Create: `internal/game/scoring.go` +- Modify: `internal/game/game.go` (append `lockPiece`) +- Test: `internal/game/scoring_test.go` +- Test: `internal/game/game_test.go` (append) + +**Interfaces:** +- Consumes: `Game`, `Board.CompleteRows`, `Board.ClearRows`, `Event`, `spawn`, `takeNext` (Tasks 2, 4). +- Produces: `func LineScore(n, level int) int`, `func ComboBonus(combo, level int) int`, `func LevelFor(lines int) int`, `const LinesPerLevel = 10`; unexported `func (g *Game) lockPiece() []Event`. + +- [ ] **Step 1: Write the failing scoring test** + +Create `internal/game/scoring_test.go`: + +```go +package game + +import "testing" + +// §13 base clear values. +func TestLineScore(t *testing.T) { + cases := []struct { + lines, level, want int + }{ + {0, 1, 0}, + {1, 1, 100}, + {2, 1, 300}, + {3, 1, 500}, + {4, 1, 800}, + {1, 7, 700}, + {4, 7, 5600}, + {5, 1, 0}, // impossible; must not panic or invent a value + } + for _, c := range cases { + if got := LineScore(c.lines, c.level); got != c.want { + t.Errorf("LineScore(%d, %d) = %d, want %d", c.lines, c.level, got, c.want) + } + } +} + +// §49.1: bonus is 50 x (combo-1) x level, so a lone clear earns nothing and +// the bonus first appears at combo 2. +func TestComboBonus(t *testing.T) { + cases := []struct { + combo, level, want int + }{ + {0, 5, 0}, + {1, 5, 0}, + {2, 1, 50}, + {2, 5, 250}, + {3, 2, 200}, + {7, 3, 900}, + } + for _, c := range cases { + if got := ComboBonus(c.combo, c.level); got != c.want { + t.Errorf("ComboBonus(%d, %d) = %d, want %d", c.combo, c.level, got, c.want) + } + } +} + +// §11: a level every ten lines, starting at level 1. +func TestLevelFor(t *testing.T) { + cases := []struct { + lines, want int + }{ + {0, 1}, {1, 1}, {9, 1}, {10, 2}, {19, 2}, {20, 3}, {127, 13}, + } + for _, c := range cases { + if got := LevelFor(c.lines); got != c.want { + t.Errorf("LevelFor(%d) = %d, want %d", c.lines, got, c.want) + } + } +} +``` + +- [ ] **Step 2: Write the failing lock-pipeline test** + +Append to `internal/game/game_test.go`: + +```go +// stackTo fills rows from y down to the floor, leaving the given holes in each. +func stackTo(g *Game, fromY int, holes ...int) { + for y := fromY; y < BoardHeight; y++ { + fill(&g.Board, y, holes...) + } +} + +func TestLockCommitsPieceAndSpawnsNext(t *testing.T) { + g := New(11) + upcoming := g.Next[0] + g.Active = Piece{Kind: KindO, X: 4, Y: BoardHeight - 2} + events := g.lockPiece() + + if !hasEvent(events, EventPieceLocked) { + t.Fatalf("lockPiece returned %v, want a PieceLocked event", kindsOf(events)) + } + if n := filledCount(&g.Board); n != 4 { + t.Errorf("%d cells locked, want 4", n) + } + if g.Active.Kind != upcoming { + t.Errorf("Active kind = %v, want the queue front %v", g.Active.Kind, upcoming) + } + if g.Active.X != SpawnX || g.Active.Y != SpawnY || g.Active.Rotation != 0 { + t.Errorf("new piece at %+v, want spawn position", g.Active) + } +} + +// §9: hold becomes available again once the piece locks. +func TestLockRestoresHoldAvailability(t *testing.T) { + g := New(11) + g.CanHold = false + g.Active = Piece{Kind: KindO, X: 4, Y: BoardHeight - 2} + g.lockPiece() + if !g.CanHold { + t.Error("CanHold must be restored after a lock") + } +} + +func TestSingleLineClearScoresAndCounts(t *testing.T) { + g := New(11) + // Leave a two-wide notch at the bottom that an O piece completes. + fill(&g.Board, BoardHeight-1, 4, 5) + g.Active = Piece{Kind: KindO, X: 3, Y: BoardHeight - 2} + events := g.lockPiece() + + cleared, ok := findEvent(events, EventLinesCleared) + if !ok { + t.Fatalf("lockPiece returned %v, want a LinesCleared event", kindsOf(events)) + } + if cleared.Value != 1 { + t.Errorf("LinesCleared Value = %d, want 1", cleared.Value) + } + if len(cleared.Rows) != 1 || cleared.Rows[0] != BoardHeight-1 { + t.Errorf("LinesCleared Rows = %v, want [%d]", cleared.Rows, BoardHeight-1) + } + if cleared.Points != 100 { + t.Errorf("LinesCleared Points = %d, want 100", cleared.Points) + } + if g.Score != 100 { + t.Errorf("Score = %d, want 100", g.Score) + } + if g.Lines != 1 { + t.Errorf("Lines = %d, want 1", g.Lines) + } + if g.Combo != 1 { + t.Errorf("Combo = %d, want 1 after the first clearing placement", g.Combo) + } + // Two cells of the O survive above the cleared row. + if n := filledCount(&g.Board); n != 2 { + t.Errorf("%d cells remain, want 2", n) + } +} + +func TestFourLineClearScoresEightHundred(t *testing.T) { + g := New(11) + for y := BoardHeight - 4; y < BoardHeight; y++ { + fill(&g.Board, y, 0) + } + // Vertical I in column 0 fills all four rows at once. + g.Active = Piece{Kind: KindI, Rotation: 1, X: -2, Y: BoardHeight - 4} + if g.Board.Collides(g.Active) { + t.Fatal("test setup: the I piece does not fit the well") + } + events := g.lockPiece() + + cleared, ok := findEvent(events, EventLinesCleared) + if !ok { + t.Fatalf("lockPiece returned %v, want a LinesCleared event", kindsOf(events)) + } + if cleared.Value != 4 || len(cleared.Rows) != 4 { + t.Errorf("cleared %d rows (%v), want 4", cleared.Value, cleared.Rows) + } + if g.Score != 800 { + t.Errorf("Score = %d, want 800", g.Score) + } + if n := filledCount(&g.Board); n != 0 { + t.Errorf("%d cells remain after a four-line clear, want 0", n) + } +} + +// §49.1: the bonus arrives at combo 2, which is where §21's effects escalate. +func TestComboAccumulatesAcrossConsecutiveClears(t *testing.T) { + g := New(11) + g.Combo = 1 // one clearing placement already happened + fill(&g.Board, BoardHeight-1, 4, 5) + g.Active = Piece{Kind: KindO, X: 3, Y: BoardHeight - 2} + events := g.lockPiece() + + if g.Combo != 2 { + t.Fatalf("Combo = %d, want 2", g.Combo) + } + cleared, _ := findEvent(events, EventLinesCleared) + if cleared.Points != 100+50 { + t.Errorf("Points = %d, want 150 (100 base + 50 combo bonus)", cleared.Points) + } + combo, ok := findEvent(events, EventComboChanged) + if !ok || combo.Value != 2 { + t.Errorf("ComboChanged = %+v, want Value 2", combo) + } +} + +func TestNonClearingPlacementBreaksCombo(t *testing.T) { + g := New(11) + g.Combo = 4 + g.Active = Piece{Kind: KindO, X: 4, Y: BoardHeight - 2} + events := g.lockPiece() + + if g.Combo != 0 { + t.Errorf("Combo = %d, want 0", g.Combo) + } + combo, ok := findEvent(events, EventComboChanged) + if !ok || combo.Value != 0 { + t.Errorf("ComboChanged = %+v (events %v), want Value 0", combo, kindsOf(events)) + } +} + +// A non-clearing placement with no combo running is quiet: nothing changed, so +// the FX layer gets no ComboChanged to react to. +func TestNonClearingPlacementWithNoComboIsQuiet(t *testing.T) { + g := New(11) + g.Active = Piece{Kind: KindO, X: 4, Y: BoardHeight - 2} + events := g.lockPiece() + if hasEvent(events, EventComboChanged) { + t.Errorf("events = %v, want no ComboChanged", kindsOf(events)) + } +} + +// §11: the level advances every ten lines, and the clear that triggers the +// level-up is still scored at the old level. +func TestLevelUpAtTenLines(t *testing.T) { + g := New(11) + g.Lines = 9 + fill(&g.Board, BoardHeight-1, 4, 5) + g.Active = Piece{Kind: KindO, X: 3, Y: BoardHeight - 2} + events := g.lockPiece() + + if g.Lines != 10 { + t.Fatalf("Lines = %d, want 10", g.Lines) + } + if g.Level != 2 { + t.Errorf("Level = %d, want 2", g.Level) + } + lvl, ok := findEvent(events, EventLevelChanged) + if !ok || lvl.Value != 2 { + t.Errorf("LevelChanged = %+v (events %v), want Value 2", lvl, kindsOf(events)) + } + if g.Score != 100 { + t.Errorf("Score = %d, want 100 (scored at level 1, the level before the clear)", g.Score) + } +} + +func TestNoLevelEventWithoutLevelChange(t *testing.T) { + g := New(11) + fill(&g.Board, BoardHeight-1, 4, 5) + g.Active = Piece{Kind: KindO, X: 3, Y: BoardHeight - 2} + events := g.lockPiece() + if hasEvent(events, EventLevelChanged) { + t.Errorf("events = %v, want no LevelChanged", kindsOf(events)) + } +} + +// A row completed inside the hidden spawn rows clears like any other. +func TestClearInsideHiddenRows(t *testing.T) { + g := New(11) + fill(&g.Board, 1, 4, 5) + stackTo(g, 2) + g.Active = Piece{Kind: KindO, X: 3, Y: 0} + if g.Board.Collides(g.Active) { + t.Fatal("test setup: the O piece does not fit the hidden-row notch") + } + events := g.lockPiece() + cleared, ok := findEvent(events, EventLinesCleared) + if !ok { + t.Fatalf("events = %v, want a LinesCleared event", kindsOf(events)) + } + // Row 1 and every row from 2 down were full, so all 21 clear. + if cleared.Value != BoardHeight-1 { + t.Errorf("cleared %d rows, want %d", cleared.Value, BoardHeight-1) + } +} + +// The event order is the §12 pipeline order, because the FX layer keys its +// sequencing off it. +func TestLockEventOrder(t *testing.T) { + g := New(11) + g.Lines = 9 + fill(&g.Board, BoardHeight-1, 4, 5) + g.Active = Piece{Kind: KindO, X: 3, Y: BoardHeight - 2} + got := kindsOf(g.lockPiece()) + want := []EventKind{EventPieceLocked, EventLinesCleared, EventComboChanged, EventLevelChanged} + if len(got) != len(want) { + t.Fatalf("events = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("events = %v, want %v", got, want) + } + } +} +``` + +- [ ] **Step 3: Run the tests to verify they fail** + +Run: `go test ./internal/game/ -run 'Score|Combo|Level|Lock|Clear'` +Expected: FAIL — `undefined: LineScore`, `undefined: lockPiece`. + +- [ ] **Step 4: Write the scoring functions** + +Create `internal/game/scoring.go`: + +```go +package game + +import ( + "math" + "time" +) + +// LinesPerLevel is how many cleared lines advance the level (§11). +const LinesPerLevel = 10 + +// Gravity curve (§11). +const ( + // BaseDropInterval is the level 1 drop period. + BaseDropInterval = 800 * time.Millisecond + // MinDropInterval is the floor: gravity never gets faster than this. + MinDropInterval = 60 * time.Millisecond + // DropFalloff is the per-level multiplier. + DropFalloff = 0.86 +) + +// LineScore is the base value for clearing n rows at the given level (§13). +// Values outside 1-4 score nothing. +func LineScore(n, level int) int { + switch n { + case 1: + return 100 * level + case 2: + return 300 * level + case 3: + return 500 * level + case 4: + return 800 * level + default: + return 0 + } +} + +// ComboBonus is the consecutive-clear bonus (§49.1): 50 x (combo-1) x level, +// so a lone clear earns nothing and the bonus starts at combo 2. +func ComboBonus(combo, level int) int { + if combo < 2 { + return 0 + } + return 50 * (combo - 1) * level +} + +// LevelFor is the level reached after clearing the given number of lines. +func LevelFor(lines int) int { + return lines/LinesPerLevel + 1 +} + +// DropInterval is the gravity period at the given level (§11). The clamp is +// load-bearing: Advance loops while the accumulator exceeds this interval, so a +// zero would never terminate. +func DropInterval(level int) time.Duration { + if level < 1 { + level = 1 + } + d := float64(BaseDropInterval) * math.Pow(DropFalloff, float64(level-1)) + if d < float64(MinDropInterval) { + return MinDropInterval + } + return time.Duration(d) +} +``` + +- [ ] **Step 5: Append the lock pipeline to `internal/game/game.go`** + +```go +// lockPiece runs the §12 pipeline: commit the piece, detect and clear complete +// rows, update the score, emit events, and spawn the next piece. Rendering may +// animate the clear afterwards; the logical result is already final here, which +// is what keeps animation off the gameplay path (§19). +func (g *Game) lockPiece() []Event { + locked := g.Active + g.Board.Lock(locked) + events := []Event{{Kind: EventPieceLocked, Piece: locked}} + + if rows := g.Board.CompleteRows(); len(rows) > 0 { + g.Board.ClearRows(rows) + g.Lines += len(rows) + g.Combo++ + points := LineScore(len(rows), g.Level) + ComboBonus(g.Combo, g.Level) + g.Score += points + events = append(events, Event{ + Kind: EventLinesCleared, + Rows: rows, + Value: len(rows), + Points: points, + }) + events = append(events, Event{Kind: EventComboChanged, Value: g.Combo}) + if level := LevelFor(g.Lines); level != g.Level { + g.Level = level + events = append(events, Event{Kind: EventLevelChanged, Value: g.Level}) + } + } else if g.Combo != 0 { + g.Combo = 0 + events = append(events, Event{Kind: EventComboChanged, Value: 0}) + } + + g.CanHold = true + return append(events, g.spawn(g.takeNext())...) +} +``` + +- [ ] **Step 6: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -run 'Score|Combo|Level|Lock|Clear' -v` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +gofmt -l . && go vet ./... && go test ./... +git add internal/game/scoring.go internal/game/scoring_test.go internal/game/game.go internal/game/game_test.go +git commit -m "feat(game): lock pipeline with line clears, scoring and combos" +``` + +--- + +### Task 7: Gravity, lock delay, and the drops + +**Files:** +- Modify: `internal/game/game.go` (append `Advance`, `SoftDrop`, `HardDrop`) +- Test: `internal/game/game_test.go` (append) +- Test: `internal/game/clock_test.go` + +**Interfaces:** +- Consumes: `lockPiece`, `DropInterval`, `LockDelay`, `MaxLockResets`, `grounded`, `tryMove`, `GhostY` (Tasks 4–6). +- Produces: `const MaxAdvance = 250 * time.Millisecond`; `func (g *Game) Advance(dt time.Duration) []Event`; `func (g *Game) SoftDrop() []Event`; `func (g *Game) HardDrop() []Event`. + +- [ ] **Step 1: Write the failing gravity and drop tests** + +First widen the import block at the top of `internal/game/game_test.go` — these tests need durations: + +```go +import ( + "testing" + "time" +) +``` + +Then append to `internal/game/game_test.go`: + +```go +func TestDropIntervalFollowsTheCurve(t *testing.T) { + if got := DropInterval(1); got != BaseDropInterval { + t.Errorf("DropInterval(1) = %v, want %v", got, BaseDropInterval) + } + // Level 2 is 800ms x 0.86 = 688ms. + if got := DropInterval(2); got < 680*time.Millisecond || got > 692*time.Millisecond { + t.Errorf("DropInterval(2) = %v, want about 688ms", got) + } + for level := 1; level < 60; level++ { + if DropInterval(level+1) > DropInterval(level) { + t.Fatalf("DropInterval is not monotone: level %d %v, level %d %v", + level, DropInterval(level), level+1, DropInterval(level+1)) + } + } + if got := DropInterval(40); got != MinDropInterval { + t.Errorf("DropInterval(40) = %v, want the %v clamp", got, MinDropInterval) + } +} + +// Review Focus 3: the interval can never reach zero, which would hang the +// gravity loop in Advance. +func TestDropIntervalNeverReachesZero(t *testing.T) { + for _, level := range []int{-5, 0, 1, 50, 500, 100000} { + if got := DropInterval(level); got < MinDropInterval { + t.Errorf("DropInterval(%d) = %v, want at least %v", level, got, MinDropInterval) + } + } +} + +func TestAdvanceBelowIntervalDoesNotMove(t *testing.T) { + g := New(21) + g.Active = Piece{Kind: KindO, X: 4, Y: 5} + events := g.Advance(100 * time.Millisecond) + if len(events) != 0 { + t.Errorf("events = %v, want none", kindsOf(events)) + } + if g.Active.Y != 5 { + t.Errorf("Y = %d, want 5", g.Active.Y) + } + if g.GravityAccumulator != 100*time.Millisecond { + t.Errorf("GravityAccumulator = %v, want 100ms", g.GravityAccumulator) + } +} + +func TestAdvanceDropsOneRowPerInterval(t *testing.T) { + g := New(21) + g.Active = Piece{Kind: KindO, X: 4, Y: 5} + events := g.Advance(BaseDropInterval) + if !hasEvent(events, EventPieceMoved) { + t.Fatalf("events = %v, want a PieceMoved event", kindsOf(events)) + } + if g.Active.Y != 6 { + t.Errorf("Y = %d, want 6", g.Active.Y) + } +} + +func TestAdvanceIsFasterAtHigherLevels(t *testing.T) { + g := New(21) + g.Level = 10 + g.Active = Piece{Kind: KindO, X: 4, Y: 5} + g.Advance(DropInterval(10)) + if g.Active.Y != 6 { + t.Errorf("Y = %d after one level-10 interval, want 6", g.Active.Y) + } +} + +// Review Focus 1: a suspended terminal hands over an enormous dt. The engine +// clamps it instead of teleporting the piece or looping thousands of times. +func TestAdvanceClampsHugeDt(t *testing.T) { + g := New(21) + g.Active = Piece{Kind: KindO, X: 4, Y: 5} + events := g.Advance(30 * time.Second) + if g.Active.Y != 5 { + t.Errorf("Y = %d after a clamped 30s step, want 5 (clamp is under one interval)", + g.Active.Y) + } + if g.GravityAccumulator != MaxAdvance { + t.Errorf("GravityAccumulator = %v, want the %v clamp", g.GravityAccumulator, MaxAdvance) + } + if len(events) != 0 { + t.Errorf("events = %v, want none", kindsOf(events)) + } + + // Even at maximum gravity the clamp allows only a handful of rows per call. + fast := New(21) + fast.Level = 40 + fast.Active = Piece{Kind: KindO, X: 4, Y: 0} + moves := 0 + for _, e := range fast.Advance(time.Hour) { + if e.Kind == EventPieceMoved { + moves++ + } + } + if want := int(MaxAdvance / MinDropInterval); moves > want { + t.Errorf("%d rows fell in one clamped call, want at most %d", moves, want) + } +} + +// A non-monotonic clock can hand back a zero or negative delta. +func TestAdvanceIgnoresNonPositiveDt(t *testing.T) { + g := New(21) + g.Active = Piece{Kind: KindO, X: 4, Y: 5} + for _, dt := range []time.Duration{0, -1, -5 * time.Second} { + if events := g.Advance(dt); len(events) != 0 { + t.Errorf("Advance(%v) returned %v, want nothing", dt, kindsOf(events)) + } + } + if g.Active.Y != 5 || g.GravityAccumulator != 0 { + t.Errorf("state moved on a non-positive dt: Y %d, accumulator %v", + g.Active.Y, g.GravityAccumulator) + } +} + +// §12: a grounded piece commits after the lock delay, not before. +func TestGroundedPieceLocksAfterLockDelay(t *testing.T) { + g := New(21) + g.Active = Piece{Kind: KindO, X: 4, Y: BoardHeight - 2} + elapsed := time.Duration(0) + for elapsed < LockDelay-MaxAdvance { + if events := g.Advance(MaxAdvance); hasEvent(events, EventPieceLocked) { + t.Fatalf("locked early, after %v", elapsed) + } + elapsed += MaxAdvance + } + events := g.Advance(MaxAdvance) + if !hasEvent(events, EventPieceLocked) { + t.Fatalf("events = %v after %v grounded, want a PieceLocked event", + kindsOf(events), elapsed+MaxAdvance) + } +} + +// Leaving the ground abandons the lock timer. +func TestLeavingTheGroundClearsTheLockTimer(t *testing.T) { + g := New(21) + fill(&g.Board, 15) + g.Active = Piece{Kind: KindO, X: 4, Y: 13} + g.Advance(200 * time.Millisecond) + if g.LockAccumulator == 0 { + t.Fatal("test setup: expected the piece to be grounded and accumulating") + } + // Slide into the empty column beyond the stack. + g.Board = Board{} + g.Advance(1) + if g.LockAccumulator != 0 { + t.Errorf("LockAccumulator = %v after the piece became airborne, want 0", + g.LockAccumulator) + } +} + +// §12: move-resetting cannot stall forever — after MaxLockResets the piece +// commits regardless. +func TestStallingIsCappedAndThePieceLocks(t *testing.T) { + g := New(21) + g.Active = Piece{Kind: KindO, X: 4, Y: BoardHeight - 2} + locked := false + for i := 0; i < MaxLockResets+40 && !locked; i++ { + if i%2 == 0 { + g.MoveLeft() + } else { + g.MoveRight() + } + for step := 0; step < 2; step++ { + if hasEvent(g.Advance(MaxAdvance), EventPieceLocked) { + locked = true + break + } + } + } + if !locked { + t.Error("a piece move-reset forever never locked") + } +} + +// §11: soft drop is +1 point per cell. +func TestSoftDrop(t *testing.T) { + g := New(21) + g.Active = Piece{Kind: KindO, X: 4, Y: 5} + events := g.SoftDrop() + if !hasEvent(events, EventPieceMoved) { + t.Fatalf("events = %v, want a PieceMoved event", kindsOf(events)) + } + if g.Active.Y != 6 { + t.Errorf("Y = %d, want 6", g.Active.Y) + } + if g.Score != 1 { + t.Errorf("Score = %d, want 1", g.Score) + } +} + +func TestSoftDropAtTheFloorIsSilent(t *testing.T) { + g := New(21) + g.Active = Piece{Kind: KindO, X: 4, Y: BoardHeight - 2} + if events := g.SoftDrop(); len(events) != 0 { + t.Errorf("events = %v, want nothing", kindsOf(events)) + } + if g.Score != 0 { + t.Errorf("Score = %d, want 0 — no points for a drop that did not happen", g.Score) + } +} + +// §11/§12: hard drop is +2 per cell and locks immediately. +func TestHardDropScoresDistanceAndLocks(t *testing.T) { + g := New(21) + g.Active = Piece{Kind: KindO, X: 4, Y: 0} + start := g.Active.Y + events := g.HardDrop() + + drop, ok := findEvent(events, EventPieceHardDropped) + if !ok { + t.Fatalf("events = %v, want a PieceHardDropped event", kindsOf(events)) + } + wantDistance := BoardHeight - 2 - start + if drop.Value != wantDistance { + t.Errorf("hard drop distance = %d, want %d", drop.Value, wantDistance) + } + if drop.Points != 2*wantDistance { + t.Errorf("hard drop points = %d, want %d", drop.Points, 2*wantDistance) + } + if g.Score != 2*wantDistance { + t.Errorf("Score = %d, want %d", g.Score, 2*wantDistance) + } + if !hasEvent(events, EventPieceLocked) { + t.Errorf("events = %v, want the piece to lock immediately", kindsOf(events)) + } + if kindsOf(events)[0] != EventPieceHardDropped { + t.Errorf("events = %v, want PieceHardDropped first", kindsOf(events)) + } +} + +func TestHardDropWithNowhereToFallStillLocks(t *testing.T) { + g := New(21) + g.Active = Piece{Kind: KindO, X: 4, Y: BoardHeight - 2} + events := g.HardDrop() + drop, _ := findEvent(events, EventPieceHardDropped) + if drop.Value != 0 || drop.Points != 0 { + t.Errorf("zero-distance hard drop = %+v, want Value and Points 0", drop) + } + if !hasEvent(events, EventPieceLocked) { + t.Errorf("events = %v, want a PieceLocked event", kindsOf(events)) + } +} +``` + +- [ ] **Step 2: Write the failing architectural test** + +Create `internal/game/clock_test.go`: + +```go +package game + +import ( + "go/ast" + "go/parser" + "go/token" + "strings" + "testing" +) + +// §49.2: the engine never reads a clock. Bubble Tea owns time and passes dt +// inward. This is the guardrail behind the determinism promise in §35 — a +// single time.Now() anywhere in this package would make replay untestable, and +// it is the kind of line that gets added innocently later. +func TestEngineNeverReadsTheClock(t *testing.T) { + fset := token.NewFileSet() + pkgs, err := parser.ParseDir(fset, ".", nil, 0) + if err != nil { + t.Fatalf("parse package: %v", err) + } + banned := map[string]bool{"Now": true, "Since": true, "Tick": true, "After": true} + for _, pkg := range pkgs { + for name, file := range pkg.Files { + if strings.HasSuffix(name, "_test.go") { + continue + } + ast.Inspect(file, func(n ast.Node) bool { + sel, ok := n.(*ast.SelectorExpr) + if !ok { + return true + } + pkgIdent, ok := sel.X.(*ast.Ident) + if !ok || pkgIdent.Name != "time" { + return true + } + if banned[sel.Sel.Name] { + t.Errorf("%s: engine must not read the clock (§49.2): time.%s", + fset.Position(sel.Pos()), sel.Sel.Name) + } + return true + }) + } + } +} +``` + +- [ ] **Step 3: Run the tests to verify they fail** + +Run: `go test ./internal/game/ -run 'Advance|Drop|Ground|Stall|Clock'` +Expected: FAIL — `undefined: MaxAdvance`, `undefined: Advance`, `undefined: SoftDrop`. `TestEngineNeverReadsTheClock` will pass already; that is fine, it is a guardrail rather than a driver. + +- [ ] **Step 4: Append the implementation to `internal/game/game.go`** + +```go +// MaxAdvance clamps a single Advance step. A terminal that was suspended, or a +// render loop that stalled, hands over an arbitrarily large dt; without this +// the piece would teleport and the gravity loop would run thousands of +// iterations. Clamping keeps Advance a pure function of its inputs, so replay +// still reproduces exactly (§49.2). +const MaxAdvance = 250 * time.Millisecond + +// Advance steps the simulation by dt and returns what happened. This is the +// engine's only notion of time: nothing here reads a clock, so the caller owns +// the clock and a canned (input, dt) stream replays a game exactly (§49.2). +func (g *Game) Advance(dt time.Duration) []Event { + if g.Over || dt <= 0 { + return nil + } + if dt > MaxAdvance { + dt = MaxAdvance + } + + var events []Event + + interval := DropInterval(g.Level) + g.GravityAccumulator += dt + for g.GravityAccumulator >= interval { + g.GravityAccumulator -= interval + if !g.tryMove(0, 1) { + // Resting on something: hold the accumulator at zero so the lock + // timer, not gravity, decides what happens next. + g.GravityAccumulator = 0 + break + } + events = append(events, Event{Kind: EventPieceMoved, Piece: g.Active}) + } + + if g.grounded() { + g.LockAccumulator += dt + if g.LockAccumulator >= LockDelay { + events = append(events, g.lockPiece()...) + } + } else { + g.LockAccumulator = 0 + } + + return events +} + +// SoftDrop nudges the piece down one row for one point (§11). +func (g *Game) SoftDrop() []Event { + if g.Over { + return nil + } + if !g.tryMove(0, 1) { + return nil + } + g.Score++ + g.GravityAccumulator = 0 + return []Event{{Kind: EventPieceMoved, Piece: g.Active, Points: 1}} +} + +// HardDrop slams the piece to its landing position for two points per cell and +// locks it on the spot (§11, §18). The dramatic part is the FX layer's job; the +// engine just resolves it instantly. +func (g *Game) HardDrop() []Event { + if g.Over { + return nil + } + distance := g.GhostY() - g.Active.Y + g.Active.Y += distance + points := 2 * distance + g.Score += points + events := []Event{{ + Kind: EventPieceHardDropped, + Piece: g.Active, + Value: distance, + Points: points, + }} + return append(events, g.lockPiece()...) +} +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -run 'Advance|Drop|Ground|Stall|Clock' -v` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +gofmt -l . && go vet ./... && go test ./... +git add internal/game/game.go internal/game/game_test.go internal/game/clock_test.go +git commit -m "feat(game): elapsed-time gravity, lock delay, soft and hard drop" +``` + +--- + +### Task 8: Hold + +**Files:** +- Modify: `internal/game/game.go` (append `UseHold`) +- Test: `internal/game/hold_test.go` + +**Interfaces:** +- Consumes: `Game`, `spawn`, `takeNext`, `Event`, `lockPiece` (Tasks 4, 6). +- Produces: `func (g *Game) UseHold() []Event`. + +- [ ] **Step 1: Write the failing test** + +Create `internal/game/hold_test.go`: + +```go +package game + +import "testing" + +// §9: with an empty hold slot, the active piece is stored and the next piece +// comes into play. +func TestFirstHoldStoresActiveAndSpawnsNext(t *testing.T) { + g := New(31) + held := g.Active.Kind + upcoming := g.Next[0] + + events := g.UseHold() + + if !hasEvent(events, EventHoldUsed) { + t.Fatalf("events = %v, want a HoldUsed event", kindsOf(events)) + } + if g.Hold == nil || *g.Hold != held { + t.Errorf("Hold = %v, want %v", g.Hold, held) + } + if g.Active.Kind != upcoming { + t.Errorf("Active kind = %v, want the queue front %v", g.Active.Kind, upcoming) + } + if len(g.Next) != NextQueueLen { + t.Errorf("len(Next) = %d, want %d", len(g.Next), NextQueueLen) + } + if g.CanHold { + t.Error("CanHold must be false after a hold") + } + used, _ := findEvent(events, EventHoldUsed) + if used.Piece.Kind != upcoming { + t.Errorf("HoldUsed Piece = %v, want the incoming piece %v", used.Piece.Kind, upcoming) + } +} + +// §9: a second hold swaps, and does not consume the queue. +func TestSecondHoldSwapsWithoutTouchingTheQueue(t *testing.T) { + g := New(31) + first := g.Active.Kind + g.UseHold() + second := g.Active.Kind + queueBefore := append([]PieceKind(nil), g.Next...) + + g.CanHold = true // as a lock would have done + g.UseHold() + + if g.Active.Kind != first { + t.Errorf("Active kind = %v, want the previously held %v", g.Active.Kind, first) + } + if g.Hold == nil || *g.Hold != second { + t.Errorf("Hold = %v, want %v", g.Hold, second) + } + for i := range queueBefore { + if g.Next[i] != queueBefore[i] { + t.Fatalf("a swap consumed the queue: %v, want %v", g.Next, queueBefore) + } + } +} + +// §9: only one hold per piece. +func TestSecondHoldBeforeLockIsBlocked(t *testing.T) { + g := New(31) + g.UseHold() + stateBefore := g.Active + holdBefore := *g.Hold + + if events := g.UseHold(); len(events) != 0 { + t.Errorf("second hold returned %v, want nothing", kindsOf(events)) + } + if g.Active != stateBefore { + t.Errorf("Active = %+v, want unchanged %+v", g.Active, stateBefore) + } + if *g.Hold != holdBefore { + t.Errorf("Hold = %v, want unchanged %v", *g.Hold, holdBefore) + } +} + +// §9: hold becomes available again after the active piece locks. +func TestHoldAvailableAgainAfterLock(t *testing.T) { + g := New(31) + g.UseHold() + g.Active = Piece{Kind: g.Active.Kind, X: 4, Y: BoardHeight - 2} + g.lockPiece() + if !g.CanHold { + t.Fatal("CanHold must be restored after the piece locks") + } + if events := g.UseHold(); !hasEvent(events, EventHoldUsed) { + t.Errorf("hold after a lock returned %v, want a HoldUsed event", kindsOf(events)) + } +} + +// §9: a held piece returns to spawn rotation and spawn position. +func TestHeldPieceReturnsAtSpawnRotation(t *testing.T) { + g := New(31) + g.Active.Rotation = 2 + g.Active.X = 7 + g.Active.Y = 9 + rotated := g.Active.Kind + + g.UseHold() + g.CanHold = true + g.UseHold() + + if g.Active.Kind != rotated { + t.Fatalf("Active kind = %v, want %v", g.Active.Kind, rotated) + } + if g.Active.Rotation != 0 || g.Active.X != SpawnX || g.Active.Y != SpawnY { + t.Errorf("restored piece = %+v, want spawn rotation and position", g.Active) + } +} + +// Holding into a full board ends the game, and says so. +func TestHoldIntoABlockedSpawnEndsTheGame(t *testing.T) { + g := New(31) + stackTo(g, 0) + g.Active = Piece{Kind: KindO, X: 4, Y: 0} + + events := g.UseHold() + + if !hasEvent(events, EventHoldUsed) { + t.Errorf("events = %v, want a HoldUsed event", kindsOf(events)) + } + if !hasEvent(events, EventGameOver) { + t.Errorf("events = %v, want a GameOver event", kindsOf(events)) + } + if !g.Over { + t.Error("Over must be true") + } + if got := kindsOf(events); got[0] != EventHoldUsed { + t.Errorf("events = %v, want HoldUsed first", got) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/game/ -run Hold` +Expected: FAIL — `undefined: UseHold` (`g.UseHold`). + +- [ ] **Step 3: Append the implementation to `internal/game/game.go`** + +```go +// UseHold swaps the active piece into the hold slot (§9). With the slot empty, +// the next queued piece comes into play instead. One hold per piece; the slot +// unlocks when the piece locks. Gameplay does not wait for the FX layer's +// quantum-storage animation. +func (g *Game) UseHold() []Event { + if g.Over || !g.CanHold { + return nil + } + outgoing := g.Active.Kind + + var incoming PieceKind + if g.Hold == nil { + incoming = g.takeNext() + } else { + incoming = *g.Hold + } + + g.Hold = &outgoing + g.CanHold = false + + // spawn sets Active, so build the event afterwards to report the piece + // that actually entered play. + spawnEvents := g.spawn(incoming) + events := []Event{{Kind: EventHoldUsed, Piece: g.Active}} + return append(events, spawnEvents...) +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `go test ./internal/game/ -run Hold -v` +Expected: PASS — six tests. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . && go vet ./... && go test ./... +git add internal/game/game.go internal/game/hold_test.go +git commit -m "feat(game): hold slot with one swap per piece" +``` + +--- + +### Task 9: Game over, input guards, and the determinism replay + +**Files:** +- Test: `internal/game/gameover_test.go` +- Test: `internal/game/replay_test.go` +- Modify: `internal/game/game.go` only if a guard turns out to be missing + +**Interfaces:** +- Consumes: everything from Tasks 1–8. No new production API — this task proves the guards already written (`if g.Over { return nil }` in `shift`, `rotate`, `SoftDrop`, `HardDrop`, `UseHold`, `Advance`) actually hold, and pins determinism. +- Produces: no new exported identifiers. + +- [ ] **Step 1: Write the failing game-over test** + +Create `internal/game/gameover_test.go`: + +```go +package game + +import ( + "testing" + "time" +) + +// §40: a blocked spawn is game over, and the event names the piece that could +// not fit so the FX layer can collapse it into a black hole (§28). +func TestBlockedSpawnEndsTheGame(t *testing.T) { + g := New(51) + // Column 9 stays empty in every row, so nothing completes and the lock + // cannot rescue the player by clearing the stack away. + stackTo(g, 0, 9) + // Carve out room for the active piece only. + for _, c := range (Piece{Kind: KindO, X: 4, Y: 0}).Cells() { + g.Board.Cells[c.Y][c.X] = Cell{} + } + g.Active = Piece{Kind: KindO, X: 4, Y: 0} + + events := g.lockPiece() + + if hasEvent(events, EventLinesCleared) { + t.Fatalf("test setup cleared lines: %v", kindsOf(events)) + } + + over, ok := findEvent(events, EventGameOver) + if !ok { + t.Fatalf("events = %v, want a GameOver event", kindsOf(events)) + } + if over.Piece.Kind != g.Active.Kind { + t.Errorf("GameOver Piece = %v, want the piece that could not spawn %v", + over.Piece.Kind, g.Active.Kind) + } + if !g.Over { + t.Error("Over must be true") + } +} + +// Review Focus 2: §28's collapse runs for over a second and §20 forbids +// blocking input, so a player will hammer keys while dead. Every input must be +// inert — no score, no movement, no events. +func TestInputsAfterGameOverAreNoOps(t *testing.T) { + g := New(51) + stackTo(g, 0) + g.Over = true + before := *g + beforeBoard := g.Board + + inputs := map[string]func() []Event{ + "MoveLeft": g.MoveLeft, + "MoveRight": g.MoveRight, + "RotateCW": g.RotateCW, + "RotateCCW": g.RotateCCW, + "SoftDrop": g.SoftDrop, + "HardDrop": g.HardDrop, + "UseHold": g.UseHold, + "Advance": func() []Event { return g.Advance(BaseDropInterval * 10) }, + } + for name, input := range inputs { + if events := input(); len(events) != 0 { + t.Errorf("%s after game over returned %v, want nothing", name, kindsOf(events)) + } + } + + if g.Active != before.Active { + t.Errorf("Active = %+v, want unchanged %+v", g.Active, before.Active) + } + if g.Score != before.Score || g.Lines != before.Lines || g.Level != before.Level || + g.Combo != before.Combo { + t.Errorf("stats changed after game over: %d/%d/%d/%d, want %d/%d/%d/%d", + g.Score, g.Lines, g.Level, g.Combo, + before.Score, before.Lines, before.Level, before.Combo) + } + if g.Board != beforeBoard { + t.Error("the board changed after game over") + } + if g.CanHold != before.CanHold { + t.Error("CanHold changed after game over") + } + if g.GravityAccumulator != before.GravityAccumulator || + g.LockAccumulator != before.LockAccumulator { + t.Error("timers advanced after game over") + } +} + +// Restart is the way back: §47 requires it to work from the game-over screen. +func TestRestartAfterGameOverIsPlayable(t *testing.T) { + g := New(51) + stackTo(g, 0) + g.Over = true + + g.Restart() + + if g.Over { + t.Fatal("Over must be false after Restart") + } + if events := g.Advance(BaseDropInterval); !hasEvent(events, EventPieceMoved) { + t.Errorf("events = %v after restart, want the piece to fall", kindsOf(events)) + } +} + +// Playing until the stack reaches orbit must actually terminate, not wedge. +func TestPlayingWithoutMovingReachesGameOver(t *testing.T) { + g := New(51) + for step := 0; step < 20000; step++ { + for _, e := range g.Advance(MaxAdvance) { + if e.Kind == EventGameOver { + if !g.Over { + t.Fatal("GameOver event without Over set") + } + return + } + } + } + t.Fatalf("no game over after 20000 steps (%v of play); score %d lines %d", + time.Duration(20000)*MaxAdvance, g.Score, g.Lines) +} +``` + +- [ ] **Step 2: Write the failing determinism test** + +Create `internal/game/replay_test.go`: + +```go +package game + +import ( + "testing" + "time" +) + +// input is one step of a canned replay: a key, then a slice of elapsed time. +type input struct { + key string + dt time.Duration +} + +// apply runs one replay step against a game. +func apply(g *Game, in input) []Event { + var events []Event + switch in.key { + case "": + case "left": + events = append(events, g.MoveLeft()...) + case "right": + events = append(events, g.MoveRight()...) + case "cw": + events = append(events, g.RotateCW()...) + case "ccw": + events = append(events, g.RotateCCW()...) + case "soft": + events = append(events, g.SoftDrop()...) + case "hard": + events = append(events, g.HardDrop()...) + case "hold": + events = append(events, g.UseHold()...) + default: + panic("unknown replay key: " + in.key) + } + return append(events, g.Advance(in.dt)...) +} + +// script is a fixed, reasonably varied stream of play. +func script() []input { + keys := []string{"", "left", "right", "cw", "ccw", "soft", "hard", "hold", "", "left", "cw", "hard"} + dts := []time.Duration{16 * time.Millisecond, 33 * time.Millisecond, 250 * time.Millisecond, 7 * time.Millisecond} + out := make([]input, 0, 900) + for i := 0; i < 900; i++ { + out = append(out, input{key: keys[i%len(keys)], dt: dts[i%len(dts)]}) + } + return out +} + +// play runs the script and returns the finished game. +func play(seed int64) *Game { + g := New(seed) + for _, in := range script() { + apply(g, in) + } + return g +} + +// §35/§49.2: same seed, same input sequence, same timing inputs, same state. +// This is the whole point of dt being an argument rather than a clock read. +func TestReplayIsDeterministic(t *testing.T) { + a := play(8675309) + b := play(8675309) + + if a.Score != b.Score || a.Lines != b.Lines || a.Level != b.Level || + a.Combo != b.Combo || a.Over != b.Over { + t.Fatalf("stats diverged:\n a: score %d lines %d level %d combo %d over %v\n b: score %d lines %d level %d combo %d over %v", + a.Score, a.Lines, a.Level, a.Combo, a.Over, + b.Score, b.Lines, b.Level, b.Combo, b.Over) + } + if a.Board != b.Board { + t.Fatalf("boards diverged:\n%s\n%s", a.Board.String(), b.Board.String()) + } + if a.Active != b.Active { + t.Errorf("active piece diverged: %+v vs %+v", a.Active, b.Active) + } + if a.GravityAccumulator != b.GravityAccumulator || a.LockAccumulator != b.LockAccumulator { + t.Errorf("timers diverged: %v/%v vs %v/%v", + a.GravityAccumulator, a.LockAccumulator, b.GravityAccumulator, b.LockAccumulator) + } + for i := range a.Next { + if a.Next[i] != b.Next[i] { + t.Fatalf("queues diverged: %v vs %v", a.Next, b.Next) + } + } + if (a.Hold == nil) != (b.Hold == nil) { + t.Fatalf("hold slots diverged: %v vs %v", a.Hold, b.Hold) + } + if a.Hold != nil && *a.Hold != *b.Hold { + t.Errorf("hold slots diverged: %v vs %v", *a.Hold, *b.Hold) + } +} + +// The replay must actually exercise the game, or it proves nothing. +func TestReplayScriptIsSubstantial(t *testing.T) { + g := play(8675309) + if g.Score == 0 { + t.Error("the replay script scored nothing; it is not exercising play") + } + if filledCount(&g.Board) == 0 && !g.Over { + t.Error("the replay script locked nothing; it is not exercising play") + } +} + +// Different seeds must diverge, or "deterministic" would just mean "fixed". +func TestReplayDivergesBySeed(t *testing.T) { + a := play(1) + b := play(2) + if a.Board == b.Board && a.Score == b.Score { + t.Error("two seeds produced an identical outcome") + } +} +``` + +- [ ] **Step 3: Run the tests** + +Run: `go test ./internal/game/ -run 'GameOver|Blocked|Restart|Playing|Replay' -v` +Expected: PASS — the guards were written in Tasks 5–8, so these should pass as written. If any fail, the guard is genuinely missing: add `if g.Over { return nil }` at the top of the offending method in `internal/game/game.go` or `internal/game/rules.go` and re-run. Do not adjust the test to match the code. + +- [ ] **Step 4: Run the whole engine suite with the race detector** + +Run: `go test ./internal/game/ -count=2 -race` +Expected: PASS. `-count=2` catches state leaking through package-level variables; `-race` is cheap insurance before the concurrent Bubble Tea layer lands. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . && go vet ./... && go test ./... +git add internal/game/gameover_test.go internal/game/replay_test.go internal/game/game.go internal/game/rules.go +git commit -m "test(game): game-over guards and seeded replay determinism" +``` + +--- + +### Task 10: CLI surface and README + +**Files:** +- Create: `cmd/cosmic-tetris/main.go` +- Test: `cmd/cosmic-tetris/main_test.go` +- Create: `README.md` + +**Interfaces:** +- Consumes: `game.New`, `game.Game.Next`, `game.Board.String` (Tasks 1–4). +- Produces: `type Config struct { Seed int64; ASCII bool; NoFX bool; ReducedMotion bool }`; `func parseFlags(args []string, out io.Writer, nowNano int64) (Config, error)`. The next plan's Bubble Tea program takes a `Config` and replaces the body of `main`. + +- [ ] **Step 1: Write the failing test** + +Create `cmd/cosmic-tetris/main_test.go`: + +```go +package main + +import ( + "errors" + "flag" + "io" + "strings" + "testing" +) + +const fakeNow = int64(1700000000000000000) + +func TestDefaultsSeedFromTheClock(t *testing.T) { + cfg, err := parseFlags(nil, io.Discard, fakeNow) + if err != nil { + t.Fatalf("parseFlags() error: %v", err) + } + if cfg.Seed != fakeNow { + t.Errorf("Seed = %d, want the supplied clock value %d", cfg.Seed, fakeNow) + } + if cfg.ASCII || cfg.NoFX || cfg.ReducedMotion { + t.Errorf("Config = %+v, want all modes off by default", cfg) + } +} + +// §35: --seed is how a player reproduces a universe. +func TestSeedFlagIsHonoured(t *testing.T) { + cfg, err := parseFlags([]string{"--seed", "1234"}, io.Discard, fakeNow) + if err != nil { + t.Fatalf("parseFlags() error: %v", err) + } + if cfg.Seed != 1234 { + t.Errorf("Seed = %d, want 1234", cfg.Seed) + } +} + +// An explicit zero is a real seed, not "unset". +func TestExplicitZeroSeedIsNotOverwritten(t *testing.T) { + cfg, err := parseFlags([]string{"--seed", "0"}, io.Discard, fakeNow) + if err != nil { + t.Fatalf("parseFlags() error: %v", err) + } + if cfg.Seed != 0 { + t.Errorf("Seed = %d, want the explicitly requested 0", cfg.Seed) + } +} + +func TestSingleDashFormIsAccepted(t *testing.T) { + cfg, err := parseFlags([]string{"-seed", "99", "-ascii"}, io.Discard, fakeNow) + if err != nil { + t.Fatalf("parseFlags() error: %v", err) + } + if cfg.Seed != 99 || !cfg.ASCII { + t.Errorf("Config = %+v, want Seed 99 and ASCII true", cfg) + } +} + +// §49.5: the whole flag surface, and nothing else. +func TestRenderingModeFlags(t *testing.T) { + cfg, err := parseFlags([]string{"--ascii", "--no-fx", "--reduced-motion"}, io.Discard, fakeNow) + if err != nil { + t.Fatalf("parseFlags() error: %v", err) + } + if !cfg.ASCII || !cfg.NoFX || !cfg.ReducedMotion { + t.Errorf("Config = %+v, want all three modes on", cfg) + } +} + +// Review Focus 4: a mistyped seed gets a readable complaint, not a panic and +// not a silent seed of zero. +func TestParseFlagsRejectsBadSeed(t *testing.T) { + for _, bad := range []string{"abc", "1e9", "12.5", ""} { + var out strings.Builder + cfg, err := parseFlags([]string{"--seed", bad}, &out, fakeNow) + if err == nil { + t.Errorf("--seed %q was accepted as %+v, want an error", bad, cfg) + continue + } + if errors.Is(err, flag.ErrHelp) { + t.Errorf("--seed %q reported help, want a parse error", bad) + } + if !strings.Contains(out.String(), "seed") { + t.Errorf("--seed %q wrote %q, want a message naming the seed flag", bad, out.String()) + } + } +} + +func TestUnknownFlagIsRejected(t *testing.T) { + var out strings.Builder + if _, err := parseFlags([]string{"--warp-drive"}, &out, fakeNow); err == nil { + t.Error("--warp-drive was accepted, want an error") + } + if !strings.Contains(out.String(), "warp-drive") { + t.Errorf("output = %q, want it to name the unknown flag", out.String()) + } +} + +func TestStrayArgumentIsRejected(t *testing.T) { + var out strings.Builder + if _, err := parseFlags([]string{"tetris.sav"}, &out, fakeNow); err == nil { + t.Error("a stray argument was accepted, want an error") + } + if !strings.Contains(out.String(), "tetris.sav") { + t.Errorf("output = %q, want it to name the unexpected argument", out.String()) + } +} + +func TestHelpIsNotAnError(t *testing.T) { + var out strings.Builder + _, err := parseFlags([]string{"--help"}, &out, fakeNow) + if !errors.Is(err, flag.ErrHelp) { + t.Fatalf("--help returned %v, want flag.ErrHelp", err) + } + text := out.String() + for _, want := range []string{"cosmic-tetris", "seed", "ascii", "no-fx", "reduced-motion"} { + if !strings.Contains(text, want) { + t.Errorf("help output does not mention %q:\n%s", want, text) + } + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./cmd/cosmic-tetris/` +Expected: FAIL — `undefined: parseFlags`. + +- [ ] **Step 3: Write the implementation** + +Create `cmd/cosmic-tetris/main.go`: + +```go +// Command cosmic-tetris is a falling-block puzzle game occurring during a +// completely unnecessary cosmological emergency. +package main + +import ( + "errors" + "flag" + "fmt" + "io" + "os" + "time" + + "cosmic-tetris/internal/game" +) + +const usage = `COSMIC TETRIS — a terminal gravity incident + +usage: cosmic-tetris [flags] + +` + +// Config is the resolved command line (§46, §49.5). +type Config struct { + Seed int64 + ASCII bool + NoFX bool + ReducedMotion bool +} + +// parseFlags parses argv without the program name, writing any diagnostics to +// out. An unset --seed resolves to nowNano so ordinary runs differ; passing +// --seed makes a run reproducible, including --seed 0. Returning flag.ErrHelp +// means the user asked for help and usage has already been written to out. +func parseFlags(args []string, out io.Writer, nowNano int64) (Config, error) { + fs := flag.NewFlagSet("cosmic-tetris", flag.ContinueOnError) + fs.SetOutput(out) + + seed := fs.Int64("seed", 0, "seed the game RNG for a reproducible universe") + ascii := fs.Bool("ascii", false, "ASCII glyphs only, for mediocre terminals") + noFX := fs.Bool("no-fx", false, "disable cosmic effects; still a good game") + reduced := fs.Bool("reduced-motion", false, "no screen shake, hyperdrive or shockwaves") + + fs.Usage = func() { + fmt.Fprint(out, usage) + fs.PrintDefaults() + } + + if err := fs.Parse(args); err != nil { + return Config{}, err + } + if fs.NArg() > 0 { + fmt.Fprintf(out, "unexpected argument: %s\n", fs.Arg(0)) + return Config{}, fmt.Errorf("unexpected argument: %s", fs.Arg(0)) + } + + cfg := Config{ + Seed: *seed, + ASCII: *ascii, + NoFX: *noFX, + ReducedMotion: *reduced, + } + if !flagWasSet(fs, "seed") { + cfg.Seed = nowNano + } + return cfg, nil +} + +// flagWasSet reports whether the user actually passed a flag, which is how an +// explicit --seed 0 stays distinguishable from an unset seed. +func flagWasSet(fs *flag.FlagSet, name string) bool { + set := false + fs.Visit(func(f *flag.Flag) { + if f.Name == name { + set = true + } + }) + return set +} + +func main() { + cfg, err := parseFlags(os.Args[1:], os.Stderr, time.Now().UnixNano()) + if err != nil { + if errors.Is(err, flag.ErrHelp) { + return + } + os.Exit(2) + } + + // The engine is complete but headless: the Bubble Tea program that renders + // it replaces this block in the next phase. Until then, report the universe + // this invocation would have opened. + g := game.New(cfg.Seed) + fmt.Printf("LOCAL UNIVERSE %016X\n", uint64(cfg.Seed)) + fmt.Printf("modes: ascii=%v no-fx=%v reduced-motion=%v\n", + cfg.ASCII, cfg.NoFX, cfg.ReducedMotion) + fmt.Printf("active: %v next: %v\n", g.Active.Kind, g.Next) + fmt.Print(g.Board.String()) + fmt.Println("UNIVERSE ONLINE (renderer arrives in phase 2)") +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `go test ./cmd/cosmic-tetris/ -v` +Expected: PASS — nine tests. + +- [ ] **Step 5: Check it actually runs** + +```bash +go run ./cmd/cosmic-tetris --seed 8675309 +go run ./cmd/cosmic-tetris --help +go run ./cmd/cosmic-tetris --seed abc; echo "exit: $?" +``` + +Expected: the first prints `LOCAL UNIVERSE 0000000000845FED`, the mode line, the active piece and five upcoming pieces, 22 rows of dots, and `UNIVERSE ONLINE`. Running it twice with the same seed prints identical piece lists. `--help` prints usage and exits 0. `--seed abc` prints a one-line complaint naming `-seed` and exits 2. + +- [ ] **Step 6: Write the README** + +Create `README.md`: + +```markdown +# Cosmic Tetris + +A falling-block puzzle game occurring during a completely unnecessary +cosmological emergency. Terminal only. See `design.md` for the build spec. + +## Status + +Phase 1 of five (`design.md` §42): the game engine is complete, tested and +headless. There is no renderer yet, so `cosmic-tetris` currently prints the +universe it would have opened and exits. Phase 2 adds the Bubble Tea terminal. + +## Run + +```bash +go run ./cmd/cosmic-tetris +go run ./cmd/cosmic-tetris --seed 8675309 +go run ./cmd/cosmic-tetris --help +``` + +Flags: `--seed N` (reproducible universe), `--ascii` (ASCII glyphs only), +`--no-fx` (no cosmic effects), `--reduced-motion` (no shake, hyperdrive or +shockwaves). + +## Test + +```bash +go test ./... +go test ./internal/game/ -count=2 -race +``` + +## Layout + +- `internal/game` — all the rules. Headless, deterministic, and it never reads + a clock: `Advance(dt)` is the only notion of time, which is what makes a + seeded replay reproducible. Emits `Event` values describing what happened. +- `cmd/cosmic-tetris` — flag parsing and entry point. + +Two rules hold the design together. The engine's RNG drives piece order and +nothing else; the effects layer will own a separate generator, so particle +counts can never perturb the bag. And events travel one way: effects observe +game state, never modify it. +``` + +- [ ] **Step 7: Commit** + +```bash +gofmt -l . && go vet ./... && go test ./... +git add cmd/cosmic-tetris/main.go cmd/cosmic-tetris/main_test.go README.md +git commit -m "feat(cli): flag surface, entry point and README" +``` + +- [ ] **Step 8: Final verification of the whole plan** + +```bash +go build ./... +go vet ./... +gofmt -l . +go test ./... -count=2 -race +``` + +Expected: build clean, vet clean, `gofmt -l` silent, all tests pass twice with the race detector. Then confirm by hand that the §47 items in scope for this plan hold: piece generation is deterministic (`TestReplayIsDeterministic`), game RNG is isolated from any FX RNG (there is only one generator and it is unexported), line clearing is correct (Task 6 tests), gravity increases (`TestDropIntervalFollowsTheCurve`), hold works (Task 8), ghost works (`TestGhostYIsTheLandingPosition`), next queue works (`TestTakeNextKeepsQueueFull`), restart works (`TestRestartRebuildsTheSameUniverse`), game logic has comprehensive unit tests. The remaining §47 items are renderer and FX work owned by later plans. diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/N1-recipe-cosmic-11/2026-09-17-cosmic-tetris-01-engine.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/N1-recipe-cosmic-11/2026-09-17-cosmic-tetris-01-engine.md new file mode 100644 index 000000000..6a4798821 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/N1-recipe-cosmic-11/2026-09-17-cosmic-tetris-01-engine.md @@ -0,0 +1,2188 @@ +# Cosmic Tetris — Plan 01: Headless Game Engine Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the complete, deterministic, headless Cosmic Tetris game engine (`internal/game`) with comprehensive unit tests, so that every later plan renders a game that already works. + +**Architecture:** A single package `internal/game` holding pure logic: a fixed-size `Board`, a `Piece` with four precomputed rotations per kind, a 7-bag generator driven by the game's own `*rand.Rand`, and a `Game` that is advanced only by `Advance(dt time.Duration) []Event`. Nothing in the package reads a clock, touches the filesystem, or knows what a terminal is. All state changes emit `Event` values that later plans feed to the FX system. + +**Tech Stack:** Go 1.26, standard library only (`math/rand`, `time`, `testing`). No third-party dependencies in this plan. + +**Spec:** `design.md` (this repo) — Phase 1 of §42. Sections most relevant: §5, §6, §7, §11, §12, §13, §34, §35, §40, §49.1, §49.2, §49.6. + +**Plan sequence:** 01-engine (this) → 02-terminal → 03-cosmic-foundation → 04-violence → 05-polish. No later plan may be started before this one's tests pass (§42: "Tests must pass before proceeding"). + +## Global Constraints + +- Language: Go. Module path: `github.com/jessev/cosmic-tetris` (module name is local only; there is no remote). +- Board: width `10`, height `22`, visible rows `20`, hidden spawn rows `2`. Row `0` is the top; rows `0`–`1` are hidden; rows `2`–`21` are visible. +- Seven piece families: `I J L O S T Z`. Four predefined rotations per piece. `O` is visually identical through rotation. +- Wall-kick offsets, tested in exactly this order: `(0,0) (-1,0) (1,0) (-2,0) (2,0) (0,-1) (-1,-1) (1,-1)`. First valid position wins; if none is valid, rotation fails. +- Gravity: level 1 = `800ms`; `interval = 800ms * 0.86^(level-1)`; clamped at a `60ms` floor. +- Level increases every `10` cleared lines. +- Soft drop: `+1 point / cell`. Hard drop: `+2 points / cell`. +- Lock delay `500ms`. A successful move or rotation while grounded resets the lock timer. `max lock resets = 15`. +- Line values: 1 line `100 × level`, 2 lines `300 × level`, 3 lines `500 × level`, 4 lines `800 × level`. +- Combo (§49.1): counts consecutive placements clearing ≥1 line; first clearing placement sets combo to `1`; a placement clearing nothing resets combo to `0`. Bonus `= 50 × (combo - 1) × level`. +- Locking order (§12): commit piece → detect complete rows → clear rows → update score → emit FX event → spawn next piece. +- §49.2: `internal/game` exposes `func (g *Game) Advance(dt time.Duration) []Event` and **nothing under `internal/game` calls `time.Now()`**. Timing is an input. +- §49.6: `Game` owns `rng *rand.Rand`, which drives the 7-bag and nothing else. `Seed int64` is recorded for display and restart. FX gets a separate generator in a later plan; the two never share. +- Maintain enough future pieces to render the next **five**. +- No goroutines, no filesystem access, no logging in this package. + +## Review Focus + +Five things the spec implies, that a player will hit, and that no obvious happy-path test covers. Each has a test assigned to the task that owns the code. + +1. **A `dt` far larger than the gravity interval** (terminal was backgrounded; one 5-second tick arrives) must apply gravity steps until the piece is grounded and then stop — never teleport the piece through the floor or the stack. → Task 8. +2. **Zero or negative `dt`** (clock skew, a duplicate frame) must change nothing and must not panic. → Task 8. +3. **Rotation with cells above the board top** (`y < 0`, an `I` piece rotated upright in the spawn rows) must treat above-board space as empty, not as a wall and not as an index panic. → Task 4. +4. **A completed row inside the hidden spawn rows** (rows 0–1, in a nearly-topped-out well) must clear and collapse like any other row. → Task 2. +5. **Input after game over** (player mashes keys during the death animation, which later plans keep interactive) must be a no-op: no movement, no score change, no panic. → Task 11. + +--- + +### Task 1: Module bootstrap, piece kinds, rotation tables + +**Files:** +- Create: `go.mod` +- Create: `internal/game/piece.go` +- Test: `internal/game/piece_test.go` +- Create: `.gitignore` + +**Interfaces:** +- Consumes: nothing. +- Produces: + - `type PieceKind uint8` with constants `KindI, KindJ, KindL, KindO, KindS, KindT, KindZ` (in that order) and `AllKinds = [7]PieceKind{...}`. + - `func (k PieceKind) String() string` → `"I" "J" "L" "O" "S" "T" "Z"`. + - `type Piece struct { Kind PieceKind; Rotation int; X, Y int }`. + - `func (p Piece) Cells() [4][2]int` — absolute board coordinates `{x, y}` of the piece's four cells. + - `func (p Piece) Normalized() Piece` — `Rotation` wrapped into `0..3` (handles `-1`). + +- [ ] **Step 1: Initialize the module and ignore file** + +```bash +go mod init github.com/jessev/cosmic-tetris +printf 'cosmic-tetris\n/dist/\n' > .gitignore +``` + +- [ ] **Step 2: Write the failing test** + +`internal/game/piece_test.go`: + +```go +package game + +import "testing" + +func TestPieceKindString(t *testing.T) { + want := []string{"I", "J", "L", "O", "S", "T", "Z"} + for i, k := range AllKinds { + if got := k.String(); got != want[i] { + t.Errorf("kind %d: got %q want %q", i, got, want[i]) + } + } +} + +func TestEveryRotationHasFourDistinctCellsInBox(t *testing.T) { + for _, k := range AllKinds { + for r := 0; r < 4; r++ { + p := Piece{Kind: k, Rotation: r, X: 0, Y: 0} + cells := p.Cells() + seen := map[[2]int]bool{} + for _, c := range cells { + if c[0] < 0 || c[0] > 3 || c[1] < 0 || c[1] > 3 { + t.Errorf("%s rot %d: cell %v outside 4x4 box", k, r, c) + } + if seen[c] { + t.Errorf("%s rot %d: duplicate cell %v", k, r, c) + } + seen[c] = true + } + if len(seen) != 4 { + t.Errorf("%s rot %d: got %d cells want 4", k, r, len(seen)) + } + } + } +} + +func TestOIsRotationInvariant(t *testing.T) { + base := Piece{Kind: KindO, Rotation: 0}.Cells() + for r := 1; r < 4; r++ { + if got := (Piece{Kind: KindO, Rotation: r}).Cells(); got != base { + t.Errorf("O rot %d: got %v want %v", r, got, base) + } + } +} + +func TestCellsTranslateWithPosition(t *testing.T) { + a := Piece{Kind: KindT, Rotation: 1, X: 0, Y: 0}.Cells() + b := Piece{Kind: KindT, Rotation: 1, X: 4, Y: 7}.Cells() + for i := range a { + if b[i][0] != a[i][0]+4 || b[i][1] != a[i][1]+7 { + t.Fatalf("cell %d not translated: %v vs %v", i, a[i], b[i]) + } + } +} + +func TestNormalizedWrapsRotation(t *testing.T) { + for in, want := range map[int]int{-1: 3, 0: 0, 4: 0, 5: 1, -4: 0} { + if got := (Piece{Rotation: in}).Normalized().Rotation; got != want { + t.Errorf("rotation %d: got %d want %d", in, got, want) + } + } +} +``` + +- [ ] **Step 3: Run the test to verify it fails** + +Run: `go test ./internal/game/ -run TestPiece -v` +Expected: FAIL — build error, `undefined: AllKinds`. + +- [ ] **Step 4: Implement `piece.go` with the pinned rotation table** + +Offsets are `{dx, dy}` inside a 4×4 box, origin top-left, `dy` increasing downward. `Cells()` adds `p.X`/`p.Y`. Copy this table exactly — it is the rotation contract every later task and test assumes: + +```go +// shapes[kind][rotation] holds the four {dx,dy} offsets of that rotation. +var shapes = [7][4][4][2]int{ + KindI: { + {{0, 1}, {1, 1}, {2, 1}, {3, 1}}, + {{2, 0}, {2, 1}, {2, 2}, {2, 3}}, + {{0, 2}, {1, 2}, {2, 2}, {3, 2}}, + {{1, 0}, {1, 1}, {1, 2}, {1, 3}}, + }, + KindJ: { + {{0, 0}, {0, 1}, {1, 1}, {2, 1}}, + {{1, 0}, {2, 0}, {1, 1}, {1, 2}}, + {{0, 1}, {1, 1}, {2, 1}, {2, 2}}, + {{1, 0}, {1, 1}, {0, 2}, {1, 2}}, + }, + KindL: { + {{2, 0}, {0, 1}, {1, 1}, {2, 1}}, + {{1, 0}, {1, 1}, {1, 2}, {2, 2}}, + {{0, 1}, {1, 1}, {2, 1}, {0, 2}}, + {{0, 0}, {1, 0}, {1, 1}, {1, 2}}, + }, + KindO: { + {{1, 0}, {2, 0}, {1, 1}, {2, 1}}, + {{1, 0}, {2, 0}, {1, 1}, {2, 1}}, + {{1, 0}, {2, 0}, {1, 1}, {2, 1}}, + {{1, 0}, {2, 0}, {1, 1}, {2, 1}}, + }, + KindS: { + {{1, 0}, {2, 0}, {0, 1}, {1, 1}}, + {{1, 0}, {1, 1}, {2, 1}, {2, 2}}, + {{1, 1}, {2, 1}, {0, 2}, {1, 2}}, + {{0, 0}, {0, 1}, {1, 1}, {1, 2}}, + }, + KindT: { + {{1, 0}, {0, 1}, {1, 1}, {2, 1}}, + {{1, 0}, {1, 1}, {2, 1}, {1, 2}}, + {{0, 1}, {1, 1}, {2, 1}, {1, 2}}, + {{1, 0}, {0, 1}, {1, 1}, {1, 2}}, + }, + KindZ: { + {{0, 0}, {1, 0}, {1, 1}, {2, 1}}, + {{2, 0}, {1, 1}, {2, 1}, {1, 2}}, + {{0, 1}, {1, 1}, {1, 2}, {2, 2}}, + {{1, 0}, {0, 1}, {1, 1}, {0, 2}}, + }, +} +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `go test ./internal/game/ -run TestPiece -v && go vet ./...` +Expected: PASS, vet clean. + +- [ ] **Step 6: Commit** + +```bash +git add go.mod .gitignore internal/game/piece.go internal/game/piece_test.go +git commit -m "feat(game): piece kinds and pinned rotation table" +``` + +--- + +### Task 2: Board — bounds, occupancy, row completion, collapse + +**Files:** +- Create: `internal/game/board.go` +- Test: `internal/game/board_test.go` + +**Interfaces:** +- Consumes: `PieceKind` from Task 1. +- Produces: + - `const Width = 10`, `Height = 22`, `VisibleHeight = 20`, `HiddenRows = 2`. + - `type Cell uint8`, `const Empty Cell = 0`, `func CellFor(k PieceKind) Cell` (`= Cell(k) + 1`), `func (c Cell) Kind() (PieceKind, bool)`. + - `type Board struct { Cells [Height][Width]Cell }`. + - `func (b *Board) At(x, y int) Cell` — `Empty` for any out-of-range coordinate. + - `func (b *Board) Set(x, y int, c Cell)` — silently ignores out-of-range writes. + - `func (b *Board) Occupied(x, y int) bool` — `true` when `x < 0 || x >= Width || y >= Height` (walls and floor) or the cell is non-empty; **`false` when `y < 0`** (above the board is open space). + - `func (b *Board) CompleteRows() []int` — ascending row indices, including hidden rows 0–1. + - `func (b *Board) ClearRows(rows []int)` — removes those rows and collapses everything above them downward; new empty rows enter at the top. + - `func (b *Board) RowFilled(y int) bool`, `func (b *Board) Fingerprint() uint64` (FNV-1a over all cells; used by the determinism test). + +- [ ] **Step 1: Write the failing test** + +`internal/game/board_test.go`: + +```go +package game + +import ( + "reflect" + "testing" +) + +func fill(b *Board, y int, xs ...int) { + for _, x := range xs { + b.Set(x, y, CellFor(KindT)) + } +} + +func fillRow(b *Board, y int, except ...int) { + skip := map[int]bool{} + for _, x := range except { + skip[x] = true + } + for x := 0; x < Width; x++ { + if !skip[x] { + b.Set(x, y, CellFor(KindI)) + } + } +} + +func TestBoardDimensions(t *testing.T) { + if Width != 10 || Height != 22 || VisibleHeight != 20 || HiddenRows != 2 { + t.Fatalf("board dimensions changed: %d %d %d %d", Width, Height, VisibleHeight, HiddenRows) + } + if HiddenRows+VisibleHeight != Height { + t.Fatal("hidden + visible must equal height") + } +} + +func TestOccupiedWallsFloorAndOpenSky(t *testing.T) { + var b Board + cases := []struct { + x, y int + want bool + }{ + {-1, 5, true}, {Width, 5, true}, {0, Height, true}, {5, Height + 3, true}, + {5, -1, false}, {0, -4, false}, {0, 0, false}, {5, 21, false}, + } + for _, c := range cases { + if got := b.Occupied(c.x, c.y); got != c.want { + t.Errorf("Occupied(%d,%d) = %v want %v", c.x, c.y, got, c.want) + } + } + b.Set(3, 10, CellFor(KindZ)) + if !b.Occupied(3, 10) { + t.Error("filled cell should be occupied") + } +} + +func TestAtAndSetIgnoreOutOfRange(t *testing.T) { + var b Board + b.Set(-1, -1, CellFor(KindO)) // must not panic + b.Set(Width, Height, CellFor(KindO)) + if b.At(-1, -1) != Empty || b.At(99, 99) != Empty { + t.Error("out-of-range At must report Empty") + } +} + +func TestCellRoundTrip(t *testing.T) { + for _, k := range AllKinds { + got, ok := CellFor(k).Kind() + if !ok || got != k { + t.Errorf("%s: round trip gave %v ok=%v", k, got, ok) + } + } + if _, ok := Empty.Kind(); ok { + t.Error("Empty must not report a kind") + } +} + +func TestCompleteRowsAscending(t *testing.T) { + var b Board + fillRow(&b, 21) + fillRow(&b, 19) + fillRow(&b, 20, 4) // one gap: not complete + if got := b.CompleteRows(); !reflect.DeepEqual(got, []int{19, 21}) { + t.Fatalf("got %v want [19 21]", got) + } +} + +func TestClearRowsCollapsesFromAbove(t *testing.T) { + var b Board + fill(&b, 18, 0) // marker above the cleared rows + fillRow(&b, 20) + fillRow(&b, 21) + b.ClearRows([]int{20, 21}) + if b.At(0, 20) != CellFor(KindT) { + t.Errorf("marker should have fallen from row 18 to row 20, got %v", b.At(0, 20)) + } + for x := 0; x < Width; x++ { + if b.At(x, 21) != Empty { + t.Errorf("row 21 should be empty after collapse, x=%d", x) + } + } + if b.At(0, 18) != Empty { + t.Error("original marker row must be vacated") + } +} + +// Review focus 4: a complete row inside the hidden spawn rows. +func TestClearRowsInHiddenSpawnRows(t *testing.T) { + var b Board + fillRow(&b, 0) + fillRow(&b, 1) + fill(&b, 5, 2) + if got := b.CompleteRows(); !reflect.DeepEqual(got, []int{0, 1}) { + t.Fatalf("hidden rows should be reported complete, got %v", got) + } + b.ClearRows([]int{0, 1}) + if b.RowFilled(0) || b.RowFilled(1) { + t.Error("cleared hidden rows must no longer read as filled") + } + if b.At(2, 5) != CellFor(KindT) { + t.Error("cells below cleared hidden rows must not move") + } + if b.At(0, 0) != Empty || b.At(0, 1) != Empty { + t.Error("cleared hidden rows must be empty") + } +} + +func TestFingerprintChangesWithContent(t *testing.T) { + var a, b Board + if a.Fingerprint() != b.Fingerprint() { + t.Fatal("equal boards must share a fingerprint") + } + b.Set(4, 4, CellFor(KindS)) + if a.Fingerprint() == b.Fingerprint() { + t.Fatal("different boards must differ") + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/game/ -run 'TestBoard|TestOccupied|TestAt|TestCell|TestComplete|TestClear|TestFingerprint' -v` +Expected: FAIL — `undefined: Board`. + +- [ ] **Step 3: Implement `board.go`** + +`ClearRows` is easiest as a single downward compaction pass: walk a write cursor from `Height-1` upward, skipping source rows that are in the clear set, then zero the remaining top rows. + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/board.go internal/game/board_test.go +git commit -m "feat(game): board occupancy, row completion, and collapse" +``` + +--- + +### Task 3: 7-bag piece generator + +**Files:** +- Create: `internal/game/bag.go` +- Test: `internal/game/bag_test.go` + +**Interfaces:** +- Consumes: `PieceKind`, `AllKinds`. +- Produces: + - `type Bag struct { queue []PieceKind }`. + - `func (b *Bag) Next(rng *rand.Rand) PieceKind` — refills with one of every kind and shuffles (via `rng.Shuffle`) whenever the queue is empty, then pops. + - `func (b *Bag) Remaining() int`. + +The bag never owns an RNG; `Game` passes its own in (§49.6). + +- [ ] **Step 1: Write the failing test** + +`internal/game/bag_test.go`: + +```go +package game + +import ( + "math/rand" + "testing" +) + +func TestEachBagContainsAllSevenKindsExactlyOnce(t *testing.T) { + rng := rand.New(rand.NewSource(1)) + var b Bag + for bagIndex := 0; bagIndex < 20; bagIndex++ { + counts := map[PieceKind]int{} + for i := 0; i < 7; i++ { + counts[b.Next(rng)]++ + } + if len(counts) != 7 { + t.Fatalf("bag %d contained %d distinct kinds: %v", bagIndex, len(counts), counts) + } + for _, k := range AllKinds { + if counts[k] != 1 { + t.Fatalf("bag %d has %d of %s", bagIndex, counts[k], k) + } + } + } +} + +func TestSeededBagIsReproducible(t *testing.T) { + draw := func() []PieceKind { + rng := rand.New(rand.NewSource(8675309)) + var b Bag + out := make([]PieceKind, 0, 35) + for i := 0; i < 35; i++ { + out = append(out, b.Next(rng)) + } + return out + } + a, c := draw(), draw() + for i := range a { + if a[i] != c[i] { + t.Fatalf("draw %d differs: %s vs %s", i, a[i], c[i]) + } + } +} + +func TestDifferentSeedsEventuallyDiffer(t *testing.T) { + drawWith := func(seed int64) []PieceKind { + rng := rand.New(rand.NewSource(seed)) + var b Bag + out := make([]PieceKind, 0, 21) + for i := 0; i < 21; i++ { + out = append(out, b.Next(rng)) + } + return out + } + a, b := drawWith(1), drawWith(2) + same := true + for i := range a { + if a[i] != b[i] { + same = false + break + } + } + if same { + t.Fatal("two seeds produced identical 21-piece sequences") + } +} + +func TestBagShufflesRatherThanCyclesInOrder(t *testing.T) { + rng := rand.New(rand.NewSource(42)) + var b Bag + ordered := 0 + for bagIndex := 0; bagIndex < 12; bagIndex++ { + got := make([]PieceKind, 7) + for i := range got { + got[i] = b.Next(rng) + } + sorted := true + for i := 1; i < 7; i++ { + if got[i] < got[i-1] { + sorted = false + } + } + if sorted { + ordered++ + } + } + if ordered > 1 { + t.Fatalf("%d of 12 bags came out in kind order; shuffle is not happening", ordered) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/game/ -run TestBag -v` +Expected: FAIL — `undefined: Bag`. + +- [ ] **Step 3: Implement `bag.go`** + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/bag.go internal/game/bag_test.go +git commit -m "feat(game): seeded 7-bag generator" +``` + +--- + +### Task 4: Rules — collision, spawn position, wall-kick rotation, ghost drop + +**Files:** +- Create: `internal/game/rules.go` +- Test: `internal/game/rules_test.go` + +**Interfaces:** +- Consumes: `Board`, `Piece`, `PieceKind`. +- Produces: + - `const SpawnX = 3`, `SpawnY = 0`. + - `func SpawnPiece(k PieceKind) Piece` → `Piece{Kind: k, Rotation: 0, X: SpawnX, Y: SpawnY}`. + - `func Collides(b *Board, p Piece) bool` — true if any cell is `b.Occupied`. + - `var KickOffsets = [8][2]int{{0, 0}, {-1, 0}, {1, 0}, {-2, 0}, {2, 0}, {0, -1}, {-1, -1}, {1, -1}}`. + - `func Rotate(b *Board, p Piece, delta int) (Piece, bool)` — `delta` is `+1` (clockwise) or `-1`; tries `KickOffsets` in order, returns the first non-colliding piece and `true`, else `p, false`. + - `func Drop(b *Board, p Piece) (Piece, int)` — the landing piece and the number of cells travelled (§10 ghost position, and the hard-drop distance). + - `func Grounded(b *Board, p Piece) bool` — true when moving down one row collides. + +- [ ] **Step 1: Write the failing test** + +`internal/game/rules_test.go`: + +```go +package game + +import "testing" + +func TestSpawnPositionIsCenteredInHiddenRows(t *testing.T) { + for _, k := range AllKinds { + p := SpawnPiece(k) + if p.X != 3 || p.Y != 0 || p.Rotation != 0 { + t.Fatalf("%s spawned at %+v", k, p) + } + var b Board + if Collides(&b, p) { + t.Fatalf("%s must spawn free on an empty board", k) + } + for _, c := range p.Cells() { + if c[1] >= HiddenRows+2 { + t.Errorf("%s spawn cell %v sits too far down the board", k, c) + } + if c[0] < 0 || c[0] >= Width { + t.Errorf("%s spawn cell %v is off-board", k, c) + } + } + } +} + +func TestCollidesWithWallsFloorAndStack(t *testing.T) { + var b Board + if !Collides(&b, Piece{Kind: KindO, Rotation: 0, X: -2, Y: 5}) { + t.Error("piece pushed through the left wall must collide") + } + if !Collides(&b, Piece{Kind: KindO, Rotation: 0, X: Width - 1, Y: 5}) { + t.Error("piece pushed through the right wall must collide") + } + if !Collides(&b, Piece{Kind: KindO, Rotation: 0, X: 4, Y: Height - 1}) { + t.Error("piece pushed through the floor must collide") + } + b.Set(4, 10, CellFor(KindI)) + if !Collides(&b, Piece{Kind: KindO, Rotation: 0, X: 3, Y: 10}) { + t.Error("piece overlapping a locked cell must collide") + } +} + +// Review focus 3: rotating with cells above the top of the board. +func TestRotationAboveBoardTopIsAllowed(t *testing.T) { + var b Board + p := Piece{Kind: KindI, Rotation: 0, X: 3, Y: -2} + got, ok := Rotate(&b, p, 1) + if !ok { + t.Fatal("rotation with cells above the board must succeed, not fail") + } + if Collides(&b, got) { + t.Fatal("resulting piece reported as colliding") + } +} + +func TestRotationKickOrderIsPinned(t *testing.T) { + want := [8][2]int{{0, 0}, {-1, 0}, {1, 0}, {-2, 0}, {2, 0}, {0, -1}, {-1, -1}, {1, -1}} + if KickOffsets != want { + t.Fatalf("kick order changed: %v", KickOffsets) + } +} + +func TestRotationKicksOffTheLeftWall(t *testing.T) { + var b Board + // Vertical I hugging the left wall; rotating to horizontal needs a nudge right. + p := Piece{Kind: KindI, Rotation: 3, X: -1, Y: 10} + if Collides(&b, p) { + t.Fatal("test setup: starting piece should be legal") + } + got, ok := Rotate(&b, p, 1) + if !ok { + t.Fatal("wall kick should have rescued this rotation") + } + if got.X <= p.X { + t.Errorf("expected a kick to the right, got X=%d from %d", got.X, p.X) + } + if Collides(&b, got) { + t.Fatal("kicked piece must be legal") + } +} + +func TestRotationFailsWhenFullyBoxedIn(t *testing.T) { + var b Board + for y := 0; y < Height; y++ { + for x := 0; x < Width; x++ { + b.Set(x, y, CellFor(KindZ)) + } + } + for _, c := range (Piece{Kind: KindT, Rotation: 0, X: 3, Y: 10}).Cells() { + b.Set(c[0], c[1], Empty) + } + p := Piece{Kind: KindT, Rotation: 0, X: 3, Y: 10} + if got, ok := Rotate(&b, p, 1); ok { + t.Fatalf("rotation should have failed, got %+v", got) + } else if got != p { + t.Fatal("failed rotation must return the piece unchanged") + } +} + +func TestRotateBothDirectionsWraps(t *testing.T) { + var b Board + p := Piece{Kind: KindT, Rotation: 0, X: 3, Y: 10} + cw, _ := Rotate(&b, p, 1) + if cw.Rotation != 1 { + t.Errorf("cw: got rotation %d want 1", cw.Rotation) + } + ccw, _ := Rotate(&b, p, -1) + if ccw.Rotation != 3 { + t.Errorf("ccw: got rotation %d want 3", ccw.Rotation) + } +} + +func TestDropReturnsLandingAndDistance(t *testing.T) { + var b Board + p := SpawnPiece(KindO) + landed, dist := Drop(&b, p) + if !Grounded(&b, landed) { + t.Error("landing position must be grounded") + } + if Collides(&b, landed) { + t.Error("landing position must be legal") + } + if dist != landed.Y-p.Y || dist <= 0 { + t.Errorf("distance %d inconsistent with %d -> %d", dist, p.Y, landed.Y) + } +} + +func TestDropOfAlreadyGroundedPieceTravelsZero(t *testing.T) { + var b Board + p := Piece{Kind: KindO, Rotation: 0, X: 4, Y: Height - 2} + landed, dist := Drop(&b, p) + if dist != 0 || landed != p { + t.Fatalf("grounded piece moved: dist=%d %+v", dist, landed) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/game/ -run 'TestSpawn|TestCollides|TestRotat|TestDrop|TestGrounded' -v` +Expected: FAIL — `undefined: SpawnPiece`. + +- [ ] **Step 3: Implement `rules.go`** + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/rules.go internal/game/rules_test.go +git commit -m "feat(game): collision, wall-kick rotation, and drop rules" +``` + +--- + +### Task 5: Scoring, level, and the gravity curve + +**Files:** +- Create: `internal/game/scoring.go` +- Test: `internal/game/scoring_test.go` + +**Interfaces:** +- Consumes: nothing. +- Produces: + - `func LineScore(lines, level int) int` — `0, 100, 300, 500, 800` × level for `0..4` lines; more than 4 lines is impossible but must return the 4-line value rather than panic. + - `func ComboBonus(combo, level int) int` — `50 * (combo-1) * level`, `0` when `combo < 2`. + - `func LevelFor(lines int) int` — `lines/10 + 1`. + - `const BaseGravity = 800 * time.Millisecond`, `MinGravity = 60 * time.Millisecond`, `GravityDecay = 0.86`. + - `func GravityInterval(level int) time.Duration` — `BaseGravity * 0.86^(level-1)`, floored at `MinGravity`; `level < 1` is treated as `1`. + - `const SoftDropPoints = 1`, `HardDropPoints = 2`. + +- [ ] **Step 1: Write the failing test** + +`internal/game/scoring_test.go`: + +```go +package game + +import ( + "testing" + "time" +) + +func TestLineScoreTable(t *testing.T) { + cases := []struct{ lines, level, want int }{ + {0, 1, 0}, {1, 1, 100}, {2, 1, 300}, {3, 1, 500}, {4, 1, 800}, + {1, 7, 700}, {4, 13, 10400}, {5, 2, 1600}, + } + for _, c := range cases { + if got := LineScore(c.lines, c.level); got != c.want { + t.Errorf("LineScore(%d,%d) = %d want %d", c.lines, c.level, got, c.want) + } + } +} + +func TestComboBonusStartsAtComboTwo(t *testing.T) { + cases := []struct{ combo, level, want int }{ + {0, 5, 0}, {1, 5, 0}, {2, 1, 50}, {2, 4, 200}, {5, 3, 600}, + } + for _, c := range cases { + if got := ComboBonus(c.combo, c.level); got != c.want { + t.Errorf("ComboBonus(%d,%d) = %d want %d", c.combo, c.level, got, c.want) + } + } +} + +func TestLevelForLines(t *testing.T) { + cases := []struct{ lines, want int }{{0, 1}, {9, 1}, {10, 2}, {19, 2}, {20, 3}, {127, 13}} + for _, c := range cases { + if got := LevelFor(c.lines); got != c.want { + t.Errorf("LevelFor(%d) = %d want %d", c.lines, got, c.want) + } + } +} + +func TestGravityIntervalCurveAndClamp(t *testing.T) { + if got := GravityInterval(1); got != 800*time.Millisecond { + t.Errorf("level 1: got %v want 800ms", got) + } + if got := GravityInterval(0); got != GravityInterval(1) { + t.Errorf("level 0 must behave as level 1, got %v", got) + } + l2 := GravityInterval(2) + if l2 < 686*time.Millisecond || l2 > 690*time.Millisecond { + t.Errorf("level 2: got %v want ~688ms", l2) + } + prev := GravityInterval(1) + for level := 2; level <= 40; level++ { + cur := GravityInterval(level) + if cur > prev { + t.Fatalf("level %d interval %v grew from %v", level, cur, prev) + } + if cur < MinGravity { + t.Fatalf("level %d interval %v fell below the %v floor", level, cur, MinGravity) + } + prev = cur + } + if GravityInterval(99) != MinGravity { + t.Errorf("very high level should sit exactly on the floor, got %v", GravityInterval(99)) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/game/ -run 'TestLine|TestCombo|TestLevel|TestGravity' -v` +Expected: FAIL — `undefined: LineScore`. + +- [ ] **Step 3: Implement `scoring.go`** + +Use `math.Pow(GravityDecay, float64(level-1))` and truncate to a `time.Duration`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/scoring.go internal/game/scoring_test.go +git commit -m "feat(game): scoring table, combo bonus, and gravity curve" +``` + +--- + +### Task 6: Event vocabulary + +**Files:** +- Create: `internal/game/events.go` +- Test: `internal/game/events_test.go` + +**Interfaces:** +- Consumes: `Piece`. +- Produces: + - `type EventKind int` with constants in this order: `PieceSpawned, PieceMoved, PieceRotated, PieceSoftDropped, PieceHardDropped, PieceLocked, HoldUsed, LinesCleared, ComboChanged, LevelChanged, GameOver`. + - `func (k EventKind) String() string` — the constant's name, e.g. `"LinesCleared"`. + - ```go + type Event struct { + Kind EventKind + Piece Piece // the piece involved, where one is + Cells [4][2]int // absolute cells of Piece at the moment of the event + Rows []int // LinesCleared: rows cleared, ascending + RowCells [][]Cell // LinesCleared: each cleared row's contents before collapse, + // in the same order as Rows. The line-clear animation + // (Plan 04) needs the row that gameplay has already deleted. + Distance int // PieceHardDropped / PieceSoftDropped: cells travelled + Combo int // ComboChanged: new value + Level int // LevelChanged: new value + Points int // score delta attributable to this event + Score int // running total after this event; Plan 05's score-rollover line reads it + Board *Board // GameOver only: the final board, so the collapse animation + // (Plan 05) can snapshot it. Read-only for consumers. + } + ``` + + - Two rules every emitter in Tasks 7–12 follows: an event with a non-zero `Points` sets `Score` to `g.Score` **after** that event's points have been applied, and the `GameOver` event sets `Board` to `&g.Board`. + +This file is a deliberate addition to §33's file list: `game.Event` is referenced by §36, and giving it its own file keeps `game.go` readable. `internal/fx/events.go` (Plan 03) is the consumer side, not a duplicate. + +- [ ] **Step 1: Write the failing test** + +`internal/game/events_test.go`: + +```go +package game + +import "testing" + +func TestEventKindNames(t *testing.T) { + want := map[EventKind]string{ + PieceSpawned: "PieceSpawned", PieceMoved: "PieceMoved", PieceRotated: "PieceRotated", + PieceSoftDropped: "PieceSoftDropped", PieceHardDropped: "PieceHardDropped", + PieceLocked: "PieceLocked", HoldUsed: "HoldUsed", LinesCleared: "LinesCleared", + ComboChanged: "ComboChanged", LevelChanged: "LevelChanged", GameOver: "GameOver", + } + for k, name := range want { + if got := k.String(); got != name { + t.Errorf("kind %d: got %q want %q", int(k), got, name) + } + } + if len(want) != 11 { + t.Fatal("event vocabulary size changed; update the FX handlers too") + } +} +``` + +- [ ] **Step 1b: Add the two consumer-facing field tests** + +Append to `internal/game/events_test.go`: + +```go +func TestEveryScoringEventCarriesTheRunningTotal(t *testing.T) { + g := New(77) + g.Start() + for i := 0; i < 12; i++ { + for _, e := range g.HardDrop() { + if e.Points != 0 && e.Score != g.Score { + t.Fatalf("%v reported Score=%d but the game is at %d", e.Kind, e.Score, g.Score) + } + } + if g.Over { + break + } + } +} + +func TestGameOverCarriesTheFinalBoard(t *testing.T) { + g := New(78) + g.Start() + fillBoardToTheTop(g) + for _, c := range g.Active.Cells() { + g.Board.Set(c[0], c[1], Empty) + } + e := has(g.HardDrop(), GameOver) + if e == nil { + t.Fatal("no GameOver event") + } + if e.Board == nil { + t.Fatal("GameOver must carry the final board so the collapse can animate it") + } + if e.Board.Fingerprint() != g.Board.Fingerprint() { + t.Error("the GameOver board is not the game's board") + } +} +``` + +`fillBoardToTheTop`, `has` and `kinds` come from Tasks 8 and 11 — if you are executing tasks in order, write these two tests in Task 11's file instead and leave `events_test.go` with the vocabulary test alone. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/game/ -run TestEventKind -v` +Expected: FAIL — `undefined: PieceSpawned`. + +- [ ] **Step 3: Implement `events.go`** + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `go test ./internal/game/ -run TestEventKind -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/events.go internal/game/events_test.go +git commit -m "feat(game): event vocabulary" +``` + +--- + +### Task 7: Game construction, next queue, and horizontal movement + +**Files:** +- Create: `internal/game/game.go` +- Test: `internal/game/game_test.go` + +**Interfaces:** +- Consumes: everything from Tasks 1–6. +- Produces: + - ```go + type Game struct { + Board Board + Active Piece + Hold *PieceKind + CanHold bool + + Next []PieceKind + Bag Bag + + Score, Lines, Level, Combo int + + GravityAccumulator time.Duration + LockAccumulator time.Duration + LockResets int + Grounded bool + Over bool + + Seed int64 + rng *rand.Rand + } + ``` + - `const NextCount = 5`, `const LockDelay = 500 * time.Millisecond`, `const MaxLockResets = 15`. + - `func New(seed int64) *Game` — level 1, combo 0, `CanHold` true, `Next` holding `NextCount` kinds, `Active` spawned from the bag, emits nothing (the caller gets the first `PieceSpawned` from `Advance`/`Start`). Provide `func (g *Game) Start() []Event` returning the initial `PieceSpawned` event. + - `func (g *Game) MoveLeft() []Event`, `func (g *Game) MoveRight() []Event` — move one column if legal, emit `PieceMoved`, reset the lock timer per Task 8's rule; return `nil` when the move is illegal or the game is over. + - `func (g *Game) RotateCW() []Event`, `func (g *Game) RotateCCW() []Event` — emit `PieceRotated` on success, `nil` on failure. + - `func (g *Game) Ghost() Piece` — the landing position of `Active` (§10). + +- [ ] **Step 1: Write the failing test** + +Append to `internal/game/game_test.go`: + +```go +package game + +import ( + "testing" + "time" +) + +func TestNewGameInitialState(t *testing.T) { + g := New(99) + if g.Level != 1 || g.Score != 0 || g.Lines != 0 || g.Combo != 0 { + t.Errorf("bad initial counters: %+v", struct{ S, L, Ln, C int }{g.Score, g.Level, g.Lines, g.Combo}) + } + if len(g.Next) != NextCount { + t.Errorf("next queue has %d entries want %d", len(g.Next), NextCount) + } + if !g.CanHold || g.Hold != nil { + t.Error("hold should start empty and available") + } + if g.Over { + t.Error("new game must not be over") + } + if g.Seed != 99 { + t.Errorf("seed not recorded: %d", g.Seed) + } + if Collides(&g.Board, g.Active) { + t.Error("first piece must spawn legally") + } +} + +func TestStartEmitsPieceSpawned(t *testing.T) { + g := New(1) + evs := g.Start() + if len(evs) != 1 || evs[0].Kind != PieceSpawned { + t.Fatalf("got %v", evs) + } + if evs[0].Piece != g.Active { + t.Error("event should carry the active piece") + } +} + +func TestNextQueueRefillsAndStaysFive(t *testing.T) { + g := New(7) + g.Start() + for i := 0; i < 30; i++ { + g.HardDrop() + if len(g.Next) != NextCount { + t.Fatalf("after drop %d the queue is %d long", i, len(g.Next)) + } + if g.Over { + break + } + } +} + +func TestSeededGamesAgreeOnPieceOrder(t *testing.T) { + order := func() []PieceKind { + g := New(8675309) + g.Start() + out := []PieceKind{g.Active.Kind} + out = append(out, g.Next...) + return out + } + a, b := order(), order() + for i := range a { + if a[i] != b[i] { + t.Fatalf("piece %d differs: %s vs %s", i, a[i], b[i]) + } + } +} + +func TestMoveLeftRightWithinWalls(t *testing.T) { + g := New(3) + g.Start() + startX := g.Active.X + if evs := g.MoveLeft(); len(evs) != 1 || evs[0].Kind != PieceMoved { + t.Fatalf("expected PieceMoved, got %v", evs) + } + if g.Active.X != startX-1 { + t.Errorf("X = %d want %d", g.Active.X, startX-1) + } + g.MoveRight() + if g.Active.X != startX { + t.Errorf("X = %d want %d after moving back", g.Active.X, startX) + } + for i := 0; i < 20; i++ { + g.MoveLeft() + } + before := g.Active + if evs := g.MoveLeft(); evs != nil { + t.Error("blocked move must emit nothing") + } + if g.Active != before { + t.Error("blocked move must not change the piece") + } +} + +func TestRotateEmitsEventOnlyOnSuccess(t *testing.T) { + g := New(4) + g.Start() + g.Active = Piece{Kind: KindT, Rotation: 0, X: 3, Y: 10} + if evs := g.RotateCW(); len(evs) != 1 || evs[0].Kind != PieceRotated { + t.Fatalf("got %v", evs) + } + if g.Active.Rotation != 1 { + t.Errorf("rotation = %d want 1", g.Active.Rotation) + } + if evs := g.RotateCCW(); len(evs) != 1 { + t.Fatalf("ccw failed: %v", evs) + } + if g.Active.Rotation != 0 { + t.Errorf("rotation = %d want 0", g.Active.Rotation) + } +} + +func TestGhostIsGroundedBelowActive(t *testing.T) { + g := New(5) + g.Start() + ghost := g.Ghost() + if ghost.Kind != g.Active.Kind || ghost.Rotation != g.Active.Rotation || ghost.X != g.Active.X { + t.Errorf("ghost must share kind/rotation/column: %+v vs %+v", ghost, g.Active) + } + if ghost.Y < g.Active.Y { + t.Error("ghost must not float above the active piece") + } + if !Grounded(&g.Board, ghost) { + t.Error("ghost must be grounded") + } +} + +var _ = time.Millisecond // keep the import while later tasks add timing tests +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/game/ -run 'TestNewGame|TestStart|TestNextQueue|TestSeededGames|TestMove|TestRotateEmits|TestGhost' -v` +Expected: FAIL — `undefined: New`. + +- [ ] **Step 3: Implement `game.go` (construction, queue, movement, ghost)** + +`HardDrop` and `Advance` are referenced by these tests only through Task 8/9; add temporary method stubs if the build needs them, and delete the stubs in those tasks. Keep an unexported helper `func (g *Game) resetLockTimer()` — Task 8 fills in its reset-counting rule. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/game.go internal/game/game_test.go +git commit -m "feat(game): game construction, next queue, movement, ghost" +``` + +--- + +### Task 8: `Advance(dt)` — gravity, lock delay, and the lock pipeline + +**Files:** +- Modify: `internal/game/game.go` +- Test: `internal/game/advance_test.go` + +**Interfaces:** +- Consumes: Task 7's `Game`. +- Produces: + - `func (g *Game) Advance(dt time.Duration) []Event` — the only clock-facing entry point (§49.2). + - `func (g *Game) lockActive() []Event` — commit → detect → clear → score → events → spawn (§12), in that order. + +Pinned semantics: +- `dt <= 0` returns `nil` and changes nothing. +- Gravity: add `dt` to `GravityAccumulator`; while `GravityAccumulator >= GravityInterval(g.Level)`, subtract one interval and try to step the piece down one row. A successful step emits `PieceMoved`, zeroes `LockAccumulator`, and zeroes `LockResets`. A blocked step stops the gravity loop for this call, leaves the remaining accumulator alone, and switches to lock accumulation. +- Locking: when the piece is grounded, add `dt` to `LockAccumulator`; at `>= LockDelay` it locks. A successful move or rotation while grounded calls `resetLockTimer()`, which zeroes `LockAccumulator` and increments `LockResets`; once `LockResets >= MaxLockResets` the timer stops being reset (the piece locks regardless). +- Lock pipeline emits, in order: `PieceLocked` (with cells) → `LinesCleared` (only when rows > 0, carrying rows and points) → `ComboChanged` (only when the value changed) → `LevelChanged` (only when the value changed) → `PieceSpawned`, or `GameOver` instead of `PieceSpawned` when the new piece collides at spawn. +- Line points: `LineScore(len(rows), levelBeforeTheClear) + ComboBonus(newCombo, levelBeforeTheClear)`. Level rises after scoring, so a clear is paid at the level it was earned on. +- `LinesCleared.RowCells` is copied out of the board **before** `ClearRows` runs — one freshly allocated `[]Cell` of length `Width` per cleared row, in the same order as `Rows`. Plan 04's supernova animation needs the row after gameplay has already deleted it. + +- [ ] **Step 1: Write the failing test** + +`internal/game/advance_test.go`: + +```go +package game + +import ( + "testing" + "time" +) + +func kinds(evs []Event) []EventKind { + out := make([]EventKind, len(evs)) + for i, e := range evs { + out[i] = e.Kind + } + return out +} + +func has(evs []Event, k EventKind) *Event { + for i := range evs { + if evs[i].Kind == k { + return &evs[i] + } + } + return nil +} + +func TestGravityStepsOncePerInterval(t *testing.T) { + g := New(11) + g.Start() + y := g.Active.Y + if evs := g.Advance(799 * time.Millisecond); evs != nil { + t.Fatalf("piece moved early: %v", kinds(evs)) + } + evs := g.Advance(2 * time.Millisecond) + if h := has(evs, PieceMoved); h == nil { + t.Fatalf("expected PieceMoved at 801ms, got %v", kinds(evs)) + } + if g.Active.Y != y+1 { + t.Errorf("Y = %d want %d", g.Active.Y, y+1) + } +} + +// Review focus 1: one enormous dt after the terminal was backgrounded. +func TestHugeDtLandsThePieceWithoutEscapingTheBoard(t *testing.T) { + g := New(12) + g.Start() + evs := g.Advance(5 * time.Second) + for _, c := range g.Active.Cells() { + if c[1] >= Height || Collides(&g.Board, g.Active) { + t.Fatalf("piece escaped the board: cell %v of %+v", c, g.Active) + } + } + if !Grounded(&g.Board, g.Active) && has(evs, PieceLocked) == nil { + t.Fatalf("5s of gravity should have grounded or locked the piece, events: %v", kinds(evs)) + } + if g.Active.Y > Height { + t.Fatal("piece fell past the floor") + } +} + +// Review focus 2: zero and negative dt. +func TestNonPositiveDtIsInert(t *testing.T) { + g := New(13) + g.Start() + before := *g + for _, dt := range []time.Duration{0, -time.Millisecond, -time.Hour} { + if evs := g.Advance(dt); evs != nil { + t.Errorf("dt=%v produced events %v", dt, kinds(evs)) + } + } + if g.Active != before.Active || g.GravityAccumulator != before.GravityAccumulator || + g.LockAccumulator != before.LockAccumulator || g.Score != before.Score { + t.Error("non-positive dt mutated state") + } +} + +func TestLockDelayIsFiveHundredMilliseconds(t *testing.T) { + g := New(14) + g.Start() + g.Active, _ = Drop(&g.Board, g.Active) // grounded + if evs := g.Advance(499 * time.Millisecond); has(evs, PieceLocked) != nil { + t.Fatal("locked before the delay elapsed") + } + evs := g.Advance(2 * time.Millisecond) + if has(evs, PieceLocked) == nil { + t.Fatalf("expected a lock at 501ms, got %v", kinds(evs)) + } + if has(evs, PieceSpawned) == nil { + t.Fatal("lock must be followed by a spawn") + } +} + +func TestMovementWhileGroundedResetsTheLockTimer(t *testing.T) { + g := New(15) + g.Start() + g.Active, _ = Drop(&g.Board, g.Active) + g.Advance(400 * time.Millisecond) + g.MoveLeft() + if g.LockAccumulator != 0 { + t.Errorf("lock accumulator = %v want 0 after a grounded move", g.LockAccumulator) + } + if g.LockResets != 1 { + t.Errorf("lock resets = %d want 1", g.LockResets) + } + if evs := g.Advance(400 * time.Millisecond); has(evs, PieceLocked) != nil { + t.Fatal("the reset should have bought another 500ms") + } +} + +func TestLockResetsAreCappedAtFifteen(t *testing.T) { + g := New(16) + g.Start() + g.Active, _ = Drop(&g.Board, g.Active) + for i := 0; i < 40; i++ { + g.Advance(100 * time.Millisecond) + g.MoveLeft() + g.MoveRight() + } + if g.LockResets > MaxLockResets { + t.Fatalf("lock resets reached %d, cap is %d", g.LockResets, MaxLockResets) + } + // With the cap reached, continued shuffling can no longer prevent a lock. + g2 := New(16) + g2.Start() + g2.Active, _ = Drop(&g2.Board, g2.Active) + locked := false + for i := 0; i < 200 && !locked; i++ { + if has(g2.Advance(100*time.Millisecond), PieceLocked) != nil { + locked = true + } + g2.MoveLeft() + g2.MoveRight() + } + if !locked { + t.Fatal("infinite stalling was possible") + } +} + +func TestLockPipelineOrderOnALineClear(t *testing.T) { + g := New(17) + g.Start() + // Fill row 21 except the two columns the O piece will occupy. + for x := 0; x < Width; x++ { + if x != 4 && x != 5 { + g.Board.Set(x, 21, CellFor(KindI)) + } + } + g.Active = Piece{Kind: KindO, Rotation: 0, X: 3, Y: 19} // cells at x=4,5 y=19,20 + evs := g.Advance(600 * time.Millisecond) // grounded already? force the lock + for i := 0; i < 5 && has(evs, PieceLocked) == nil; i++ { + evs = append(evs, g.Advance(600*time.Millisecond)...) + } + got := kinds(evs) + lockAt, clearAt, spawnAt := -1, -1, -1 + for i, k := range got { + switch k { + case PieceLocked: + lockAt = i + case LinesCleared: + clearAt = i + case PieceSpawned: + spawnAt = i + } + } + if lockAt < 0 || clearAt < 0 || spawnAt < 0 { + t.Fatalf("missing events in %v", got) + } + if !(lockAt < clearAt && clearAt < spawnAt) { + t.Fatalf("wrong order %v; want PieceLocked < LinesCleared < PieceSpawned", got) + } + if g.Lines != 1 { + t.Errorf("lines = %d want 1", g.Lines) + } + if g.Score != 100 { + t.Errorf("score = %d want 100 (single at level 1, no combo bonus)", g.Score) + } + if e := has(evs, LinesCleared); len(e.Rows) != 1 || e.Rows[0] != 21 { + t.Errorf("cleared rows = %v want [21]", e.Rows) + } +} + +func TestLinesClearedCarriesThePreCollapseRowContents(t *testing.T) { + g := New(19) + g.Start() + for x := 0; x < Width; x++ { + if x != 4 && x != 5 { + g.Board.Set(x, 21, CellFor(KindI)) + } + } + g.Active = Piece{Kind: KindO, Rotation: 0, X: 3, Y: 0} + evs := g.HardDrop() + e := has(evs, LinesCleared) + if e == nil { + t.Fatalf("no clear in %v", kinds(evs)) + } + if len(e.RowCells) != len(e.Rows) { + t.Fatalf("RowCells has %d rows, Rows has %d", len(e.RowCells), len(e.Rows)) + } + row := e.RowCells[0] + if len(row) != Width { + t.Fatalf("snapshot row is %d wide want %d", len(row), Width) + } + for x, c := range row { + if c == Empty { + t.Errorf("snapshot column %d is empty; the row was complete when it cleared", x) + } + } + if row[4] != CellFor(KindO) || row[0] != CellFor(KindI) { + t.Errorf("snapshot lost cell identity: %v", row) + } +} + +func TestGravityIntervalFollowsLevel(t *testing.T) { + g := New(18) + g.Start() + g.Level = 5 + want := GravityInterval(5) + y := g.Active.Y + g.Advance(want - time.Millisecond) + if g.Active.Y != y { + t.Fatal("moved before the level-5 interval elapsed") + } + g.Advance(2 * time.Millisecond) + if g.Active.Y != y+1 { + t.Fatalf("Y = %d want %d at level 5", g.Active.Y, y+1) + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/game/ -run 'TestGravity|TestHugeDt|TestNonPositiveDt|TestLock' -v` +Expected: FAIL — `Advance` not implemented (or the stub returns nil). + +- [ ] **Step 3: Implement `Advance` and `lockActive` in `game.go`** + +Guard the gravity loop with a bounded iteration count so a pathological `dt` cannot spin: the loop naturally ends when the piece grounds, and lock accumulation takes over. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/game.go internal/game/advance_test.go +git commit -m "feat(game): Advance(dt) with gravity, lock delay, and lock pipeline" +``` + +--- + +### Task 9: Soft drop, hard drop, and drop scoring + +**Files:** +- Modify: `internal/game/game.go` +- Test: `internal/game/drop_test.go` + +**Interfaces:** +- Consumes: Task 8's lock pipeline. +- Produces: + - `func (g *Game) SoftDrop() []Event` — one row down if legal: `+SoftDropPoints`, zeroes `GravityAccumulator`, emits `PieceSoftDropped{Distance: 1, Points: 1}`. When blocked, emits nothing and does not score. + - `func (g *Game) HardDrop() []Event` — teleports to `Drop()`'s landing position, scores `HardDropPoints × distance`, emits `PieceHardDropped{Distance, Points, Cells}` and then locks immediately (no lock delay), so the returned slice continues with the Task 8 lock pipeline. + +- [ ] **Step 1: Write the failing test** + +`internal/game/drop_test.go`: + +```go +package game + +import "testing" + +func TestSoftDropScoresOnePointPerCell(t *testing.T) { + g := New(21) + g.Start() + y := g.Active.Y + evs := g.SoftDrop() + if h := has(evs, PieceSoftDropped); h == nil || h.Distance != 1 || h.Points != 1 { + t.Fatalf("got %v", evs) + } + if g.Active.Y != y+1 || g.Score != 1 { + t.Errorf("Y=%d score=%d want %d and 1", g.Active.Y, g.Score, y+1) + } + if g.GravityAccumulator != 0 { + t.Error("soft drop should restart the gravity interval") + } +} + +func TestSoftDropOnTheFloorScoresNothing(t *testing.T) { + g := New(22) + g.Start() + g.Active, _ = Drop(&g.Board, g.Active) + before := g.Score + if evs := g.SoftDrop(); evs != nil { + t.Errorf("blocked soft drop emitted %v", kinds(evs)) + } + if g.Score != before { + t.Errorf("score changed from %d to %d", before, g.Score) + } +} + +func TestHardDropScoresTwoPerCellAndLocksImmediately(t *testing.T) { + g := New(23) + g.Start() + landing, dist := Drop(&g.Board, g.Active) + evs := g.HardDrop() + h := has(evs, PieceHardDropped) + if h == nil { + t.Fatalf("no PieceHardDropped in %v", kinds(evs)) + } + if h.Distance != dist || h.Points != 2*dist { + t.Errorf("distance=%d points=%d want %d and %d", h.Distance, h.Points, dist, 2*dist) + } + if g.Score != 2*dist { + t.Errorf("score = %d want %d", g.Score, 2*dist) + } + if has(evs, PieceLocked) == nil { + t.Fatal("hard drop must lock without waiting for the lock delay") + } + if has(evs, PieceSpawned) == nil { + t.Fatal("hard drop must be followed by a spawn") + } + for _, c := range landing.Cells() { + if c[1] >= 0 && g.Board.At(c[0], c[1]) == Empty { + t.Errorf("landing cell %v was not committed to the board", c) + } + } +} + +// Review focus 3 support: hard-dropping a piece that is already resting. +func TestHardDropOfAGroundedPieceScoresZeroAndStillLocks(t *testing.T) { + g := New(24) + g.Start() + g.Active, _ = Drop(&g.Board, g.Active) + evs := g.HardDrop() + if h := has(evs, PieceHardDropped); h == nil || h.Distance != 0 || h.Points != 0 { + t.Fatalf("got %v", evs) + } + if g.Score != 0 { + t.Errorf("score = %d want 0", g.Score) + } + if has(evs, PieceLocked) == nil { + t.Fatal("must still lock") + } +} + +func TestHardDropOrderIsDropThenLock(t *testing.T) { + g := New(25) + g.Start() + got := kinds(g.HardDrop()) + if len(got) < 3 || got[0] != PieceHardDropped { + t.Fatalf("first event should be PieceHardDropped, got %v", got) + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/game/ -run 'TestSoftDrop|TestHardDrop' -v` +Expected: FAIL — `undefined: (*Game).SoftDrop`. + +- [ ] **Step 3: Implement `SoftDrop` and `HardDrop`** + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/game.go internal/game/drop_test.go +git commit -m "feat(game): soft drop and hard drop with scoring" +``` + +--- + +### Task 10: Hold + +**Files:** +- Modify: `internal/game/game.go` +- Test: `internal/game/hold_test.go` + +**Interfaces:** +- Consumes: Task 7's `Game`, Task 8's spawn path. +- Produces: + - `func (g *Game) HoldPiece() []Event` — §9 rules: swaps `Active` with `Hold`; when `Hold` is empty, stores the active kind and spawns the next piece; allowed **once** before the active piece locks; the piece coming out of hold returns to spawn rotation and spawn position. Emits `HoldUsed` (carrying the outgoing piece) followed by `PieceSpawned`. Returns `nil` when `CanHold` is false or the game is over. + - `CanHold` is set back to `true` by the lock pipeline (Task 8). + +- [ ] **Step 1: Write the failing test** + +`internal/game/hold_test.go`: + +```go +package game + +import "testing" + +func TestFirstHoldStoresActiveAndSpawnsNext(t *testing.T) { + g := New(31) + g.Start() + active := g.Active.Kind + upNext := g.Next[0] + evs := g.HoldPiece() + if has(evs, HoldUsed) == nil || has(evs, PieceSpawned) == nil { + t.Fatalf("got %v", kinds(evs)) + } + if g.Hold == nil || *g.Hold != active { + t.Errorf("hold = %v want %s", g.Hold, active) + } + if g.Active.Kind != upNext { + t.Errorf("active = %s want %s", g.Active.Kind, upNext) + } + if len(g.Next) != NextCount { + t.Errorf("queue length %d want %d", len(g.Next), NextCount) + } + if g.CanHold { + t.Error("hold must be spent") + } +} + +func TestSecondHoldBeforeLockIsBlocked(t *testing.T) { + g := New(32) + g.Start() + g.HoldPiece() + before := g.Active + if evs := g.HoldPiece(); evs != nil { + t.Errorf("second hold emitted %v", kinds(evs)) + } + if g.Active != before { + t.Error("blocked hold changed the active piece") + } +} + +func TestHoldSwapsAndResetsRotationAndPosition(t *testing.T) { + g := New(33) + g.Start() + g.HoldPiece() + g.HardDrop() // restores CanHold + stored := *g.Hold + g.RotateCW() + g.MoveLeft() + g.SoftDrop() + activeBefore := g.Active.Kind + g.HoldPiece() + if *g.Hold != activeBefore { + t.Errorf("hold = %s want %s", *g.Hold, activeBefore) + } + if g.Active.Kind != stored { + t.Errorf("active = %s want the previously held %s", g.Active.Kind, stored) + } + if want := SpawnPiece(stored); g.Active != want { + t.Errorf("piece out of hold = %+v want spawn state %+v", g.Active, want) + } +} + +func TestHoldIsRestoredAfterLock(t *testing.T) { + g := New(34) + g.Start() + g.HoldPiece() + if g.CanHold { + t.Fatal("setup: hold should be spent") + } + g.HardDrop() + if !g.CanHold { + t.Error("locking must restore the hold") + } +} + +func TestHoldQueueDoesNotLosePieces(t *testing.T) { + g := New(35) + g.Start() + seen := map[PieceKind]int{} + seen[g.Active.Kind]++ + for i := 0; i < 14 && !g.Over; i++ { + g.HoldPiece() + g.HardDrop() + seen[g.Active.Kind]++ + } + if len(seen) < 5 { + t.Errorf("hold/drop cycling produced only %d distinct kinds: %v", len(seen), seen) + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/game/ -run TestHold -v` +Expected: FAIL — `undefined: (*Game).HoldPiece`. + +- [ ] **Step 3: Implement `HoldPiece`** + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/game.go internal/game/hold_test.go +git commit -m "feat(game): hold with single-use-per-piece rule" +``` + +--- + +### Task 11: Game over and restart + +**Files:** +- Modify: `internal/game/game.go` +- Test: `internal/game/gameover_test.go` + +**Interfaces:** +- Consumes: Task 8's spawn path. +- Produces: + - `Over bool` becomes `true` when a freshly spawned piece collides; the spawn emits `GameOver` instead of `PieceSpawned`. + - Every input method (`MoveLeft/MoveRight/RotateCW/RotateCCW/SoftDrop/HardDrop/HoldPiece`) and `Advance` return `nil` and mutate nothing once `Over` is true. + - `func (g *Game) Restart()` — resets the game in place from `g.Seed` (used by `r`; the app layer calls `Start()` afterwards). + +- [ ] **Step 1: Write the failing test** + +`internal/game/gameover_test.go`: + +```go +package game + +import ( + "testing" + "time" +) + +func fillBoardToTheTop(g *Game) { + for y := 0; y < Height; y++ { + for x := 0; x < Width; x++ { + g.Board.Set(x, y, CellFor(KindZ)) + } + } +} + +func TestBlockedSpawnEndsTheGame(t *testing.T) { + g := New(41) + g.Start() + fillBoardToTheTop(g) + // Carve out just enough room for the active piece to fall and lock. + for _, c := range g.Active.Cells() { + g.Board.Set(c[0], c[1], Empty) + } + evs := g.HardDrop() + if has(evs, GameOver) == nil { + t.Fatalf("expected GameOver, got %v", kinds(evs)) + } + if has(evs, PieceSpawned) != nil { + t.Error("a game-ending spawn must not also report PieceSpawned") + } + if !g.Over { + t.Error("Over flag not set") + } +} + +// Review focus 5: input after game over. +func TestInputAfterGameOverIsInert(t *testing.T) { + g := New(42) + g.Start() + fillBoardToTheTop(g) + for _, c := range g.Active.Cells() { + g.Board.Set(c[0], c[1], Empty) + } + g.HardDrop() + if !g.Over { + t.Fatal("setup: game should be over") + } + snapshot := *g + fns := map[string]func() []Event{ + "MoveLeft": g.MoveLeft, "MoveRight": g.MoveRight, + "RotateCW": g.RotateCW, "RotateCCW": g.RotateCCW, + "SoftDrop": g.SoftDrop, "HardDrop": g.HardDrop, "HoldPiece": g.HoldPiece, + } + for name, fn := range fns { + if evs := fn(); evs != nil { + t.Errorf("%s after game over emitted %v", name, kinds(evs)) + } + } + if evs := g.Advance(2 * time.Second); evs != nil { + t.Errorf("Advance after game over emitted %v", kinds(evs)) + } + if g.Score != snapshot.Score || g.Active != snapshot.Active || + g.Board.Fingerprint() != snapshot.Board.Fingerprint() { + t.Error("state changed after game over") + } +} + +func TestRestartResetsEverythingFromTheSameSeed(t *testing.T) { + g := New(43) + g.Start() + for i := 0; i < 6; i++ { + g.HardDrop() + } + g.Restart() + fresh := New(43) + fresh.Start() + g.Start() + if g.Score != 0 || g.Lines != 0 || g.Level != 1 || g.Combo != 0 || g.Over { + t.Errorf("counters not reset: %+v", struct{ S, L, Ln, C int }{g.Score, g.Level, g.Lines, g.Combo}) + } + if g.Board.Fingerprint() != fresh.Board.Fingerprint() { + t.Error("board not cleared") + } + if g.Active.Kind != fresh.Active.Kind { + t.Errorf("restart drew %s, a fresh game with the same seed drew %s", g.Active.Kind, fresh.Active.Kind) + } + if g.Hold != nil || !g.CanHold { + t.Error("hold not reset") + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/game/ -run 'TestBlockedSpawn|TestInputAfterGameOver|TestRestart' -v` +Expected: FAIL — no `GameOver` emitted / `undefined: (*Game).Restart`. + +- [ ] **Step 3: Implement the game-over guard and `Restart`** + +Add a single `if g.Over { return nil }` guard at the top of every input method and `Advance`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/game.go internal/game/gameover_test.go +git commit -m "feat(game): game over on blocked spawn, inert input, restart" +``` + +--- + +### Task 12: Combo and level progression across placements + +**Files:** +- Modify: `internal/game/game.go` (only if the tests expose a gap) +- Test: `internal/game/progression_test.go` + +**Interfaces:** +- Consumes: Tasks 8, 9. Produces no new API — this task pins §13/§49.1 behaviour across a sequence of placements, which single-lock tests cannot express. + +- [ ] **Step 1: Write the failing test** + +`internal/game/progression_test.go`: + +```go +package game + +import "testing" + +// clearOneRow fills row `y` except columns 4 and 5, then hard-drops an O piece +// into the gap. Returns the events from the placement. +func clearOneRow(t *testing.T, g *Game, y int) []Event { + t.Helper() + for x := 0; x < Width; x++ { + if x != 4 && x != 5 { + g.Board.Set(x, y, CellFor(KindI)) + } + } + g.Active = Piece{Kind: KindO, Rotation: 0, X: 3, Y: 0} + return g.HardDrop() +} + +func TestComboStartsAtOneOnTheFirstClear(t *testing.T) { + g := New(51) + g.Start() + evs := clearOneRow(t, g, 21) + if g.Combo != 1 { + t.Errorf("combo = %d want 1", g.Combo) + } + if e := has(evs, ComboChanged); e == nil || e.Combo != 1 { + t.Errorf("expected ComboChanged{1}, got %v", evs) + } + // Bonus is 50 x (combo-1) x level = 0 at combo 1. + if e := has(evs, LinesCleared); e.Points != 100 { + t.Errorf("points = %d want 100 with no combo bonus", e.Points) + } +} + +func TestSecondConsecutiveClearAddsComboBonus(t *testing.T) { + g := New(52) + g.Start() + clearOneRow(t, g, 21) + scoreAfterFirst := g.Score + evs := clearOneRow(t, g, 21) + if g.Combo != 2 { + t.Errorf("combo = %d want 2", g.Combo) + } + // single (100 x level 1) + bonus (50 x 1 x 1) = 150 + if got := g.Score - scoreAfterFirst; got < 150 { + t.Errorf("second clear added %d, want at least 150 (100 + 50 combo bonus)", got) + } + if e := has(evs, ComboChanged); e == nil || e.Combo != 2 { + t.Errorf("expected ComboChanged{2}, got %v", evs) + } +} + +func TestPlacementWithoutAClearResetsCombo(t *testing.T) { + g := New(53) + g.Start() + clearOneRow(t, g, 21) + if g.Combo != 1 { + t.Fatal("setup") + } + g.Active = SpawnPiece(KindO) + evs := g.HardDrop() // lands on empty floor, clears nothing + if g.Combo != 0 { + t.Errorf("combo = %d want 0", g.Combo) + } + if e := has(evs, ComboChanged); e == nil || e.Combo != 0 { + t.Errorf("expected ComboChanged{0}, got %v", kinds(evs)) + } + if has(evs, LinesCleared) != nil { + t.Error("no rows were complete") + } +} + +func TestNoComboEventWhenTheValueDoesNotChange(t *testing.T) { + g := New(54) + g.Start() + g.Active = SpawnPiece(KindO) + g.HardDrop() + g.Active = SpawnPiece(KindO) + evs := g.HardDrop() // combo was already 0 + if has(evs, ComboChanged) != nil { + t.Errorf("combo stayed 0 but ComboChanged was emitted: %v", kinds(evs)) + } +} + +func TestLevelRisesEveryTenLines(t *testing.T) { + g := New(55) + g.Start() + sawLevelChange := false + for i := 0; i < 10; i++ { + evs := clearOneRow(t, g, 21) + if has(evs, LevelChanged) != nil { + sawLevelChange = true + if e := has(evs, LevelChanged); e.Level != 2 { + t.Errorf("LevelChanged carried level %d want 2", e.Level) + } + if i != 9 { + t.Errorf("level changed after %d lines, want 10", i+1) + } + } + if g.Over { + t.Fatalf("game ended early at clear %d", i) + } + } + if g.Lines != 10 { + t.Fatalf("lines = %d want 10", g.Lines) + } + if g.Level != 2 { + t.Errorf("level = %d want 2", g.Level) + } + if !sawLevelChange { + t.Error("no LevelChanged event was emitted") + } +} + +func TestFourLineClearScoresEightHundredTimesLevel(t *testing.T) { + g := New(56) + g.Start() + for y := 18; y <= 21; y++ { + for x := 0; x < Width; x++ { + if x != 4 { + g.Board.Set(x, y, CellFor(KindI)) + } + } + } + g.Active = Piece{Kind: KindI, Rotation: 1, X: 2, Y: 0} // vertical I in column 4 + evs := g.HardDrop() + e := has(evs, LinesCleared) + if e == nil || len(e.Rows) != 4 { + t.Fatalf("expected a four-line clear, got %v", evs) + } + if g.Lines != 4 { + t.Errorf("lines = %d want 4", g.Lines) + } + dropPoints := has(evs, PieceHardDropped).Points + if got := g.Score - dropPoints; got != 800 { + t.Errorf("clear scored %d want 800", got) + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail (or reveal a real gap)** + +Run: `go test ./internal/game/ -run 'TestCombo|TestSecond|TestPlacement|TestNoCombo|TestLevelRises|TestFourLine' -v` +Expected: FAIL. If some already pass, that is fine — fix only what fails. + +- [ ] **Step 3: Fix the combo/level bookkeeping in `game.go` until the tests pass** + +Watch the `LinesCleared.Points` split: line value and combo bonus are both computed at the pre-clear level, and both belong in that event's `Points`. + +- [ ] **Step 4: Run the full package** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/game.go internal/game/progression_test.go +git commit -m "test(game): combo indexing and level progression across placements" +``` + +--- + +### Task 13: Determinism replay test + +**Files:** +- Create: `internal/game/determinism_test.go` + +**Interfaces:** +- Consumes: the whole engine. Produces no API — this is §35's promise made checkable, per §49.2. + +- [ ] **Step 1: Write the failing test** + +`internal/game/determinism_test.go`: + +```go +package game + +import ( + "testing" + "time" +) + +type inputCode int + +const ( + inNone inputCode = iota + inLeft + inRight + inCW + inCCW + inSoft + inHard + inHold +) + +// replay runs a fixed, boring-looking script of (input, dt) pairs and returns a +// compact description of the final state. +func replay(seed int64) (score, lines, level, combo int, fp uint64, over bool) { + g := New(seed) + g.Start() + script := []struct { + in inputCode + dt time.Duration + }{} + // A deterministic pseudo-script: no RNG, just arithmetic on the index. + for i := 0; i < 600; i++ { + in := inputCode(i % 8) + dt := time.Duration(7+(i*13)%90) * time.Millisecond + script = append(script, struct { + in inputCode + dt time.Duration + }{in, dt}) + } + for _, step := range script { + switch step.in { + case inLeft: + g.MoveLeft() + case inRight: + g.MoveRight() + case inCW: + g.RotateCW() + case inCCW: + g.RotateCCW() + case inSoft: + g.SoftDrop() + case inHard: + g.HardDrop() + case inHold: + g.HoldPiece() + } + g.Advance(step.dt) + } + return g.Score, g.Lines, g.Level, g.Combo, g.Board.Fingerprint(), g.Over +} + +func TestReplayIsReproducible(t *testing.T) { + s1, l1, lv1, c1, f1, o1 := replay(8675309) + s2, l2, lv2, c2, f2, o2 := replay(8675309) + if s1 != s2 || l1 != l2 || lv1 != lv2 || c1 != c2 || f1 != f2 || o1 != o2 { + t.Fatalf("same seed and script diverged:\n %d %d %d %d %x %v\n %d %d %d %d %x %v", + s1, l1, lv1, c1, f1, o1, s2, l2, lv2, c2, f2, o2) + } + if s1 == 0 && l1 == 0 { + t.Fatal("the script did nothing; it must actually play the game") + } +} + +func TestReplayDependsOnTheSeed(t *testing.T) { + s1, l1, _, _, f1, _ := replay(1) + s2, l2, _, _, f2, _ := replay(2) + if s1 == s2 && l1 == l2 && f1 == f2 { + t.Fatal("two different seeds produced an identical game") + } +} + +func TestReplayDependsOnTimingInputs(t *testing.T) { + run := func(dt time.Duration) uint64 { + g := New(77) + g.Start() + for i := 0; i < 300; i++ { + if i%9 == 0 { + g.MoveLeft() + } + g.Advance(dt) + } + return g.Board.Fingerprint() + } + if run(20*time.Millisecond) == run(200*time.Millisecond) { + t.Fatal("timing is not affecting the simulation; gravity may not be dt-driven") + } +} + +func TestEngineNeverReadsTheClock(t *testing.T) { + // Guard for §49.2. Kept as a test so it runs in CI with everything else. + t.Skip("enforced by the grep step in the plan; see TestNoTimeNowInGamePackage") +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/game/ -run TestReplay -v` +Expected: FAIL initially only if something is non-deterministic; a PASS here is also a valid result. If `TestReplayIsReproducible` fails, find the shared or global RNG and remove it. + +- [ ] **Step 3: Replace the skipped clock test with a real one** + +Delete `TestEngineNeverReadsTheClock` and add, in the same file: + +```go +func TestNoTimeNowInGamePackage(t *testing.T) { + entries, err := os.ReadDir(".") + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + name := e.Name() + if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + src, err := os.ReadFile(name) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(src, []byte("time.Now()")) { + t.Errorf("%s calls time.Now(); §49.2 forbids a clock in internal/game", name) + } + } +} +``` + +Add the `bytes`, `os`, `strings` imports. + +- [ ] **Step 4: Run the whole suite with the race detector** + +Run: `go test ./... -race -count=2 && go vet ./...` +Expected: PASS twice (the `-count=2` catches state leaking through package-level variables). + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/determinism_test.go +git commit -m "test(game): seeded replay determinism and no-clock guard" +``` + +--- + +### Task 14: Engine gate — full-suite run and coverage read + +**Files:** +- Create: `Makefile` + +**Interfaces:** +- Consumes: everything. Produces: `make test`, `make cover`, `make lint` targets used by every later plan. + +- [ ] **Step 1: Write the Makefile** + +```make +.PHONY: test cover lint build run +test: + go test ./... -race +cover: + go test ./internal/game/ -coverprofile=/tmp/cosmic.cover && go tool cover -func=/tmp/cosmic.cover | tail -1 +lint: + go vet ./... +build: + go build -o cosmic-tetris ./cmd/cosmic-tetris +run: build + ./cosmic-tetris +``` + +`build`/`run` will fail until Plan 02 creates `cmd/cosmic-tetris`; that is expected. + +- [ ] **Step 2: Run the gate** + +Run: `make test && make lint && make cover` +Expected: all tests PASS; coverage of `internal/game` at or above 85% of statements. If it is lower, find the untested branch and add the missing case to the task that owns it. + +- [ ] **Step 3: Confirm §42's Phase 1 checklist by inspection** + +Tick off, against the test names that prove each one: pieces, board, bag, movement, rotation, gravity, locking, line clearing, hold, scoring, game over. Anything without a test gets one before this plan is called done. + +- [ ] **Step 4: Commit** + +```bash +git add Makefile +git commit -m "chore: make targets for test, cover, lint, build" +``` + +--- + +## Done when + +- `make test` passes with `-race`. +- `internal/game` has no import outside the standard library, no `time.Now()`, no goroutine, no I/O. +- Every §40 bullet under Board, Pieces, Bag, Hold, Drop, Score, Game over, and Determinism maps to a named test. +- A `Game` can be played from first spawn to game over through `Advance` and the input methods alone, with `Event`s describing everything that happened — which is exactly what Plan 02 renders. diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/N1-recipe-cosmic-11/2026-09-17-cosmic-tetris-02-terminal.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/N1-recipe-cosmic-11/2026-09-17-cosmic-tetris-02-terminal.md new file mode 100644 index 000000000..d8b653bd7 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/N1-recipe-cosmic-11/2026-09-17-cosmic-tetris-02-terminal.md @@ -0,0 +1,1805 @@ +# Cosmic Tetris — Plan 02: Playable Terminal Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Turn the Plan 01 engine into a genuinely good, fully playable terminal game — Bubble Tea event loop, keyboard, board/HUD/ghost/next/hold rendering, adaptive layout, pause, restart, help, and the CLI — with golden layout tests. + +**Architecture:** Every frame is composited into a character grid (`render.Canvas`) and emitted as one string, so later plans can draw stars behind the board and particles over it without touching this code. `internal/render` owns all drawing and is handed an immutable `Frame` describing what to draw; it imports `internal/game` but never the app. `internal/app` owns the Bubble Tea `Model`, the single 60 Hz frame clock, and key handling; it converts elapsed wall time into the engine's `Advance(dt)`. + +**Tech Stack:** Go 1.26, `charm.land/bubbletea/v2`, `charm.land/lipgloss/v2`, `charm.land/bubbles/v2` (key + help only). + +**Spec:** `design.md` — Phase 2 of §42. Sections most relevant: §4, §5, §8, §9, §10, §26, §30, §31, §32, §33, §34, §36, §37, §39, §41, §46, §49.3, §49.4, §49.7. + +**Prerequisite:** Plan 01 complete; `make test` green. + +## Global Constraints + +- Dependencies are exactly `charm.land/bubbletea/v2`, `charm.land/lipgloss/v2`, `charm.land/bubbles/v2`. Bubbles is used **only** for key bindings, help, and (Plan 05) the boot spinner. Do not abstract Bubble Tea away behind a homegrown framework (§3). +- One logical block occupies `2 terminal columns × 1 terminal row`. +- Glyphs (§49.4): pieces `██` (ASCII `[]`); ghost `░░` in full/reduced, `··` in ASCII. Pieces use a bright foreground on filled glyphs — **not** the foreground+background pairing §26 offers as an alternative. The active piece renders one step brighter than locked cells. +- Ghost rendering must never obscure locked blocks (§10). +- The render pass must not mutate game state (§37). `render` takes values and read-only pointers and returns a string. +- Rendering order per frame is §37's twelve steps; steps 2/6/9/10 (FX and banners) are stubs in this plan and filled in by Plans 03–05. +- Minimum usable terminal: `~40 columns × ~24 rows`. Below that, show §31's too-small notice. Handle resize live; never crash from a resize. +- Small-terminal drop order (§49.3): title border first, then mission control, then stats labels. NEXT never stacks above or below the board; at small sizes it sits beside the board and truncates to 3 upcoming pieces. Board and controls are the last two elements standing. +- Visual updates target `60 Hz`. One animation clock; gravity is elapsed-time based; input must not wait for ticks. +- CLI surface, exactly (§49.5): `cosmic-tetris`, `--seed 1234`, `--ascii`, `--no-fx`, `--reduced-motion`, `--help`. Nothing else. +- Controls (§8): `← / h` left, `→ / l` right, `↓ / j` soft drop, `↑ / k / x` clockwise, `z` counter-clockwise, `space` hard drop, `c` hold, `p` pause, `r` restart, `?` help, `q / esc` quit. WASD aliases `a d s w`. +- No filesystem access during gameplay; no goroutine per frame; no synchronous logging (§38). +- §49.7: the §4 mockup communicates mood and element placement, not geometry. The ANSI-stripped golden tests are the binding layout contract. + +## Review Focus + +1. **The first frame, before any `WindowSizeMsg`** — width and height are `0`. `View()` must return something harmless rather than panic or index a zero-size canvas. → Task 4 (layout) and Task 8 (model). +2. **A keypress that arrives before the first frame tick** — `LastFrame` is the zero `time.Time`, so a naive `Now.Sub(LastFrame)` is decades. The dt clamp must survive it. → Task 8. +3. **A terminal below the minimum, and the notice itself not fitting** — at 34×19 the §31 notice must render inside 34 columns, and the app must recover live when the terminal grows. → Task 6. +4. **Odd/large terminal widths** — a 22-column board centered in an odd-width terminal, and a 300×100 terminal: no line may exceed the terminal width and nothing may overlap. → Task 2 (canvas clipping) and Task 6 (frame invariants). +5. **`--ascii` output containing a non-ASCII byte anywhere** — one stray `║` in an overlay defeats the whole mode on a terminal that cannot draw it. → Task 6, asserted across every overlay. + +--- + +### Task 1: Dependencies, CLI parsing, and the config type + +**Files:** +- Modify: `go.mod` +- Create: `internal/app/config.go` +- Test: `internal/app/config_test.go` + +**Interfaces:** +- Consumes: nothing from Plan 01 except the module. +- Produces: + - ```go + type Config struct { + Seed int64 + ASCII bool + NoFX bool + ReducedMotion bool + } + ``` + - `func ParseArgs(args []string) (Config, string, error)` — `args` excludes the program name. Returns the config, a non-empty usage string when `--help` was requested (caller prints it and exits 0), and an error for anything unrecognized. `--seed` defaults to `0`, which the caller replaces with a clock-derived seed; a seed given explicitly is used verbatim. + - `func (c Config) SeedOrRandom(now time.Time) int64` — returns `c.Seed` when non-zero, else `now.UnixNano()`. This is where the clock enters the program; the engine still never reads one. + +- [ ] **Step 1: Add the dependencies** + +```bash +go get charm.land/bubbletea/v2@latest charm.land/lipgloss/v2@latest charm.land/bubbles/v2@latest +go mod tidy +``` + +If the `charm.land` vanity paths do not resolve, use `github.com/charmbracelet/bubbletea/v2`, `github.com/charmbracelet/lipgloss/v2`, `github.com/charmbracelet/bubbles/v2` instead, and note the substitution in a `## Dependencies` line in `README.md` (created in Plan 05). Do not proceed with v1 of any of the three. + +- [ ] **Step 2: Record the real v2 APIs you will code against** + +```bash +go doc charm.land/bubbletea/v2 Model +go doc charm.land/bubbletea/v2 KeyPressMsg +go doc charm.land/bubbletea/v2 WindowSizeMsg +go doc charm.land/bubbletea/v2 Tick +go doc charm.land/lipgloss/v2 Style +go doc charm.land/lipgloss/v2 Color +``` + +Expected: `Model` shows v2's signatures (`Init`, `Update`, `View`). Write the exact three method signatures into a comment at the top of `internal/app/model.go` when you create it in Task 8, and code to those, not to remembered v1 shapes. + +- [ ] **Step 3: Write the failing test** + +`internal/app/config_test.go`: + +```go +package app + +import ( + "strings" + "testing" + "time" +) + +func TestParseArgsDefaults(t *testing.T) { + cfg, usage, err := ParseArgs(nil) + if err != nil || usage != "" { + t.Fatalf("err=%v usage=%q", err, usage) + } + if cfg != (Config{}) { + t.Errorf("defaults should be the zero config, got %+v", cfg) + } +} + +func TestParseArgsEveryFlag(t *testing.T) { + cfg, _, err := ParseArgs([]string{"--seed", "1234", "--ascii", "--no-fx", "--reduced-motion"}) + if err != nil { + t.Fatal(err) + } + want := Config{Seed: 1234, ASCII: true, NoFX: true, ReducedMotion: true} + if cfg != want { + t.Errorf("got %+v want %+v", cfg, want) + } +} + +func TestParseArgsHelpReturnsUsage(t *testing.T) { + _, usage, err := ParseArgs([]string{"--help"}) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"--seed", "--ascii", "--no-fx", "--reduced-motion", "cosmic-tetris"} { + if !strings.Contains(usage, want) { + t.Errorf("usage missing %q:\n%s", want, usage) + } + } +} + +func TestParseArgsRejectsUnknownFlags(t *testing.T) { + if _, _, err := ParseArgs([]string{"--turbo"}); err == nil { + t.Fatal("unknown flag accepted") + } + if _, _, err := ParseArgs([]string{"--seed", "banana"}); err == nil { + t.Fatal("non-numeric seed accepted") + } +} + +func TestSeedOrRandom(t *testing.T) { + now := time.Unix(0, 4242) + if got := (Config{Seed: 7}).SeedOrRandom(now); got != 7 { + t.Errorf("explicit seed: got %d want 7", got) + } + if got := (Config{}).SeedOrRandom(now); got != 4242 { + t.Errorf("random seed: got %d want the clock value 4242", got) + } +} +``` + +- [ ] **Step 4: Run the test to verify it fails** + +Run: `go test ./internal/app/ -v` +Expected: FAIL — `undefined: ParseArgs`. + +- [ ] **Step 5: Implement `config.go`** + +Use `flag.NewFlagSet("cosmic-tetris", flag.ContinueOnError)` with its output redirected to a `bytes.Buffer` so `ParseArgs` returns usage instead of printing it. + +- [ ] **Step 6: Run the test to verify it passes** + +Run: `go test ./internal/app/ -v` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add go.mod go.sum internal/app/config.go internal/app/config_test.go +git commit -m "feat(app): charm v2 dependencies and CLI parsing" +``` + +--- + +### Task 2: The character canvas + +**Files:** +- Create: `internal/render/canvas.go` +- Test: `internal/render/canvas_test.go` + +**Interfaces:** +- Consumes: nothing. +- Produces: + - ```go + type Paint struct { + FG string // "" = terminal default; hex "#22f0ff" or an ANSI index "51" + Bold bool + Faint bool + } + type Canvas struct { W, H int /* unexported cell storage */ } + ``` + - `func NewCanvas(w, h int) *Canvas` — `w` or `h` ≤ 0 yields a canvas with those dims and no cells; all writes are no-ops. + - `func (c *Canvas) Fill(r rune, p Paint)`, `func (c *Canvas) Clear()`. + - `func (c *Canvas) Set(x, y int, r rune, p Paint)` — silently ignores out-of-bounds coordinates. + - `func (c *Canvas) SetString(x, y int, s string, p Paint)` — writes left to right by rune, clipping at the right edge; returns nothing. + - `func (c *Canvas) Rune(x, y int) rune`, `func (c *Canvas) PaintAt(x, y int) Paint` — `' '` / zero `Paint` out of bounds; used by tests and by the FX readability guard in Plan 04. + - `func (c *Canvas) Blit(src *Canvas, x, y int)` — copies `src` at the offset, clipping; used by screen shake in Plan 04. + - `func (c *Canvas) String() string` — `H` lines joined by `\n`, each exactly `W` display columns before styling, with adjacent equal-`Paint` runs coalesced into one styled segment. + +- [ ] **Step 1: Write the failing test** + +`internal/render/canvas_test.go`: + +```go +package render + +import ( + "regexp" + "strings" + "testing" +) + +var ansi = regexp.MustCompile("\x1b\\[[0-9;?]*[a-zA-Z]") + +func plain(s string) string { return ansi.ReplaceAllString(s, "") } + +func TestNewCanvasStartsBlank(t *testing.T) { + c := NewCanvas(4, 2) + got := plain(c.String()) + if got != " \n " { + t.Fatalf("got %q", got) + } +} + +func TestSetAndRuneRoundTrip(t *testing.T) { + c := NewCanvas(3, 3) + p := Paint{FG: "#ff00ff", Bold: true} + c.Set(1, 1, '✦', p) + if c.Rune(1, 1) != '✦' { + t.Errorf("rune = %q", c.Rune(1, 1)) + } + if c.PaintAt(1, 1) != p { + t.Errorf("paint = %+v", c.PaintAt(1, 1)) + } +} + +func TestWritesOutsideTheCanvasAreIgnored(t *testing.T) { + c := NewCanvas(3, 2) + for _, pt := range [][2]int{{-1, 0}, {0, -1}, {3, 0}, {0, 2}, {99, 99}, {-99, -99}} { + c.Set(pt[0], pt[1], 'X', Paint{}) // must not panic + } + if strings.Contains(plain(c.String()), "X") { + t.Fatal("an out-of-bounds write landed on the canvas") + } +} + +func TestZeroSizeCanvasIsSafe(t *testing.T) { + for _, dims := range [][2]int{{0, 0}, {0, 10}, {10, 0}, {-5, -5}} { + c := NewCanvas(dims[0], dims[1]) + c.Set(0, 0, 'X', Paint{}) + c.SetString(0, 0, "hello", Paint{}) + c.Fill('#', Paint{}) + _ = c.String() // must not panic + } +} + +func TestSetStringClipsAtTheRightEdge(t *testing.T) { + c := NewCanvas(5, 1) + c.SetString(3, 0, "ABCDEF", Paint{}) + if got := plain(c.String()); got != " AB" { + t.Fatalf("got %q want %q", got, " AB") + } +} + +func TestEveryLineIsExactlyWColumnsWide(t *testing.T) { + c := NewCanvas(8, 3) + c.SetString(0, 1, "hi", Paint{}) + for i, line := range strings.Split(plain(c.String()), "\n") { + if n := len([]rune(line)); n != 8 { + t.Errorf("line %d has %d columns want 8: %q", i, n, line) + } + } +} + +func TestBlitClipsBothWays(t *testing.T) { + src := NewCanvas(2, 2) + src.Fill('#', Paint{}) + dst := NewCanvas(4, 4) + dst.Blit(src, 3, 3) // only one cell lands + dst.Blit(src, -1, -1) // only one cell lands + got := plain(dst.String()) + if strings.Count(got, "#") != 2 { + t.Fatalf("expected 2 surviving cells, got %q", got) + } +} + +func TestEqualPaintRunsAreCoalesced(t *testing.T) { + c := NewCanvas(10, 1) + c.SetString(0, 0, "##########", Paint{FG: "#22f0ff"}) + out := c.String() + if n := strings.Count(out, "\x1b["); n > 4 { + t.Errorf("ten identically painted cells produced %d escape sequences; runs are not coalesced", n) + } + if plain(out) != "##########" { + t.Errorf("content changed: %q", plain(out)) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/render/ -v` +Expected: FAIL — `undefined: NewCanvas`. + +- [ ] **Step 3: Implement `canvas.go`** + +Store cells as two flat slices (`[]rune`, `[]Paint`) of length `W*H`. In `String()`, walk each row accumulating runes while `Paint` is unchanged, then emit the accumulated segment through a `Paint`→`lipgloss.Style` conversion memoized in a `map[Paint]lipgloss.Style` on the canvas. An empty `Paint` emits the raw segment with no styling at all. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/canvas.go internal/render/canvas_test.go +git commit -m "feat(render): clipping character canvas with paint-run coalescing" +``` + +--- + +### Task 3: Rendering modes, glyph sets, and the neon palette + +**Files:** +- Create: `internal/render/palette.go` +- Test: `internal/render/palette_test.go` + +**Interfaces:** +- Consumes: `game.PieceKind`, `Paint`. +- Produces: + - `type Mode int` with `ModeFull, ModeReduced, ModeASCII`; `func (m Mode) String() string`. + - `func DetectMode(env func(string) string, forceASCII bool) Mode` — `forceASCII` wins; else `COLORTERM` in `{"truecolor","24bit"}` → `ModeFull`; else `TERM` containing `"256color"` → `ModeReduced`; else `TERM` empty or `"dumb"` → `ModeASCII`; else `ModeReduced`. Taking `env` as a parameter keeps this testable. + - ```go + type Glyphs struct { + Block, Ghost, Empty string // two columns each + BorderH, BorderV string + BorderTL, BorderTR, BorderBL, BorderBR string + Bullet string // mission-control prefix: "☄" / ">" + } + func GlyphsFor(m Mode) Glyphs + ``` + Full/reduced: `██`, `░░`, ` `, `═`, `║`, `╔╗╚╝`, `☄`. ASCII: `[]`, `..`, ` `, `-`, `|`, `+` for all four corners, prefix `>`. (§49.4 writes the ASCII ghost as `··`, which is U+00B7 and not ASCII; Step 3 resolves that.) + - `func PieceColorFor(k game.PieceKind, m Mode) string` — the palette below. + - `func LockedPaint(k game.PieceKind, m Mode) Paint` → color, not bold. + - `func ActivePaint(k game.PieceKind, m Mode) Paint` → same color, `Bold: true` (§49.4 "one step brighter"). + - `func GhostPaint(k game.PieceKind, m Mode) Paint` → same color, `Faint: true`. + - `func ChromePaint(m Mode) Paint`, `func LabelPaint(m Mode) Paint`, `func ValuePaint(m Mode) Paint`. + +Pinned palette (§26 intent, made concrete): + +| Kind | Intent | Full (hex) | Reduced (ANSI 256) | ASCII (ANSI 8) | +|---|---|---|---|---| +| I | plasma cyan | `#22f0ff` | `51` | `6` | +| J | deep electric blue | `#2b6bff` | `27` | `4` | +| L | solar orange | `#ff8a1f` | `208` | `3` | +| O | stellar gold | `#ffd23f` | `220` | `3` | +| S | alien green | `#3dff85` | `48` | `2` | +| T | ultraviolet | `#a45cff` | `141` | `5` | +| Z | supernova pink | `#ff2e6e` | `198` | `1` | + +- [ ] **Step 1: Write the failing test** + +`internal/render/palette_test.go`: + +```go +package render + +import ( + "testing" + "unicode" + + "github.com/jessev/cosmic-tetris/internal/game" +) + +func env(m map[string]string) func(string) string { + return func(k string) string { return m[k] } +} + +func TestDetectMode(t *testing.T) { + cases := []struct { + name string + vars map[string]string + force bool + want Mode + }{ + {"forced ascii beats truecolor", map[string]string{"COLORTERM": "truecolor"}, true, ModeASCII}, + {"truecolor", map[string]string{"COLORTERM": "truecolor", "TERM": "xterm-256color"}, false, ModeFull}, + {"24bit", map[string]string{"COLORTERM": "24bit"}, false, ModeFull}, + {"256color", map[string]string{"TERM": "screen-256color"}, false, ModeReduced}, + {"dumb", map[string]string{"TERM": "dumb"}, false, ModeASCII}, + {"empty env", map[string]string{}, false, ModeASCII}, + {"plain xterm", map[string]string{"TERM": "xterm"}, false, ModeReduced}, + } + for _, c := range cases { + if got := DetectMode(env(c.vars), c.force); got != c.want { + t.Errorf("%s: got %v want %v", c.name, got, c.want) + } + } +} + +func TestGlyphWidths(t *testing.T) { + for _, m := range []Mode{ModeFull, ModeReduced, ModeASCII} { + g := GlyphsFor(m) + for name, s := range map[string]string{"Block": g.Block, "Ghost": g.Ghost, "Empty": g.Empty} { + if n := len([]rune(s)); n != 2 { + t.Errorf("%v %s = %q is %d runes, must be exactly 2 columns", m, name, s, n) + } + } + } +} + +func TestPinnedGlyphChoices(t *testing.T) { + full := GlyphsFor(ModeFull) + if full.Block != "██" || full.Ghost != "░░" { + t.Errorf("full mode glyphs = %q / %q want ██ / ░░", full.Block, full.Ghost) + } + if GlyphsFor(ModeReduced).Ghost != "░░" { + t.Error("reduced mode must also use ░░") + } + if a := GlyphsFor(ModeASCII); a.Block != "[]" { + t.Errorf("ascii block = %q want []", a.Block) + } +} + +func TestASCIIModeGlyphsAreASCII(t *testing.T) { + g := GlyphsFor(ModeASCII) + for name, s := range map[string]string{ + "Block": g.Block, "Ghost": g.Ghost, "Empty": g.Empty, + "BorderH": g.BorderH, "BorderV": g.BorderV, "BorderTL": g.BorderTL, + "BorderTR": g.BorderTR, "BorderBL": g.BorderBL, "BorderBR": g.BorderBR, + "Bullet": g.Bullet, + } { + for _, r := range s { + if r > unicode.MaxASCII { + t.Errorf("ascii mode %s contains %q (U+%04X)", name, r, r) + } + } + } +} + +func TestEveryKindHasADistinctColourInEveryMode(t *testing.T) { + for _, m := range []Mode{ModeFull, ModeReduced} { + seen := map[string]game.PieceKind{} + for _, k := range game.AllKinds { + c := PieceColorFor(k, m) + if c == "" { + t.Errorf("%v: %s has no colour", m, k) + } + if prev, dup := seen[c]; dup { + t.Errorf("%v: %s and %s share colour %s", m, prev, k, c) + } + seen[c] = k + } + } +} + +func TestActiveIsBrighterThanLockedWhichIsBrighterThanGhost(t *testing.T) { + for _, m := range []Mode{ModeFull, ModeReduced, ModeASCII} { + k := game.KindT + a, l, g := ActivePaint(k, m), LockedPaint(k, m), GhostPaint(k, m) + if !a.Bold || l.Bold { + t.Errorf("%v: active must be bold and locked must not (%+v / %+v)", m, a, l) + } + if !g.Faint || l.Faint { + t.Errorf("%v: ghost must be faint and locked must not (%+v / %+v)", m, g, l) + } + if a.FG != l.FG || l.FG != g.FG { + t.Errorf("%v: all three states must share the piece hue", m) + } + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/render/ -run 'TestDetectMode|TestGlyph|TestPinned|TestASCII|TestEveryKind|TestActiveIs' -v` +Expected: FAIL — `undefined: DetectMode`. + +- [ ] **Step 3: Resolve the ASCII ghost glyph, then implement `palette.go`** + +`··` (U+00B7 MIDDLE DOT) is not ASCII, and §32 says ASCII mode makes "no special Unicode assumptions". Ship `..` in `ModeASCII`, keep `░░` in full and reduced as §49.4 pins, and put this comment above `GlyphsFor`: + +```go +// §49.4 pins the ASCII ghost as "··" (U+00B7), which is not ASCII. ASCII mode +// exists precisely for terminals that cannot be trusted with non-ASCII bytes +// (§32), and Plan 02 Task 6 asserts the whole frame is ASCII in that mode, so +// the ghost is ".." here. Full and reduced modes use ░░ exactly as pinned. +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/palette.go internal/render/palette_test.go +git commit -m "feat(render): modes, glyph sets, and the neon space palette" +``` + +--- + +### Task 4: Adaptive layout + +**Files:** +- Create: `internal/render/layout.go` +- Test: `internal/render/layout_test.go` + +**Interfaces:** +- Consumes: `game.Width`, `game.VisibleHeight`. +- Produces: + - `type Size int` with `SizeTooSmall, SizeSmall, SizeMedium, SizeWide`. + - `type Rect struct { X, Y, W, H int }`, `func (r Rect) Empty() bool`, `func (r Rect) Right() int`, `func (r Rect) Bottom() int`. + - `const MinCols = 40`, `MinRows = 24`, `BoardCols = game.Width * 2` (20), `BoardRows = game.VisibleHeight` (20), `FrameCols = BoardCols + 2` (22), `FrameRows = BoardRows + 2` (22). + - ```go + type Layout struct { + Size Size + Width, Height int + Frame Rect // board including its border + Left Rect // hold + stats column; Empty when dropped + Right Rect // next column (+ stats when Left is empty) + Title Rect // Empty when dropped + Mission Rect // Empty when dropped + Controls Rect + NextCount int + ShowStatLabels bool + } + func Compute(w, h int) Layout + ``` + +Pinned thresholds — this table is the contract the golden tests then freeze: + +| Condition | Size | +|---|---| +| `w < 40 || h < 24` | `SizeTooSmall` | +| `w < 46 || h < 26` | `SizeSmall` | +| `w < 64` | `SizeMedium` | +| otherwise | `SizeWide` | + +Element rules: +- `Title` present when `h >= 26`; `Mission` present when `h >= 25`; `Controls` always present; `ShowStatLabels = Size != SizeSmall`. (Title goes first, then mission control, then labels — §49.3's order as height shrinks.) +- `Left` (width 14) present only in `SizeWide`; it carries HOLD then SCORE/LINES/LEVEL. +- `Right` width 10; `NextCount = 5` in `SizeWide`, otherwise `3`. Outside `SizeWide` the `Right` column carries HOLD, then NEXT, then the stats. +- Vertical stack, centred: `rows = titleRows + FrameRows + missionRows + 1`, `Y = max(0, (h-rows)/2)`. +- Horizontal: `content = Left.W + FrameCols + Right.W`, `X = max(0, (w-content)/2)`; the board `Frame` sits after `Left`. +- `SizeTooSmall` returns a `Layout` with everything `Empty` except `Width`/`Height`. + +- [ ] **Step 1: Write the failing test** + +`internal/render/layout_test.go`: + +```go +package render + +import "testing" + +func TestSizeThresholds(t *testing.T) { + cases := []struct { + w, h int + want Size + }{ + {34, 19, SizeTooSmall}, {39, 40, SizeTooSmall}, {80, 23, SizeTooSmall}, + {40, 24, SizeSmall}, {45, 30, SizeSmall}, {80, 25, SizeSmall}, + {46, 26, SizeMedium}, {63, 40, SizeMedium}, + {64, 26, SizeWide}, {200, 60, SizeWide}, + } + for _, c := range cases { + if got := Compute(c.w, c.h).Size; got != c.want { + t.Errorf("%dx%d: got %v want %v", c.w, c.h, got, c.want) + } + } +} + +// Review focus 1: no WindowSizeMsg yet. +func TestZeroAndNegativeSizesAreTooSmallAndInert(t *testing.T) { + for _, c := range [][2]int{{0, 0}, {-1, -1}, {0, 50}, {50, 0}} { + l := Compute(c[0], c[1]) + if l.Size != SizeTooSmall { + t.Errorf("%v: got %v", c, l.Size) + } + if !l.Frame.Empty() || !l.Controls.Empty() { + t.Errorf("%v: nothing should be laid out", c) + } + } +} + +func TestBoardIsAlwaysTwentyTwoByTwentyTwo(t *testing.T) { + for _, c := range [][2]int{{40, 24}, {50, 30}, {80, 40}, {300, 100}} { + l := Compute(c[0], c[1]) + if l.Frame.W != FrameCols || l.Frame.H != FrameRows { + t.Errorf("%v: frame %dx%d want %dx%d", c, l.Frame.W, l.Frame.H, FrameCols, FrameRows) + } + } + if FrameCols != 22 || FrameRows != 22 { + t.Fatalf("frame constants drifted: %dx%d", FrameCols, FrameRows) + } +} + +func TestEverythingStaysInsideTheTerminal(t *testing.T) { + for w := 40; w <= 200; w += 7 { // odd steps land on odd widths too + for h := 24; h <= 80; h += 5 { + l := Compute(w, h) + rects := map[string]Rect{"frame": l.Frame, "left": l.Left, "right": l.Right, + "title": l.Title, "mission": l.Mission, "controls": l.Controls} + for name, r := range rects { + if r.Empty() { + continue + } + if r.X < 0 || r.Y < 0 || r.Right() > w || r.Bottom() > h { + t.Fatalf("%dx%d: %s %+v escapes the terminal", w, h, name, r) + } + } + } + } +} + +func TestColumnsDoNotOverlapTheBoard(t *testing.T) { + for w := 40; w <= 200; w += 3 { + l := Compute(w, 40) + if !l.Left.Empty() && l.Left.Right() > l.Frame.X { + t.Fatalf("w=%d: left column overlaps the board", w) + } + if !l.Right.Empty() && l.Right.X < l.Frame.Right() { + t.Fatalf("w=%d: right column overlaps the board", w) + } + } +} + +func TestNextNeverStacksAboveOrBelowTheBoard(t *testing.T) { + for w := 40; w <= 120; w += 2 { + for h := 24; h <= 60; h += 2 { + l := Compute(w, h) + if l.Right.Empty() { + t.Fatalf("%dx%d: NEXT must always have a column", w, h) + } + if l.Right.X < l.Frame.Right() { + t.Fatalf("%dx%d: NEXT is not beside the board", w, h) + } + } + } +} + +func TestDropOrderIsTitleThenMissionThenLabels(t *testing.T) { + tall := Compute(80, 40) + if tall.Title.Empty() || tall.Mission.Empty() || !tall.ShowStatLabels { + t.Fatal("a large terminal should show everything") + } + if l := Compute(80, 25); !l.Title.Empty() || l.Mission.Empty() { + t.Errorf("h=25 should drop the title and keep mission control: %+v", l) + } + if l := Compute(80, 24); !l.Title.Empty() || !l.Mission.Empty() { + t.Errorf("h=24 should drop the title and mission control: %+v", l) + } + if l := Compute(40, 24); l.ShowStatLabels { + t.Error("the 40x24 minimum should drop stat labels too") + } + if Compute(40, 24).Controls.Empty() { + t.Error("controls must survive at the minimum size") + } +} + +func TestNextCountTruncatesAtSmallSizes(t *testing.T) { + if got := Compute(80, 40).NextCount; got != 5 { + t.Errorf("wide: got %d want 5", got) + } + if got := Compute(40, 24).NextCount; got != 3 { + t.Errorf("small: got %d want 3", got) + } + if got := Compute(50, 30).NextCount; got != 3 { + t.Errorf("medium: got %d want 3", got) + } +} + +func TestHoldColumnOnlyInWide(t *testing.T) { + if Compute(80, 40).Left.Empty() { + t.Error("wide layout should have the left hold/stats column") + } + if !Compute(50, 30).Left.Empty() { + t.Error("medium and below should fold HOLD into the right column") + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/render/ -run 'TestSize|TestZeroAnd|TestBoardIs|TestEverythingStays|TestColumns|TestNext|TestDropOrder|TestHoldColumn' -v` +Expected: FAIL — `undefined: Compute`. + +- [ ] **Step 3: Implement `layout.go`** + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS. Fix the thresholds in the table above only by changing the table and the test together, never one alone. + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/layout.go internal/render/layout_test.go +git commit -m "feat(render): adaptive layout with pinned size thresholds" +``` + +--- + +### Task 5: Board, ghost, active piece, and border + +**Files:** +- Create: `internal/render/board.go` +- Test: `internal/render/board_test.go` + +**Interfaces:** +- Consumes: `Canvas`, `Paint`, `Glyphs`, `Mode`, `Rect`, `game.Board`, `game.Piece`. +- Produces: + - ```go + type BoardView struct { + Board *game.Board + Active game.Piece + Ghost game.Piece + ShowActive bool // false during the game-over collapse (Plan 05) + ShowGhost bool + } + func DrawBoard(c *Canvas, r Rect, v BoardView, m Mode, border Paint) + ``` + Draws, in §37's order: locked cells, then ghost (skipping any cell already occupied by a locked cell or by the active piece), then the active piece, then the border box around `r`. + - `func BoardCellXY(r Rect, x, y int) (int, int)` — maps a logical board cell to the canvas position of its left column: `r.X + 1 + x*2`, `r.Y + 1 + (y - game.HiddenRows)`. Cells in the hidden rows map to a `y` above the frame interior and are not drawn. + +- [ ] **Step 1: Write the failing test** + +`internal/render/board_test.go`: + +```go +package render + +import ( + "strings" + "testing" + + "github.com/jessev/cosmic-tetris/internal/game" +) + +func boardCanvas(t *testing.T, v BoardView, m Mode) (*Canvas, Rect) { + t.Helper() + r := Rect{X: 0, Y: 0, W: FrameCols, H: FrameRows} + c := NewCanvas(FrameCols, FrameRows) + DrawBoard(c, r, v, m, Paint{}) + return c, r +} + +func TestLockedCellsRenderAsTwoColumnBlocks(t *testing.T) { + var b game.Board + b.Set(0, game.HiddenRows, game.CellFor(game.KindI)) + c, r := boardCanvas(t, BoardView{Board: &b}, ModeFull) + x, y := BoardCellXY(r, 0, game.HiddenRows) + if c.Rune(x, y) != '█' || c.Rune(x+1, y) != '█' { + t.Fatalf("expected a two-column block at (%d,%d), got %q%q", x, y, c.Rune(x, y), c.Rune(x+1, y)) + } + if c.PaintAt(x, y).FG != PieceColorFor(game.KindI, ModeFull) { + t.Error("locked cell is not painted with its piece colour") + } + if c.PaintAt(x, y).Bold { + t.Error("locked cells must not be bold; only the active piece is") + } +} + +func TestHiddenRowsAreNotDrawn(t *testing.T) { + var b game.Board + for x := 0; x < game.Width; x++ { + b.Set(x, 0, game.CellFor(game.KindZ)) + b.Set(x, 1, game.CellFor(game.KindZ)) + } + c, _ := boardCanvas(t, BoardView{Board: &b}, ModeFull) + if strings.Contains(plain(c.String()), "█") { + t.Fatal("hidden spawn rows leaked into the visible board") + } +} + +func TestActivePieceIsBoldAndOnTop(t *testing.T) { + var b game.Board + p := game.Piece{Kind: game.KindO, Rotation: 0, X: 4, Y: 10} + c, r := boardCanvas(t, BoardView{Board: &b, Active: p, ShowActive: true}, ModeFull) + for _, cell := range p.Cells() { + x, y := BoardCellXY(r, cell[0], cell[1]) + if !c.PaintAt(x, y).Bold { + t.Errorf("active cell %v is not bold", cell) + } + } +} + +func TestGhostNeverObscuresLockedBlocks(t *testing.T) { + var b game.Board + b.Set(4, 20, game.CellFor(game.KindI)) + b.Set(5, 20, game.CellFor(game.KindI)) + ghost := game.Piece{Kind: game.KindO, Rotation: 0, X: 3, Y: 19} // overlaps row 20 columns 4,5 + c, r := boardCanvas(t, BoardView{Board: &b, Ghost: ghost, ShowGhost: true}, ModeFull) + x, y := BoardCellXY(r, 4, 20) + if c.Rune(x, y) != '█' { + t.Fatalf("locked cell was overwritten by the ghost: %q", c.Rune(x, y)) + } +} + +func TestGhostNeverObscuresTheActivePiece(t *testing.T) { + var b game.Board + active := game.Piece{Kind: game.KindO, Rotation: 0, X: 3, Y: 19} + c, r := boardCanvas(t, BoardView{Board: &b, Active: active, Ghost: active, ShowActive: true, ShowGhost: true}, ModeFull) + for _, cell := range active.Cells() { + x, y := BoardCellXY(r, cell[0], cell[1]) + if c.Rune(x, y) != '█' { + t.Fatalf("ghost overwrote the active piece at %v: %q", cell, c.Rune(x, y)) + } + } +} + +func TestGhostUsesThePinnedGlyphPerMode(t *testing.T) { + var b game.Board + ghost := game.Piece{Kind: game.KindO, Rotation: 0, X: 3, Y: 19} + for mode, want := range map[Mode]rune{ModeFull: '░', ModeReduced: '░', ModeASCII: '.'} { + c, r := boardCanvas(t, BoardView{Board: &b, Ghost: ghost, ShowGhost: true}, mode) + x, y := BoardCellXY(r, 4, 19) + if got := c.Rune(x, y); got != want { + t.Errorf("%v: ghost glyph %q want %q", mode, got, want) + } + } +} + +func TestBorderBoxSurroundsTheWell(t *testing.T) { + var b game.Board + c, r := boardCanvas(t, BoardView{Board: &b}, ModeFull) + g := GlyphsFor(ModeFull) + if string(c.Rune(r.X, r.Y)) != g.BorderTL || string(c.Rune(r.Right()-1, r.Y)) != g.BorderTR { + t.Error("top corners wrong") + } + if string(c.Rune(r.X, r.Bottom()-1)) != g.BorderBL || string(c.Rune(r.Right()-1, r.Bottom()-1)) != g.BorderBR { + t.Error("bottom corners wrong") + } + for x := r.X + 1; x < r.Right()-1; x++ { + if string(c.Rune(x, r.Y)) != g.BorderH { + t.Fatalf("top edge broken at x=%d: %q", x, c.Rune(x, r.Y)) + } + } + for y := r.Y + 1; y < r.Bottom()-1; y++ { + if string(c.Rune(r.X, y)) != g.BorderV { + t.Fatalf("left edge broken at y=%d: %q", y, c.Rune(r.X, y)) + } + } +} + +func TestBorderPaintIsTheCallersEnergyColour(t *testing.T) { + var b game.Board + r := Rect{W: FrameCols, H: FrameRows} + c := NewCanvas(FrameCols, FrameRows) + want := Paint{FG: "#ff00ff", Bold: true} + DrawBoard(c, r, BoardView{Board: &b}, ModeFull, want) + if got := c.PaintAt(r.X, r.Y); got != want { + t.Errorf("border paint = %+v want %+v", got, want) + } +} + +func TestDrawBoardDoesNotMutateTheGame(t *testing.T) { + g := game.New(5) + g.Start() + before := g.Board.Fingerprint() + activeBefore := g.Active + c := NewCanvas(FrameCols, FrameRows) + DrawBoard(c, Rect{W: FrameCols, H: FrameRows}, + BoardView{Board: &g.Board, Active: g.Active, Ghost: g.Ghost(), ShowActive: true, ShowGhost: true}, + ModeFull, Paint{}) + if g.Board.Fingerprint() != before || g.Active != activeBefore { + t.Fatal("rendering mutated game state") + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/render/ -run 'TestLocked|TestHidden|TestActivePiece|TestGhost|TestBorder|TestDrawBoard' -v` +Expected: FAIL — `undefined: DrawBoard`. + +- [ ] **Step 3: Implement `board.go`** + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/board.go internal/render/board_test.go +git commit -m "feat(render): board, ghost, active piece, and border box" +``` + +--- + +### Task 6: HUD, overlays, frame assembly, and golden layout tests + +**Files:** +- Create: `internal/render/hud.go` +- Create: `internal/render/render.go` +- Create: `internal/render/golden_test.go` +- Create: `internal/render/testdata/` (golden files, generated) +- Test: `internal/render/hud_test.go`, `internal/render/render_test.go` + +**Interfaces:** +- Consumes: Tasks 2–5. +- Produces (`hud.go`): + - `func DrawMiniPiece(c *Canvas, x, y int, k game.PieceKind, m Mode)` — draws a kind in a 4×2-cell (8×2 column) box at its rotation-0 shape. + - `func DrawHold(c *Canvas, r Rect, hold *game.PieceKind, showLabel bool, m Mode)` + - `func DrawNext(c *Canvas, r Rect, next []game.PieceKind, count int, showLabel bool, m Mode)` + - `func DrawStats(c *Canvas, r Rect, g *game.Game, showLabels bool, m Mode)` — `SCORE` zero-padded to 8 digits, `LINES` to 3, `LEVEL` to 2 (§4). + - `func DrawControls(c *Canvas, r Rect, width int, m Mode)` — §4's hint line, truncated to `width` on narrow terminals. + - `func DrawMission(c *Canvas, r Rect, msg string, m Mode)` — `☄ MISSION CONTROL: ` (`>` in ASCII), truncated to the rect. + - `func DrawTitle(c *Canvas, r Rect, seedLabel string, m Mode)` — `✦ COSMIC TETRIS` and `LOCAL UNIVERSE `, where `seedLabel` is the seed rendered as 4 uppercase hex digits. +- Produces (`render.go`): + - ```go + type Overlay int + const (OverlayNone Overlay = iota; OverlayPause; OverlayHelp; OverlayGameOver; OverlayTooSmall) + + type Frame struct { + Game *game.Game + Layout Layout + Mode Mode + Overlay Overlay + Mission string + HelpText string // rendered by app.Model from the bubbles help model + Border Paint // energy colour; Plan 03 animates it + } + func Render(f Frame) string + ``` + - `func Box(lines []string, m Mode, p Paint) []string` — the rounded-corner overlay box used by pause/help/game over (`╭─╮ │ ╰─╯`, ASCII `+-+ |`). + - `func CenterOverlay(c *Canvas, lines []string, m Mode, p Paint)` — centres the box, clipping rather than overflowing. + - `func TooSmallLines(w, h int) []string` — §31's exact copy: `THIS UNIVERSE IS TOO SMALL`, `resize terminal to continue`, `current: 34 × 19`, `needed: approximately 40 × 24` (the `×` becomes `x` in ASCII mode; at these sizes assume nothing). + +- [ ] **Step 1: Write the failing HUD tests** + +`internal/render/hud_test.go`: + +```go +package render + +import ( + "strings" + "testing" + + "github.com/jessev/cosmic-tetris/internal/game" +) + +func TestStatsZeroPadding(t *testing.T) { + g := game.New(1) + g.Score, g.Lines, g.Level = 129340, 42, 7 + c := NewCanvas(14, 12) + DrawStats(c, Rect{W: 14, H: 12}, g, true, ModeFull) + out := plain(c.String()) + for _, want := range []string{"SCORE", "00129340", "LINES", "042", "LEVEL", "07"} { + if !strings.Contains(out, want) { + t.Errorf("stats missing %q:\n%s", want, out) + } + } +} + +func TestStatsWithoutLabels(t *testing.T) { + g := game.New(1) + g.Score, g.Lines, g.Level = 5, 42, 7 + c := NewCanvas(10, 12) + DrawStats(c, Rect{W: 10, H: 12}, g, false, ModeFull) + out := plain(c.String()) + if strings.Contains(out, "LINES") { + t.Errorf("labels should be dropped:\n%s", out) + } + if !strings.Contains(out, "042") { + t.Errorf("values must stay:\n%s", out) + } +} + +func TestNextShowsRequestedCountAndNoMore(t *testing.T) { + next := []game.PieceKind{game.KindI, game.KindJ, game.KindL, game.KindO, game.KindS} + for _, count := range []int{3, 5} { + c := NewCanvas(10, 22) + DrawNext(c, Rect{W: 10, H: 22}, next, count, true, ModeFull) + blocks := strings.Count(plain(c.String()), "█") + if blocks != count*4*2 { + t.Errorf("count=%d: %d block columns, want %d", count, blocks, count*4*2) + } + } +} + +func TestNextSurvivesAShortQueue(t *testing.T) { + c := NewCanvas(10, 22) + DrawNext(c, Rect{W: 10, H: 22}, []game.PieceKind{game.KindI}, 5, true, ModeFull) // must not panic + if !strings.Contains(plain(c.String()), "█") { + t.Error("the one available piece should still render") + } +} + +func TestHoldRendersEmptyAndFilled(t *testing.T) { + c := NewCanvas(14, 6) + DrawHold(c, Rect{W: 14, H: 6}, nil, true, ModeFull) + if strings.Contains(plain(c.String()), "█") { + t.Error("an empty hold must not draw a piece") + } + k := game.KindO + c2 := NewCanvas(14, 6) + DrawHold(c2, Rect{W: 14, H: 6}, &k, true, ModeFull) + if !strings.Contains(plain(c2.String()), "█") { + t.Error("a held piece should render") + } +} + +func TestControlsTruncateInsteadOfOverflowing(t *testing.T) { + for _, w := range []int{20, 34, 40, 80} { + c := NewCanvas(w, 1) + DrawControls(c, Rect{W: w, H: 1}, w, ModeFull) + for _, line := range strings.Split(plain(c.String()), "\n") { + if len([]rune(line)) != w { + t.Fatalf("w=%d produced a %d-column line", w, len([]rune(line))) + } + } + } +} + +func TestTitleShowsSeedAsHex(t *testing.T) { + c := NewCanvas(60, 1) + DrawTitle(c, Rect{W: 60, H: 1}, "7F3A", ModeFull) + out := plain(c.String()) + if !strings.Contains(out, "COSMIC TETRIS") || !strings.Contains(out, "LOCAL UNIVERSE 7F3A") { + t.Errorf("title line wrong:\n%s", out) + } +} +``` + +- [ ] **Step 2: Run them to verify they fail, then implement `hud.go`** + +Run: `go test ./internal/render/ -run 'TestStats|TestNext|TestHold|TestControls|TestTitle' -v` +Expected: FAIL — `undefined: DrawStats`. Then implement and re-run to PASS. + +- [ ] **Step 3: Write the failing frame-invariant tests** + +`internal/render/render_test.go`: + +```go +package render + +import ( + "strings" + "testing" + "unicode" + + "github.com/jessev/cosmic-tetris/internal/game" +) + +// fixtureGame builds a deterministic mid-game position by writing cells +// directly, so goldens do not churn when engine tuning changes. +func fixtureGame() *game.Game { + g := game.New(0x7F3A) + g.Start() + g.Score, g.Lines, g.Level, g.Combo = 129340, 42, 7, 2 + g.Active = game.Piece{Kind: game.KindT, Rotation: 0, X: 3, Y: 8} + hold := game.KindL + g.Hold = &hold + g.Next = []game.PieceKind{game.KindI, game.KindO, game.KindS, game.KindZ, game.KindJ} + for i, row := range []struct { + y int + xs []int + }{ + {21, []int{0, 1, 2, 3, 4, 6, 7, 8, 9}}, + {20, []int{0, 1, 2, 7, 8, 9}}, + {19, []int{0, 1, 8, 9}}, + {18, []int{0, 9}}, + } { + for _, x := range row.xs { + g.Board.Set(x, row.y, game.CellFor(game.AllKinds[(x+i)%7])) + } + } + return g +} + +// frameFor builds a fixture frame. Its strings must be legal for the mode it +// is asked for: an ASCII fixture containing an arrow would fail the ASCII +// purity tests for the fixture's sake rather than the renderer's. +func frameFor(w, h int, m Mode, o Overlay) Frame { + helpText := "← → move ↑ rotate SPACE yeet" + if m == ModeASCII { + helpText = "h l move k rotate SPACE yeet" + } + return Frame{ + Game: fixtureGame(), + Layout: Compute(w, h), + Mode: m, + Overlay: o, + Mission: "GRAVITY TAX INCREASED", + HelpText: helpText, + } +} + +func lines(s string) []string { return strings.Split(plain(s), "\n") } + +func TestRenderNeverExceedsTheTerminal(t *testing.T) { + for w := 40; w <= 200; w += 7 { + for h := 24; h <= 70; h += 5 { + got := lines(Render(frameFor(w, h, ModeFull, OverlayNone))) + if len(got) > h { + t.Fatalf("%dx%d produced %d lines", w, h, len(got)) + } + for i, line := range got { + if n := len([]rune(line)); n > w { + t.Fatalf("%dx%d line %d is %d columns: %q", w, h, i, n, line) + } + } + } + } +} + +func TestRenderIsStableAcrossEveryOverlayAndMode(t *testing.T) { + for _, m := range []Mode{ModeFull, ModeReduced, ModeASCII} { + for _, o := range []Overlay{OverlayNone, OverlayPause, OverlayHelp, OverlayGameOver, OverlayTooSmall} { + for _, dims := range [][2]int{{40, 24}, {50, 30}, {80, 40}, {34, 19}, {0, 0}, {200, 60}} { + out := Render(frameFor(dims[0], dims[1], m, o)) + for i, line := range lines(out) { + if dims[0] > 0 && len([]rune(line)) > dims[0] { + t.Fatalf("mode=%v overlay=%v %v line %d too wide", m, o, dims, i) + } + } + } + } + } +} + +// Review focus 5: --ascii must emit only ASCII. +func TestASCIIModeEmitsOnlyASCII(t *testing.T) { + for _, o := range []Overlay{OverlayNone, OverlayPause, OverlayHelp, OverlayGameOver, OverlayTooSmall} { + for _, dims := range [][2]int{{40, 24}, {80, 40}, {34, 19}} { + f := frameFor(dims[0], dims[1], ModeASCII, o) + f.Mission = "MOON NOTIFIED" + for _, r := range plain(Render(f)) { + if r > unicode.MaxASCII { + t.Fatalf("overlay=%v %v emitted %q (U+%04X)", o, dims, r, r) + } + } + } + } +} + +// Review focus 3: the too-small notice must fit the too-small terminal. +func TestTooSmallNoticeFitsAndReportsBothSizes(t *testing.T) { + out := Render(frameFor(34, 19, ModeFull, OverlayTooSmall)) + got := plain(out) + for _, want := range []string{"THIS UNIVERSE IS TOO SMALL", "resize terminal to continue", "34", "19", "40", "24"} { + if !strings.Contains(got, want) { + t.Errorf("notice missing %q:\n%s", want, got) + } + } + for i, line := range lines(out) { + if len([]rune(line)) > 34 { + t.Fatalf("line %d overflows a 34-column terminal: %q", i, line) + } + } + if n := len(lines(out)); n > 19 { + t.Fatalf("notice used %d of 19 rows", n) + } +} + +func TestBoardOccupiesExactlyItsLayoutRect(t *testing.T) { + f := frameFor(80, 40, ModeFull, OverlayNone) + got := lines(Render(f)) + g := GlyphsFor(ModeFull) + top := got[f.Layout.Frame.Y] + runes := []rune(top) + if string(runes[f.Layout.Frame.X]) != g.BorderTL { + t.Errorf("board top-left is not at the layout position: %q", top) + } + if string(runes[f.Layout.Frame.Right()-1]) != g.BorderTR { + t.Error("board top-right is not at the layout position") + } +} + +func TestHUDDoesNotCorruptTheBoard(t *testing.T) { + f := frameFor(80, 40, ModeFull, OverlayNone) + withHUD := lines(Render(f)) + bare := f + bare.Mission, bare.HelpText = "", "" + // The well interior must be identical whether or not the HUD text is present. + for y := f.Layout.Frame.Y; y < f.Layout.Frame.Bottom(); y++ { + a := []rune(withHUD[y])[f.Layout.Frame.X:f.Layout.Frame.Right()] + b := []rune(lines(Render(bare))[y])[f.Layout.Frame.X:f.Layout.Frame.Right()] + if string(a) != string(b) { + t.Fatalf("row %d of the well changed with the HUD:\n%q\n%q", y, string(a), string(b)) + } + } +} + +func TestRenderDoesNotMutateTheGame(t *testing.T) { + f := frameFor(80, 40, ModeFull, OverlayNone) + before := *f.Game + beforeFP := f.Game.Board.Fingerprint() + Render(f) + if f.Game.Board.Fingerprint() != beforeFP || f.Game.Active != before.Active || + f.Game.Score != before.Score || f.Game.Combo != before.Combo { + t.Fatal("Render mutated game state (§37)") + } +} + +func TestResizeSweepNeverPanics(t *testing.T) { + for w := 0; w <= 120; w++ { + for h := 0; h <= 50; h += 3 { + Render(frameFor(w, h, ModeFull, OverlayNone)) + Render(frameFor(w, h, ModeASCII, OverlayHelp)) + } + } +} +``` + +- [ ] **Step 4: Run them to verify they fail** + +Run: `go test ./internal/render/ -run TestRender -v` +Expected: FAIL — `undefined: Render`. + +- [ ] **Step 5: Implement `render.go`** + +`Render` allocates one canvas of `f.Layout.Width × f.Layout.Height`, walks §37's twelve steps (leaving 2, 6, 9, 10 as comments naming the plan that fills them), and returns `c.String()`. `OverlayTooSmall` short-circuits to the notice. `OverlayGameOver` draws §28's final card (score/lines/level, `r REBOOT UNIVERSE`, `q ACCEPT COSMIC DEATH`, subtitle `CAUSE: EXCESSIVE GEOMETRY`) over the frozen board. `OverlayPause` draws §30's box. `OverlayHelp` draws §39's `FLIGHT MANUAL` box using `f.HelpText`. + +- [ ] **Step 6: Run the tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS. + +- [ ] **Step 7: Add the golden suite (§41)** + +`internal/render/golden_test.go`: + +```go +package render + +import ( + "flag" + "os" + "path/filepath" + "testing" +) + +var updateGolden = flag.Bool("update", false, "rewrite golden files") + +func assertGolden(t *testing.T, name, got string) { + t.Helper() + path := filepath.Join("testdata", name+".txt") + if *updateGolden { + if err := os.MkdirAll("testdata", 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(got), 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(path) + if err != nil { + t.Fatalf("%v (run: go test ./internal/render/ -update)", err) + } + if got != string(want) { + t.Errorf("%s changed.\n--- got ---\n%s\n--- want ---\n%s", name, got, want) + } +} + +func TestGoldenLayouts(t *testing.T) { + cases := []struct { + name string + w, h int + mode Mode + overlay Overlay + }{ + {"wide", 80, 40, ModeFull, OverlayNone}, + {"medium", 50, 30, ModeFull, OverlayNone}, + {"small", 40, 24, ModeFull, OverlayNone}, + {"pause", 80, 40, ModeFull, OverlayPause}, + {"gameover", 80, 40, ModeFull, OverlayGameOver}, + {"help", 80, 40, ModeFull, OverlayHelp}, + {"ascii", 80, 40, ModeASCII, OverlayNone}, + {"toosmall", 34, 19, ModeFull, OverlayTooSmall}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + assertGolden(t, c.name, plain(Render(frameFor(c.w, c.h, c.mode, c.overlay)))) + }) + } +} +``` + +- [ ] **Step 8: Record the goldens, then read every one of them** + +```bash +go test ./internal/render/ -update +go test ./internal/render/ +ls internal/render/testdata/ +cat internal/render/testdata/wide.txt +``` + +Read each recorded file and check it by eye against §4's mood: board centred, nothing overlapping, HOLD left, NEXT right, stats readable, mission control and controls on their own lines. The mockup is not geometry (§49.7) but it is the intent. Fix the drawing code and re-record until each file looks like a game you would want to play, and only then commit them. + +- [ ] **Step 9: Commit** + +```bash +git add internal/render/hud.go internal/render/render.go internal/render/hud_test.go internal/render/render_test.go internal/render/golden_test.go internal/render/testdata +git commit -m "feat(render): HUD, overlays, frame assembly, and golden layout tests" +``` + +--- + +### Task 7: Key bindings and the help model + +**Files:** +- Create: `internal/app/keys.go` +- Test: `internal/app/keys_test.go` + +**Interfaces:** +- Consumes: `charm.land/bubbles/v2/key`, `charm.land/bubbles/v2/help`. +- Produces: + - ```go + type KeyMap struct { + Left, Right, SoftDrop, RotateCW, RotateCCW, HardDrop, Hold, + Pause, Restart, Help, Quit key.Binding + } + func DefaultKeyMap() KeyMap + func (k KeyMap) ShortHelp() []key.Binding + func (k KeyMap) FullHelp() [][]key.Binding + ``` + - Bindings, with §39's flavour text as the help descriptions: left `left h a`, right `right l d`, soft drop `down j s`, rotate CW `up k x w`, rotate CCW `z`, hard drop `space` ("YEET"), hold `c` ("quantum storage"), pause `p` ("suspend spacetime"), restart `r` ("reboot universe"), help `?`, quit `q esc ctrl+c`. + +- [ ] **Step 1: Write the failing test** + +`internal/app/keys_test.go`: + +```go +package app + +import ( + "strings" + "testing" +) + +func TestEverySpecifiedKeyIsBound(t *testing.T) { + km := DefaultKeyMap() + want := map[string][]string{ + "left": {"left", "h", "a"}, + "right": {"right", "l", "d"}, + "softdrop": {"down", "j", "s"}, + "rotatecw": {"up", "k", "x", "w"}, + "rotateccw": {"z"}, + "harddrop": {" "}, + "hold": {"c"}, + "pause": {"p"}, + "restart": {"r"}, + "help": {"?"}, + "quit": {"q", "esc"}, + } + got := map[string][]string{ + "left": km.Left.Keys(), "right": km.Right.Keys(), "softdrop": km.SoftDrop.Keys(), + "rotatecw": km.RotateCW.Keys(), "rotateccw": km.RotateCCW.Keys(), + "harddrop": km.HardDrop.Keys(), "hold": km.Hold.Keys(), "pause": km.Pause.Keys(), + "restart": km.Restart.Keys(), "help": km.Help.Keys(), "quit": km.Quit.Keys(), + } + for name, wantKeys := range want { + have := strings.Join(got[name], ",") + for _, k := range wantKeys { + if !strings.Contains(have, k) { + t.Errorf("%s: %q missing from %q", name, k, have) + } + } + } +} + +func TestSpaceIsHardDropNotSomethingElse(t *testing.T) { + km := DefaultKeyMap() + for _, k := range km.HardDrop.Keys() { + if k == " " || k == "space" { + return + } + } + t.Fatalf("space is not bound to hard drop: %v", km.HardDrop.Keys()) +} + +func TestHelpDescriptionsUseTheFlightManualVoice(t *testing.T) { + km := DefaultKeyMap() + if got := km.HardDrop.Help().Desc; !strings.Contains(strings.ToUpper(got), "YEET") { + t.Errorf("hard drop help = %q, want the §39 wording", got) + } + for _, b := range km.ShortHelp() { + if b.Help().Desc == "" { + t.Errorf("binding %v has no help text", b.Keys()) + } + } + if len(km.FullHelp()) == 0 { + t.Error("FullHelp must return the manual layout") + } +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `go test ./internal/app/ -run 'TestEverySpecified|TestSpaceIs|TestHelpDescriptions' -v` +Expected: FAIL — `undefined: DefaultKeyMap`. + +- [ ] **Step 3: Implement `keys.go`** + +Check the real key-name strings your bubbletea version reports for space and the arrows before finalizing: `go doc charm.land/bubbletea/v2 KeyPressMsg` and, if needed, a two-line scratch program that prints `msg.String()`. + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `go test ./internal/app/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/app/keys.go internal/app/keys_test.go +git commit -m "feat(app): key bindings and flight-manual help text" +``` + +--- + +### Task 8: The Bubble Tea model, frame clock, and input + +**Files:** +- Create: `internal/app/messages.go` +- Create: `internal/app/model.go` +- Create: `internal/app/update.go` +- Test: `internal/app/model_test.go` + +**Interfaces:** +- Consumes: `Config`, `KeyMap`, `game.Game`, `render.*`. +- Produces: + - `messages.go`: `type FrameMsg struct { Now time.Time }`, `func frameTick() tea.Cmd` (a `tea.Tick` of `FrameInterval`), `const FrameInterval = 16 * time.Millisecond`, `const MaxFrameDelta = 250 * time.Millisecond`. + - ```go + type AppState int + const (StatePlaying AppState = iota; StatePaused; StateHelp; StateGameOver) + + type Model struct { + Cfg Config + Game *game.Game + Mode render.Mode + Width, Height int + State AppState + LastFrame time.Time + Keys KeyMap + Help help.Model + Mission string + Events []game.Event // last frame's events; Plan 03 hands these to FX + } + func NewModel(cfg Config, mode render.Mode, seed int64) *Model + func (m *Model) Init() (tea.Model, tea.Cmd) // signatures per the v2 doc read in Task 1 + func (m *Model) Update(tea.Msg) (tea.Model, tea.Cmd) + func (m *Model) View() string + func (m *Model) Delta(now time.Time) time.Duration // clamped to [0, MaxFrameDelta]; 0 when LastFrame is zero + func (m *Model) overlay() render.Overlay + ``` + - Behaviour: `FrameMsg` advances the game by `Delta(now)` (only in `StatePlaying`), stores the events, and re-arms the tick. Key presses act immediately, in the same `Update` call, without waiting for a tick. `p` toggles pause; `?` toggles help (and help pauses gameplay so the player is not killed while reading); `r` restarts; `q`/`esc`/`ctrl+c` quits; `WindowSizeMsg` stores the size. When `Game.Over`, `State` becomes `StateGameOver` and only `r` and `q` act. + +- [ ] **Step 1: Write the failing test** + +`internal/app/model_test.go`: + +```go +package app + +import ( + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/jessev/cosmic-tetris/internal/render" +) + +func testModel(t *testing.T) *Model { + t.Helper() + m := NewModel(Config{Seed: 4242}, render.ModeFull, 4242) + m.Width, m.Height = 80, 40 + return m +} + +// keyMsg builds a KeyPressMsg from the same string form the KeyMap uses, so +// tests can say "left" or "ctrl+c" and not just single runes. Check the field +// and constant names against the `go doc` output recorded in Task 1 — this is +// the one helper that touches Bubble Tea's key representation directly, and +// every later plan's tests go through it. +func keyMsg(key string) tea.KeyPressMsg { + named := map[string]tea.KeyMsg{ + "left": {Code: tea.KeyLeft}, "right": {Code: tea.KeyRight}, + "up": {Code: tea.KeyUp}, "down": {Code: tea.KeyDown}, + "esc": {Code: tea.KeyEscape}, "enter": {Code: tea.KeyEnter}, + "ctrl+c": {Code: 'c', Mod: tea.ModCtrl}, + " ": {Code: tea.KeySpace, Text: " "}, + } + if k, ok := named[key]; ok { + return tea.KeyPressMsg(k) + } + return tea.KeyPressMsg{Code: []rune(key)[0], Text: key} +} + +func press(t *testing.T, m *Model, key string) *Model { + t.Helper() + next, _ := m.Update(keyMsg(key)) + return next.(*Model) +} + +// Review focus 2: a keypress before the first frame tick. +func TestDeltaIsZeroOnTheFirstFrameAndClampedAfterwards(t *testing.T) { + m := testModel(t) + if got := m.Delta(time.Now()); got != 0 { + t.Errorf("first frame delta = %v want 0", got) + } + m.LastFrame = time.Unix(1000, 0) + if got := m.Delta(time.Unix(1000, 0).Add(30 * time.Millisecond)); got != 30*time.Millisecond { + t.Errorf("normal delta = %v want 30ms", got) + } + if got := m.Delta(time.Unix(1000, 0).Add(9 * time.Hour)); got != MaxFrameDelta { + t.Errorf("suspended-terminal delta = %v want the %v clamp", got, MaxFrameDelta) + } + if got := m.Delta(time.Unix(999, 0)); got != 0 { + t.Errorf("backwards clock delta = %v want 0", got) + } +} + +// Review focus 1: rendering before any WindowSizeMsg. +func TestViewBeforeAnyWindowSizeDoesNotPanic(t *testing.T) { + m := NewModel(Config{Seed: 1}, render.ModeFull, 1) + if out := m.View(); out == "" { + t.Log("empty view before sizing is acceptable") + } +} + +func TestInputActsImmediatelyWithoutWaitingForATick(t *testing.T) { + m := testModel(t) + x := m.Game.Active.X + m = press(t, m, "h") + if m.Game.Active.X != x-1 { + t.Fatalf("left press did not move the piece: %d -> %d", x, m.Game.Active.X) + } + y := m.Game.Active.Y + m = press(t, m, "j") + if m.Game.Active.Y != y+1 { + t.Fatal("soft drop did not act immediately") + } +} + +func TestFrameMsgAdvancesGravityAndRearmsTheTick(t *testing.T) { + m := testModel(t) + m.LastFrame = time.Unix(0, 0) + next, cmd := m.Update(FrameMsg{Now: time.Unix(0, 0).Add(900 * time.Millisecond)}) + m = next.(*Model) + if cmd == nil { + t.Fatal("the frame clock stopped; nothing re-armed the tick") + } + if len(m.Events) == 0 { + t.Error("900ms should have produced at least one gravity event") + } +} + +func TestPauseFreezesGameplayButKeepsRendering(t *testing.T) { + m := testModel(t) + m = press(t, m, "p") + if m.State != StatePaused { + t.Fatalf("state = %v want paused", m.State) + } + before := m.Game.Active + next, _ := m.Update(FrameMsg{Now: time.Unix(0, 0).Add(5 * time.Second)}) + m = next.(*Model) + if m.Game.Active != before { + t.Error("gravity ran while paused") + } + if !strings.Contains(plainOut(m.View()), "TEMPORAL SUSPENSION") { + t.Error("pause overlay missing") + } + m = press(t, m, "p") + if m.State != StatePlaying { + t.Error("p did not resume") + } +} + +func TestHelpTogglesAndPausesPlay(t *testing.T) { + m := testModel(t) + m = press(t, m, "?") + if m.State != StateHelp { + t.Fatalf("state = %v want help", m.State) + } + if m.overlay() != render.OverlayHelp { + t.Error("overlay should be help") + } + before := m.Game.Active + next, _ := m.Update(FrameMsg{Now: time.Unix(0, 0).Add(3 * time.Second)}) + m = next.(*Model) + if m.Game.Active != before { + t.Error("the game must not run while the manual is open") + } + m = press(t, m, "?") + if m.State != StatePlaying { + t.Error("? did not close the manual") + } +} + +func TestRestartResetsTheGame(t *testing.T) { + m := testModel(t) + for i := 0; i < 4; i++ { + m = press(t, m, " ") + } + if m.Game.Score == 0 { + t.Fatal("setup: expected some score from hard drops") + } + m = press(t, m, "r") + if m.Game.Score != 0 || m.Game.Lines != 0 || m.State != StatePlaying { + t.Errorf("restart left score=%d lines=%d state=%v", m.Game.Score, m.Game.Lines, m.State) + } +} + +func TestQuitKeysReturnQuitCommand(t *testing.T) { + for _, k := range []string{"q"} { + m := testModel(t) + _, cmd := m.Update(tea.KeyPressMsg{Code: []rune(k)[0], Text: k}) + if cmd == nil { + t.Fatalf("%q did not produce a command", k) + } + } +} + +func TestGameOverStateOnlyAcceptsRestartAndQuit(t *testing.T) { + m := testModel(t) + // Fill the well so the next spawn is blocked. + for y := 0; y < 22; y++ { + for x := 0; x < 10; x++ { + m.Game.Board.Set(x, y, 1) + } + } + for _, c := range m.Game.Active.Cells() { + m.Game.Board.Set(c[0], c[1], 0) + } + m = press(t, m, " ") + if m.State != StateGameOver { + t.Fatalf("state = %v want game over", m.State) + } + before := m.Game.Score + for _, k := range []string{"h", "l", "j", "c", "z", " "} { + m = press(t, m, k) + } + if m.Game.Score != before { + t.Error("gameplay keys acted after game over") + } + m = press(t, m, "r") + if m.State != StatePlaying { + t.Error("r must reboot the universe from the game-over state") + } +} + +func TestWindowSizeIsStoredAndTooSmallIsReported(t *testing.T) { + m := testModel(t) + next, _ := m.Update(tea.WindowSizeMsg{Width: 34, Height: 19}) + m = next.(*Model) + if m.Width != 34 || m.Height != 19 { + t.Fatalf("size not stored: %dx%d", m.Width, m.Height) + } + if m.overlay() != render.OverlayTooSmall { + t.Error("34x19 should select the too-small overlay") + } + next, _ = m.Update(tea.WindowSizeMsg{Width: 80, Height: 40}) + m = next.(*Model) + if m.overlay() != render.OverlayNone { + t.Error("growing the terminal must recover") + } +} + +func TestResizeStormNeverPanics(t *testing.T) { + m := testModel(t) + for w := 0; w < 120; w += 3 { + for h := 0; h < 50; h += 3 { + next, _ := m.Update(tea.WindowSizeMsg{Width: w, Height: h}) + m = next.(*Model) + _ = m.View() + } + } +} +``` + +Add a tiny helper in the same file: `func plainOut(s string) string` stripping ANSI with the same regexp used in `internal/render` (duplicating five lines here is cheaper than exporting a test helper across packages). + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/app/ -v` +Expected: FAIL — `undefined: NewModel`. + +- [ ] **Step 3: Implement `messages.go`, `model.go`, and `update.go`** + +Match the v2 `Model` signatures you recorded in Task 1 Step 2; if v2's `Update` returns `(tea.Model, tea.Cmd)` with a value receiver, adapt the tests' type assertions accordingly rather than fighting the library. Construct the `KeyPressMsg` literals in the tests to match whatever your version's struct actually looks like — the assertions, not the literals, are the contract. + +Key repeat (§8 "holding left/right should support repeated movement") comes from the terminal's own auto-repeat: each repeat is another `KeyPressMsg` and each moves the piece. Do not add a repeat timer. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./... -race` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/app/messages.go internal/app/model.go internal/app/update.go internal/app/model_test.go +git commit -m "feat(app): bubbletea model, frame clock, input, pause, help, restart" +``` + +--- + +### Task 9: `main.go` and the playable build + +**Files:** +- Create: `cmd/cosmic-tetris/main.go` +- Test: `cmd/cosmic-tetris/main_test.go` + +**Interfaces:** +- Consumes: `app.ParseArgs`, `app.NewModel`, `render.DetectMode`. +- Produces: the binary. `main` parses args, prints usage and exits `0` for `--help`, prints the error and usage to stderr and exits `2` for a bad flag, otherwise builds the model and runs `tea.NewProgram(m, tea.WithAltScreen())`. + +- [ ] **Step 1: Write the failing test** + +`cmd/cosmic-tetris/main_test.go`: + +```go +package main + +import ( + "os/exec" + "strings" + "testing" +) + +func build(t *testing.T) string { + t.Helper() + bin := t.TempDir() + "/cosmic-tetris" + out, err := exec.Command("go", "build", "-o", bin, ".").CombinedOutput() + if err != nil { + t.Fatalf("build failed: %v\n%s", err, out) + } + return bin +} + +func TestHelpExitsZeroAndListsTheWholeCLI(t *testing.T) { + out, err := exec.Command(build(t), "--help").CombinedOutput() + if err != nil { + t.Fatalf("--help exited non-zero: %v\n%s", err, out) + } + for _, want := range []string{"--seed", "--ascii", "--no-fx", "--reduced-motion"} { + if !strings.Contains(string(out), want) { + t.Errorf("--help output missing %q:\n%s", want, out) + } + } +} + +func TestUnknownFlagFailsLoudly(t *testing.T) { + out, err := exec.Command(build(t), "--wormhole").CombinedOutput() + if err == nil { + t.Fatalf("unknown flag exited zero:\n%s", out) + } + if !strings.Contains(strings.ToLower(string(out)), "wormhole") { + t.Errorf("error should name the bad flag:\n%s", out) + } +} + +func TestBinaryStartsAndExitsCleanlyWithoutATTY(t *testing.T) { + cmd := exec.Command(build(t), "--seed", "1", "--no-fx") + out, err := cmd.CombinedOutput() + // Without a TTY, bubbletea may refuse to start; what matters is that it + // neither hangs nor panics. + if strings.Contains(string(out), "panic:") { + t.Fatalf("binary panicked:\n%s", out) + } + _ = err +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `go test ./cmd/cosmic-tetris/ -v` +Expected: FAIL — no `main.go` to build. + +- [ ] **Step 3: Implement `main.go`** + +- [ ] **Step 4: Run the tests and build** + +Run: `go test ./... -race && make build` +Expected: PASS, and `./cosmic-tetris` exists. + +- [ ] **Step 5: Play it** + +Run: `./cosmic-tetris --seed 8675309` +Check by hand: pieces fall; `←/→` move and repeat when held; `↑`/`z` rotate; `↓` soft-drops; `space` hard-drops and locks instantly; `c` holds once per piece; the ghost sits under the piece; the next queue shows five; `p` pauses; `?` opens the manual; `r` restarts; `q` quits and the terminal is left clean. Resize the window while playing, including down past 40×24 and back. Then run `./cosmic-tetris --ascii` and confirm it is still perfectly playable. + +- [ ] **Step 6: Commit** + +```bash +git add cmd/cosmic-tetris/main.go cmd/cosmic-tetris/main_test.go +git commit -m "feat(cmd): cosmic-tetris binary with the pinned CLI surface" +``` + +--- + +## Done when + +- `make test` passes with `-race`; goldens exist for wide, medium, small, pause, game over, help, ASCII, and too-small. +- The game is genuinely fun with no effects at all — §42's Phase 2 bar ("At this point it should already be a genuinely good game"). +- No line of output ever exceeds the terminal width at any size from 0×0 up. +- `--ascii` emits nothing but ASCII. +- `internal/render` imports `internal/game` and `lipgloss`, and nothing else of ours; `internal/app` imports both plus bubbletea/bubbles. Neither `render` nor `game` imports `app`. diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/N1-recipe-cosmic-11/2026-09-17-cosmic-tetris-03-cosmic-foundation.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/N1-recipe-cosmic-11/2026-09-17-cosmic-tetris-03-cosmic-foundation.md new file mode 100644 index 000000000..4c2156644 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/N1-recipe-cosmic-11/2026-09-17-cosmic-tetris-03-cosmic-foundation.md @@ -0,0 +1,1265 @@ +# Cosmic Tetris — Plan 03: Cosmic Foundation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Put the universe behind the game: an independent FX simulation (`internal/fx`) with a three-layer starfield, an energy-reactive board border, short-lived piece trails, and a Mission Control status channel — wired into the app so the terminal feels alive while the player does nothing. + +**Architecture:** `GameState → emits events → FXWorld → simulates spectacle → Renderer` (§14). `internal/fx` observes `[]game.Event` and advances on `dt`; it holds its **own** `*rand.Rand`, distinct from the game's, and has no way to reach back into `game.Game` (it stores no pointer to one). `internal/render` grows draw functions that take FX data as plain slices. `internal/flavor` owns the words. + +**Tech Stack:** Go 1.26, `charm.land/lipgloss/v2` (in render only). `internal/fx` and `internal/flavor` are standard-library-only. + +**Spec:** `design.md` — Phase 3 of §42. Sections most relevant: §14, §15, §17, §25, §26, §27, §32, §34, §35, §44, §49.5, §49.6. + +**Prerequisite:** Plans 01 and 02 complete; `make test` green; the game is already fun with no effects. + +## Global Constraints + +- The FX system may observe game events. It may **never modify GameState** (§14). `fx` does not import anything that would let it: it takes events and numbers, never a `*game.Game`. +- FX randomness uses a different RNG from the game (§35). Seed derivation, pinned here: game `seed`; fx `seed ^ 0x5DEECE66D`; flavor `seed ^ 0x2545F4914F6CDD1D`. Enabling, disabling, or intensifying effects must not change piece order. +- Three star depth layers (§15). Glyph inventory `. · ˚ ✦ ✧ *`: far is slow, dim, mostly `.`; mid is medium with `·` and `˚`; near is fast and bright with `✦` and `✧`. Stars drift downward; star velocity increases subtly with level. +- Never make the background so busy that the board becomes harder to read (§15). Nothing outside the board may be drawn inside the well over a locked or active cell (§44). +- Trail lifetime `~100–160ms`; hard drops produce a stronger vertical trail (§17). Trails are FX only. +- Board border colour shifts slowly over time through deep violet, electric cyan, magenta, stellar blue, hot white; the shift is subtle; during major events the gradient moves rapidly. The border is the game's energy-state indicator (§25). +- Mission Control is one line, triggered contextually, and messages get time to breathe — do not rotate them constantly (§27). +- `--no-fx` yields no FX simulation at all and must still be a good game (§32). `--reduced-motion` (§49.5) suppresses screen shake, hyperdrive acceleration, and shockwaves — all introduced in Plan 04 — while leaving colour, trails, and particles alone; in this plan it therefore behaves like full FX. +- Performance (§38): no goroutine per particle or per frame, no filesystem access, no per-frame logging, reusable slices. + +## Review Focus + +1. **`--no-fx`, where every FX slice is empty and the `*fx.World` is nil** — the render path and the model must both survive a nil world at every call site, not just the ones exercised by the happy path. → Task 6. +2. **The first FX frame, before any `WindowSizeMsg`** — a 0×0 world seeding stars over zero area must not divide by zero, loop forever, or allocate wildly. → Task 1. +3. **One enormous `dt` after the terminal was suspended** — stars must not teleport off into nowhere and trails must expire cleanly; `fx.World.Advance` clamps its own `dt` rather than trusting the caller. → Task 1. +4. **Mission Control changing on every event** — the spec's own "do not rotate messages constantly" is a behaviour with a testable floor: a minimum dwell time, with only higher-priority events allowed to preempt. → Task 5. +5. **FX perturbing the game** — piece order, score, and board must be byte-identical for the same seed and inputs whether FX is off, reduced, or full. This is §35's "very important", and it is only real if a test pins it. → Task 7. + +--- + +### Task 1: The FX world — options, clock, resize, and event intake + +**Files:** +- Create: `internal/fx/world.go` +- Create: `internal/fx/events.go` +- Test: `internal/fx/world_test.go` + +**Interfaces:** +- Consumes: `game.Event`, `game.EventKind`. +- Produces (`world.go`): + - ```go + type Intensity int + const (IntensityOff Intensity = iota; IntensityReduced; IntensityFull) + + type Options struct { + Seed int64 + Intensity Intensity + ASCII bool + Level int // starting level, for star velocity + } + + type World struct { /* all fields unexported */ } + + func NewWorld(cols, rows int, o Options) *World + func (w *World) Resize(cols, rows int) + func (w *World) SetLevel(level int) + func (w *World) Advance(dt time.Duration) + func (w *World) Elapsed() time.Duration + func (w *World) Intensity() Intensity + func (w *World) Size() (cols, rows int) + ``` + - `const MaxAdvance = 250 * time.Millisecond` — `Advance` clamps `dt` into `[0, MaxAdvance]` itself. +- Produces (`events.go`): + - `func (w *World) Handle(evs []game.Event)` — the single intake point; dispatches to the per-effect handlers added by later tasks. Unknown event kinds are ignored, never fatal. + - `type Context struct{ Level, Combo int; BoardRect Rect }` if a handler needs board geometry, where `type Rect struct{ X, Y, W, H int }` mirrors the render rect in FX-local terms; `func (w *World) SetBoardRect(r Rect)` is called by the app after each layout. + +- [ ] **Step 1: Write the failing test** + +`internal/fx/world_test.go`: + +```go +package fx + +import ( + "testing" + "time" + + "github.com/jessev/cosmic-tetris/internal/game" +) + +func TestNewWorldRecordsSizeAndIntensity(t *testing.T) { + w := NewWorld(80, 40, Options{Seed: 1, Intensity: IntensityFull, Level: 1}) + if c, r := w.Size(); c != 80 || r != 40 { + t.Errorf("size = %dx%d want 80x40", c, r) + } + if w.Intensity() != IntensityFull { + t.Error("intensity not recorded") + } +} + +// Review focus 2: no WindowSizeMsg yet. +func TestZeroSizeWorldIsSafe(t *testing.T) { + for _, dims := range [][2]int{{0, 0}, {0, 40}, {80, 0}, {-3, -3}} { + w := NewWorld(dims[0], dims[1], Options{Seed: 2, Intensity: IntensityFull}) + w.Advance(16 * time.Millisecond) + w.Handle([]game.Event{{Kind: game.PieceLocked}}) + w.Resize(dims[1], dims[0]) + w.Advance(16 * time.Millisecond) // must not panic or hang + } +} + +// Review focus 3: a huge dt after suspend. +func TestAdvanceClampsItsOwnDt(t *testing.T) { + w := NewWorld(80, 40, Options{Seed: 3, Intensity: IntensityFull}) + w.Advance(9 * time.Hour) + if w.Elapsed() > MaxAdvance { + t.Fatalf("elapsed = %v; Advance must clamp to %v", w.Elapsed(), MaxAdvance) + } + before := w.Elapsed() + w.Advance(-time.Second) + if w.Elapsed() != before { + t.Error("a negative dt must be inert") + } +} + +func TestElapsedAccumulates(t *testing.T) { + w := NewWorld(80, 40, Options{Seed: 4, Intensity: IntensityFull}) + for i := 0; i < 10; i++ { + w.Advance(16 * time.Millisecond) + } + if got := w.Elapsed(); got != 160*time.Millisecond { + t.Errorf("elapsed = %v want 160ms", got) + } +} + +func TestHandleIgnoresUnknownAndEmptyEvents(t *testing.T) { + w := NewWorld(80, 40, Options{Seed: 5, Intensity: IntensityFull}) + w.Handle(nil) + w.Handle([]game.Event{}) + w.Handle([]game.Event{{Kind: game.EventKind(9999)}}) // must not panic +} + +func TestIntensityOffSimulatesNothing(t *testing.T) { + w := NewWorld(80, 40, Options{Seed: 6, Intensity: IntensityOff}) + w.Handle([]game.Event{{Kind: game.LinesCleared, Rows: []int{20, 21}}}) + w.Advance(100 * time.Millisecond) + if len(w.Stars()) != 0 || len(w.Trails()) != 0 { + t.Errorf("IntensityOff produced %d stars and %d trails", len(w.Stars()), len(w.Trails())) + } +} + +func TestFXHoldsItsOwnRandomnessAndIsReproducible(t *testing.T) { + run := func() []Star { + w := NewWorld(80, 40, Options{Seed: 99, Intensity: IntensityFull}) + for i := 0; i < 20; i++ { + w.Advance(16 * time.Millisecond) + } + return w.Stars() + } + a, b := run(), run() + if len(a) != len(b) { + t.Fatalf("star counts differ: %d vs %d", len(a), len(b)) + } + for i := range a { + if a[i] != b[i] { + t.Fatalf("star %d differs: %+v vs %+v", i, a[i], b[i]) + } + } +} +``` + +`Stars()` and `Trails()` land in Tasks 2 and 4; add them as empty-returning stubs now so this task compiles, and let those tasks fill them in. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -v` +Expected: FAIL — `undefined: NewWorld`. + +- [ ] **Step 3: Implement `world.go` and `events.go`** + +`NewWorld` builds `rng: rand.New(rand.NewSource(o.Seed))`. The world stores no reference to any game type beyond the `game.Event` values it is handed. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/fx/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/world.go internal/fx/events.go internal/fx/world_test.go +git commit -m "feat(fx): world skeleton with its own clock, RNG, and event intake" +``` + +--- + +### Task 2: Three-layer starfield + +**Files:** +- Create: `internal/fx/starfield.go` +- Modify: `internal/fx/world.go` +- Test: `internal/fx/starfield_test.go` + +**Interfaces:** +- Consumes: Task 1's `World`. +- Produces: + - ```go + type Layer int + const (LayerFar Layer = iota; LayerMid; LayerNear) + + type Star struct { + X, Y float64 + Layer Layer + Glyph rune + Bright float64 // 0..1 + } + func (w *World) Stars() []Star + func (w *World) StarSpeedScale() float64 + ``` + - Pinned tuning: + - Count: `clamp(cols*rows/45, 12, 400)`, split `50%` far, `30%` mid, `20%` near. + - Base downward speeds in rows/second: far `0.6`, mid `1.6`, near `3.2`. + - Level scaling: `scale = min(2.5, 1 + 0.06*(level-1))`, applied to all layers (§15 "star velocity subtly increases"). + - Brightness: far `0.25`, mid `0.55`, near `0.9`. + - Glyphs — full/reduced: far `.`, mid `·` and `˚`, near `✦`, `✧`, and occasionally `*`. ASCII: far `.`, mid `:`, near `+` and `*`. + - A star passing below the last row respawns on row `-1` at a fresh random column; on `Resize`, stars outside the new viewport respawn inside it and the count is re-derived, so the population stays bounded. + +- [ ] **Step 1: Write the failing test** + +`internal/fx/starfield_test.go`: + +```go +package fx + +import ( + "testing" + "time" + "unicode" +) + +func full(cols, rows int, seed int64) *World { + return NewWorld(cols, rows, Options{Seed: seed, Intensity: IntensityFull, Level: 1}) +} + +func TestStarCountScalesWithAreaAndIsBounded(t *testing.T) { + small := len(full(40, 24, 1).Stars()) + big := len(full(200, 60, 1).Stars()) + if small < 12 { + t.Errorf("40x24 produced %d stars; the floor is 12", small) + } + if big <= small { + t.Errorf("a bigger terminal should hold more stars (%d vs %d)", big, small) + } + if huge := len(full(1000, 1000, 1).Stars()); huge > 400 { + t.Errorf("1000x1000 produced %d stars; the cap is 400", huge) + } +} + +func TestAllThreeLayersArePopulated(t *testing.T) { + counts := map[Layer]int{} + for _, s := range full(80, 40, 2).Stars() { + counts[s.Layer]++ + } + for _, l := range []Layer{LayerFar, LayerMid, LayerNear} { + if counts[l] == 0 { + t.Errorf("layer %v is empty: %v", l, counts) + } + } + if counts[LayerFar] <= counts[LayerNear] { + t.Errorf("far stars should outnumber near stars: %v", counts) + } +} + +func TestNearStarsAreBrighterAndFasterThanFarStars(t *testing.T) { + w := full(80, 40, 3) + before := map[Layer]float64{} + after := map[Layer]float64{} + bright := map[Layer]float64{} + for _, s := range w.Stars() { + before[s.Layer] += s.Y + bright[s.Layer] = s.Bright + } + w.Advance(200 * time.Millisecond) + for _, s := range w.Stars() { + after[s.Layer] += s.Y + } + farDrift := after[LayerFar] - before[LayerFar] + nearDrift := after[LayerNear] - before[LayerNear] + if nearDrift <= farDrift { + t.Errorf("near stars drifted %v, far stars %v; near must be faster", nearDrift, farDrift) + } + if !(bright[LayerNear] > bright[LayerMid] && bright[LayerMid] > bright[LayerFar]) { + t.Errorf("brightness is not ordered by depth: %v", bright) + } +} + +func TestStarsDriftDownward(t *testing.T) { + w := full(80, 40, 4) + first := w.Stars()[0] + w.Advance(500 * time.Millisecond) + if w.Stars()[0].Y <= first.Y && w.Stars()[0].Y > 0 { + t.Errorf("star did not drift down: %v -> %v", first.Y, w.Stars()[0].Y) + } +} + +func TestStarsStayInsideTheViewport(t *testing.T) { + w := full(80, 40, 5) + for i := 0; i < 500; i++ { + w.Advance(16 * time.Millisecond) + for _, s := range w.Stars() { + if s.X < 0 || s.X >= 80 || s.Y < -1 || s.Y >= 40 { + t.Fatalf("frame %d: star escaped at %+v", i, s) + } + } + } +} + +func TestHigherLevelsSpeedStarsUpButNotWildly(t *testing.T) { + w := full(80, 40, 6) + base := w.StarSpeedScale() + if base != 1 { + t.Errorf("level 1 scale = %v want 1", base) + } + w.SetLevel(7) + if s := w.StarSpeedScale(); s <= 1 || s > 2.5 { + t.Errorf("level 7 scale = %v, want above 1 and at or below 2.5", s) + } + w.SetLevel(99) + if s := w.StarSpeedScale(); s != 2.5 { + t.Errorf("level 99 scale = %v want the 2.5 cap", s) + } +} + +// Review focus 3 continued. +func TestHugeDtDoesNotFlingStarsAway(t *testing.T) { + w := full(80, 40, 7) + w.Advance(9 * time.Hour) + for _, s := range w.Stars() { + if s.Y < -1 || s.Y >= 40 { + t.Fatalf("star at %+v after a huge dt", s) + } + } +} + +func TestResizeKeepsTheStarPopulationBoundedAndInside(t *testing.T) { + w := full(200, 60, 8) + for i := 0; i < 60; i++ { + w.Resize(40+i, 24+i%20) + w.Advance(16 * time.Millisecond) + cols, rows := w.Size() + if n := len(w.Stars()); n > 400 { + t.Fatalf("resize %d left %d stars", i, n) + } + for _, s := range w.Stars() { + if s.X < 0 || s.X >= float64(cols) || s.Y >= float64(rows) { + t.Fatalf("resize %d to %dx%d left a star at %+v", i, cols, rows, s) + } + } + } +} + +func TestASCIIStarGlyphsAreASCII(t *testing.T) { + w := NewWorld(80, 40, Options{Seed: 9, Intensity: IntensityFull, ASCII: true}) + for i := 0; i < 200; i++ { + w.Advance(16 * time.Millisecond) + for _, s := range w.Stars() { + if s.Glyph > unicode.MaxASCII { + t.Fatalf("ascii mode produced star glyph %q", s.Glyph) + } + } + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -run Star -v` +Expected: FAIL — stars are still an empty stub. + +- [ ] **Step 3: Implement `starfield.go`** + +Reuse the star slice across frames — update in place, never reallocate per frame (§38). + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/fx/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/starfield.go internal/fx/world.go internal/fx/starfield_test.go +git commit -m "feat(fx): three-layer starfield with level-scaled drift" +``` + +--- + +### Task 3: Border energy state + +**Files:** +- Create: `internal/fx/border.go` +- Test: `internal/fx/border_test.go` + +**Interfaces:** +- Consumes: Task 1's `World`, `game.Event`. +- Produces: + - `func (w *World) BorderPhase() float64` — position in the slow colour cycle, `[0,1)`. Advances at `1 / BorderCyclePeriod` per second, multiplied by `1 + 4*energy` (§25 "during major events the gradient moves rapidly"). + - `func (w *World) BorderEnergy() float64` — `[0,1]`, the energy-state indicator. + - `const BorderCyclePeriod = 24 * time.Second`. + - Energy inputs, pinned: `PieceLocked +0.10`, `PieceHardDropped +0.20`, `LinesCleared +0.20 × rows`, `ComboChanged +0.08 × combo`, `LevelChanged +0.50`, `GameOver +1.00`. Decay: `energy *= 0.5^(dt / 400ms)`, clamped to `[0,1]`. + +- [ ] **Step 1: Write the failing test** + +`internal/fx/border_test.go`: + +```go +package fx + +import ( + "testing" + "time" + + "github.com/jessev/cosmic-tetris/internal/game" +) + +func TestBorderPhaseCyclesSlowlyWhenCalm(t *testing.T) { + w := full(80, 40, 11) + start := w.BorderPhase() + w.Advance(200 * time.Millisecond) + moved := w.BorderPhase() - start + if moved <= 0 { + t.Fatalf("phase did not advance: %v -> %v", start, w.BorderPhase()) + } + if moved > 0.05 { + t.Errorf("phase moved %v in 200ms; §25 asks for a subtle shift", moved) + } +} + +func TestBorderPhaseWrapsWithoutJumping(t *testing.T) { + w := full(80, 40, 12) + for i := 0; i < 4000; i++ { + w.Advance(16 * time.Millisecond) + if p := w.BorderPhase(); p < 0 || p >= 1 { + t.Fatalf("phase left [0,1): %v", p) + } + } +} + +func TestEventsRaiseEnergyByMagnitude(t *testing.T) { + single := full(80, 40, 13) + single.Handle([]game.Event{{Kind: game.LinesCleared, Rows: []int{21}}}) + quad := full(80, 40, 13) + quad.Handle([]game.Event{{Kind: game.LinesCleared, Rows: []int{18, 19, 20, 21}}}) + if quad.BorderEnergy() <= single.BorderEnergy() { + t.Errorf("a four-line clear should excite the border more: %v vs %v", + quad.BorderEnergy(), single.BorderEnergy()) + } + lockOnly := full(80, 40, 13) + lockOnly.Handle([]game.Event{{Kind: game.PieceLocked}}) + if lockOnly.BorderEnergy() >= single.BorderEnergy() { + t.Error("a plain lock should excite the border less than a line clear") + } +} + +func TestEnergyIsClampedToOne(t *testing.T) { + w := full(80, 40, 14) + for i := 0; i < 50; i++ { + w.Handle([]game.Event{{Kind: game.LevelChanged, Level: i}}) + } + if e := w.BorderEnergy(); e > 1 { + t.Fatalf("energy = %v", e) + } +} + +func TestEnergyDecaysBackToCalm(t *testing.T) { + w := full(80, 40, 15) + w.Handle([]game.Event{{Kind: game.LinesCleared, Rows: []int{18, 19, 20, 21}}}) + hot := w.BorderEnergy() + if hot <= 0 { + t.Fatal("setup: energy should have risen") + } + w.Advance(400 * time.Millisecond) + half := w.BorderEnergy() + if half >= hot { + t.Errorf("energy did not decay: %v -> %v", hot, half) + } + for i := 0; i < 100; i++ { + w.Advance(100 * time.Millisecond) + } + if e := w.BorderEnergy(); e > 0.01 { + t.Errorf("energy is still %v after 10s of calm", e) + } +} + +func TestHighEnergySpeedsThePhaseUp(t *testing.T) { + calm := full(80, 40, 16) + calm.Advance(100 * time.Millisecond) + calmMoved := calm.BorderPhase() + + hot := full(80, 40, 16) + hot.Handle([]game.Event{{Kind: game.GameOver}}) + hot.Advance(100 * time.Millisecond) + if hot.BorderPhase() <= calmMoved { + t.Errorf("an excited border should cycle faster: %v vs %v", hot.BorderPhase(), calmMoved) + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -run Border -v` +Expected: FAIL — `undefined: (*World).BorderPhase`. + +- [ ] **Step 3: Implement `border.go`** + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/fx/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/border.go internal/fx/border_test.go +git commit -m "feat(fx): border energy state and colour phase" +``` + +--- + +### Task 4: Piece trails + +**Files:** +- Create: `internal/fx/trail.go` +- Test: `internal/fx/trail_test.go` + +**Interfaces:** +- Consumes: Task 1's `World`, `game.Event` (`PieceMoved`, `PieceSoftDropped`, `PieceHardDropped`, `Cells`, `Piece`, `Distance`). +- Produces: + - ```go + type Trail struct { + BX, BY int // logical board cell + Kind game.PieceKind + Age time.Duration + Life time.Duration + } + func (w *World) Trails() []Trail + func (t Trail) Stage() int // 0,1,2 by Age/Life thirds: ▓ ▒ ░ + ``` + - Pinned tuning: normal trail life `140ms`; hard-drop trail life `220ms` and emitted for **every** cell the piece crossed (§18's vertical ion trail), reconstructed from the landing cells and `Distance`. Trails are keyed by board cell; a newer trail on the same cell replaces the older one. Cap the live trail count at `256`, dropping the oldest. + +- [ ] **Step 1: Write the failing test** + +`internal/fx/trail_test.go`: + +```go +package fx + +import ( + "testing" + "time" + + "github.com/jessev/cosmic-tetris/internal/game" +) + +func movedEvent(p game.Piece) game.Event { + return game.Event{Kind: game.PieceMoved, Piece: p, Cells: p.Cells()} +} + +func TestMovementLeavesATrail(t *testing.T) { + w := full(80, 40, 21) + p := game.Piece{Kind: game.KindT, Rotation: 0, X: 3, Y: 8} + w.Handle([]game.Event{movedEvent(p)}) + if len(w.Trails()) == 0 { + t.Fatal("a move produced no trail") + } + for _, tr := range w.Trails() { + if tr.Kind != game.KindT { + t.Errorf("trail carries kind %v want T", tr.Kind) + } + } +} + +func TestTrailsExpireWithinTheSpecifiedWindow(t *testing.T) { + w := full(80, 40, 22) + w.Handle([]game.Event{movedEvent(game.Piece{Kind: game.KindI, X: 3, Y: 8})}) + w.Advance(90 * time.Millisecond) + if len(w.Trails()) == 0 { + t.Error("trails should still be alive at 90ms (spec: 100-160ms)") + } + w.Advance(150 * time.Millisecond) + if n := len(w.Trails()); n != 0 { + t.Errorf("%d trails alive after 240ms; the window is ~100-160ms", n) + } +} + +func TestHardDropTrailIsStrongerAndCoversTheWholePath(t *testing.T) { + w := full(80, 40, 23) + landing := game.Piece{Kind: game.KindO, Rotation: 0, X: 4, Y: 18} + w.Handle([]game.Event{{ + Kind: game.PieceHardDropped, Piece: landing, Cells: landing.Cells(), Distance: 12, + }}) + trails := w.Trails() + if len(trails) < 12 { + t.Fatalf("a 12-cell hard drop left only %d trail cells", len(trails)) + } + rows := map[int]bool{} + longest := time.Duration(0) + for _, tr := range trails { + rows[tr.BY] = true + if tr.Life > longest { + longest = tr.Life + } + } + if len(rows) < 12 { + t.Errorf("the ion trail covers only %d distinct rows", len(rows)) + } + normal := full(80, 40, 23) + normal.Handle([]game.Event{movedEvent(game.Piece{Kind: game.KindO, X: 4, Y: 8})}) + if longest <= normal.Trails()[0].Life { + t.Error("hard-drop trails must live longer than movement trails") + } +} + +func TestTrailStageRampsWithAge(t *testing.T) { + w := full(80, 40, 24) + w.Handle([]game.Event{movedEvent(game.Piece{Kind: game.KindS, X: 3, Y: 8})}) + stages := map[int]bool{} + for i := 0; i < 9; i++ { + for _, tr := range w.Trails() { + stages[tr.Stage()] = true + } + w.Advance(16 * time.Millisecond) + } + if len(stages) < 3 { + t.Errorf("trail fading only reached stages %v; want 0, 1 and 2", stages) + } + for s := range stages { + if s < 0 || s > 2 { + t.Errorf("stage %d out of range", s) + } + } +} + +func TestTrailCountIsCapped(t *testing.T) { + w := full(80, 40, 25) + for i := 0; i < 400; i++ { + p := game.Piece{Kind: game.KindZ, X: i % 7, Y: i % 20} + w.Handle([]game.Event{movedEvent(p)}) + } + if n := len(w.Trails()); n > 256 { + t.Fatalf("%d trails alive; the cap is 256", n) + } +} + +func TestNoTrailsWhenFXIsOff(t *testing.T) { + w := NewWorld(80, 40, Options{Seed: 26, Intensity: IntensityOff}) + w.Handle([]game.Event{movedEvent(game.Piece{Kind: game.KindL, X: 3, Y: 8})}) + if len(w.Trails()) != 0 { + t.Error("IntensityOff must not simulate trails") + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -run Trail -v` +Expected: FAIL — trails are still an empty stub. + +- [ ] **Step 3: Implement `trail.go`** + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/fx/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/trail.go internal/fx/trail_test.go +git commit -m "feat(fx): short-lived ion trails, stronger on hard drops" +``` + +--- + +### Task 5: Mission Control channel + +**Files:** +- Create: `internal/flavor/messages.go` +- Create: `internal/flavor/channel.go` +- Test: `internal/flavor/channel_test.go` + +**Interfaces:** +- Consumes: `game.Event`. +- Produces (`messages.go`) — the copy, verbatim from §27 plus §21/§22 where those tasks need it: + - `var Ambient = []string{...}` containing at least: `GRAVITY REMAINS MOSTLY LEGAL`, `TETROMINO INJECTION SUCCESSFUL`, `STRUCTURAL VIBES: QUESTIONABLE`, `LOCAL UNIVERSE STABLE*`, `* DEFINITION OF STABLE UNDER REVIEW`, `MOON NOTIFIED`, `ORBITAL OSHA HAS ENTERED THE CHAT`, `WE HAVE EXCEEDED THE RECOMMENDED NUMBER OF BLOCKS`, `PHYSICS TEAM SAYS KEEP GOING`, `NOMINALISH`. + - `var ComboLines = map[int]string{5: "COMBO 5 // UNAUTHORIZED ORBITAL MANEUVER", 6: "COMBO 6 // STRUCTURAL REALITY FAILURE", 7: "COMBO 7 // NASA DENIES EVERYTHING"}` and `func ComboLine(n int) string` for `n > 7` (reuse the 7 line). + - `var LevelSubtitles = []string{"GRAVITY TAX INCREASED", "LOCAL PHYSICS UPDATED WITHOUT CONSENT", "PLEASE SECURE ALL LOOSE TETROMINOES"}`. + - `var FourLineBanners = []string{"✦ EVENT HORIZON ✦", "QUADRUPLE COSMIC INCIDENT", "FOUR ROWS HAVE LEFT THE CHAT", "SPACE-TIME HAS FILED A COMPLAINT"}`. +- Produces (`channel.go`): + - ```go + type Priority int + const (PrioAmbient Priority = iota; PrioRoutine; PrioCombo; PrioBig) + + type Channel struct { /* unexported */ } + func NewChannel(rng *rand.Rand) *Channel + func (c *Channel) Handle(evs []game.Event) + func (c *Channel) Advance(dt time.Duration) + func (c *Channel) Message() string + func (c *Channel) Say(msg string, p Priority) // used by Plans 04-05 for one-offs + ``` + - `const MinDwell = 2500 * time.Millisecond`, `const AmbientAfter = 9 * time.Second`. + - Preemption rule: a new message replaces the current one only when its priority is **higher**, or the current message has been shown for at least `MinDwell`. Event mapping: `LevelChanged` → `PrioBig` with a `LevelSubtitles` pick; `LinesCleared` with 4 rows → `PrioBig`; `ComboChanged` with `combo >= 2` → `PrioCombo`; `PieceLocked`/`LinesCleared` (1–3 rows) → `PrioRoutine` ambient pick; nothing for moves and rotations. After `AmbientAfter` with no message change, pick a fresh `Ambient` line at `PrioAmbient`. + +- [ ] **Step 1: Write the failing test** + +`internal/flavor/channel_test.go`: + +```go +package flavor + +import ( + "math/rand" + "strings" + "testing" + "time" + + "github.com/jessev/cosmic-tetris/internal/game" +) + +func chn(seed int64) *Channel { return NewChannel(rand.New(rand.NewSource(seed))) } + +func TestChannelStartsWithSomethingToSay(t *testing.T) { + c := chn(1) + if strings.TrimSpace(c.Message()) == "" { + t.Fatal("mission control should open with a line, not silence") + } +} + +// Review focus 4: messages must get time to breathe. +func TestLowPriorityEventsCannotFlickerTheLine(t *testing.T) { + c := chn(2) + c.Handle([]game.Event{{Kind: game.PieceLocked}}) + first := c.Message() + for i := 0; i < 20; i++ { + c.Advance(50 * time.Millisecond) // 1s total, well under MinDwell + c.Handle([]game.Event{{Kind: game.PieceLocked}}) + if c.Message() != first { + t.Fatalf("message changed after %dms: %q -> %q", (i+1)*50, first, c.Message()) + } + } + c.Advance(MinDwell) + c.Handle([]game.Event{{Kind: game.PieceLocked}}) + if c.Message() == first { + t.Error("after the dwell time a routine event should be allowed to change the line") + } +} + +func TestBigEventsPreemptImmediately(t *testing.T) { + c := chn(3) + c.Handle([]game.Event{{Kind: game.PieceLocked}}) + routine := c.Message() + c.Advance(100 * time.Millisecond) + c.Handle([]game.Event{{Kind: game.LevelChanged, Level: 8}}) + if c.Message() == routine { + t.Fatal("a level change must preempt a routine line") + } + got := c.Message() + found := false + for _, s := range LevelSubtitles { + if got == s { + found = true + } + } + if !found { + t.Errorf("level-up line %q is not one of the §22 subtitles", got) + } +} + +func TestComboLinesAppearFromComboTwoUp(t *testing.T) { + c := chn(4) + c.Handle([]game.Event{{Kind: game.ComboChanged, Combo: 1}}) + c.Advance(3 * time.Second) + solo := c.Message() + c.Handle([]game.Event{{Kind: game.ComboChanged, Combo: 5}}) + if c.Message() == solo { + t.Fatal("combo 5 should have something to say") + } + if !strings.Contains(c.Message(), "COMBO 5") { + t.Errorf("got %q, want the §21 combo-5 line", c.Message()) + } +} + +func TestComboLineForHighCombosReusesTheTopLine(t *testing.T) { + if got, want := ComboLine(12), ComboLine(7); got != want { + t.Errorf("ComboLine(12) = %q want %q", got, want) + } + if ComboLine(1) != "" { + t.Errorf("combo 1 should have no line, got %q", ComboLine(1)) + } +} + +func TestFourLineClearPreempts(t *testing.T) { + c := chn(5) + c.Handle([]game.Event{{Kind: game.PieceLocked}}) + before := c.Message() + c.Handle([]game.Event{{Kind: game.LinesCleared, Rows: []int{18, 19, 20, 21}}}) + if c.Message() == before { + t.Error("a four-line clear must preempt") + } +} + +func TestAmbientLineArrivesAfterALongQuietStretch(t *testing.T) { + c := chn(6) + c.Handle([]game.Event{{Kind: game.PieceLocked}}) + first := c.Message() + changed := false + for i := 0; i < 40; i++ { + c.Advance(500 * time.Millisecond) + if c.Message() != first { + changed = true + break + } + } + if !changed { + t.Errorf("mission control went silent for 20s on %q", first) + } +} + +func TestSayRespectsPriority(t *testing.T) { + c := chn(7) + c.Say("BIG NEWS", PrioBig) + c.Say("small news", PrioAmbient) + if c.Message() != "BIG NEWS" { + t.Errorf("got %q; a low-priority Say must not interrupt", c.Message()) + } +} + +func TestEveryPinnedSpecLineIsPresent(t *testing.T) { + joined := strings.Join(Ambient, "\n") + for _, want := range []string{ + "GRAVITY REMAINS MOSTLY LEGAL", "TETROMINO INJECTION SUCCESSFUL", + "STRUCTURAL VIBES: QUESTIONABLE", "LOCAL UNIVERSE STABLE*", + "MOON NOTIFIED", "ORBITAL OSHA HAS ENTERED THE CHAT", + "WE HAVE EXCEEDED THE RECOMMENDED NUMBER OF BLOCKS", "PHYSICS TEAM SAYS KEEP GOING", + } { + if !strings.Contains(joined, want) { + t.Errorf("ambient copy is missing %q", want) + } + } + if len(FourLineBanners) < 4 { + t.Errorf("only %d four-line banners; §20 lists four", len(FourLineBanners)) + } +} + +func TestChannelIsReproducibleForASeed(t *testing.T) { + run := func() []string { + c := chn(8) + out := []string{} + for i := 0; i < 20; i++ { + c.Advance(600 * time.Millisecond) + c.Handle([]game.Event{{Kind: game.PieceLocked}}) + out = append(out, c.Message()) + } + return out + } + a, b := run(), run() + for i := range a { + if a[i] != b[i] { + t.Fatalf("step %d differs: %q vs %q", i, a[i], b[i]) + } + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/flavor/ -v` +Expected: FAIL — `undefined: NewChannel`. + +- [ ] **Step 3: Implement `messages.go` and `channel.go`** + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/flavor/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/flavor internal/flavor/channel_test.go +git commit -m "feat(flavor): mission control channel with dwell time and priorities" +``` + +--- + +### Task 6: Draw the universe — render integration + +**Files:** +- Create: `internal/render/fxdraw.go` +- Modify: `internal/render/render.go` (`Frame` gains FX fields; §37 steps 2 and 6 become real) +- Modify: `internal/render/palette.go` (border colour ramp) +- Test: `internal/render/fxdraw_test.go` +- Modify: `internal/render/golden_test.go` (add two FX goldens) + +**Interfaces:** +- Consumes: `fx.Star`, `fx.Trail`, `Canvas`, `Mode`. +- Produces: + - `func DrawStarfield(c *Canvas, stars []fx.Star, m Mode)` — §37 step 2, before anything else; a star is skipped if the target cell is already non-blank. + - `func DrawTrails(c *Canvas, frame Rect, trails []fx.Trail, m Mode)` — §37 step 6; maps board cells with `BoardCellXY`, and **skips any cell whose current rune is not a space**, so trails can never cover a locked or active block (§44). + - `func BorderPaint(phase, energy float64, m Mode) Paint` — §25's palette: deep violet `#7a2bff`, electric cyan `#22f0ff`, magenta `#ff2ee6`, stellar blue `#2b6bff`, hot white `#f2f6ff`; `phase` interpolates around the ring by picking the nearest stop (no per-cell gradient yet — Plan 04 adds the travelling pulse), and `energy > 0.6` sets `Bold`. + - `func StarPaint(s fx.Star, m Mode) Paint` — brightness bands: `< 0.4` → faint grey `#5a6480`; `< 0.7` → `#9aa6c4`; else `#e6ecff` bold. + - `Frame` gains: `Stars []fx.Star`, `Trails []fx.Trail`, `NoFX bool`. All are optional; a zero `Frame` renders the game with no FX at all. + +- [ ] **Step 1: Write the failing test** + +`internal/render/fxdraw_test.go`: + +```go +package render + +import ( + "strings" + "testing" + "time" + "unicode" + + "github.com/jessev/cosmic-tetris/internal/fx" + "github.com/jessev/cosmic-tetris/internal/game" +) + +func TestStarfieldDrawsBehindEverything(t *testing.T) { + c := NewCanvas(10, 3) + c.Set(5, 1, '█', Paint{}) + DrawStarfield(c, []fx.Star{ + {X: 5, Y: 1, Glyph: '✦', Bright: 1}, + {X: 2, Y: 2, Glyph: '.', Bright: 0.2}, + }, ModeFull) + if c.Rune(5, 1) != '█' { + t.Error("a star overwrote existing content") + } + if c.Rune(2, 2) != '.' { + t.Errorf("star not drawn on a blank cell: %q", c.Rune(2, 2)) + } +} + +func TestStarsOutsideTheCanvasAreIgnored(t *testing.T) { + c := NewCanvas(4, 2) + DrawStarfield(c, []fx.Star{{X: -3, Y: -3, Glyph: '*'}, {X: 99, Y: 99, Glyph: '*'}}, ModeFull) + if strings.Contains(plain(c.String()), "*") { + t.Error("an off-canvas star was drawn") + } +} + +// §44: effects may never obscure the board. +func TestTrailsNeverCoverBoardCells(t *testing.T) { + r := Rect{X: 0, Y: 0, W: FrameCols, H: FrameRows} + var b game.Board + b.Set(4, 10, game.CellFor(game.KindI)) + active := game.Piece{Kind: game.KindO, Rotation: 0, X: 6, Y: 12} + c := NewCanvas(FrameCols, FrameRows) + DrawBoard(c, r, BoardView{Board: &b, Active: active, ShowActive: true}, ModeFull, Paint{}) + trails := []fx.Trail{ + {BX: 4, BY: 10, Kind: game.KindZ, Life: 140_000_000}, + {BX: 7, BY: 12, Kind: game.KindZ, Life: 140_000_000}, + {BX: 2, BY: 15, Kind: game.KindZ, Life: 140_000_000}, + } + DrawTrails(c, r, trails, ModeFull) + for _, cell := range [][2]int{{4, 10}, {7, 12}} { + x, y := BoardCellXY(r, cell[0], cell[1]) + if c.Rune(x, y) != '█' { + t.Errorf("trail covered the block at %v: %q", cell, c.Rune(x, y)) + } + } + x, y := BoardCellXY(r, 2, 15) + if c.Rune(x, y) == ' ' { + t.Error("a trail on an empty cell should have been drawn") + } +} + +func TestTrailGlyphsFadeAndAreASCIISafe(t *testing.T) { + r := Rect{W: FrameCols, H: FrameRows} + seen := map[rune]bool{} + for stage := 0; stage < 3; stage++ { + c := NewCanvas(FrameCols, FrameRows) + trails := []fx.Trail{{ + BX: 3, BY: 12, Kind: game.KindI, + Life: 300 * time.Millisecond, + Age: time.Duration(stage) * 100 * time.Millisecond, + }} + DrawTrails(c, r, trails, ModeFull) + x, y := BoardCellXY(r, 3, 12) + seen[c.Rune(x, y)] = true + } + if len(seen) < 2 { + t.Errorf("trail glyphs did not change with age: %v", seen) + } + c := NewCanvas(FrameCols, FrameRows) + DrawTrails(c, r, []fx.Trail{{BX: 3, BY: 12, Kind: game.KindI, Life: 300 * time.Millisecond}}, ModeASCII) + for _, ru := range plain(c.String()) { + if ru > unicode.MaxASCII { + t.Fatalf("ascii mode trail emitted %q", ru) + } + } +} + +func TestBorderPaintWalksThePaletteAndBrightensWithEnergy(t *testing.T) { + seen := map[string]bool{} + for i := 0; i < 10; i++ { + seen[BorderPaint(float64(i)/10, 0, ModeFull).FG] = true + } + if len(seen) < 3 { + t.Errorf("the border only used %d colours across a full cycle: %v", len(seen), seen) + } + if BorderPaint(0, 0, ModeFull).Bold { + t.Error("a calm border should not be bold") + } + if !BorderPaint(0, 1, ModeFull).Bold { + t.Error("a fully excited border should be bold") + } + if got := BorderPaint(0, 1, ModeASCII).FG; got == "" { + t.Error("ascii mode still needs some border colour") + } +} + +func TestStarPaintDimsFarStars(t *testing.T) { + far := StarPaint(fx.Star{Bright: 0.25}, ModeFull) + near := StarPaint(fx.Star{Bright: 0.9}, ModeFull) + if far.FG == near.FG { + t.Error("far and near stars should not share a colour") + } + if !near.Bold || far.Bold { + t.Errorf("near stars should be bold and far stars should not: %+v / %+v", near, far) + } +} + +// Review focus 1: the --no-fx path. +func TestFrameWithNoFXDataRendersFine(t *testing.T) { + f := frameFor(80, 40, ModeFull, OverlayNone) + f.NoFX = true + f.Stars, f.Trails = nil, nil + out := plain(Render(f)) + if !strings.Contains(out, "COSMIC TETRIS") { + t.Error("the game should still render with FX off") + } + // The board's own ghost still uses ░, so only star glyphs are checked here. + for _, glyph := range []string{"✦", "✧", "˚", "·"} { + if strings.Contains(out, glyph) { + t.Errorf("star glyph %q appeared with FX disabled", glyph) + } + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/render/ -run 'TestStar|TestTrail|TestBorderPaint|TestFrameWithNoFX' -v` +Expected: FAIL — `undefined: DrawStarfield`. + +- [ ] **Step 3: Implement `fxdraw.go` and extend `render.go`** + +In `Render`, §37 step 2 draws the starfield across the whole canvas first; step 6 draws trails after the active piece and before the border. Neither may run when `f.NoFX` is set. + +- [ ] **Step 4: Run the tests, then add and inspect two FX goldens** + +Add to the `TestGoldenLayouts` table in `golden_test.go` two cases built from a frame with a fixed star list and trail list (construct them literally in the test — never from a live `fx.World`, whose tuning will keep changing): `{"wide_fx", 80, 40, ModeFull, OverlayNone}` and `{"ascii_fx", 80, 40, ModeASCII, OverlayNone}`. + +Run: `go test ./internal/render/ -update && go test ./internal/render/ && cat internal/render/testdata/wide_fx.txt` +Expected: PASS, and the recorded frame shows stars around the board with the well itself unpolluted. + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/fxdraw.go internal/render/render.go internal/render/palette.go internal/render/fxdraw_test.go internal/render/golden_test.go internal/render/testdata +git commit -m "feat(render): draw starfield, trails, and the energy border" +``` + +--- + +### Task 7: Wire FX into the app, with RNG isolation proven + +**Files:** +- Modify: `internal/app/model.go`, `internal/app/update.go` +- Test: `internal/app/fx_test.go` + +**Interfaces:** +- Consumes: `fx.World`, `flavor.Channel`, the render additions. +- Produces: + - `Model` gains `FX *fx.World` (nil when `--no-fx`) and `Chan *flavor.Channel`. + - `func NewModel(cfg Config, mode render.Mode, seed int64) *Model` now also builds, unless `cfg.NoFX`: + - `fx.NewWorld(0, 0, fx.Options{Seed: seed ^ 0x5DEECE66D, Intensity: intensityFor(cfg), ASCII: mode == render.ModeASCII, Level: 1})` + - `flavor.NewChannel(rand.New(rand.NewSource(seed ^ 0x2545F4914F6CDD1D)))` + - `func intensityFor(cfg Config) fx.Intensity` — `IntensityOff` when `NoFX`, `IntensityReduced` when `ReducedMotion`, else `IntensityFull`. + - Per frame, in this order: advance the game and collect events → `FX.Handle(events)` and `Chan.Handle(events)` → `FX.Advance(dt)` and `Chan.Advance(dt)` → `FX.SetLevel(m.Game.Level)`. On `WindowSizeMsg`, `FX.Resize(w, h)` and `FX.SetBoardRect(...)` from the fresh layout. `View()` fills `Frame.Stars`, `Frame.Trails`, `Frame.Border = render.BorderPaint(FX.BorderPhase(), FX.BorderEnergy(), mode)`, and `Frame.Mission = Chan.Message()`. With `--no-fx`, `Frame.NoFX` is true, the FX fields stay nil, `Frame.Border` is a fixed chrome paint, and `Frame.Mission` is empty. + +- [ ] **Step 1: Write the failing test** + +`internal/app/fx_test.go`: + +```go +package app + +import ( + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/jessev/cosmic-tetris/internal/fx" + "github.com/jessev/cosmic-tetris/internal/render" +) + +// modelWith builds an 80x40 model that honours the config, so later plans can +// reuse it for --ascii and --seed cases. +func modelWith(t *testing.T, cfg Config) *Model { + t.Helper() + mode := render.ModeFull + if cfg.ASCII { + mode = render.ModeASCII + } + seed := cfg.Seed + if seed == 0 { + seed = 4242 + } + m := NewModel(cfg, mode, seed) + next, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 40}) + return next.(*Model) +} + +// Review focus 5: FX must not perturb the game. +func TestPieceOrderIsIdenticalAcrossFXModes(t *testing.T) { + play := func(cfg Config) (int, int, uint64, string) { + m := modelWith(t, cfg) + for i := 0; i < 120; i++ { + switch i % 5 { + case 0: + m = press(t, m, "h") + case 1: + m = press(t, m, "l") + case 2: + m = press(t, m, "k") + case 3: + m = press(t, m, " ") + } + next, _ := m.Update(FrameMsg{Now: time.Unix(0, 0).Add(time.Duration(i) * 20 * time.Millisecond)}) + m = next.(*Model) + } + return m.Game.Score, m.Game.Lines, m.Game.Board.Fingerprint(), m.Game.Active.Kind.String() + } + full := play(Config{Seed: 4242}) + reduced := play(Config{Seed: 4242, ReducedMotion: true}) + off := play(Config{Seed: 4242, NoFX: true}) + if full != reduced || full != off { + t.Fatalf("FX changed the game:\n full=%v\n reduced=%v\n off=%v", full, reduced, off) + } +} + +func TestFXSeedIsNotTheGameSeed(t *testing.T) { + m := modelWith(t, Config{Seed: 4242}) + if m.FX == nil { + t.Fatal("FX world missing") + } + if m.Game.Seed != 4242 { + t.Errorf("game seed = %d want 4242", m.Game.Seed) + } + // Two worlds seeded the same way must agree; a world seeded with the raw + // game seed must not (that would mean the derivation was skipped). + a := fx.NewWorld(80, 40, fx.Options{Seed: 4242 ^ 0x5DEECE66D, Intensity: fx.IntensityFull}) + b := fx.NewWorld(80, 40, fx.Options{Seed: 4242, Intensity: fx.IntensityFull}) + if len(a.Stars()) == 0 || len(b.Stars()) == 0 { + t.Fatal("stars missing") + } + if a.Stars()[0] == b.Stars()[0] { + t.Error("the FX seed derivation is not being applied") + } +} + +// Review focus 1: nil FX everywhere. +func TestNoFXModelNeverTouchesANilWorld(t *testing.T) { + m := modelWith(t, Config{Seed: 7, NoFX: true}) + if m.FX != nil { + t.Fatal("--no-fx should not build an FX world") + } + for i := 0; i < 50; i++ { + m = press(t, m, []string{"h", "l", " ", "c", "p", "p", "?", "?"}[i%8]) + next, _ := m.Update(FrameMsg{Now: time.Unix(0, 0).Add(time.Duration(i) * 30 * time.Millisecond)}) + m = next.(*Model) + next, _ = m.Update(tea.WindowSizeMsg{Width: 40 + i%60, Height: 24 + i%20}) + m = next.(*Model) + _ = m.View() // must not panic + } +} + +func TestFXWorldTracksTerminalSizeAndLevel(t *testing.T) { + m := modelWith(t, Config{Seed: 8}) + if c, r := m.FX.Size(); c != 80 || r != 40 { + t.Errorf("FX size = %dx%d, want the terminal size", c, r) + } + m.Game.Level = 9 + next, _ := m.Update(FrameMsg{Now: time.Unix(0, 0).Add(20 * time.Millisecond)}) + m = next.(*Model) + if m.FX.StarSpeedScale() <= 1 { + t.Error("FX did not learn the new level") + } +} + +func TestMissionControlAppearsInTheView(t *testing.T) { + m := modelWith(t, Config{Seed: 9}) + out := plainOut(m.View()) + if !strings.Contains(out, "MISSION CONTROL") { + t.Errorf("mission control line missing:\n%s", out) + } + quiet := modelWith(t, Config{Seed: 9, NoFX: true}) + if strings.Contains(plainOut(quiet.View()), "MISSION CONTROL") { + t.Error("--no-fx should not narrate") + } +} + +func TestStarsAppearInTheViewOverTime(t *testing.T) { + m := modelWith(t, Config{Seed: 10}) + before := plainOut(m.View()) + for i := 0; i < 30; i++ { + next, _ := m.Update(FrameMsg{Now: time.Unix(0, 0).Add(time.Duration(i) * 40 * time.Millisecond)}) + m = next.(*Model) + } + if plainOut(m.View()) == before { + t.Error("the terminal is not alive: 1.2s passed and nothing on screen moved") + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/app/ -v` +Expected: FAIL — `m.FX` undefined. + +- [ ] **Step 3: Wire it up in `model.go` and `update.go`** + +- [ ] **Step 4: Run everything** + +Run: `go test ./... -race && make lint` +Expected: PASS. + +- [ ] **Step 5: Play all three modes** + +```bash +make build +./cosmic-tetris --seed 8675309 # stars drift, border breathes, trails follow pieces, mission control talks +./cosmic-tetris --seed 8675309 --no-fx # still a good game, no narration, no stars +./cosmic-tetris --seed 8675309 --ascii # same universe, ASCII glyphs +``` + +Check by hand: the board is never harder to read than it was in Plan 02; mission control does not flicker; stars visibly speed up as the level climbs. + +- [ ] **Step 6: Commit** + +```bash +git add internal/app/model.go internal/app/update.go internal/app/fx_test.go +git commit -m "feat(app): wire the FX world and mission control, with isolated RNGs" +``` + +--- + +## Done when + +- `make test` passes with `-race`. +- The same seed and input sequence produce a byte-identical game with FX off, reduced, and full. +- `internal/fx` imports only the standard library and `internal/game`, holds no `*game.Game`, and has no method that could mutate one. +- Starting the game and touching nothing shows a moving starfield, a slowly shifting border, and mission-control commentary — three of §43's six first-thirty-seconds items, with trails making a fourth as soon as the player moves. +- `--no-fx` renders and plays with a nil FX world at every call site. diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/N1-recipe-cosmic-11/2026-09-17-cosmic-tetris-04-violence.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/N1-recipe-cosmic-11/2026-09-17-cosmic-tetris-04-violence.md new file mode 100644 index 000000000..29074e8b8 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/N1-recipe-cosmic-11/2026-09-17-cosmic-tetris-04-violence.md @@ -0,0 +1,1867 @@ +# Cosmic Tetris — Plan 04: Violence Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the universe lose its composure — particle physics, hard-drop impact, screen shake, the line-clear supernova, shockwaves, hyperdrive, and the four-line astronomical event — without ever slowing the controls or hiding the board. + +**Architecture:** Every effect is a small struct owned by `fx.World`, advanced by the same `Advance(dt)` and fed by the same `Handle(events)` as Plan 03. The renderer asks the world for flat slices (particles, rings, banner text, shake offset, flash level) and composites them onto the canvas. Nothing here can reach the game: the FX world still holds no `*game.Game`, and gameplay never waits for an animation. + +**Tech Stack:** Go 1.26; `internal/fx` remains standard-library-only. + +**Spec:** `design.md` — Phase 4 of §42. Sections most relevant: §16, §17, §18, §19, §20, §21, §22, §23, §24, §38, §43, §44, §49.5. + +**Prerequisite:** Plans 01–03 complete; `make test` green. + +## Global Constraints + +- The FX system may observe game events and may **never modify GameState** (§14). Effects never alter gameplay, and no random effect may change what the game does (§44). +- Never obscure the active piece. Never make controls lag. Never delay gameplay for animation. Never make screen shake exceed roughly one cell. Never allow particles to permanently alter the rendered board. Board readability remains sacred (§21, §44). +- Particle integration per step (§23): `position += velocity × dt`; `velocity += acceleration × dt`; `velocity *= drag`; `life -= dt`. Forces: gravity, drag, radial explosion force, random angular variation. No collision detection. Particles die when `life <= 0` or they leave the viewport. Floating positions are converted to terminal cells at render time. +- Durations, pinned by the spec: hard-drop screen shake `~80ms`; line-clear animation total `~220ms`; shockwave `~300ms`; four-line banner `~700ms`; hyperdrive timeline `0ms` pause → `50ms` stretch → `100ms` violent acceleration → `500ms` peak → `800ms` decay → `1100ms` normal. +- Shake pattern, deterministic (§18): `0,+1` → `-1,0` → `+1,0` → `0,-1` → `0,0`. +- Hyperdrive triggers: four-line clear, large combo, new high score (§16). +- Combo escalation (§21): combo 2 small sparks; combo 3 meteor particles; combo 4 HUD pulsing; combo 5+ mission control loses control of the mission. +- Four-line clear fires simultaneously (§20): hyperdrive, larger screen shake, border gradient pulse, particle eruption, HUD flash, temporary star density increase, giant banner. The banner must not block gameplay input. +- Level-up notification slides/fades away without pausing the game (§22). +- Shockwaves are used **sparingly** (§24). +- `--reduced-motion` (§49.5) suppresses screen shake, hyperdrive acceleration, and shockwaves, and leaves colour, trails, and particles alone. +- Performance (§38): no goroutine per particle or per frame, no filesystem access during gameplay, no per-frame logging, reusable slices. A few hundred particles must be trivial. + +## Review Focus + +1. **Sustained four-line clears** (a good player clearing quads for minutes) must leave particle and ring counts bounded, with the oldest dropped, and per-frame work flat. → Task 1 and Task 9. +2. **Screen shake at the edge of the terminal** — a one-cell shift must never push content off-screen, change the line count, or change any line's width, or the whole frame reflows and flickers. → Task 3. +3. **Particles inside the well** — debris drifting over the stack must never cover a locked or active cell, and must never leave a permanent mark once it dies. → Task 9. +4. **A piece locking during the 220ms line-clear animation** — gameplay has already collapsed those rows, so the animation is drawing rows that no longer exist; the frame must stay coherent and the new piece must render correctly. → Task 4. +5. **`--reduced-motion`** must suppress exactly shake, hyperdrive acceleration, and shockwaves — and must not quietly kill particles, trails, or colour, which are the reason to play. → Task 9. + +--- + +### Task 1: Particle simulation + +**Files:** +- Create: `internal/fx/particle.go` +- Test: `internal/fx/particle_test.go` + +**Interfaces:** +- Consumes: Task-1-of-Plan-03's `World`. +- Produces: + - ```go + type Particle struct { + X, Y float64 // canvas cells; fractional + VX, VY float64 // cells per second + Life float64 // seconds remaining + MaxLife float64 + Glyph rune + Brightness float64 // 0..1 + Hue string // "" = default hot white; otherwise a palette key set by the emitter + } + func (w *World) Particles() []Particle // the live backing slice; read-only for callers + func (w *World) ParticleCount() int + func (w *World) Emit(p Particle) + func (w *World) EmitBurst(x, y float64, n int, spec BurstSpec) + type BurstSpec struct { + Speed float64 // mean radial speed, cells/sec + SpeedJitter float64 + Life float64 // mean seconds + Glyphs []rune + Hue string + UpBias float64 // added to -VY, for debris that should fly upward + } + ``` + - Pinned physics constants: `ParticleGravity = 14.0` cells/s² downward, `ParticleDrag = 0.90` per `1/60 s` step (applied as `pow(drag, dt*60)`), `MaxParticles = 600` (oldest dropped when full). The backing slice is allocated once at `MaxParticles` capacity and never regrown (§38). + - Glyph inventories: full/reduced `· * ✦ +` (§18); ASCII `. * + o`. + - `Advance` integrates in the §23 order, then culls particles with `Life <= 0` or positions outside `[-1, cols] × [-1, rows]`. + +- [ ] **Step 1: Write the failing test** + +`internal/fx/particle_test.go`: + +```go +package fx + +import ( + "math" + "testing" + "time" +) + +func TestParticleMovesWithItsVelocity(t *testing.T) { + w := full(80, 40, 31) + w.Emit(Particle{X: 10, Y: 10, VX: 6, VY: -12, Life: 5, MaxLife: 5, Glyph: '*', Brightness: 1}) + w.Advance(100 * time.Millisecond) + p := w.Particles()[0] + if p.X <= 10 { + t.Errorf("X did not advance: %v", p.X) + } + if p.Y >= 10 { + t.Errorf("Y should have risen: %v", p.Y) + } +} + +func TestGravityPullsParticlesDownAndDragSlowsThem(t *testing.T) { + w := full(80, 40, 32) + w.Emit(Particle{X: 10, Y: 10, VX: 20, VY: 0, Life: 5, MaxLife: 5, Glyph: '*'}) + w.Advance(100 * time.Millisecond) + p := w.Particles()[0] + if p.VY <= 0 { + t.Errorf("gravity did not act: VY = %v", p.VY) + } + if p.VX >= 20 { + t.Errorf("drag ate no horizontal speed: VX = %v", p.VX) + } + if p.VX <= 0 { + t.Errorf("drag reversed the particle: VX = %v", p.VX) + } +} + +func TestParticlesDieWhenLifeRunsOut(t *testing.T) { + w := full(80, 40, 33) + w.Emit(Particle{X: 10, Y: 10, Life: 0.1, MaxLife: 0.1, Glyph: '*'}) + w.Advance(50 * time.Millisecond) + if w.ParticleCount() != 1 { + t.Fatalf("died too early: %d", w.ParticleCount()) + } + w.Advance(100 * time.Millisecond) + if w.ParticleCount() != 0 { + t.Fatalf("still alive past its life: %d", w.ParticleCount()) + } +} + +func TestParticlesLeavingTheViewportAreCulled(t *testing.T) { + w := full(80, 40, 34) + w.Emit(Particle{X: 79, Y: 39, VX: 500, VY: 500, Life: 10, MaxLife: 10, Glyph: '*'}) + w.Advance(100 * time.Millisecond) + if w.ParticleCount() != 0 { + t.Fatalf("an off-screen particle survived: %+v", w.Particles()) + } +} + +func TestBurstFliesOutwardInAllDirections(t *testing.T) { + w := full(80, 40, 35) + w.EmitBurst(40, 20, 40, BurstSpec{Speed: 10, SpeedJitter: 4, Life: 0.5, Glyphs: []rune{'*', '✦'}}) + if n := w.ParticleCount(); n != 40 { + t.Fatalf("burst produced %d particles want 40", n) + } + var left, right, up, down int + for _, p := range w.Particles() { + if p.VX < 0 { + left++ + } + if p.VX > 0 { + right++ + } + if p.VY < 0 { + up++ + } + if p.VY > 0 { + down++ + } + if math.IsNaN(p.X) || math.IsNaN(p.VX) || math.IsInf(p.VY, 0) { + t.Fatalf("burst produced a broken particle: %+v", p) + } + } + for name, n := range map[string]int{"left": left, "right": right, "up": up, "down": down} { + if n == 0 { + t.Errorf("no particles went %s", name) + } + } +} + +func TestBurstUpBiasThrowsDebrisUpward(t *testing.T) { + w := full(80, 40, 36) + w.EmitBurst(40, 20, 30, BurstSpec{Speed: 8, Life: 0.5, Glyphs: []rune{'*'}, UpBias: 12}) + up := 0 + for _, p := range w.Particles() { + if p.VY < 0 { + up++ + } + } + if up < 20 { + t.Errorf("only %d of 30 particles went up despite the bias", up) + } +} + +// Review focus 1: bounded under sustained abuse. +func TestParticleCountIsCappedUnderSustainedBursts(t *testing.T) { + w := full(80, 40, 37) + for i := 0; i < 200; i++ { + w.EmitBurst(40, 20, 60, BurstSpec{Speed: 5, Life: 30, Glyphs: []rune{'*'}}) + w.Advance(16 * time.Millisecond) + if n := w.ParticleCount(); n > MaxParticles { + t.Fatalf("iteration %d: %d particles alive, cap is %d", i, n, MaxParticles) + } + } +} + +// §38: reusable slices, no per-frame allocation. +func TestParticleSliceIsReusedNotReallocatedEveryFrame(t *testing.T) { + w := full(80, 40, 38) + start := cap(w.Particles()) + if start < MaxParticles { + t.Fatalf("cap = %d at construction; preallocate the full %d (§38)", start, MaxParticles) + } + for i := 0; i < 3000; i++ { + w.EmitBurst(40, 20, 40, BurstSpec{Speed: 8, Life: 1, Glyphs: []rune{'*'}}) + w.Advance(16 * time.Millisecond) + if c := cap(w.Particles()); c != start { + t.Fatalf("frame %d reallocated the backing array: cap %d then %d", i, start, c) + } + } +} + +func TestNoParticlesWhenFXIsOff(t *testing.T) { + w := NewWorld(80, 40, Options{Seed: 39, Intensity: IntensityOff}) + w.EmitBurst(40, 20, 50, BurstSpec{Speed: 5, Life: 1, Glyphs: []rune{'*'}}) + if w.ParticleCount() != 0 { + t.Error("IntensityOff must not simulate particles") + } +} + +func TestASCIIParticleGlyphsAreASCII(t *testing.T) { + w := NewWorld(80, 40, Options{Seed: 40, Intensity: IntensityFull, ASCII: true}) + w.EmitBurst(40, 20, 50, BurstSpec{Speed: 5, Life: 1}) // no explicit glyphs: the world picks + for _, p := range w.Particles() { + if p.Glyph > 127 { + t.Fatalf("ascii mode emitted %q", p.Glyph) + } + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -run Particle -v` +Expected: FAIL — `undefined: Particle`. + +- [ ] **Step 3: Implement `particle.go`** + +Keep one backing slice on the world; cull by swapping the dead entry with the last live one and shrinking the length. `EmitBurst` with an empty `Glyphs` picks from the mode-appropriate inventory. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/fx/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/particle.go internal/fx/particle_test.go +git commit -m "feat(fx): terminal-space particle physics with a hard cap" +``` + +--- + +### Task 2: Hard-drop impact — debris, shake, border flash + +**Files:** +- Create: `internal/fx/impact.go` +- Test: `internal/fx/impact_test.go` + +**Interfaces:** +- Consumes: `Particle`, `BurstSpec`, `World.SetBoardRect`, `game.Event` (`PieceHardDropped`). +- Produces: + - `func (w *World) ShakeOffset() (dx, dy int)` — the current offset, always within `[-1, 1]` on both axes, and always `0,0` when `Intensity != IntensityFull`. + - `func (w *World) shakeFor(d time.Duration, strong bool)` — starts a shake; `strong` (four-line) uses the same one-cell pattern but runs it for `160ms` instead of `80ms`, because §44 caps the amplitude, not the duration. + - `const ShakeStep = 16 * time.Millisecond`, `ShakeDuration = 80 * time.Millisecond`, `StrongShakeDuration = 160 * time.Millisecond`. + - `var ShakePattern = [5][2]int{{0, 1}, {-1, 0}, {1, 0}, {0, -1}, {0, 0}}`. + - Impact handling on `PieceHardDropped`: emit debris from the contact area — `8 + 3×min(distance,6)` particles, `UpBias` proportional to distance, spread across the piece's occupied columns; start a shake; add border energy (Plan 03's `BorderEnergy` already rises on this event, per its pinned table) and set a short border flash via `func (w *World) BorderFlash() float64` (`1.0` at impact, decaying to `0` over `250ms`). + +- [ ] **Step 1: Write the failing test** + +`internal/fx/impact_test.go`: + +```go +package fx + +import ( + "testing" + "time" + + "github.com/jessev/cosmic-tetris/internal/game" +) + +func hardDrop(distance int) game.Event { + p := game.Piece{Kind: game.KindI, Rotation: 1, X: 4, Y: 18} + return game.Event{Kind: game.PieceHardDropped, Piece: p, Cells: p.Cells(), Distance: distance} +} + +func boardWorld(seed int64) *World { + w := full(80, 40, seed) + w.SetBoardRect(Rect{X: 20, Y: 4, W: 22, H: 22}) + return w +} + +func TestHardDropEmitsDebrisScaledWithDistance(t *testing.T) { + near := boardWorld(41) + near.Handle([]game.Event{hardDrop(1)}) + far := boardWorld(41) + far.Handle([]game.Event{hardDrop(15)}) + if near.ParticleCount() == 0 { + t.Fatal("a hard drop produced no debris") + } + if far.ParticleCount() <= near.ParticleCount() { + t.Errorf("a longer drop should throw more debris: %d vs %d", + far.ParticleCount(), near.ParticleCount()) + } +} + +func TestDebrisAppearsNearTheContactArea(t *testing.T) { + w := boardWorld(42) + w.Handle([]game.Event{hardDrop(10)}) + for _, p := range w.Particles() { + if p.X < 20 || p.X > 42 { + t.Errorf("debris at X=%v is outside the board columns 20..42", p.X) + } + if p.Y < 4 || p.Y > 26 { + t.Errorf("debris at Y=%v is outside the board rows 4..26", p.Y) + } + } +} + +func TestShakeRunsForEightyMillisecondsAndStopsAtZero(t *testing.T) { + w := boardWorld(43) + w.Handle([]game.Event{hardDrop(8)}) + moved := false + for elapsed := time.Duration(0); elapsed < ShakeDuration; elapsed += ShakeStep { + dx, dy := w.ShakeOffset() + if dx != 0 || dy != 0 { + moved = true + } + w.Advance(ShakeStep) + } + if !moved { + t.Fatal("no shake happened") + } + w.Advance(ShakeStep) + if dx, dy := w.ShakeOffset(); dx != 0 || dy != 0 { + t.Errorf("shake did not settle: %d,%d", dx, dy) + } +} + +// §44: never exceed roughly one cell. +func TestShakeNeverExceedsOneCell(t *testing.T) { + w := boardWorld(44) + for i := 0; i < 40; i++ { + w.Handle([]game.Event{hardDrop(20)}) + for j := 0; j < 20; j++ { + dx, dy := w.ShakeOffset() + if dx < -1 || dx > 1 || dy < -1 || dy > 1 { + t.Fatalf("shake offset %d,%d exceeds one cell", dx, dy) + } + w.Advance(8 * time.Millisecond) + } + } +} + +func TestShakeFollowsThePinnedPattern(t *testing.T) { + if ShakePattern != [5][2]int{{0, 1}, {-1, 0}, {1, 0}, {0, -1}, {0, 0}} { + t.Fatalf("the §18 shake pattern changed: %v", ShakePattern) + } + w := boardWorld(45) + w.Handle([]game.Event{hardDrop(8)}) + seen := [][2]int{} + for i := 0; i < 6; i++ { + dx, dy := w.ShakeOffset() + seen = append(seen, [2]int{dx, dy}) + w.Advance(ShakeStep) + } + if seen[0] != ShakePattern[0] { + t.Errorf("shake started at %v want %v", seen[0], ShakePattern[0]) + } +} + +func TestReducedMotionSuppressesShakeButKeepsDebris(t *testing.T) { + w := NewWorld(80, 40, Options{Seed: 46, Intensity: IntensityReduced}) + w.SetBoardRect(Rect{X: 20, Y: 4, W: 22, H: 22}) + w.Handle([]game.Event{hardDrop(12)}) + for i := 0; i < 10; i++ { + if dx, dy := w.ShakeOffset(); dx != 0 || dy != 0 { + t.Fatalf("reduced motion produced a shake: %d,%d", dx, dy) + } + w.Advance(ShakeStep) + } + if w.ParticleCount() == 0 { + t.Error("reduced motion should keep the debris (§49.5)") + } +} + +func TestBorderFlashesOnImpactAndDecays(t *testing.T) { + w := boardWorld(47) + w.Handle([]game.Event{hardDrop(10)}) + if f := w.BorderFlash(); f < 0.9 { + t.Fatalf("flash = %v at impact, want ~1", f) + } + w.Advance(300 * time.Millisecond) + if f := w.BorderFlash(); f > 0.05 { + t.Errorf("flash = %v after 300ms, want ~0", f) + } +} + +func TestStrongShakeLastsLongerNotWider(t *testing.T) { + w := boardWorld(48) + w.shakeFor(StrongShakeDuration, true) + frames := 0 + for { + dx, dy := w.ShakeOffset() + if dx == 0 && dy == 0 && frames > 2 { + break + } + if dx < -1 || dx > 1 || dy < -1 || dy > 1 { + t.Fatalf("strong shake exceeded one cell: %d,%d", dx, dy) + } + w.Advance(ShakeStep) + frames++ + if frames > 40 { + t.Fatal("strong shake never settled") + } + } + if time.Duration(frames)*ShakeStep < ShakeDuration { + t.Errorf("strong shake lasted only %v", time.Duration(frames)*ShakeStep) + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -run 'TestHardDrop|TestDebris|TestShake|TestReducedMotionSuppresses|TestBorderFlash|TestStrong' -v` +Expected: FAIL — `undefined: (*World).ShakeOffset`. + +- [ ] **Step 3: Implement `impact.go`** + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/fx/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/impact.go internal/fx/impact_test.go +git commit -m "feat(fx): hard-drop impact with debris, one-cell shake, border flash" +``` + +--- + +### Task 3: Screen shake in the renderer + +**Files:** +- Modify: `internal/render/render.go` +- Test: `internal/render/shake_test.go` + +**Interfaces:** +- Consumes: `Canvas.Blit`, `Frame`. +- Produces: + - `Frame` gains `ShakeX, ShakeY int`. + - `Render` composites the board region through a temporary canvas offset by `(ShakeX, ShakeY)` — the board frame and its contents move, the outer chrome does not (§18 "shift the rendered board"). Cells vacated by the shift become blank; content shifted past the frame edge is clipped. + - `func clampShake(dx, dy int) (int, int)` — hard-clamps to `[-1,1]`, so a bug in FX can never produce a five-cell lurch. + +- [ ] **Step 1: Write the failing test** + +`internal/render/shake_test.go`: + +```go +package render + +import ( + "strings" + "testing" +) + +// Review focus 2: shake must not change the frame's shape. +func TestShakeNeverChangesLineCountOrWidth(t *testing.T) { + base := lines(Render(frameFor(80, 40, ModeFull, OverlayNone))) + for _, off := range [][2]int{{0, 1}, {-1, 0}, {1, 0}, {0, -1}, {1, 1}, {-1, -1}} { + f := frameFor(80, 40, ModeFull, OverlayNone) + f.ShakeX, f.ShakeY = off[0], off[1] + got := lines(Render(f)) + if len(got) != len(base) { + t.Fatalf("offset %v changed the line count: %d vs %d", off, len(got), len(base)) + } + for i := range got { + if len([]rune(got[i])) != len([]rune(base[i])) { + t.Fatalf("offset %v changed line %d width: %d vs %d", + off, i, len([]rune(got[i])), len([]rune(base[i]))) + } + } + } +} + +func TestShakeActuallyMovesTheBoard(t *testing.T) { + base := Render(frameFor(80, 40, ModeFull, OverlayNone)) + f := frameFor(80, 40, ModeFull, OverlayNone) + f.ShakeX, f.ShakeY = 1, 0 + if plain(Render(f)) == plain(base) { + t.Fatal("a shake offset changed nothing on screen") + } +} + +func TestShakeIsClampedToOneCell(t *testing.T) { + f := frameFor(80, 40, ModeFull, OverlayNone) + f.ShakeX, f.ShakeY = 40, -40 + out := lines(Render(f)) + if len(out) == 0 { + t.Fatal("no output") + } + g := GlyphsFor(ModeFull) + found := false + for _, line := range out { + if strings.Contains(line, g.BorderTL) { + found = true + } + } + if !found { + t.Error("a wild shake offset threw the board off screen; clamp it") + } + if dx, dy := clampShake(40, -40); dx != 1 || dy != -1 { + t.Errorf("clampShake(40,-40) = %d,%d want 1,-1", dx, dy) + } +} + +func TestShakeAtTheMinimumTerminalSizeStaysInside(t *testing.T) { + for _, off := range [][2]int{{0, 1}, {-1, 0}, {1, 0}, {0, -1}} { + f := frameFor(40, 24, ModeFull, OverlayNone) + f.ShakeX, f.ShakeY = off[0], off[1] + for i, line := range lines(Render(f)) { + if n := len([]rune(line)); n > 40 { + t.Fatalf("offset %v line %d is %d columns wide", off, i, n) + } + } + } +} + +func TestOverlaysDoNotShake(t *testing.T) { + // The pause box should stay put even if a shake is in flight. + a := frameFor(80, 40, ModeFull, OverlayPause) + b := frameFor(80, 40, ModeFull, OverlayPause) + b.ShakeX, b.ShakeY = 1, 1 + pick := func(f Frame) string { + for _, line := range lines(Render(f)) { + if strings.Contains(line, "TEMPORAL SUSPENSION") { + return line + } + } + return "" + } + if pick(a) == "" { + t.Fatal("pause overlay missing") + } + if pick(a) != pick(b) { + t.Error("the overlay moved with the shake; only the board should shake") + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/render/ -run Shake -v` +Expected: FAIL — `Frame` has no `ShakeX`. + +- [ ] **Step 3: Implement the shake compositing in `render.go`** + +Draw the board (steps 3–7 of §37) into a canvas the size of `Layout.Frame`, then `Blit` it onto the main canvas at `Frame.X+dx, Frame.Y+dy`, blanking the frame region first so the vacated cells are clean. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/render.go internal/render/shake_test.go +git commit -m "feat(render): one-cell board shake that never reflows the frame" +``` + +--- + +### Task 4: Line-clear supernova + +**Files:** +- Create: `internal/fx/lineclear.go` +- Test: `internal/fx/lineclear_test.go` + +**Interfaces:** +- Consumes: `game.Event` (`LinesCleared`, with `Rows` and `RowCells`), `World.SetBoardRect`, particles. +- Produces: + - ```go + type ClearPhase int + const (PhaseCriticalMass ClearPhase = iota; PhaseSupernova; PhaseCollapse; PhaseDone) + + type ClearAnim struct { + Row int // logical board row + Cells []game.Cell // snapshot, pre-collapse + Age time.Duration + } + func (a ClearAnim) Phase() ClearPhase + func (a ClearAnim) Progress() float64 // 0..1 within the whole animation + func (w *World) ClearAnims() []ClearAnim + ``` + - `const ClearDuration = 220 * time.Millisecond`, with phase boundaries at `70ms` (A→B) and `150ms` (B→C), per §19's three phases inside ~220ms. + - On `LinesCleared`: one `ClearAnim` per row, holding the row snapshot; at the B→C boundary each row erupts into debris whose horizontal velocity is proportional to `column - center` (§19 "particles should inherit some horizontal velocity from their location relative to center") — `10` particles per row, `VX = (col - 4.5) * 1.6`, `UpBias 6`. + - Anims are dropped once `Age >= ClearDuration`. + +- [ ] **Step 1: Write the failing test** + +`internal/fx/lineclear_test.go`: + +```go +package fx + +import ( + "testing" + "time" + + "github.com/jessev/cosmic-tetris/internal/game" +) + +func clearEvent(rows ...int) game.Event { + snap := make([][]game.Cell, len(rows)) + for i := range rows { + row := make([]game.Cell, game.Width) + for x := range row { + row[x] = game.CellFor(game.AllKinds[x%7]) + } + snap[i] = row + } + return game.Event{Kind: game.LinesCleared, Rows: rows, RowCells: snap} +} + +func TestClearAnimIsCreatedPerRow(t *testing.T) { + w := boardWorld(51) + w.Handle([]game.Event{clearEvent(18, 19, 20, 21)}) + anims := w.ClearAnims() + if len(anims) != 4 { + t.Fatalf("got %d anims want 4", len(anims)) + } + rows := map[int]bool{} + for _, a := range anims { + rows[a.Row] = true + if len(a.Cells) != game.Width { + t.Errorf("row %d snapshot is %d wide", a.Row, len(a.Cells)) + } + } + for _, r := range []int{18, 19, 20, 21} { + if !rows[r] { + t.Errorf("row %d missing", r) + } + } +} + +func TestPhasesRunAThenBThenCWithinTwoTwentyMillis(t *testing.T) { + w := boardWorld(52) + w.Handle([]game.Event{clearEvent(21)}) + seen := []ClearPhase{} + for elapsed := time.Duration(0); elapsed <= ClearDuration; elapsed += 20 * time.Millisecond { + if anims := w.ClearAnims(); len(anims) > 0 { + p := anims[0].Phase() + if len(seen) == 0 || seen[len(seen)-1] != p { + seen = append(seen, p) + } + } + w.Advance(20 * time.Millisecond) + } + want := []ClearPhase{PhaseCriticalMass, PhaseSupernova, PhaseCollapse} + if len(seen) < 3 { + t.Fatalf("phases observed: %v want at least %v", seen, want) + } + for i := range want { + if seen[i] != want[i] { + t.Fatalf("phase order %v want %v", seen, want) + } + } +} + +func TestClearAnimEndsAndIsForgotten(t *testing.T) { + w := boardWorld(53) + w.Handle([]game.Event{clearEvent(21)}) + w.Advance(ClearDuration + 20*time.Millisecond) + if n := len(w.ClearAnims()); n != 0 { + t.Fatalf("%d anims still alive after the animation window", n) + } +} + +func TestCollapsePhaseThrowsDebrisOutwardFromTheCentre(t *testing.T) { + w := boardWorld(54) + w.Handle([]game.Event{clearEvent(21)}) + w.Advance(160 * time.Millisecond) // into phase C + if w.ParticleCount() == 0 { + t.Fatal("the collapse produced no debris") + } + var leftGoingLeft, rightGoingRight int + cx := 20 + 1 + 9 // board rect X + border + centre-ish column in canvas terms + for _, p := range w.Particles() { + if p.X < float64(cx) && p.VX < 0 { + leftGoingLeft++ + } + if p.X > float64(cx) && p.VX > 0 { + rightGoingRight++ + } + } + if leftGoingLeft == 0 || rightGoingRight == 0 { + t.Errorf("debris did not inherit outward horizontal velocity (%d left, %d right)", + leftGoingLeft, rightGoingRight) + } +} + +func TestProgressRunsZeroToOne(t *testing.T) { + w := boardWorld(55) + w.Handle([]game.Event{clearEvent(21)}) + last := -1.0 + for i := 0; i < 12; i++ { + anims := w.ClearAnims() + if len(anims) == 0 { + break + } + p := anims[0].Progress() + if p < 0 || p > 1 { + t.Fatalf("progress %v out of range", p) + } + if p < last { + t.Fatalf("progress went backwards: %v then %v", last, p) + } + last = p + w.Advance(20 * time.Millisecond) + } + if last < 0.5 { + t.Errorf("progress only reached %v", last) + } +} + +// Review focus 4: gameplay does not wait, so a second clear can arrive mid-animation. +func TestASecondClearDuringTheAnimationIsHandledCleanly(t *testing.T) { + w := boardWorld(56) + w.Handle([]game.Event{clearEvent(21)}) + w.Advance(100 * time.Millisecond) + w.Handle([]game.Event{clearEvent(20, 21)}) + if n := len(w.ClearAnims()); n != 3 { + t.Fatalf("expected the old anim plus two new ones, got %d", n) + } + for i := 0; i < 30; i++ { + w.Advance(20 * time.Millisecond) + } + if n := len(w.ClearAnims()); n != 0 { + t.Errorf("%d anims outlived their window", n) + } +} + +func TestMissingSnapshotDoesNotCrashTheAnimation(t *testing.T) { + w := boardWorld(57) + w.Handle([]game.Event{{Kind: game.LinesCleared, Rows: []int{21}}}) // no RowCells + w.Advance(ClearDuration + 20*time.Millisecond) // must not panic +} + +func TestNoClearAnimWhenFXIsOff(t *testing.T) { + w := NewWorld(80, 40, Options{Seed: 58, Intensity: IntensityOff}) + w.Handle([]game.Event{clearEvent(21)}) + if len(w.ClearAnims()) != 0 { + t.Error("IntensityOff must not animate clears") + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -run 'TestClear|TestPhases|TestCollapse|TestProgress|TestASecondClear|TestMissingSnapshot' -v` +Expected: FAIL — `undefined: ClearAnim`. + +- [ ] **Step 3: Implement `lineclear.go`** + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/fx/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/lineclear.go internal/fx/lineclear_test.go +git commit -m "feat(fx): three-phase line-clear supernova with centre-relative debris" +``` + +--- + +### Task 5: Shockwaves + +**Files:** +- Create: `internal/fx/shockwave.go` +- Test: `internal/fx/shockwave_test.go` + +**Interfaces:** +- Consumes: `World`, `game.Event`. +- Produces: + - ```go + type Shockwave struct { + X, Y float64 // canvas cells + Age time.Duration + Life time.Duration + Radius float64 // cells, derived from Age + } + func (w *World) Shockwaves() []Shockwave + func (s Shockwave) Glyph() rune // · ○ ◌ ◯ by radius band; ASCII: . o O 0 + ``` + - `const ShockwaveLife = 300 * time.Millisecond`, `ShockwaveMaxRadius = 9.0`, `MaxShockwaves = 4`. + - Triggered only by four-line clears and by game over — used sparingly (§24). Never created when `Intensity != IntensityFull` (§49.5 suppresses shockwaves under reduced motion). + - Because the terminal has no circles, the renderer places glyphs on an ellipse with a 2:1 column:row ratio; `func (s Shockwave) Points(ascii bool) [][2]int` returns the integer cell offsets for the current radius. + +- [ ] **Step 1: Write the failing test** + +`internal/fx/shockwave_test.go`: + +```go +package fx + +import ( + "testing" + "time" + + "github.com/jessev/cosmic-tetris/internal/game" +) + +func TestFourLineClearCreatesAShockwave(t *testing.T) { + w := boardWorld(61) + w.Handle([]game.Event{clearEvent(18, 19, 20, 21)}) + if len(w.Shockwaves()) == 0 { + t.Fatal("a four-line clear made no shockwave") + } +} + +func TestSingleClearMakesNoShockwave(t *testing.T) { + w := boardWorld(62) + w.Handle([]game.Event{clearEvent(21)}) + if n := len(w.Shockwaves()); n != 0 { + t.Errorf("a single clear made %d shockwaves; §24 says use them sparingly", n) + } +} + +func TestShockwaveExpandsThenDies(t *testing.T) { + w := boardWorld(63) + w.Handle([]game.Event{clearEvent(18, 19, 20, 21)}) + first := w.Shockwaves()[0].Radius + w.Advance(100 * time.Millisecond) + if w.Shockwaves()[0].Radius <= first { + t.Errorf("radius did not grow: %v -> %v", first, w.Shockwaves()[0].Radius) + } + w.Advance(ShockwaveLife) + if n := len(w.Shockwaves()); n != 0 { + t.Errorf("%d shockwaves alive past %v", n, ShockwaveLife) + } +} + +func TestShockwaveRadiusIsBounded(t *testing.T) { + w := boardWorld(64) + w.Handle([]game.Event{clearEvent(18, 19, 20, 21)}) + for i := 0; i < 20; i++ { + for _, s := range w.Shockwaves() { + if s.Radius > ShockwaveMaxRadius { + t.Fatalf("radius %v exceeds the cap", s.Radius) + } + } + w.Advance(20 * time.Millisecond) + } +} + +func TestShockwaveCountIsCapped(t *testing.T) { + w := boardWorld(65) + for i := 0; i < 20; i++ { + w.Handle([]game.Event{clearEvent(18, 19, 20, 21)}) + if n := len(w.Shockwaves()); n > MaxShockwaves { + t.Fatalf("%d shockwaves alive, cap is %d", n, MaxShockwaves) + } + } +} + +func TestShockwavePointsFormAWideEllipse(t *testing.T) { + w := boardWorld(66) + w.Handle([]game.Event{clearEvent(18, 19, 20, 21)}) + w.Advance(150 * time.Millisecond) + pts := w.Shockwaves()[0].Points(false) + if len(pts) < 6 { + t.Fatalf("only %d ring points", len(pts)) + } + var maxDX, maxDY int + for _, p := range pts { + if p[0] > maxDX { + maxDX = p[0] + } + if p[1] > maxDY { + maxDY = p[1] + } + } + if maxDX <= maxDY { + t.Errorf("ring is %d wide and %d tall; terminal cells are tall, so it must be wider", maxDX, maxDY) + } +} + +func TestShockwaveGlyphChangesWithRadiusAndStaysASCIISafe(t *testing.T) { + small := Shockwave{Radius: 1, Life: ShockwaveLife} + big := Shockwave{Radius: 8, Life: ShockwaveLife} + if small.Glyph() == big.Glyph() { + t.Error("ring glyph should change as the ring grows") + } + w := NewWorld(80, 40, Options{Seed: 67, Intensity: IntensityFull, ASCII: true}) + w.SetBoardRect(Rect{X: 20, Y: 4, W: 22, H: 22}) + w.Handle([]game.Event{clearEvent(18, 19, 20, 21)}) + for _, s := range w.Shockwaves() { + if s.Glyph() > 127 { + t.Errorf("ascii mode ring glyph %q", s.Glyph()) + } + } +} + +// §49.5 +func TestReducedMotionSuppressesShockwaves(t *testing.T) { + w := NewWorld(80, 40, Options{Seed: 68, Intensity: IntensityReduced}) + w.SetBoardRect(Rect{X: 20, Y: 4, W: 22, H: 22}) + w.Handle([]game.Event{clearEvent(18, 19, 20, 21)}) + if n := len(w.Shockwaves()); n != 0 { + t.Fatalf("reduced motion produced %d shockwaves", n) + } + if w.ParticleCount() == 0 { + t.Error("reduced motion should still erupt particles") + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -run Shock -v` +Expected: FAIL — `undefined: Shockwave`. + +- [ ] **Step 3: Implement `shockwave.go`** + +`Points` walks angles in fixed increments (e.g. 16 steps) and dedupes the resulting integer cells, scaling `dx` by 2. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/fx/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/shockwave.go internal/fx/shockwave_test.go +git commit -m "feat(fx): sparing elliptical shockwaves" +``` + +--- + +### Task 6: Hyperdrive + +**Files:** +- Create: `internal/fx/hyperdrive.go` +- Modify: `internal/fx/starfield.go` (consume the multiplier and the stretch flag) +- Test: `internal/fx/hyperdrive_test.go` + +**Interfaces:** +- Consumes: `World`, starfield. +- Produces: + - `func (w *World) TriggerHyperdrive()`, `func (w *World) HyperdriveActive() bool`, `func (w *World) HyperdriveFactor() float64` — the star-speed multiplier over the §16 timeline: `0ms` factor `0` (stars pause), `50ms` factor `0.2` with `HyperdriveStretch() == true`, `100ms` ramping violently, `500ms` peak `8.0`, `800ms` decaying, `1100ms` back to `1.0` and inactive. + - `const HyperdriveLife = 1100 * time.Millisecond`, `HyperdrivePeak = 8.0`. + - `func (w *World) NoteScore(score int)` — tracks the session high score so a new high triggers hyperdrive (§16). Triggers: `LinesCleared` with 4 rows; `ComboChanged` with `Combo >= 4` ("large combo"); a new high score. + - Under `IntensityReduced`, `TriggerHyperdrive` is a no-op and `HyperdriveFactor()` stays `1.0` (§49.5 suppresses hyperdrive acceleration); the starfield keeps its normal drift. + - The starfield multiplies its per-layer speed by `StarSpeedScale() * HyperdriveFactor()`; near stars stretch to `|` glyphs while `HyperdriveStretch()` is true. + +- [ ] **Step 1: Write the failing test** + +`internal/fx/hyperdrive_test.go`: + +```go +package fx + +import ( + "testing" + "time" + + "github.com/jessev/cosmic-tetris/internal/game" +) + +func TestHyperdriveTimelineFollowsTheSpec(t *testing.T) { + w := boardWorld(71) + w.TriggerHyperdrive() + if !w.HyperdriveActive() { + t.Fatal("not active immediately after the trigger") + } + if f := w.HyperdriveFactor(); f > 0.05 { + t.Errorf("at 0ms stars should pause, factor = %v", f) + } + w.Advance(60 * time.Millisecond) + if !w.HyperdriveStretch() { + t.Error("at ~50ms stars should be stretching") + } + w.Advance(440 * time.Millisecond) // ~500ms: peak + peak := w.HyperdriveFactor() + if peak < 4 { + t.Errorf("peak factor = %v, want a violent multiple (up to %v)", peak, HyperdrivePeak) + } + if peak > HyperdrivePeak { + t.Errorf("peak factor %v exceeds the cap %v", peak, HyperdrivePeak) + } + w.Advance(350 * time.Millisecond) // ~850ms: decaying + if d := w.HyperdriveFactor(); d >= peak { + t.Errorf("factor did not decay: %v then %v", peak, d) + } + w.Advance(300 * time.Millisecond) // past 1100ms + if w.HyperdriveActive() { + t.Error("hyperdrive outlived its 1100ms window") + } + if f := w.HyperdriveFactor(); f != 1 { + t.Errorf("factor = %v after the window, want 1", f) + } +} + +func TestFourLineClearTriggersHyperdrive(t *testing.T) { + w := boardWorld(72) + w.Handle([]game.Event{clearEvent(18, 19, 20, 21)}) + if !w.HyperdriveActive() { + t.Error("a four-line clear must trigger hyperdrive (§16)") + } +} + +func TestSmallClearDoesNotTriggerHyperdrive(t *testing.T) { + w := boardWorld(73) + w.Handle([]game.Event{clearEvent(21)}) + if w.HyperdriveActive() { + t.Error("a single clear should not trigger hyperdrive") + } +} + +func TestLargeComboTriggersHyperdrive(t *testing.T) { + w := boardWorld(74) + w.Handle([]game.Event{{Kind: game.ComboChanged, Combo: 2}}) + if w.HyperdriveActive() { + t.Error("combo 2 is not a large combo") + } + w.Handle([]game.Event{{Kind: game.ComboChanged, Combo: 4}}) + if !w.HyperdriveActive() { + t.Error("combo 4 should count as a large combo") + } +} + +func TestNewHighScoreTriggersHyperdriveOnce(t *testing.T) { + w := boardWorld(75) + w.NoteScore(100) + if !w.HyperdriveActive() { + t.Error("the first high score should trigger hyperdrive") + } + w.Advance(HyperdriveLife + 50*time.Millisecond) + w.NoteScore(50) + if w.HyperdriveActive() { + t.Error("a lower score must not re-trigger") + } + w.NoteScore(200) + if !w.HyperdriveActive() { + t.Error("beating the high score should trigger again") + } +} + +func TestHyperdriveActuallySpeedsStarsUp(t *testing.T) { + calm := full(80, 40, 76) + calm.Advance(100 * time.Millisecond) + calmDrift := 0.0 + for _, s := range calm.Stars() { + calmDrift += s.Y + } + + fast := full(80, 40, 76) + fast.TriggerHyperdrive() + fast.Advance(500 * time.Millisecond) // reach peak + fast.Advance(100 * time.Millisecond) + fastDrift := 0.0 + for _, s := range fast.Stars() { + fastDrift += s.Y + } + if fastDrift <= calmDrift { + t.Errorf("hyperdrive did not move the starfield harder (%v vs %v)", fastDrift, calmDrift) + } + for _, s := range fast.Stars() { + if s.Y >= 40 || s.Y < -1 { + t.Fatalf("hyperdrive threw a star out of the viewport: %+v", s) + } + } +} + +// §49.5 +func TestReducedMotionSuppressesHyperdrive(t *testing.T) { + w := NewWorld(80, 40, Options{Seed: 77, Intensity: IntensityReduced}) + w.SetBoardRect(Rect{X: 20, Y: 4, W: 22, H: 22}) + w.Handle([]game.Event{clearEvent(18, 19, 20, 21)}) + if w.HyperdriveActive() { + t.Fatal("reduced motion must not accelerate the starfield") + } + if f := w.HyperdriveFactor(); f != 1 { + t.Errorf("factor = %v want 1", f) + } + if len(w.Stars()) == 0 { + t.Error("stars themselves must survive reduced motion") + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -run Hyper -v` +Expected: FAIL — `undefined: (*World).TriggerHyperdrive`. + +- [ ] **Step 3: Implement `hyperdrive.go` and hook the starfield** + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/fx/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/hyperdrive.go internal/fx/starfield.go internal/fx/hyperdrive_test.go +git commit -m "feat(fx): hyperdrive with the §16 timeline" +``` + +--- + +### Task 7: The four-line event, banners, HUD flash, combo tiers, level-up notice + +**Files:** +- Create: `internal/fx/major.go` +- Modify: `internal/flavor/messages.go` (only if a line is missing) +- Test: `internal/fx/major_test.go` + +**Interfaces:** +- Consumes: everything above, plus `flavor.FourLineBanners` and `flavor.LevelSubtitles`. +- Produces: + - ```go + type Banner struct { + Text string + Subtitle string + Age, Life time.Duration + Style BannerStyle // BannerHuge (four-line) or BannerNotice (level-up) + } + func (w *World) Banners() []Banner + func (b Banner) Alpha() float64 // 1 while fresh, fading over the last third + func (b Banner) OffsetY() int // the level-up notice slides up as it fades + func (w *World) HUDFlash() float64 // 0..1 + func (w *World) HUDPulse() float64 // 0..1, oscillating while combo >= 4 + func (w *World) StarDensityBoost() float64 // 1.0 normally, up to 1.6 after a quad + ``` + - `const BannerLife = 700 * time.Millisecond` (§20), `NoticeLife = 1200 * time.Millisecond` (§22 slides/fades away), `MaxBanners = 2`. + - Four-line handling fires all of §20 at once: `TriggerHyperdrive()`, `shakeFor(StrongShakeDuration, true)`, border energy (already wired), a `~90`-particle eruption from the cleared band, a `BannerHuge` with a `flavor.FourLineBanners` pick, `HUDFlash() = 1`, `StarDensityBoost()` raised for `1.5s`, and a shockwave (Task 5). + - Combo tiers (§21): `combo 2` → a `12`-particle spark burst; `combo 3` → `20` meteor particles (fast, shallow angle, longer life); `combo 4` → `HUDPulse()` becomes non-zero; `combo 5+` → escalating particle counts plus a `flavor.ComboLine(combo)` handed to the channel by the app. + - `LevelChanged` → a `BannerNotice` with `LEVEL nn` and a `flavor.LevelSubtitles` pick, plus the `GRAVITY ANOMALY DETECTED` heading from §22. + - `func asciiFold(s string) string` (unexported) — when `Options.ASCII` is set, every banner and subtitle string is folded before it is stored: `✦` → `*`, `·` → `.`, `˚` → `'`, and any other rune above 127 is dropped. `flavor.FourLineBanners` includes `✦ EVENT HORIZON ✦`, so without this the `--ascii` build emits non-ASCII the moment a quad lands. + +- [ ] **Step 1: Write the failing test** + +`internal/fx/major_test.go`: + +```go +package fx + +import ( + "strings" + "testing" + "time" + + "github.com/jessev/cosmic-tetris/internal/flavor" + "github.com/jessev/cosmic-tetris/internal/game" +) + +func TestFourLineClearFiresEverythingAtOnce(t *testing.T) { + w := boardWorld(81) + w.Handle([]game.Event{clearEvent(18, 19, 20, 21)}) + if !w.HyperdriveActive() { + t.Error("no hyperdrive") + } + if dx, dy := w.ShakeOffset(); dx == 0 && dy == 0 { + t.Error("no screen shake") + } + if w.BorderEnergy() < 0.5 { + t.Errorf("border energy only %v", w.BorderEnergy()) + } + if w.ParticleCount() < 40 { + t.Errorf("only %d particles erupted", w.ParticleCount()) + } + if w.HUDFlash() < 0.9 { + t.Errorf("HUD flash = %v", w.HUDFlash()) + } + if w.StarDensityBoost() <= 1 { + t.Errorf("star density boost = %v", w.StarDensityBoost()) + } + if len(w.Shockwaves()) == 0 { + t.Error("no shockwave") + } + banners := w.Banners() + if len(banners) == 0 { + t.Fatal("no banner") + } + found := false + for _, b := range flavor.FourLineBanners { + if banners[0].Text == b { + found = true + } + } + if !found { + t.Errorf("banner text %q is not one of the §20 banners", banners[0].Text) + } + if banners[0].Style != BannerHuge { + t.Error("a four-line banner should be the giant style") + } +} + +func TestBannerLivesAboutSevenHundredMillisecondsAndFades(t *testing.T) { + w := boardWorld(82) + w.Handle([]game.Event{clearEvent(18, 19, 20, 21)}) + if a := w.Banners()[0].Alpha(); a < 0.99 { + t.Errorf("fresh banner alpha = %v want 1", a) + } + w.Advance(600 * time.Millisecond) + if len(w.Banners()) == 0 { + t.Fatal("banner vanished early") + } + if a := w.Banners()[0].Alpha(); a >= 1 { + t.Errorf("banner should be fading by 600ms, alpha = %v", a) + } + w.Advance(200 * time.Millisecond) + if n := len(w.Banners()); n != 0 { + t.Errorf("%d banners alive past %v", n, BannerLife) + } +} + +func TestBannerCountIsCapped(t *testing.T) { + w := boardWorld(83) + for i := 0; i < 10; i++ { + w.Handle([]game.Event{clearEvent(18, 19, 20, 21)}) + } + if n := len(w.Banners()); n > MaxBanners { + t.Fatalf("%d banners alive, cap is %d", n, MaxBanners) + } +} + +func TestLevelUpShowsASlidingNotice(t *testing.T) { + w := boardWorld(84) + w.Handle([]game.Event{{Kind: game.LevelChanged, Level: 8}}) + bs := w.Banners() + if len(bs) == 0 { + t.Fatal("no level-up notice") + } + b := bs[0] + if b.Style != BannerNotice { + t.Error("level up should use the notice style") + } + if !strings.Contains(b.Text, "GRAVITY ANOMALY") && !strings.Contains(b.Text, "LEVEL 08") { + t.Errorf("notice text %q does not match §22", b.Text) + } + if !strings.Contains(b.Text+b.Subtitle, "08") { + t.Errorf("the notice should name the level: %q / %q", b.Text, b.Subtitle) + } + start := w.Banners()[0].OffsetY() + w.Advance(600 * time.Millisecond) + if len(w.Banners()) == 0 { + t.Fatal("notice vanished too early") + } + if w.Banners()[0].OffsetY() == start { + t.Error("the notice should slide as it fades (§22)") + } + w.Advance(NoticeLife) + if n := len(w.Banners()); n != 0 { + t.Errorf("%d notices alive past %v", n, NoticeLife) + } +} + +func TestComboTiersEscalate(t *testing.T) { + counts := map[int]int{} + for _, combo := range []int{2, 3, 5, 7} { + w := boardWorld(85) + w.Handle([]game.Event{{Kind: game.ComboChanged, Combo: combo}}) + counts[combo] = w.ParticleCount() + } + if counts[2] == 0 { + t.Error("combo 2 should throw small sparks") + } + if !(counts[2] < counts[3] && counts[3] < counts[5] && counts[5] <= counts[7]) { + t.Errorf("combo effects do not escalate: %v", counts) + } +} + +func TestHUDPulseStartsAtComboFour(t *testing.T) { + w := boardWorld(86) + w.Handle([]game.Event{{Kind: game.ComboChanged, Combo: 3}}) + if w.HUDPulse() != 0 { + t.Errorf("combo 3 should not pulse the HUD, got %v", w.HUDPulse()) + } + w.Handle([]game.Event{{Kind: game.ComboChanged, Combo: 4}}) + w.Advance(100 * time.Millisecond) + if w.HUDPulse() == 0 { + t.Error("combo 4 should pulse the HUD (§21)") + } + w.Handle([]game.Event{{Kind: game.ComboChanged, Combo: 0}}) + w.Advance(100 * time.Millisecond) + if w.HUDPulse() != 0 { + t.Error("breaking the combo should stop the pulse") + } +} + +func TestHUDFlashAndDensityBoostDecay(t *testing.T) { + w := boardWorld(87) + w.Handle([]game.Event{clearEvent(18, 19, 20, 21)}) + w.Advance(2 * time.Second) + if f := w.HUDFlash(); f > 0.05 { + t.Errorf("flash = %v after 2s", f) + } + if b := w.StarDensityBoost(); b > 1.01 { + t.Errorf("density boost = %v after 2s, want back to 1", b) + } +} + +func TestBannersAreEmptyWhenFXIsOff(t *testing.T) { + w := NewWorld(80, 40, Options{Seed: 88, Intensity: IntensityOff}) + w.Handle([]game.Event{clearEvent(18, 19, 20, 21), {Kind: game.LevelChanged, Level: 3}}) + if len(w.Banners()) != 0 { + t.Error("IntensityOff must not banner") + } +} + +func TestASCIIModeBannersAreASCII(t *testing.T) { + // flavor.FourLineBanners contains "✦ EVENT HORIZON ✦"; ascii mode must fold it. + for seed := int64(0); seed < 40; seed++ { + w := NewWorld(80, 40, Options{Seed: seed, Intensity: IntensityFull, ASCII: true}) + w.SetBoardRect(Rect{X: 20, Y: 4, W: 22, H: 22}) + w.Handle([]game.Event{clearEvent(18, 19, 20, 21), {Kind: game.LevelChanged, Level: 12}}) + for _, b := range w.Banners() { + for _, r := range b.Text + b.Subtitle { + if r > 127 { + t.Fatalf("seed %d: banner %q contains %q", seed, b.Text, r) + } + } + } + w.Advance(2 * time.Second) + } +} + +func TestNonASCIIModeKeepsTheNiceGlyphs(t *testing.T) { + seen := false + for seed := int64(0); seed < 60 && !seen; seed++ { + w := boardWorld(seed) + w.Handle([]game.Event{clearEvent(18, 19, 20, 21)}) + if strings.Contains(w.Banners()[0].Text, "✦") { + seen = true + } + } + if !seen { + t.Error("full mode never produced the ✦ banner; the fold is running unconditionally") + } +} + +func TestReducedMotionStillBannersAndFlashes(t *testing.T) { + w := NewWorld(80, 40, Options{Seed: 89, Intensity: IntensityReduced}) + w.SetBoardRect(Rect{X: 20, Y: 4, W: 22, H: 22}) + w.Handle([]game.Event{clearEvent(18, 19, 20, 21)}) + if len(w.Banners()) == 0 { + t.Error("reduced motion should keep the banner; it suppresses motion, not comedy") + } + if w.HUDFlash() == 0 { + t.Error("reduced motion should keep the HUD flash") + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -run 'TestFourLine|TestBanner|TestLevelUp|TestCombo|TestHUD' -v` +Expected: FAIL — `undefined: Banner`. + +- [ ] **Step 3: Implement `major.go`** + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/fx/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/major.go internal/fx/major_test.go internal/flavor/messages.go +git commit -m "feat(fx): four-line event, banners, HUD flash, combo tiers, level notice" +``` + +--- + +### Task 8: Render the violence + +**Files:** +- Modify: `internal/render/fxdraw.go`, `internal/render/render.go`, `internal/render/hud.go` +- Test: `internal/render/violence_test.go` +- Modify: `internal/render/golden_test.go` (add a four-line-clear golden) + +**Interfaces:** +- Consumes: `fx.Particle`, `fx.Shockwave`, `fx.ClearAnim`, `fx.Banner`. +- Produces: + - `Frame` gains `Particles []fx.Particle`, `Shockwaves []fx.Shockwave`, `ClearAnims []fx.ClearAnim`, `Banners []fx.Banner`, `HUDFlash float64`, `HUDPulse float64`, `BorderFlash float64`. + - `func DrawParticles(c *Canvas, frame Rect, ps []fx.Particle, m Mode)` — rounds float positions to cells; **skips any cell inside `frame` whose rune is not blank**, so debris never covers a locked or active block (§44); outside the frame it draws freely. + - `func DrawShockwaves(c *Canvas, ss []fx.Shockwave, m Mode)`. + - `func DrawClearAnims(c *Canvas, frame Rect, as []fx.ClearAnim, m Mode)` — phase A paints the snapshot row in `▓` with alternating hot cells; phase B paints an outward `░░▓▓██✦✦██▓▓░░` wavefront from the row centre; phase C paints sparse debris glyphs. **This is the one effect allowed to draw over the well's cells**, and only on the rows it is animating: gameplay has already deleted those rows and §19 requires them to keep exploding for 220ms. It is composited after the locked board and before the ghost and active piece, so §44's "never obscure the active piece" still holds. + - `func DrawBanners(c *Canvas, l Layout, bs []fx.Banner, m Mode)` — `BannerHuge` centres over the board; `BannerNotice` boxes itself above the board and honours `OffsetY()`. + - `hud.go` gains a flash/pulse-aware `func statPaint(base Paint, flash, pulse float64) Paint`. + +- [ ] **Step 1: Write the failing test** + +`internal/render/violence_test.go`: + +```go +package render + +import ( + "strings" + "testing" + "time" + + "github.com/jessev/cosmic-tetris/internal/fx" + "github.com/jessev/cosmic-tetris/internal/game" +) + +// Review focus 3: particles must never cover the board. +func TestParticlesNeverCoverBoardCells(t *testing.T) { + r := Rect{X: 0, Y: 0, W: FrameCols, H: FrameRows} + var b game.Board + b.Set(4, 12, game.CellFor(game.KindI)) + active := game.Piece{Kind: game.KindO, Rotation: 0, X: 6, Y: 14} + c := NewCanvas(FrameCols, FrameRows) + DrawBoard(c, r, BoardView{Board: &b, Active: active, ShowActive: true}, ModeFull, Paint{}) + + bx, by := BoardCellXY(r, 4, 12) + ax, ay := BoardCellXY(r, 7, 14) + DrawParticles(c, r, []fx.Particle{ + {X: float64(bx), Y: float64(by), Glyph: '*', Life: 1, MaxLife: 1, Brightness: 1}, + {X: float64(ax), Y: float64(ay), Glyph: '*', Life: 1, MaxLife: 1, Brightness: 1}, + }, ModeFull) + if c.Rune(bx, by) != '█' { + t.Error("a particle covered a locked block") + } + if c.Rune(ax, ay) != '█' { + t.Error("a particle covered the active piece") + } +} + +func TestParticlesDrawOutsideTheBoardFreely(t *testing.T) { + c := NewCanvas(60, 20) + frame := Rect{X: 20, Y: 2, W: FrameCols, H: FrameRows} + DrawParticles(c, frame, []fx.Particle{{X: 4, Y: 4, Glyph: '*', Life: 1, MaxLife: 1, Brightness: 1}}, ModeFull) + if c.Rune(4, 4) != '*' { + t.Errorf("particle outside the board was not drawn: %q", c.Rune(4, 4)) + } +} + +func TestParticlesLeaveNoPermanentMark(t *testing.T) { + f := frameFor(80, 40, ModeFull, OverlayNone) + clean := plain(Render(f)) + f.Particles = []fx.Particle{{X: 4, Y: 4, Glyph: '*', Life: 1, MaxLife: 1, Brightness: 1}} + dirty := plain(Render(f)) + if clean == dirty { + t.Fatal("the particle was not drawn at all") + } + f.Particles = nil + if plain(Render(f)) != clean { + t.Error("a dead particle left a permanent mark on the frame (§44)") + } +} + +func TestParticlesOutsideTheCanvasAreIgnored(t *testing.T) { + c := NewCanvas(10, 5) + DrawParticles(c, Rect{W: 10, H: 5}, []fx.Particle{ + {X: -50, Y: -50, Glyph: '*', Life: 1, MaxLife: 1}, + {X: 1e9, Y: 1e9, Glyph: '*', Life: 1, MaxLife: 1}, + }, ModeFull) // must not panic + if strings.Contains(plain(c.String()), "*") { + t.Error("an off-canvas particle was drawn") + } +} + +func TestClearAnimPaintsTheVanishedRow(t *testing.T) { + r := Rect{W: FrameCols, H: FrameRows} + row := make([]game.Cell, game.Width) + for x := range row { + row[x] = game.CellFor(game.KindI) + } + for _, age := range []time.Duration{0, 100 * time.Millisecond, 190 * time.Millisecond} { + var b game.Board // the row is already gone from the board + c := NewCanvas(FrameCols, FrameRows) + DrawBoard(c, r, BoardView{Board: &b}, ModeFull, Paint{}) + DrawClearAnims(c, r, []fx.ClearAnim{{Row: 20, Cells: row, Age: age}}, ModeFull) + x, y := BoardCellXY(r, 0, 20) + if c.Rune(x, y) == ' ' && c.Rune(x+9, y) == ' ' { + t.Errorf("age %v: the exploding row rendered as empty space", age) + } + } +} + +// Review focus 4: a new piece during the animation. +func TestActivePieceStaysVisibleDuringAClearAnimation(t *testing.T) { + f := frameFor(80, 40, ModeFull, OverlayNone) + f.Game.Active = game.Piece{Kind: game.KindT, Rotation: 0, X: 3, Y: 20} + row := make([]game.Cell, game.Width) + for x := range row { + row[x] = game.CellFor(game.KindZ) + } + f.ClearAnims = []fx.ClearAnim{{Row: 20, Cells: row, Age: 100 * time.Millisecond}} + out := lines(Render(f)) + for _, cell := range f.Game.Active.Cells() { + x, y := BoardCellXY(f.Layout.Frame, cell[0], cell[1]) + if y < 0 || y >= len(out) { + continue + } + if got := []rune(out[y])[x]; got != '█' { + t.Errorf("the clear animation covered the active piece at %v: %q", cell, got) + } + } +} + +func TestBannerIsCentredOverTheBoardAndFitsTheTerminal(t *testing.T) { + for _, dims := range [][2]int{{40, 24}, {80, 40}} { + f := frameFor(dims[0], dims[1], ModeFull, OverlayNone) + f.Banners = []fx.Banner{{Text: "SPACE-TIME HAS FILED A COMPLAINT", Life: 700 * time.Millisecond, Style: fx.BannerHuge}} + out := lines(Render(f)) + hit := false + for i, line := range out { + if len([]rune(line)) > dims[0] { + t.Fatalf("%v: banner overflowed line %d", dims, i) + } + if strings.Contains(line, "SPACE-TIME") || strings.Contains(line, "COMPLAINT") { + hit = true + } + } + if !hit { + t.Errorf("%v: banner never appeared", dims) + } + } +} + +func TestShockwaveRingsDrawAroundTheirCentre(t *testing.T) { + c := NewCanvas(40, 20) + DrawShockwaves(c, []fx.Shockwave{{X: 20, Y: 10, Radius: 4, Life: 300 * time.Millisecond}}, ModeFull) + out := plain(c.String()) + if strings.TrimSpace(out) == "" { + t.Fatal("the shockwave drew nothing") + } + if c.Rune(20, 10) != ' ' { + t.Error("a ring should be hollow at its centre") + } +} + +func TestHUDFlashChangesTheStatPaintButNotTheText(t *testing.T) { + a := frameFor(80, 40, ModeFull, OverlayNone) + b := frameFor(80, 40, ModeFull, OverlayNone) + b.HUDFlash = 1 + if plain(Render(a)) != plain(Render(b)) { + t.Error("a HUD flash must change colour only, never the characters") + } + if Render(a) == Render(b) { + t.Error("a HUD flash should change something about the styling") + } +} + +func TestEverythingStillASCIIInASCIIMode(t *testing.T) { + f := frameFor(80, 40, ModeASCII, OverlayNone) + f.Particles = []fx.Particle{{X: 4, Y: 4, Glyph: '*', Life: 1, MaxLife: 1, Brightness: 1}} + f.Shockwaves = []fx.Shockwave{{X: 30, Y: 10, Radius: 3, Life: 300 * time.Millisecond}} + row := make([]game.Cell, game.Width) + for x := range row { + row[x] = game.CellFor(game.KindI) + } + f.ClearAnims = []fx.ClearAnim{{Row: 20, Cells: row, Age: 100 * time.Millisecond}} + f.Banners = []fx.Banner{{Text: "QUADRUPLE COSMIC INCIDENT", Life: 700 * time.Millisecond, Style: fx.BannerHuge}} + for _, r := range plain(Render(f)) { + if r > 127 { + t.Fatalf("ascii mode emitted %q (U+%04X)", r, r) + } + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/render/ -run 'TestParticles|TestClearAnim|TestActivePieceStays|TestBannerIs|TestShockwaveRings|TestHUDFlash|TestEverythingStill' -v` +Expected: FAIL — `undefined: DrawParticles`. + +- [ ] **Step 3: Implement the draw functions and extend `Render`** + +Final composite order inside `Render`: starfield → locked board → clear anims → ghost → active piece → trails → particles (board-local) → border → HOLD/NEXT/stats → shockwaves and global particles → banners → mission control → controls. Document this order in a comment mapping it to §37's twelve steps and noting the one deliberate deviation (clear anims before the ghost, so the active piece is never obscured). + +- [ ] **Step 4: Run the tests, then record a violence golden** + +Add `{"quad", 80, 40, ModeFull, OverlayNone}` to `TestGoldenLayouts`, built from a frame with a fixed banner, a fixed particle list, a fixed clear anim, and `ShakeX/ShakeY = 0,1`. + +Run: `go test ./internal/render/ -update && go test ./internal/render/ && cat internal/render/testdata/quad.txt` +Expected: PASS, and the recorded frame is legible — you can still see the well. + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/fxdraw.go internal/render/render.go internal/render/hud.go internal/render/violence_test.go internal/render/golden_test.go internal/render/testdata +git commit -m "feat(render): particles, supernova rows, shockwaves, banners, HUD flash" +``` + +--- + +### Task 9: Wire it up, then audit restraint and performance + +**Files:** +- Modify: `internal/app/model.go`, `internal/app/update.go` +- Test: `internal/app/violence_test.go` +- Test: `internal/fx/restraint_test.go` + +**Interfaces:** +- Consumes: everything. +- Produces: the model now fills every new `Frame` field from the world each `View()`, calls `FX.NoteScore(m.Game.Score)` each frame, and hands `flavor.ComboLine(combo)` to the channel on `ComboChanged` with `combo >= 5` (§21's "Mission Control has lost control of the mission"). + +- [ ] **Step 1: Write the failing app test** + +`internal/app/violence_test.go`: + +```go +package app + +import ( + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/jessev/cosmic-tetris/internal/fx" + "github.com/jessev/cosmic-tetris/internal/game" +) + +// advance drives `frames` FrameMsg ticks of `step` each. It starts from the +// model's own LastFrame so it can be called repeatedly on the same model. +func advance(t *testing.T, m *Model, frames int, step time.Duration) *Model { + t.Helper() + now := m.LastFrame + if now.IsZero() { + now = time.Unix(0, 0) + } + for i := 0; i < frames; i++ { + now = now.Add(step) + next, _ := m.Update(FrameMsg{Now: now}) + m = next.(*Model) + } + return m +} + +func TestHardDropProducesVisibleViolence(t *testing.T) { + m := modelWith(t, Config{Seed: 4242}) + m = advance(t, m, 2, 16*time.Millisecond) + before := plainOut(m.View()) + m = press(t, m, " ") + m = advance(t, m, 2, 16*time.Millisecond) + if m.FX.ParticleCount() == 0 { + t.Error("a hard drop produced no particles") + } + if plainOut(m.View()) == before { + t.Error("the hard drop changed nothing visible") + } +} + +// Review focus 5: the reduced-motion matrix, end to end. +func TestReducedMotionSuppressesExactlyThreeThings(t *testing.T) { + play := func(cfg Config) *Model { + m := modelWith(t, cfg) + for i := 0; i < 8; i++ { + m = press(t, m, "left") + m = press(t, m, " ") + m = advance(t, m, 2, 16*time.Millisecond) + } + return m + } + fullM, redM := play(Config{Seed: 1}), play(Config{Seed: 1, ReducedMotion: true}) + + if redM.FX.Intensity() != fx.IntensityReduced { + t.Errorf("reduced motion intensity = %v", redM.FX.Intensity()) + } + // The three things §49.5 suppresses, forced directly so the test does not + // depend on the seed happening to produce a quad. + redM.FX.Handle([]game.Event{{Kind: game.LinesCleared, Rows: []int{18, 19, 20, 21}}}) + if dx, dy := redM.FX.ShakeOffset(); dx != 0 || dy != 0 { + t.Error("reduced motion shook the screen") + } + if redM.FX.HyperdriveActive() { + t.Error("reduced motion engaged hyperdrive") + } + if len(redM.FX.Shockwaves()) != 0 { + t.Error("reduced motion made a shockwave") + } + // And the things it must leave alone. + if redM.FX.ParticleCount() == 0 { + t.Error("reduced motion killed the particles; §49.5 says leave them alone") + } + if len(fullM.FX.Trails()) > 0 && len(redM.FX.Trails()) == 0 { + t.Error("reduced motion killed the trails; §49.5 says leave them alone") + } + if redM.FX.BorderEnergy() == 0 { + t.Error("reduced motion killed the border animation") + } +} + +// §44: never make controls lag; never delay gameplay for animation. +func TestInputStillActsWhileEverythingIsExploding(t *testing.T) { + m := modelWith(t, Config{Seed: 5}) + m.FX.TriggerHyperdrive() + for i := 0; i < 40; i++ { + m.FX.EmitBurst(20, 10, 30, fx.BurstSpec{Speed: 6, Life: 5, Glyphs: []rune{'*'}}) + } + if m.FX.ParticleCount() == 0 { + t.Fatal("setup: no particles") + } + x := m.Game.Active.X + m = press(t, m, "left") + if m.Game.Active.X != x-1 { + t.Fatal("the piece did not move while the FX world was saturated") + } + if !strings.Contains(plainOut(m.View()), "SCORE") { + t.Error("the HUD disappeared under the effects") + } +} + +func TestViewStaysWithinTheTerminalDuringAQuad(t *testing.T) { + m := modelWith(t, Config{Seed: 6}) + next, _ := m.Update(tea.WindowSizeMsg{Width: 40, Height: 24}) + m = next.(*Model) + m.FX.Handle([]game.Event{{Kind: game.LinesCleared, Rows: []int{18, 19, 20, 21}}}) + m = advance(t, m, 60, 16*time.Millisecond) + for i, line := range strings.Split(plainOut(m.View()), "\n") { + if n := len([]rune(line)); n > 40 { + t.Fatalf("line %d is %d columns wide during a quad", i, n) + } + } +} +``` + +These tests hand synthetic `LinesCleared` events straight to `m.FX` rather than manoeuvring the real engine into a quad: the assertions are about FX state, and Task 7's own tests already cover the event mapping. `advance` here replaces Plan 02's ad-hoc tick loops — move it into a shared `helpers_test.go` in this package if Plan 02 already defined something like it. + +- [ ] **Step 2: Write the failing restraint test** + +`internal/fx/restraint_test.go`: + +```go +package fx + +import ( + "testing" + "time" + + "github.com/jessev/cosmic-tetris/internal/game" +) + +// Review focus 1: minutes of quads must not grow anything. +func TestSustainedQuadsKeepEverythingBounded(t *testing.T) { + w := boardWorld(91) + for i := 0; i < 600; i++ { // ~10s of quads at one per frame, far beyond human + w.Handle([]game.Event{ + clearEvent(18, 19, 20, 21), + {Kind: game.ComboChanged, Combo: 9}, + {Kind: game.LevelChanged, Level: i % 20}, + hardDrop(15), + }) + w.Advance(16 * time.Millisecond) + if n := w.ParticleCount(); n > MaxParticles { + t.Fatalf("frame %d: %d particles", i, n) + } + if n := len(w.Shockwaves()); n > MaxShockwaves { + t.Fatalf("frame %d: %d shockwaves", i, n) + } + if n := len(w.Banners()); n > MaxBanners { + t.Fatalf("frame %d: %d banners", i, n) + } + if n := len(w.Trails()); n > 256 { + t.Fatalf("frame %d: %d trails", i, n) + } + if n := len(w.ClearAnims()); n > 64 { + t.Fatalf("frame %d: %d clear anims", i, n) + } + } +} + +func TestAdvanceStaysCheapWithAFullWorld(t *testing.T) { + w := boardWorld(92) + for i := 0; i < 20; i++ { + w.Handle([]game.Event{clearEvent(18, 19, 20, 21)}) + } + start := time.Now() // a test may read the clock; internal/game may not + for i := 0; i < 6000; i++ { + w.Advance(16 * time.Millisecond) + } + if elapsed := time.Since(start); elapsed > 2*time.Second { + t.Errorf("6000 frames took %v; the terminal should be the bottleneck, not the arithmetic", elapsed) + } +} + +// §14: FX may observe events and may never modify GameState. +func TestFXNeverMutatesTheGame(t *testing.T) { + w := boardWorld(93) + g := game.New(1) + g.Start() + g.HardDrop() + + score, lines, level := g.Score, g.Lines, g.Level + active := g.Active + fingerprint := g.Board.Fingerprint() + + for _, k := range []game.EventKind{ + game.PieceSpawned, game.PieceMoved, game.PieceRotated, game.PieceSoftDropped, + game.PieceHardDropped, game.PieceLocked, game.HoldUsed, game.LinesCleared, + game.ComboChanged, game.LevelChanged, game.GameOver, + } { + w.Handle([]game.Event{{Kind: k, Piece: g.Active, Cells: g.Active.Cells(), + Rows: []int{21}, Combo: 5, Level: 4, Distance: 3}}) + w.Advance(16 * time.Millisecond) + } + + if g.Score != score || g.Lines != lines || g.Level != level || + g.Active != active || g.Board.Fingerprint() != fingerprint { + t.Fatal("FX mutated game state (§14)") + } +} +``` + +- [ ] **Step 3: Run both to verify they fail, then wire the model** + +Run: `go test ./internal/app/ ./internal/fx/ -v` +Expected: FAIL first, then implement the `Frame` filling and combo-line hand-off, then PASS. + +- [ ] **Step 4: Run everything** + +Run: `go test ./... -race && make lint && make cover` +Expected: PASS. + +- [ ] **Step 5: Take the §43 coolness acceptance test** + +```bash +make build +./cosmic-tetris --seed 8675309 +``` + +Within the first 30 seconds you must see: moving starfield, animated board border, piece trails, hard-drop impact, particles, mission-control commentary. On your first completed line: the supernova clear animation, debris, and a border reaction. Set up a four-line clear (drop three flat pieces, leave column 4 open, drop a vertical `I`) and confirm the reaction is the one §43 specifies. Then: + +```bash +./cosmic-tetris --seed 8675309 --reduced-motion # no shake, no hyperdrive, no rings; still colourful and lively +./cosmic-tetris --seed 8675309 --ascii # violence in ASCII +./cosmic-tetris --seed 8675309 --no-fx # still a good game +``` + +If a four-line clear does not produce an immediate involuntary reaction, that is a bug in this plan's output, not a matter of taste (§43: "That is an actual product requirement"). Turn the dials — particle counts, banner size, flash intensity — and re-record the goldens. + +- [ ] **Step 6: Commit** + +```bash +git add internal/app internal/fx/restraint_test.go +git commit -m "feat(app): wire the violence, with restraint and performance audits" +``` + +--- + +## Done when + +- `make test` passes with `-race`. +- A four-line clear fires hyperdrive, a stronger shake, a border pulse, a particle eruption, a HUD flash, extra stars, a shockwave, and a giant banner — all at once, and the board is still readable while it happens. +- Screen shake never exceeds one cell and never changes the frame's line count or line widths. +- Particles never cover a locked or active cell and never leave a mark behind. +- `--reduced-motion` suppresses exactly shake, hyperdrive, and shockwaves. +- Sustained abuse leaves every FX collection bounded. +- §43's six first-thirty-seconds items are all present, and the first completed line supernovas. diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/N1-recipe-cosmic-11/2026-09-17-cosmic-tetris-05-polish.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/N1-recipe-cosmic-11/2026-09-17-cosmic-tetris-05-polish.md new file mode 100644 index 000000000..e06727658 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/N1-recipe-cosmic-11/2026-09-17-cosmic-tetris-05-polish.md @@ -0,0 +1,1858 @@ +# Cosmic Tetris — Plan 05: Absurd Polish Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Boot the universe with a second of unnecessary drama, collapse it into a black hole when the player dies, keep the terminal quiet and legible at every size and capability level, and ship the thing. + +**Architecture:** Two new app states bracket gameplay — `StateBoot` before it and `StateCollapsing` after it — each driven by the same `FrameMsg` clock and rendered by the same `render.Render`. The collapse simulation lives in `internal/fx` like every other effect; the boot screen is pure rendering with no simulation at all. The last two tasks add no features: they audit what the previous four plans built against §32, §38, §43 and §47, and ship the repository. + +**Tech Stack:** Go 1.26; Bubble Tea v2; `internal/fx` and `internal/game` remain standard-library-only. + +**Spec:** `design.md` — Phase 5 of §42. Sections most relevant: §28, §29, §30, §32, §38, §39, §43, §45, §46, §47, §48, §49.4, §49.5. + +**Prerequisite:** Plans 01–04 complete; `make test` green. + +## Global Constraints + +- All of Plan 04's restraint rules still bind (§44): never obscure the active piece, never make controls lag, never delay gameplay for animation, never require reading flavor text, no random effect may alter gameplay, shake stays within roughly one cell, particles leave no permanent mark, comedy never overwhelms playability. +- The engine still never reads a clock (§49.2): nothing under `internal/game` may call `time.Now()`. Boot and collapse timing lives in `internal/app` and `internal/fx`. +- Game over is theatrical and must **not** instantly replace the board (§28). Timeline: `0–300ms` everything freezes with `SIGNAL LOST`; `300–900ms` blocks fall inward toward the centre; `900–1300ms` the board collapses into a black hole; then the final card. +- Boot is approximately one second of excessive drama (§29), any key skips it, and there is no menu. +- Pause freezes gameplay and gameplay-related particles; background stars **may** continue drifting very slowly (§30). +- ASCII mode makes no special Unicode assumptions (§32). Every byte the program can emit under `--ascii` must be ASCII — including flavor text, banners, the boot screen, the black hole, and the too-small notice. +- The CLI surface stays exactly `cosmic-tetris`, `--seed N`, `--ascii`, `--no-fx`, `--reduced-motion`, `--help` (§46, §49.5). Nothing else is added by this plan. +- Terminal output must not visibly flicker under normal conditions (§47): one write per frame, no screen clear, and a frame's line count and per-line width must depend only on the terminal size and the mode. +- §45's tiny details are **occasional**. They are cheap, encouraged, and must never crowd out real event commentary. +- The boring modes must still be good: `--no-fx` is a good game, `--ascii` is a good game (§32). +- The codebase should stay small enough to understand in an afternoon (§48). This plan adds no abstraction layers. + +## Review Focus + +1. **A key pressed during boot** must skip the boot screen — and must not also be delivered to a game that has not started, so a stray `←` does not move an unspawned piece and a `q` still quits. → Task 1. +2. **`r` or `q` pressed mid-collapse** must work immediately (§44: controls never lag) and `r` must produce a clean universe: no leftover collapse state, no leftover particles, no boot replay. → Task 4. +3. **A resize during boot or the collapse**, including down below the 40×24 minimum and back, must not crash or leave a half-drawn frame. → Task 1 and Task 4. +4. **`p` or `?` pressed during the collapse** must not stack a pause box on top of a black hole, and must not stall the sequence so the player can never reach the final card. → Task 4. +5. **The §45 rare lines are rolled 60 times a second**, so a naive `1-in-50` check fires roughly once a second and buries mission control. Rarity must be per-event or per-elapsed-interval, not per-frame. → Task 5. + +--- + +### Task 1: Boot sequence + +**Files:** +- Create: `internal/render/boot.go` +- Modify: `internal/render/render.go` (add `OverlayBoot`, `Frame.BootAge`) +- Modify: `internal/app/model.go`, `internal/app/update.go` +- Test: `internal/render/boot_test.go`, `internal/app/boot_test.go` + +**Interfaces:** +- Consumes: `Canvas`, `Layout`, `Mode`, `CenterOverlay`, `AppState`, `FrameMsg`. +- Produces (`render`): + - `OverlayBoot` appended to the `Overlay` const block; `Frame` gains `BootAge time.Duration`. + - ```go + const BootDuration = 1200 * time.Millisecond + func BootLines(age time.Duration, m Mode) []string + func DrawBoot(c *Canvas, l Layout, age time.Duration, m Mode) + ``` + - Pinned reveal schedule, from §29's copy, revealed a line at a time: + | at | line | + |---|---| + | `0ms` | `✦` (ASCII `*`) | + | `0ms` | `C O S M I C` | + | `150ms` | `T E T R I S` | + | `350ms` | `INITIALIZING LOCAL UNIVERSE...` | + | `500ms` | `gravity ........ OK` | + | `650ms` | `spacetime ...... OK` | + | `800ms` | `tetrominoes .... QUESTIONABLE` | + | `1000ms` | `UNIVERSE ONLINE` | + | `1200ms` | done — gameplay starts | + - `BootLines` returns only the lines revealed so far, so the screen builds up; it never returns a line the schedule has not reached. +- Produces (`app`): + - `StateBoot` appended to the `AppState` const block, and it is the state `NewModel` starts in unless the terminal is below minimum. `m.overlay()` maps it to `render.OverlayBoot`, and `View()` fills `Frame.BootAge` from `m.BootAge`. + - `func (m *Model) bootDone()` — switches to `StatePlaying` and calls `m.Game.Start()`. + - `FrameMsg` in `StateBoot` accumulates `m.BootAge` (using the same clamped `Delta`) and calls `bootDone()` at `render.BootDuration`; it does **not** advance the game. + - Any `tea.KeyPressMsg` in `StateBoot` skips: `q`/`esc`/`ctrl+c` still quit; every other key calls `bootDone()` and is otherwise **swallowed** — it does not fall through to the gameplay key handler. + +- [ ] **Step 1: Write the failing render test** + +`internal/render/boot_test.go`: + +```go +package render + +import ( + "strings" + "testing" + "time" +) + +func bootFrame(w, h int, m Mode, age time.Duration) Frame { + f := frameFor(w, h, m, OverlayBoot) + f.BootAge = age + return f +} + +func TestBootRevealsItsLinesInOrder(t *testing.T) { + steps := []struct { + age time.Duration + want string + }{ + {0, "C O S M I C"}, + {200 * time.Millisecond, "T E T R I S"}, + {400 * time.Millisecond, "INITIALIZING LOCAL UNIVERSE..."}, + {550 * time.Millisecond, "gravity ........ OK"}, + {700 * time.Millisecond, "spacetime ...... OK"}, + {850 * time.Millisecond, "tetrominoes .... QUESTIONABLE"}, + {1050 * time.Millisecond, "UNIVERSE ONLINE"}, + } + for i, s := range steps { + got := strings.Join(BootLines(s.age, ModeFull), "\n") + if !strings.Contains(got, s.want) { + t.Errorf("at %v: %q missing from\n%s", s.age, s.want, got) + } + // Nothing from a later step may have leaked in. + for _, later := range steps[i+1:] { + if strings.Contains(got, later.want) { + t.Errorf("at %v: %q appeared early", s.age, later.want) + } + } + } +} + +func TestBootLinesGrowMonotonically(t *testing.T) { + prev := 0 + for age := time.Duration(0); age <= BootDuration; age += 50 * time.Millisecond { + n := len(BootLines(age, ModeFull)) + if n < prev { + t.Fatalf("at %v the boot screen lost lines: %d then %d", age, prev, n) + } + prev = n + } + if prev < 7 { + t.Errorf("the finished boot screen has only %d lines", prev) + } +} + +func TestBootScreenIsASCIIInASCIIMode(t *testing.T) { + for age := time.Duration(0); age <= BootDuration; age += 50 * time.Millisecond { + for _, line := range BootLines(age, ModeASCII) { + for _, r := range line { + if r > 127 { + t.Fatalf("at %v boot emitted %q", age, r) + } + } + } + } +} + +func TestBootFrameFitsEveryTerminalSize(t *testing.T) { + for _, dims := range [][2]int{{40, 24}, {46, 26}, {64, 30}, {80, 40}, {200, 60}} { + for _, age := range []time.Duration{0, 600 * time.Millisecond, BootDuration} { + out := lines(Render(bootFrame(dims[0], dims[1], ModeFull, age))) + if len(out) > dims[1] { + t.Fatalf("%v at %v: %d lines for a %d-row terminal", dims, age, len(out), dims[1]) + } + for i, line := range out { + if n := len([]rune(line)); n > dims[0] { + t.Fatalf("%v at %v: line %d is %d columns", dims, age, i, n) + } + } + } + } +} + +// Review focus 3: a resize down below the minimum during boot. +func TestBootAtAnAbsurdlySmallSizeStillRenders(t *testing.T) { + for _, dims := range [][2]int{{1, 1}, {8, 3}, {34, 19}} { + f := bootFrame(dims[0], dims[1], ModeFull, 600*time.Millisecond) + out := Render(f) // must not panic + for i, line := range lines(out) { + if n := len([]rune(line)); n > dims[0] { + t.Fatalf("%v: line %d is %d columns", dims, i, n) + } + } + } +} + +func TestBootDoesNotDrawTheBoard(t *testing.T) { + g := GlyphsFor(ModeFull) + out := plain(Render(bootFrame(80, 40, ModeFull, 600*time.Millisecond))) + if strings.Contains(out, g.BorderTL) { + t.Error("the boot screen should not show the well; §29 is a takeover screen") + } +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `go test ./internal/render/ -run Boot -v` +Expected: FAIL — `undefined: OverlayBoot`. + +- [ ] **Step 3: Implement `boot.go` and the `OverlayBoot` branch in `Render`** + +`OverlayBoot` short-circuits like `OverlayTooSmall` does: a blank canvas of the terminal size with the revealed lines centred. Below the minimum size, `OverlayTooSmall` still wins. + +- [ ] **Step 4: Run it to verify it passes** + +Run: `go test ./internal/render/ -v` +Expected: PASS. + +- [ ] **Step 5: Write the failing app test** + +`internal/app/boot_test.go`: + +```go +package app + +import ( + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/jessev/cosmic-tetris/internal/render" +) + +func TestModelStartsInBoot(t *testing.T) { + m := testModel(t) + if m.State != StateBoot { + t.Fatalf("state = %v want StateBoot", m.State) + } + if !strings.Contains(plainOut(m.View()), "C O S M I C") { + t.Error("the boot screen is not on screen") + } +} + +func TestBootEndsOnItsOwnAndStartsTheGame(t *testing.T) { + m := testModel(t) + now := time.Unix(0, 0) + for i := 0; i < 100 && m.State == StateBoot; i++ { + now = now.Add(16 * time.Millisecond) + next, _ := m.Update(FrameMsg{Now: now}) + m = next.(*Model) + } + if m.State != StatePlaying { + t.Fatalf("state = %v after %v of ticks", m.State, render.BootDuration) + } + if m.Game.Active.Kind == 0 && m.Game.Over { + t.Error("the game did not start") + } +} + +func TestTheGameDoesNotAdvanceDuringBoot(t *testing.T) { + m := testModel(t) + y := m.Game.Active.Y + now := time.Unix(0, 0) + for i := 0; i < 3; i++ { + now = now.Add(16 * time.Millisecond) + next, _ := m.Update(FrameMsg{Now: now}) + m = next.(*Model) + } + if m.State != StateBoot { + t.Fatal("boot ended too early for this test") + } + if m.Game.Active.Y != y { + t.Error("gravity ran during the boot sequence") + } +} + +// Review focus 1. +func TestAnyKeySkipsBootWithoutMovingThePiece(t *testing.T) { + for _, key := range []string{"left", "right", "down", "up", " ", "c", "x", "z", "p", "?"} { + m := testModel(t) + x, y := m.Game.Active.X, m.Game.Active.Y + m = press(t, m, key) + if m.State != StatePlaying { + t.Errorf("%q did not skip boot (state = %v)", key, m.State) + } + if m.Game.Active.X != x || m.Game.Active.Y != y { + t.Errorf("%q skipped boot AND moved the piece to %d,%d", key, m.Game.Active.X, m.Game.Active.Y) + } + } +} + +func TestQuitStillWorksDuringBoot(t *testing.T) { + for _, key := range []string{"q", "esc", "ctrl+c"} { + m := testModel(t) + if _, cmd := m.Update(keyMsg(key)); cmd == nil { + t.Errorf("%q during boot returned no command; it must quit", key) + } + } +} + +func TestResizeDuringBootIsSafe(t *testing.T) { + m := testModel(t) + for _, dims := range [][2]int{{20, 10}, {80, 40}, {34, 19}, {200, 60}} { + next, _ := m.Update(tea.WindowSizeMsg{Width: dims[0], Height: dims[1]}) + m = next.(*Model) + out := plainOut(m.View()) // must not panic + for i, line := range strings.Split(out, "\n") { + if n := len([]rune(line)); n > dims[0] { + t.Fatalf("%v: line %d is %d columns during boot", dims, i, n) + } + } + } +} + +func TestBootDoesNotReplayAfterRestart(t *testing.T) { + m := testModel(t) + m = press(t, m, "x") // skip + m = press(t, m, "r") + if m.State == StateBoot { + t.Error("restart replayed the boot sequence; §29 boots the process, not the round") + } +} +``` + +`keyMsg(key string) tea.KeyPressMsg` and `press` already exist in this package from Plan 02's tests — reuse them. This file's `tea` import is only for `WindowSizeMsg`. + +- [ ] **Step 6: Run it to verify it fails, implement, and run it again** + +Run: `go test ./internal/app/ -run Boot -v` — FAIL, then implement `StateBoot`, then PASS. + +- [ ] **Step 7: Repair the older app tests that assumed play starts immediately** + +Every test written in Plans 02 and 04 built a model and pressed a key expecting gameplay. Add one helper and use it in all of them, rather than special-casing: + +```go +// skipBoot presses a harmless key to dismiss the boot screen. +func skipBoot(t *testing.T, m *Model) *Model { + t.Helper() + if m.State == StateBoot { + m = press(t, m, "x") + } + if m.State != StatePlaying { + t.Fatalf("skipBoot left the model in %v", m.State) + } + return m +} +``` + +Run: `go test ./... -race` +Expected: PASS. Do not weaken an older assertion to make it pass — add the `skipBoot` call. + +- [ ] **Step 8: Commit** + +```bash +git add internal/render/boot.go internal/render/boot_test.go internal/render/render.go internal/app +git commit -m "feat: one second of excessive boot drama, skippable by any key" +``` + +--- + +### Task 2: Pause semantics + +**Files:** +- Modify: `internal/fx/world.go` +- Modify: `internal/app/update.go` +- Test: `internal/fx/pause_test.go` + +**Interfaces:** +- Consumes: `World`, `AppState`. +- Produces: + - `func (w *World) SetPaused(p bool)`, `func (w *World) Paused() bool`. + - `const PausedStarScale = 0.15` — while paused, `Advance` moves the starfield and the border phase at `PausedStarScale` of normal and freezes everything else: particles, trails, clear anims, shockwaves, hyperdrive, shake, flashes, banners, and the mission-control dwell timer all hold their exact state (§30). + - The app calls `FX.SetPaused(m.State == StatePaused || m.State == StateHelp)` whenever the state changes — help pauses gameplay, so it pauses gameplay FX too. + +- [ ] **Step 1: Write the failing test** + +`internal/fx/pause_test.go`: + +```go +package fx + +import ( + "testing" + "time" + + "github.com/jessev/cosmic-tetris/internal/game" +) + +func TestPauseFreezesGameplayEffectsButNotStars(t *testing.T) { + w := boardWorld(101) + w.Handle([]game.Event{hardDrop(10), movedEvent(game.Piece{Kind: game.KindT, X: 4, Y: 10})}) + w.Advance(16 * time.Millisecond) + + starsBefore := 0.0 + for _, s := range w.Stars() { + starsBefore += s.Y + } + particles := append([]Particle(nil), w.Particles()...) + trails := len(w.Trails()) + + w.SetPaused(true) + w.Advance(500 * time.Millisecond) + + if len(w.Particles()) != len(particles) { + t.Errorf("particles changed while paused: %d then %d", len(particles), len(w.Particles())) + } + for i, p := range w.Particles() { + if p != particles[i] { + t.Errorf("particle %d moved while paused: %+v then %+v", i, particles[i], p) + } + } + if len(w.Trails()) != trails { + t.Error("trails expired while paused") + } + starsAfter := 0.0 + for _, s := range w.Stars() { + starsAfter += s.Y + } + if starsAfter == starsBefore { + t.Error("§30 allows the background stars to keep drifting; they stopped dead") + } +} + +func TestPausedStarsDriftVerySlowly(t *testing.T) { + drift := func(paused bool) float64 { + w := full(80, 40, 102) + w.SetPaused(paused) + before := 0.0 + for _, s := range w.Stars() { + before += s.Y + } + w.Advance(500 * time.Millisecond) + after := 0.0 + for _, s := range w.Stars() { + after += s.Y + } + return after - before + } + slow, fast := drift(true), drift(false) + if slow <= 0 { + t.Fatalf("paused stars drifted %v", slow) + } + if slow > fast*0.5 { + t.Errorf("paused drift %v is not 'very slowly' next to %v", slow, fast) + } +} + +func TestUnpausingResumesWithoutASurge(t *testing.T) { + w := boardWorld(103) + w.Handle([]game.Event{hardDrop(10)}) + w.Advance(16 * time.Millisecond) + w.SetPaused(true) + w.Advance(10 * time.Second) // a long coffee break + frozen := append([]Particle(nil), w.Particles()...) + w.SetPaused(false) + w.Advance(16 * time.Millisecond) + if len(frozen) == 0 { + t.Skip("no particles to check") + } + moved := w.Particles()[0] + if dx := moved.X - frozen[0].X; dx > 2 || dx < -2 { + t.Errorf("the paused time was banked and released at once (dx = %v)", dx) + } +} + +func TestPauseDoesNotStopBannersFromExistingJustFromAging(t *testing.T) { + w := boardWorld(104) + w.Handle([]game.Event{clearEvent(18, 19, 20, 21)}) + if len(w.Banners()) == 0 { + t.Fatal("no banner") + } + w.SetPaused(true) + w.Advance(5 * time.Second) + if len(w.Banners()) == 0 { + t.Error("the banner aged out while the universe was frozen") + } +} + +func TestPausedIsReportedBack(t *testing.T) { + w := boardWorld(105) + if w.Paused() { + t.Error("a fresh world is not paused") + } + w.SetPaused(true) + if !w.Paused() { + t.Error("SetPaused(true) did not stick") + } +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `go test ./internal/fx/ -run Pause -v` +Expected: FAIL — `undefined: (*World).SetPaused`. + +- [ ] **Step 3: Implement, and wire `SetPaused` in `update.go`** + +- [ ] **Step 4: Run it to verify it passes** + +Run: `go test ./internal/fx/ ./internal/app/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/world.go internal/fx/pause_test.go internal/app/update.go +git commit -m "feat(fx): pause freezes gameplay effects and lets the stars drift" +``` + +--- + +### Task 3: The collapse simulation + +**Files:** +- Create: `internal/fx/collapse.go` +- Test: `internal/fx/collapse_test.go` + +**Interfaces:** +- Consumes: `World`, `game.Event` (`GameOver`), `game.Board`. +- Produces: + - ```go + type CollapsePhase int + const ( + CollapseFreeze CollapsePhase = iota // 0-300ms: SIGNAL LOST + CollapseFall // 300-900ms: blocks fall inward + CollapseHole // 900-1300ms: black hole + CollapseDone // the final card + ) + type FallingBlock struct { + X, Y float64 // board coordinates, fractional + Cell game.Cell + } + func (w *World) StartCollapse(b *game.Board) + func (w *World) CollapseAge() time.Duration + func (w *World) CollapsePhase() CollapsePhase + func (w *World) FallingBlocks() []FallingBlock + ``` + - Pinned boundaries, straight from §28: `CollapseFreezeEnd = 300ms`, `CollapseFallEnd = 900ms`, `CollapseHoleEnd = 1300ms`. + - `StartCollapse` snapshots every non-empty board cell into a `FallingBlock`. The board is not touched — FX never modifies game state (§14). + - During `CollapseFall`, each block accelerates toward the board centre (`x = 4.5`, `y = 10.5` in board coordinates) with an inverse-square-ish pull clamped so nothing overshoots into a jitter loop; blocks within `0.6` cells of the centre are removed (they have crossed the horizon). During `CollapseHole` all remaining blocks are removed over the phase. + - `GameOver` in `Handle` starts the collapse if the event carries a board pointer; if it does not, the app calls `StartCollapse` explicitly. Also: one shockwave and a `BannerHuge`-free burst of particles at the centre, plus border energy `1.0` (already pinned in Plan 03's table). + - `func BlackHole(ascii bool) []string` — §28's art, exactly: + ```text + · + ˚ + \ | / + --- ● --- + / | \ + * + ``` + with the ASCII substitution `·`→`.`, `˚`→`'`, `●`→`@`. + +- [ ] **Step 1: Write the failing test** + +`internal/fx/collapse_test.go`: + +```go +package fx + +import ( + "strings" + "testing" + "time" + + "github.com/jessev/cosmic-tetris/internal/game" +) + +func fullishBoard() *game.Board { + var b game.Board + for y := 12; y < game.Height; y++ { + for x := 0; x < game.Width; x++ { + if (x+y)%3 != 0 { + b.Set(x, y, game.CellFor(game.KindS)) + } + } + } + return &b +} + +func TestCollapseSnapshotsEveryFilledCell(t *testing.T) { + w := boardWorld(111) + b := fullishBoard() + want := 0 + for y := 0; y < game.Height; y++ { + for x := 0; x < game.Width; x++ { + if b.At(x, y) != game.Empty { + want++ + } + } + } + w.StartCollapse(b) + if got := len(w.FallingBlocks()); got != want { + t.Fatalf("snapshotted %d blocks want %d", got, want) + } +} + +func TestCollapseDoesNotTouchTheBoard(t *testing.T) { + w := boardWorld(112) + b := fullishBoard() + before := b.Fingerprint() + w.StartCollapse(b) + w.Advance(1500 * time.Millisecond) + if b.Fingerprint() != before { + t.Fatal("the collapse mutated the board (§14)") + } +} + +func TestCollapsePhasesFollowTheSpecTimeline(t *testing.T) { + w := boardWorld(113) + w.StartCollapse(fullishBoard()) + checks := []struct { + at time.Duration + want CollapsePhase + }{ + {0, CollapseFreeze}, + {299 * time.Millisecond, CollapseFreeze}, + {301 * time.Millisecond, CollapseFall}, + {899 * time.Millisecond, CollapseFall}, + {901 * time.Millisecond, CollapseHole}, + {1299 * time.Millisecond, CollapseHole}, + {1301 * time.Millisecond, CollapseDone}, + } + last := time.Duration(0) + for _, c := range checks { + w.Advance(c.at - last) + last = c.at + if got := w.CollapsePhase(); got != c.want { + t.Errorf("at %v phase = %v want %v", c.at, got, c.want) + } + } +} + +func TestNothingMovesDuringTheFreeze(t *testing.T) { + w := boardWorld(114) + w.StartCollapse(fullishBoard()) + start := append([]FallingBlock(nil), w.FallingBlocks()...) + w.Advance(250 * time.Millisecond) + got := w.FallingBlocks() + if len(got) != len(start) { + t.Fatalf("blocks vanished during the freeze: %d then %d", len(start), len(got)) + } + for i := range got { + if got[i] != start[i] { + t.Fatalf("block %d moved during the freeze: %+v then %+v", i, start[i], got[i]) + } + } +} + +func TestBlocksFallInwardTowardTheCentre(t *testing.T) { + w := boardWorld(115) + w.StartCollapse(fullishBoard()) + dist := func() float64 { + sum := 0.0 + for _, b := range w.FallingBlocks() { + dx, dy := b.X-4.5, b.Y-10.5 + sum += dx*dx + dy*dy + } + return sum + } + w.Advance(310 * time.Millisecond) + before := dist() + w.Advance(300 * time.Millisecond) + after := dist() + if after >= before { + t.Errorf("blocks did not converge: %v then %v", before, after) + } +} + +func TestTheUniverseIsEmptyByTheEnd(t *testing.T) { + w := boardWorld(116) + w.StartCollapse(fullishBoard()) + w.Advance(1400 * time.Millisecond) + if n := len(w.FallingBlocks()); n != 0 { + t.Errorf("%d blocks survived the black hole", n) + } + if w.CollapsePhase() != CollapseDone { + t.Error("the collapse never finished") + } +} + +func TestCollapseSurvivesAHugeDtInOneStep(t *testing.T) { + w := boardWorld(117) + w.StartCollapse(fullishBoard()) + w.Advance(10 * time.Second) // a suspended laptop + if w.CollapsePhase() != CollapseDone { + t.Errorf("phase = %v after 10s", w.CollapsePhase()) + } + for _, b := range w.FallingBlocks() { + t.Fatalf("a block survived: %+v", b) + } +} + +func TestCollapseOfAnEmptyBoardIsFine(t *testing.T) { + w := boardWorld(118) + var b game.Board + w.StartCollapse(&b) + w.Advance(1400 * time.Millisecond) // must not panic or divide by zero + if w.CollapsePhase() != CollapseDone { + t.Error("an empty universe still has to expire") + } +} + +func TestGameOverEventStartsTheDrama(t *testing.T) { + w := boardWorld(119) + w.Handle([]game.Event{{Kind: game.GameOver, Board: fullishBoard()}}) + if len(w.FallingBlocks()) == 0 { + t.Error("the GameOver event did not start the collapse") + } + if w.BorderEnergy() < 0.9 { + t.Errorf("border energy %v; game over is a major event", w.BorderEnergy()) + } + if len(w.Shockwaves()) == 0 { + t.Error("no shockwave at the end of the universe") + } +} + +func TestBlackHoleArtMatchesTheSpecAndFoldsToASCII(t *testing.T) { + art := strings.Join(BlackHole(false), "\n") + for _, want := range []string{"\\ | /", "--- ● ---", "/ | \\"} { + if !strings.Contains(art, want) { + t.Errorf("%q missing from the black hole:\n%s", want, art) + } + } + ascii := strings.Join(BlackHole(true), "\n") + for _, r := range ascii { + if r > 127 { + t.Fatalf("ascii black hole contains %q", r) + } + } + if !strings.Contains(ascii, "--- @ ---") { + t.Errorf("ascii singularity missing:\n%s", ascii) + } +} + +func TestReducedMotionStillCollapsesTheUniverse(t *testing.T) { + // §49.5 suppresses shake, hyperdrive and shockwaves — not the ending. + w := NewWorld(80, 40, Options{Seed: 120, Intensity: IntensityReduced}) + w.SetBoardRect(Rect{X: 20, Y: 4, W: 22, H: 22}) + w.StartCollapse(fullishBoard()) + if len(w.FallingBlocks()) == 0 { + t.Fatal("reduced motion skipped the collapse") + } + w.Advance(1400 * time.Millisecond) + if w.CollapsePhase() != CollapseDone { + t.Error("reduced motion never reached the end") + } +} +``` + +One note while transcribing: `BlackHole` is a package function, not a method — `func BlackHole(ascii bool) []string`. + +- [ ] **Step 2: Run it to verify it fails** + +Run: `go test ./internal/fx/ -run 'Collapse|BlackHole|TestBlocksFall|TestTheUniverse|TestNothingMoves|TestGameOverEvent' -v` +Expected: FAIL — `undefined: CollapsePhase`. + +- [ ] **Step 3: Implement `collapse.go`** + +- [ ] **Step 4: Run it to verify it passes** + +Run: `go test ./internal/fx/ ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/collapse.go internal/fx/collapse_test.go internal/game +git commit -m "feat(fx): collapse the universe into a black hole" +``` + +--- + +### Task 4: Rendering and sequencing the end of the universe + +**Files:** +- Create: `internal/render/collapse.go` +- Modify: `internal/render/render.go`, `internal/app/update.go`, `internal/app/model.go` +- Test: `internal/render/collapse_test.go`, `internal/app/gameover_test.go` +- Modify: `internal/render/golden_test.go` + +**Interfaces:** +- Consumes: `fx.CollapsePhase`, `fx.FallingBlock`, `fx.BlackHole`, `BoardView.ShowActive`. +- Produces (`render`): + - `OverlayCollapse` appended to the `Overlay` const block; `Frame` gains `Collapse CollapseView`: + ```go + type CollapseView struct { + Phase fx.CollapsePhase + Age time.Duration + Blocks []fx.FallingBlock + } + ``` + - `func DrawSignalLost(c *Canvas, l Layout, m Mode)` — `SIGNAL LOST` centred over the board. + - `func DrawFallingBlocks(c *Canvas, frame Rect, bs []fx.FallingBlock, m Mode)` — rounds board coordinates to cells and clips to the frame. + - `func DrawBlackHole(c *Canvas, l Layout, age time.Duration, m Mode)` — `fx.BlackHole` centred over the board, its brightness rising with `age`. + - `OverlayCollapse` draws: the frozen well **without** the active piece or ghost (`ShowActive: false`, `ShowGhost: false`), then per phase — `CollapseFreeze` adds `SIGNAL LOST`; `CollapseFall` draws the falling blocks instead of the locked board; `CollapseHole` draws the black hole. `OverlayGameOver` (Plan 02's final card) is used once `CollapseDone` is reached. The HUD, border and starfield keep drawing throughout — the universe ends, the instruments do not. +- Produces (`app`): + - `StateCollapsing` appended to the `AppState` const block; `m.overlay()` maps it to `render.OverlayCollapse`, and `View()` fills `Frame.Collapse` from the world (or from `m.CollapseAge` alone when `FX == nil`). + - On the frame where `Game.Over` first becomes true: `State = StateCollapsing`, `FX.StartCollapse(&m.Game.Board)` (skipped when `FX == nil`), and `Chan.Say("SIGNAL LOST", flavor.PrioBig)`. + - `FrameMsg` in `StateCollapsing` advances FX but not the game, and moves to `StateGameOver` when `FX == nil` after `fx.CollapseHoleEnd`, or when `FX.CollapsePhase() == fx.CollapseDone`. With `--no-fx` the app keeps its own `CollapseAge` so the sequence still takes 1300ms and still shows `SIGNAL LOST`; there are simply no falling blocks and no black hole. + - Keys during `StateCollapsing`: `r` restarts immediately (clearing collapse state), `q`/`esc`/`ctrl+c` quit immediately, and **every other key including `p` and `?` is ignored** — you cannot pause your own death. + - `func (m *Model) restart()` — a single place that rebuilds the game, resets FX (`m.FX = fx.NewWorld(...)` with the same derived seed, or a `Reset()` if that reads better), clears `CollapseAge`, and returns to `StatePlaying` without replaying boot. Used by `r` in every state. + +- [ ] **Step 1: Write the failing render test** + +`internal/render/collapse_test.go`: + +```go +package render + +import ( + "strings" + "testing" + "time" + + "github.com/jessev/cosmic-tetris/internal/fx" + "github.com/jessev/cosmic-tetris/internal/game" +) + +func collapseFrame(w, h int, m Mode, phase fx.CollapsePhase, age time.Duration) Frame { + f := frameFor(w, h, m, OverlayCollapse) + f.Collapse = CollapseView{Phase: phase, Age: age, Blocks: []fx.FallingBlock{ + {X: 0, Y: 21, Cell: game.CellFor(game.KindI)}, + {X: 4.4, Y: 11.2, Cell: game.CellFor(game.KindT)}, + {X: 9, Y: 15.8, Cell: game.CellFor(game.KindZ)}, + }} + return f +} + +func TestFreezePhaseSaysSignalLost(t *testing.T) { + out := plain(Render(collapseFrame(80, 40, ModeFull, fx.CollapseFreeze, 100*time.Millisecond))) + if !strings.Contains(out, "SIGNAL LOST") { + t.Errorf("§28 opens with SIGNAL LOST:\n%s", out) + } + if strings.Contains(out, "UNIVERSE EXPIRED") { + t.Error("the final card appeared during the freeze; §28 says do not instantly replace the board") + } +} + +func TestFallPhaseDrawsFallingBlocks(t *testing.T) { + f := collapseFrame(80, 40, ModeFull, fx.CollapseFall, 500*time.Millisecond) + out := plain(Render(f)) + if !strings.Contains(out, "██") { + t.Error("no blocks are falling") + } + if strings.Contains(out, "SIGNAL LOST") { + t.Error("SIGNAL LOST outlived the freeze phase") + } +} + +func TestHolePhaseDrawsTheBlackHole(t *testing.T) { + out := plain(Render(collapseFrame(80, 40, ModeFull, fx.CollapseHole, 1000*time.Millisecond))) + if !strings.Contains(out, "●") { + t.Errorf("no singularity:\n%s", out) + } +} + +func TestCollapseNeverDrawsTheActivePiece(t *testing.T) { + f := collapseFrame(80, 40, ModeFull, fx.CollapseFall, 500*time.Millisecond) + f.Game.Active = game.Piece{Kind: game.KindI, Rotation: 1, X: 4, Y: 2} + out := lines(Render(f)) + for _, cell := range f.Game.Active.Cells() { + x, y := BoardCellXY(f.Layout.Frame, cell[0], cell[1]) + if y < 0 || y >= len(out) { + continue + } + if []rune(out[y])[x] == '█' { + t.Errorf("the dead piece is still on screen at %v", cell) + } + } +} + +func TestTheHUDSurvivesTheEndOfTheUniverse(t *testing.T) { + for _, phase := range []fx.CollapsePhase{fx.CollapseFreeze, fx.CollapseFall, fx.CollapseHole} { + out := plain(Render(collapseFrame(80, 40, ModeFull, phase, 500*time.Millisecond))) + if !strings.Contains(out, "SCORE") { + t.Errorf("phase %v lost the HUD", phase) + } + } +} + +func TestCollapseFrameIsStableAtEverySize(t *testing.T) { + for _, dims := range [][2]int{{40, 24}, {50, 30}, {80, 40}} { + base := len(lines(Render(frameFor(dims[0], dims[1], ModeFull, OverlayNone)))) + for _, phase := range []fx.CollapsePhase{fx.CollapseFreeze, fx.CollapseFall, fx.CollapseHole, fx.CollapseDone} { + out := lines(Render(collapseFrame(dims[0], dims[1], ModeFull, phase, 700*time.Millisecond))) + if len(out) != base { + t.Errorf("%v phase %v: %d lines want %d", dims, phase, len(out), base) + } + for i, line := range out { + if n := len([]rune(line)); n > dims[0] { + t.Fatalf("%v phase %v line %d is %d columns", dims, phase, i, n) + } + } + } + } +} + +func TestCollapseIsASCIICleanInASCIIMode(t *testing.T) { + for _, phase := range []fx.CollapsePhase{fx.CollapseFreeze, fx.CollapseFall, fx.CollapseHole, fx.CollapseDone} { + for _, r := range plain(Render(collapseFrame(80, 40, ModeASCII, phase, 700*time.Millisecond))) { + if r > 127 { + t.Fatalf("phase %v emitted %q", phase, r) + } + } + } +} + +func TestFallingBlocksOutsideTheFrameAreClipped(t *testing.T) { + f := collapseFrame(40, 24, ModeFull, fx.CollapseFall, 500*time.Millisecond) + f.Collapse.Blocks = append(f.Collapse.Blocks, + fx.FallingBlock{X: -900, Y: -900, Cell: game.CellFor(game.KindI)}, + fx.FallingBlock{X: 1e6, Y: 1e6, Cell: game.CellFor(game.KindI)}, + ) + out := lines(Render(f)) // must not panic + for i, line := range out { + if n := len([]rune(line)); n > 40 { + t.Fatalf("line %d is %d columns", i, n) + } + } +} +``` + +- [ ] **Step 2: Run it to verify it fails, implement `collapse.go`, run it again** + +Run: `go test ./internal/render/ -run 'Collapse|Freeze|Fall|Hole|TestTheHUD' -v` — FAIL, implement, PASS. + +- [ ] **Step 3: Write the failing app test** + +`internal/app/gameover_test.go`: + +```go +package app + +import ( + "strings" + "testing" + "time" + + "github.com/jessev/cosmic-tetris/internal/fx" +) + +// kill drives the model to game over by hard-dropping into one column until a +// spawn fails. It accepts a model in any state, including one already dead. +func kill(t *testing.T, m *Model) *Model { + t.Helper() + if m.State == StateBoot { + m = skipBoot(t, m) + } + for i := 0; i < 2000 && !m.Game.Over; i++ { + m = press(t, m, " ") + } + if !m.Game.Over { + t.Fatal("could not reach game over by hard-dropping") + } + // One more frame so the model notices. + next, _ := m.Update(FrameMsg{Now: m.LastFrame.Add(16 * time.Millisecond)}) + return next.(*Model) +} + +func TestGameOverEntersTheCollapseNotTheCard(t *testing.T) { + m := kill(t, testModel(t)) + if m.State != StateCollapsing { + t.Fatalf("state = %v want StateCollapsing", m.State) + } + out := plainOut(m.View()) + if strings.Contains(out, "UNIVERSE EXPIRED") { + t.Error("§28: do not instantly replace the board") + } + if !strings.Contains(out, "SIGNAL LOST") { + t.Errorf("no SIGNAL LOST:\n%s", out) + } +} + +func TestCollapseReachesTheFinalCard(t *testing.T) { + m := kill(t, testModel(t)) + m = advance(t, m, 120, 16*time.Millisecond) // ~1.9s, well past 1300ms + if m.State != StateGameOver { + t.Fatalf("state = %v after the collapse window", m.State) + } + out := plainOut(m.View()) + for _, want := range []string{"UNIVERSE EXPIRED", "SCORE", "LINES", "LEVEL", "REBOOT UNIVERSE", "ACCEPT COSMIC DEATH"} { + if !strings.Contains(out, want) { + t.Errorf("the final card is missing %q:\n%s", want, out) + } + } +} + +func TestCollapseAlsoHappensWithFXDisabled(t *testing.T) { + m := kill(t, modelWith(t, Config{Seed: 4242, NoFX: true})) + if m.State != StateCollapsing { + t.Fatalf("no-fx skipped straight to %v", m.State) + } + if !strings.Contains(plainOut(m.View()), "SIGNAL LOST") { + t.Error("no-fx lost the SIGNAL LOST beat") + } + m = advance(t, m, 120, 16*time.Millisecond) + if m.State != StateGameOver { + t.Fatalf("no-fx never reached the card (state = %v)", m.State) + } +} + +// Review focus 2. +func TestRestartDuringTheCollapseIsInstantAndClean(t *testing.T) { + m := kill(t, testModel(t)) + m = advance(t, m, 20, 16*time.Millisecond) // mid-fall + m = press(t, m, "r") + if m.State != StatePlaying { + t.Fatalf("state = %v after r", m.State) + } + if m.Game.Over { + t.Error("the new game is already over") + } + if m.Game.Score != 0 || m.Game.Lines != 0 { + t.Errorf("score/lines carried over: %d/%d", m.Game.Score, m.Game.Lines) + } + if m.FX != nil { + if n := len(m.FX.FallingBlocks()); n != 0 { + t.Errorf("%d collapse blocks survived the restart", n) + } + if m.FX.CollapsePhase() != fx.CollapseDone && m.FX.CollapsePhase() != 0 { + t.Errorf("collapse state survived the restart: %v", m.FX.CollapsePhase()) + } + } + if strings.Contains(plainOut(m.View()), "SIGNAL LOST") { + t.Error("SIGNAL LOST survived the restart") + } +} + +func TestQuitDuringTheCollapseIsInstant(t *testing.T) { + for _, key := range []string{"q", "esc", "ctrl+c"} { + m := kill(t, testModel(t)) + _, cmd := m.Update(keyMsg(key)) + if cmd == nil { + t.Errorf("%q during the collapse did not quit", key) + } + } +} + +// Review focus 4. +func TestYouCannotPauseYourOwnDeath(t *testing.T) { + for _, key := range []string{"p", "?"} { + m := kill(t, testModel(t)) + m = press(t, m, key) + if m.State != StateCollapsing { + t.Errorf("%q changed the state to %v mid-collapse", key, m.State) + } + out := plainOut(m.View()) + if strings.Contains(out, "TEMPORAL SUSPENSION") || strings.Contains(out, "FLIGHT MANUAL") { + t.Errorf("%q opened an overlay over the black hole", key) + } + m = advance(t, m, 120, 16*time.Millisecond) + if m.State != StateGameOver { + t.Errorf("%q stalled the sequence; state = %v", key, m.State) + } + } +} + +// Review focus 3. +func TestResizeDuringTheCollapseIsSafe(t *testing.T) { + m := kill(t, testModel(t)) + for _, dims := range [][2]int{{20, 10}, {40, 24}, {200, 60}, {34, 19}} { + m = resize(t, m, dims[0], dims[1]) + m = advance(t, m, 5, 16*time.Millisecond) + for i, line := range strings.Split(plainOut(m.View()), "\n") { + if n := len([]rune(line)); n > dims[0] { + t.Fatalf("%v: line %d is %d columns mid-collapse", dims, i, n) + } + } + } + m = advance(t, m, 120, 16*time.Millisecond) + if m.State != StateGameOver { + t.Errorf("resizing derailed the sequence; state = %v", m.State) + } +} + +func TestTheGameDoesNotAdvanceDuringTheCollapse(t *testing.T) { + m := kill(t, testModel(t)) + fp := m.Game.Board.Fingerprint() + score := m.Game.Score + m = advance(t, m, 60, 16*time.Millisecond) + if m.Game.Board.Fingerprint() != fp || m.Game.Score != score { + t.Error("the game kept running while the universe was collapsing") + } +} +``` + +`skipBoot` comes from Task 1, `advance` from Plan 04's `internal/app/violence_test.go`, and `plainOut`/`press`/`keyMsg` from Plan 02's. Add `func resize(t *testing.T, m *Model, w, h int) *Model` (a `tea.WindowSizeMsg` wrapper) next to them. `kill` is used again by Task 7, so put it in the shared helpers file rather than in `gameover_test.go`. + +- [ ] **Step 4: Run it to verify it fails, implement the states, run it again** + +Run: `go test ./internal/app/ -v` — FAIL, implement `StateCollapsing` and `restart()`, PASS. + +- [ ] **Step 5: Record two new goldens** + +Add `{"collapse_fall", 80, 40, ModeFull, OverlayCollapse}` and `{"collapse_hole", 80, 40, ModeFull, OverlayCollapse}` to `TestGoldenLayouts` (the table needs a `collapse CollapseView` column, or a small per-case hook). + +Run: `go test ./internal/render/ -update && go test ./internal/render/ && cat internal/render/testdata/collapse_hole.txt` +Expected: PASS, and the recorded frame shows a black hole where the well used to be. + +- [ ] **Step 6: Commit** + +```bash +git add internal/render internal/app +git commit -m "feat: SIGNAL LOST, an inward collapse, and a black hole where the well was" +``` + +--- + +### Task 5: The tiny details (§45) + +**Files:** +- Create: `internal/flavor/extras.go` +- Create: `internal/fx/shooting.go` +- Test: `internal/flavor/extras_test.go`, `internal/fx/shooting_test.go` + +**Interfaces:** +- Consumes: `flavor.Channel`, `flavor.Priority`, `game.Event`, `World`, `Star`. +- Produces (`flavor/extras.go`) — §45's copy, verbatim: + - ```go + const ( + ExtraTerminal = "DID YOU KNOW YOU'RE IN A TERMINAL?" + ExtraRollover = "NUMBER BECAME BIGGER" + ExtraKineticRod = "KINETIC ROD DEPLOYED" + ExtraCube = "CUBE ADJACENT OBJECT SECURED" + ExtraCaptain = "CAPTAIN?" + ) + func (c *Channel) HandleExtras(evs []game.Event) + ``` + - Trigger rules, each **deterministic given the event stream** so no roll can differ between two runs of the same seed in a way that matters, and each rolled **at most once per triggering event** — never per frame (Review focus 5): + - `PieceHardDropped` with `Kind == KindI` and `Rotation` odd (vertical) → `ExtraKineticRod` at `PrioRoutine`, 1-in-3. + - `HoldUsed` with `Kind == KindO` → `ExtraCube` at `PrioRoutine`, 1-in-2. + - Score crossing a power-of-ten boundary (`10_000`, `100_000`, `1_000_000`) → `ExtraRollover` at `PrioCombo`, always. + - `ExtraTerminal` — 1-in-400 on `PieceLocked`, at `PrioAmbient`, so it cannot displace anything real. + - `func (c *Channel) NoteIdle(d time.Duration)` — `ExtraCaptain` at `PrioAmbient` once `d >= IdleBeforeCaptain` (`const IdleBeforeCaptain = 12 * time.Second`) with no input yet; it fires once and never again. + - Every extra goes through the existing `Say`, so `MinDwell` and priority preemption still govern the line — an extra can never stomp a level-up notice. +- Produces (`fx/shooting.go`): + - ```go + type ShootingStar struct { X, Y, VX, VY, Life float64; Tail int } + func (w *World) ShootingStars() []ShootingStar + const ShootingStarChancePerSecond = 0.08 // ~one every 12s of idle board + ``` + - A shooting star is spawned only while the board is idle (`func (w *World) NoteBoardIdle(d time.Duration)` reports how long since the last gameplay event; the roll happens only when `d > 3s`), at most `1` alive at a time, and it is rolled once per **simulated second**, not once per frame. + - Render: `func DrawShootingStars(c *Canvas, ss []fx.ShootingStar, m Mode)` in `internal/render/fxdraw.go`, drawing a 3-cell tail with `· ˚ ✦` (ASCII `. ' *`), skipping non-blank cells like the starfield does. + +- [ ] **Step 1: Write the failing flavor test** + +`internal/flavor/extras_test.go`: + +```go +package flavor + +import ( + "strings" + "testing" + "time" + + "github.com/jessev/cosmic-tetris/internal/game" +) + +func TestVerticalIHardDropEventuallySaysKineticRod(t *testing.T) { + ev := game.Event{Kind: game.PieceHardDropped, Piece: game.Piece{Kind: game.KindI, Rotation: 1}} + seen := false + for seed := int64(0); seed < 30 && !seen; seed++ { + c := chn(seed) + for i := 0; i < 10 && !seen; i++ { + c.HandleExtras([]game.Event{ev}) + c.Advance(3 * time.Second) + if c.Message() == ExtraKineticRod { + seen = true + } + } + } + if !seen { + t.Error("a vertical I never deployed a kinetic rod") + } +} + +func TestHorizontalIHardDropNeverSaysKineticRod(t *testing.T) { + ev := game.Event{Kind: game.PieceHardDropped, Piece: game.Piece{Kind: game.KindI, Rotation: 0}} + for seed := int64(0); seed < 30; seed++ { + c := chn(seed) + for i := 0; i < 20; i++ { + c.HandleExtras([]game.Event{ev}) + c.Advance(3 * time.Second) + if c.Message() == ExtraKineticRod { + t.Fatalf("seed %d: a flat I claimed to be a kinetic rod", seed) + } + } + } +} + +func TestHoldingAnOSecuresACubeAdjacentObject(t *testing.T) { + ev := game.Event{Kind: game.HoldUsed, Piece: game.Piece{Kind: game.KindO}} + seen := false + for seed := int64(0); seed < 20 && !seen; seed++ { + c := chn(seed) + for i := 0; i < 10 && !seen; i++ { + c.HandleExtras([]game.Event{ev}) + c.Advance(3 * time.Second) + if c.Message() == ExtraCube { + seen = true + } + } + } + if !seen { + t.Error("no cube was ever secured") + } +} + +func TestScoreRolloverAnnouncesItself(t *testing.T) { + c := chn(7) + c.HandleExtras([]game.Event{{Kind: game.PieceLocked, Score: 9_800}}) + c.Advance(3 * time.Second) + c.HandleExtras([]game.Event{{Kind: game.PieceLocked, Score: 10_200}}) + if c.Message() != ExtraRollover { + t.Errorf("crossing 10,000 said %q", c.Message()) + } + c.Advance(3 * time.Second) + c.HandleExtras([]game.Event{{Kind: game.PieceLocked, Score: 10_300}}) + if c.Message() == ExtraRollover { + t.Error("the rollover line repeated without a new boundary") + } +} + +// Review focus 5: rarity must not be per-frame. +func TestTheTerminalJokeStaysRare(t *testing.T) { + c := chn(11) + hits := 0 + for i := 0; i < 2000; i++ { + c.HandleExtras([]game.Event{{Kind: game.PieceLocked, Score: 100}}) + c.Advance(3 * time.Second) + if c.Message() == ExtraTerminal { + hits++ + c.Say("something else", PrioRoutine) + } + } + if hits > 20 { + t.Errorf("the rare line fired %d times in 2000 locks; §45 says extremely rare", hits) + } +} + +func TestExtrasAreNotRolledPerAdvance(t *testing.T) { + c := chn(12) + c.Say("REAL NEWS", PrioBig) + for i := 0; i < 600; i++ { // ten seconds of frames, no events at all + c.HandleExtras(nil) + c.Advance(16 * time.Millisecond) + } + for _, extra := range []string{ExtraKineticRod, ExtraCube, ExtraRollover, ExtraTerminal} { + if c.Message() == extra { + t.Errorf("an extra (%q) appeared with no event to trigger it", extra) + } + } +} + +func TestExtrasCannotStompABigMessage(t *testing.T) { + c := chn(13) + c.Say("GRAVITY ANOMALY DETECTED", PrioBig) + for i := 0; i < 50; i++ { + c.HandleExtras([]game.Event{ + {Kind: game.PieceHardDropped, Piece: game.Piece{Kind: game.KindI, Rotation: 1}}, + {Kind: game.HoldUsed, Piece: game.Piece{Kind: game.KindO}}, + }) + } + if c.Message() != "GRAVITY ANOMALY DETECTED" { + t.Errorf("an extra displaced a PrioBig line: %q", c.Message()) + } +} + +func TestCaptainAsksAfterALongSilence(t *testing.T) { + c := chn(14) + c.NoteIdle(5 * time.Second) + if strings.Contains(c.Message(), "CAPTAIN") { + t.Error("five seconds is not a long idle") + } + c.NoteIdle(IdleBeforeCaptain + time.Second) + if !strings.Contains(c.Message(), "CAPTAIN") { + t.Errorf("mission control never checked in: %q", c.Message()) + } + c.Say("moving on", PrioRoutine) + c.Advance(3 * time.Second) + c.NoteIdle(IdleBeforeCaptain + 10*time.Second) + if strings.Contains(c.Message(), "CAPTAIN") { + t.Error("the captain line repeated; it fires once") + } +} + +func TestEveryExtraIsASCII(t *testing.T) { + for _, s := range []string{ExtraTerminal, ExtraRollover, ExtraKineticRod, ExtraCube, ExtraCaptain} { + for _, r := range s { + if r > 127 { + t.Errorf("%q contains %q", s, r) + } + } + } +} +``` + +The rollover check reads `Event.Score`, which Plan 01's Task 6 already puts on every scoring event. + +- [ ] **Step 2: Write the failing shooting-star test** + +`internal/fx/shooting_test.go`: + +```go +package fx + +import ( + "testing" + "time" +) + +func TestAnIdleBoardEventuallyGetsAShootingStar(t *testing.T) { + seen := false + for seed := int64(0); seed < 20 && !seen; seed++ { + w := full(80, 40, seed) + for i := 0; i < 60*60; i++ { // one minute of idle + w.NoteBoardIdle(time.Duration(i) * 16 * time.Millisecond) + w.Advance(16 * time.Millisecond) + if len(w.ShootingStars()) > 0 { + seen = true + break + } + } + } + if !seen { + t.Error("a minute of idle board produced no shooting star") + } +} + +func TestOnlyOneShootingStarAtATime(t *testing.T) { + w := full(80, 40, 21) + for i := 0; i < 60*120; i++ { + w.NoteBoardIdle(10 * time.Second) + w.Advance(16 * time.Millisecond) + if n := len(w.ShootingStars()); n > 1 { + t.Fatalf("%d shooting stars at once", n) + } + } +} + +func TestABusyBoardGetsNoShootingStars(t *testing.T) { + w := full(80, 40, 22) + for i := 0; i < 60*120; i++ { + w.NoteBoardIdle(200 * time.Millisecond) // constant activity + w.Advance(16 * time.Millisecond) + if len(w.ShootingStars()) > 0 { + t.Fatal("a shooting star crossed a busy board") + } + } +} + +func TestShootingStarsCrossTheScreenAndLeave(t *testing.T) { + w := full(80, 40, 23) + for i := 0; i < 60*60 && len(w.ShootingStars()) == 0; i++ { + w.NoteBoardIdle(10 * time.Second) + w.Advance(16 * time.Millisecond) + } + if len(w.ShootingStars()) == 0 { + t.Skip("no star spawned for this seed") + } + start := w.ShootingStars()[0] + moved := false + for i := 0; i < 600; i++ { + w.Advance(16 * time.Millisecond) + ss := w.ShootingStars() + if len(ss) == 0 { + if !moved { + t.Error("the star died without moving") + } + return + } + if ss[0].X != start.X || ss[0].Y != start.Y { + moved = true + } + } + t.Error("the shooting star never left") +} + +func TestNoShootingStarsWhenFXIsOff(t *testing.T) { + w := NewWorld(80, 40, Options{Seed: 24, Intensity: IntensityOff}) + for i := 0; i < 60*120; i++ { + w.NoteBoardIdle(10 * time.Second) + w.Advance(16 * time.Millisecond) + } + if len(w.ShootingStars()) != 0 { + t.Error("IntensityOff produced a shooting star") + } +} +``` + +- [ ] **Step 3: Run both to verify they fail** + +Run: `go test ./internal/flavor/ ./internal/fx/ -run 'Extra|Kinetic|Cube|Rollover|Terminal|Captain|Shooting|Idle' -v` +Expected: FAIL — `undefined: HandleExtras`, `undefined: ShootingStar`. + +- [ ] **Step 4: Implement both, plus `DrawShootingStars` and the app wiring** + +The app calls `Chan.HandleExtras(m.Events)`, `Chan.NoteIdle(m.sinceInput)` and `FX.NoteBoardIdle(m.sinceGameplayEvent)` in the same place it already calls `Chan.Handle`. + +- [ ] **Step 5: Run everything** + +Run: `go test ./... -v` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add internal/flavor internal/fx/shooting.go internal/fx/shooting_test.go internal/render/fxdraw.go internal/app +git commit -m "feat(flavor): the cheap encouraged nonsense from §45" +``` + +--- + +### Task 6: Responsive FX and the flicker audit + +**Files:** +- Modify: `internal/fx/world.go` (density scaling by size) +- Test: `internal/fx/responsive_test.go`, `internal/render/flicker_test.go` + +**Interfaces:** +- Consumes: `Options`, `Resize`, `Mode`. Capability detection is already done and tested: `render.DetectMode(env func(string) string, forceASCII bool) Mode` from Plan 02 Task 3 — do not add a second one. +- Produces: + - `func (w *World) budget() float64` (unexported) — `1.0` at `>= 64×26`, `0.6` below that, `0.35` below `46×26`. Star counts, particle caps, trail caps and the shockwave cap are all multiplied by it, so a small terminal gets a proportionally calmer universe rather than a soup of glyphs. + - No new CLI flags (§46). + +- [ ] **Step 1: Write the failing tests** + +`internal/fx/responsive_test.go`: + +```go +package fx + +import ( + "testing" + "time" + + "github.com/jessev/cosmic-tetris/internal/game" +) + +func TestSmallTerminalsGetACalmerUniverse(t *testing.T) { + count := func(cols, rows int) (stars, particles int) { + w := full(cols, rows, 131) + w.SetBoardRect(Rect{X: 1, Y: 1, W: 22, H: 22}) + for i := 0; i < 200; i++ { + w.Handle([]game.Event{clearEvent(18, 19, 20, 21), hardDrop(12)}) + w.Advance(16 * time.Millisecond) + } + return len(w.Stars()), w.ParticleCount() + } + bigStars, bigParts := count(120, 50) + smallStars, smallParts := count(40, 24) + if smallStars >= bigStars { + t.Errorf("a 40x24 terminal has %d stars vs %d on 120x50", smallStars, bigStars) + } + if smallParts >= bigParts { + t.Errorf("a 40x24 terminal saturates at %d particles vs %d on 120x50", smallParts, bigParts) + } +} + +func TestResizingRescalesTheBudgetBothWays(t *testing.T) { + w := full(120, 50, 132) + big := len(w.Stars()) + w.Resize(40, 24) + small := len(w.Stars()) + if small >= big { + t.Errorf("shrinking did not reduce the star count: %d then %d", big, small) + } + w.Resize(120, 50) + if again := len(w.Stars()); again <= small { + t.Errorf("growing back did not restore the stars: %d", again) + } + for _, s := range w.Stars() { + if s.X < 0 || s.X >= 120 || s.Y < -1 || s.Y >= 50 { + t.Fatalf("a star was left outside after the resize: %+v", s) + } + } +} + +func TestASmallTerminalIsStillFun(t *testing.T) { + w := full(40, 24, 133) + w.SetBoardRect(Rect{X: 1, Y: 1, W: 22, H: 22}) + w.Handle([]game.Event{clearEvent(18, 19, 20, 21)}) + if len(w.Stars()) == 0 { + t.Error("no stars on a small terminal") + } + if w.ParticleCount() == 0 { + t.Error("no particles on a small terminal") + } + if len(w.Banners()) == 0 { + t.Error("no banner on a small terminal") + } +} +``` + +`internal/render/flicker_test.go`: + +```go +package render + +import ( + "strings" + "testing" + "time" + + "github.com/jessev/cosmic-tetris/internal/fx" +) + +// §47: output must not visibly flicker. A frame's shape may depend on the +// terminal size and the mode, and on nothing else. +func TestFrameShapeIsIndependentOfContent(t *testing.T) { + for _, dims := range [][2]int{{40, 24}, {50, 30}, {80, 40}, {200, 60}} { + for _, m := range []Mode{ModeFull, ModeReduced, ModeASCII} { + var want []int + variants := []func(f *Frame){ + func(f *Frame) {}, + func(f *Frame) { f.Mission = "" }, + func(f *Frame) { f.Mission = strings.Repeat("VERY LONG NEWS ", 30) }, + func(f *Frame) { f.ShakeX, f.ShakeY = 1, -1 }, + func(f *Frame) { f.HUDFlash, f.HUDPulse, f.BorderFlash = 1, 1, 1 }, + func(f *Frame) { + f.Particles = []fx.Particle{{X: 3, Y: 3, Glyph: '*', Life: 1, MaxLife: 1}} + f.Shockwaves = []fx.Shockwave{{X: 20, Y: 10, Radius: 5, Life: 300 * time.Millisecond}} + }, + func(f *Frame) { + f.Banners = []fx.Banner{{Text: strings.Repeat("BANNER ", 20), Life: 700 * time.Millisecond, Style: fx.BannerHuge}} + }, + } + for i, mutate := range variants { + f := frameFor(dims[0], dims[1], m, OverlayNone) + mutate(&f) + got := []int{} + for _, line := range lines(Render(f)) { + got = append(got, len([]rune(line))) + } + if want == nil { + want = got + continue + } + if len(got) != len(want) { + t.Fatalf("%v %v variant %d: %d lines want %d", dims, m, i, len(got), len(want)) + } + for j := range got { + if got[j] != want[j] { + t.Fatalf("%v %v variant %d: line %d is %d columns want %d", + dims, m, i, j, got[j], want[j]) + } + } + } + } + } +} + +func TestRenderEmitsNoCursorOrClearSequences(t *testing.T) { + // Bubble Tea owns the cursor. A frame that clears the screen or homes the + // cursor itself is the classic source of terminal flicker. + for _, o := range []Overlay{OverlayNone, OverlayPause, OverlayHelp, OverlayGameOver, OverlayBoot, OverlayCollapse, OverlayTooSmall} { + out := Render(frameFor(80, 40, ModeFull, o)) + for _, bad := range []string{"\x1b[2J", "\x1b[H", "\x1b[J", "\r", "\x1b[?25", "\t"} { + if strings.Contains(out, bad) { + t.Errorf("overlay %v emitted %q", o, bad) + } + } + } +} + +func TestRenderDoesNotEndWithATrailingNewline(t *testing.T) { + out := Render(frameFor(80, 40, ModeFull, OverlayNone)) + if strings.HasSuffix(out, "\n") { + t.Error("a trailing newline scrolls the terminal by one line every frame") + } +} +``` + +- [ ] **Step 2: Run both to verify they fail** + +Run: `go test ./internal/fx/ ./internal/render/ -run 'Small|Resizing|FrameShape|CursorOr|TrailingNewline' -v` +Expected: FAIL. + +- [ ] **Step 3: Implement the budget, and fix whatever the flicker test exposes** + +The flicker test is the one most likely to find real bugs in Plans 02–04 (a long mission-control string that wraps, a banner that pushes a line wide). Fix the renderer, not the test. + +- [ ] **Step 4: Run everything** + +Run: `go test ./... -race` +Expected: PASS. Re-record goldens only if a real fix changed the intended layout. + +- [ ] **Step 5: Watch it with your own eyes** + +```bash +make build +./cosmic-tetris --seed 99 +``` + +Resize the terminal repeatedly while playing, from full-screen down past 40×24 and back. Nothing may flicker, tear, or leave debris in the scrollback. + +- [ ] **Step 6: Commit** + +```bash +git add internal/fx internal/render +git commit -m "feat: scale effects to the terminal and kill flicker" +``` + +--- + +### Task 7: ASCII purity and help audit + +**Files:** +- Test: `internal/app/ascii_test.go` +- Test: `internal/app/help_test.go` +- Modify: whatever the audits break + +**Interfaces:** +- Consumes: the whole program. +- Produces: no new API. Two end-to-end audits that drive the real model through every state and assert §32 and §39. + +- [ ] **Step 1: Write the failing ASCII audit** + +`internal/app/ascii_test.go`: + +```go +package app + +import ( + "strings" + "testing" + "time" + + "github.com/jessev/cosmic-tetris/internal/render" +) + +// §32: ASCII mode makes no special Unicode assumptions. Not one byte. +func TestNothingInASCIIModeIsEverNonASCII(t *testing.T) { + check := func(t *testing.T, label string, m *Model) { + t.Helper() + for _, r := range plainOut(m.View()) { + if r > 127 { + t.Fatalf("%s emitted %q (U+%04X)", label, r, r) + } + } + } + for seed := int64(1); seed <= 5; seed++ { + cfg := Config{Seed: seed, ASCII: true} + m := modelWith(t, cfg) + if m.Mode != render.ModeASCII { + t.Fatalf("mode = %v", m.Mode) + } + + check(t, "boot", m) + m = advance(t, m, 3, 16*time.Millisecond) + check(t, "mid-boot", m) + m = skipBoot(t, m) + check(t, "first playing frame", m) + + // Play hard for a while: locks, clears, combos, levels, hyperdrive. + for i := 0; i < 300 && !m.Game.Over; i++ { + m = press(t, m, []string{"left", "right", "x", "z", "c", " "}[i%6]) + m = advance(t, m, 2, 16*time.Millisecond) + check(t, "playing", m) + } + for _, key := range []string{"p", "p", "?", "?"} { + m = press(t, m, key) + check(t, "overlay "+key, m) + } + for _, dims := range [][2]int{{34, 19}, {40, 24}, {200, 60}} { + m = resize(t, m, dims[0], dims[1]) + check(t, "resized", m) + } + m = resize(t, m, 80, 40) + m = press(t, m, "r") // a clean universe, whatever state the loop left behind + m = kill(t, m) + for i := 0; i < 120; i++ { + check(t, "collapsing", m) + m = advance(t, m, 1, 16*time.Millisecond) + } + check(t, "final card", m) + } +} + +func TestNonASCIIModeActuallyUsesTheNiceGlyphs(t *testing.T) { + // The complement: the audit above must not have been satisfied by making + // the whole game ASCII. + m := skipBoot(t, testModel(t)) + m = advance(t, m, 60, 16*time.Millisecond) + out := plainOut(m.View()) + for _, want := range []string{"█", "╭", "☄"} { + if !strings.Contains(out, want) { + t.Errorf("full mode is missing %q", want) + } + } +} +``` + +- [ ] **Step 2: Write the failing help audit** + +`internal/app/help_test.go`: + +```go +package app + +import ( + "strings" + "testing" +) + +// §39's flight manual, every binding. +func TestHelpListsEveryBinding(t *testing.T) { + m := skipBoot(t, testModel(t)) + m = press(t, m, "?") + out := plainOut(m.View()) + if !strings.Contains(out, "FLIGHT MANUAL") { + t.Fatalf("no flight manual:\n%s", out) + } + for _, want := range []string{ + "move spacecraft", "accelerate doom", "rotate geometry", "rotate other way", + "YEET", "quantum storage", "suspend spacetime", "reboot universe", + "abandon mission", "close this nonsense", + } { + if !strings.Contains(out, want) { + t.Errorf("the manual is missing %q:\n%s", want, out) + } + } +} + +func TestHelpPausesGameplayAndClosesAgain(t *testing.T) { + m := skipBoot(t, testModel(t)) + m = press(t, m, "?") + if m.State != StateHelp { + t.Fatalf("state = %v", m.State) + } + fp := m.Game.Board.Fingerprint() + y := m.Game.Active.Y + m = advance(t, m, 120, 16*time.Millisecond) // ~2s: gravity would have moved it + if m.Game.Active.Y != y || m.Game.Board.Fingerprint() != fp { + t.Error("the game kept playing while the manual was open") + } + m = press(t, m, "?") + if m.State != StatePlaying { + t.Errorf("? did not close the manual (state = %v)", m.State) + } + m = advance(t, m, 120, 16*time.Millisecond) + if m.Game.Active.Y == y && !m.Game.Over { + t.Error("gravity did not resume after closing the manual") + } +} + +func TestHelpFitsTheSmallestSupportedTerminal(t *testing.T) { + m := skipBoot(t, testModel(t)) + m = resize(t, m, 40, 24) + m = press(t, m, "?") + for i, line := range strings.Split(plainOut(m.View()), "\n") { + if n := len([]rune(line)); n > 40 { + t.Fatalf("line %d of the manual is %d columns on a 40-column terminal", i, n) + } + } +} +``` + +- [ ] **Step 3: Run both to verify they fail** + +Run: `go test ./internal/app/ -run 'ASCII|NonASCII|Help' -v` +Expected: FAIL — most likely on a banner or flavor line with a `✦` in it, and on any §39 copy that drifted. + +- [ ] **Step 4: Fix every violation, then run again** + +Fix at the source: fold non-ASCII in the mode that must not have it, rather than special-casing the test. The likely offenders, in order: + +- The `KeyMap` help strings — §39's manual is written with `← → ↓ ↑`. In `ModeASCII` the help must describe the letter keys (`h l`, `j`, `k`) instead. +- `flavor.FourLineBanners`, which contains `✦ EVENT HORIZON ✦` (Plan 04 folds this in `fx`; confirm the fold is reached). +- `render.TooSmallLines`' `×`, which Plan 02 already switches to `x` — verify. +- Any glyph written as a literal instead of through `GlyphsFor(m)`. + +- [ ] **Step 4b: Run the whole suite** + +Run: `go test ./... -race` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/app internal/render internal/flavor internal/fx +git commit -m "test: end-to-end ASCII purity and flight-manual audits" +``` + +--- + +### Task 8: Ship it + +**Files:** +- Create: `README.md` +- Create: `LICENSE` +- Modify: `Makefile` +- Test: the §47 checklist, run by hand + +**Interfaces:** +- Consumes: the finished program. +- Produces: `make build`, `make test`, `make lint`, `make cover`, and a `README.md` documenting exactly the §46 CLI and nothing that does not exist. + +- [ ] **Step 1: Write `README.md`** + +Contents, and only these: the §48 one-line pitch (a tiny terminal arcade game that happens to use falling tetrominoes, while the universe increasingly loses its shit around the player), a build/run block, the full §8 control table copied from `internal/app/keys.go`, the §46 flag list with one line each, a short "how it works" paragraph naming `internal/game` (deterministic, clock-free), `internal/fx` (event-driven, never mutates the game), `internal/render` (character-grid compositing) and `internal/app` (Bubble Tea), and a note that `--seed N` reproduces a run exactly. No roadmap, no badges for CI that does not exist, no features that are not in the binary. + +- [ ] **Step 2: Add `LICENSE`** + +MIT, `Copyright (c) 2026 Jesse Vincent`. If the repository has a house license elsewhere, match it instead of inventing one. + +- [ ] **Step 3: Check the Makefile gate is complete** + +`make test` must run `go test ./... -race`; `make lint` must run `go vet ./...` and `gofmt -l` with a non-empty result failing the build; `make build` must produce `./cosmic-tetris`; `make cover` must print per-package coverage. Add whatever is missing. + +Run: `make test && make lint && make build && make cover` +Expected: PASS, and `internal/game` coverage above 90% (§40: game logic receives the serious testing). + +- [ ] **Step 4: Walk the §47 definition of done** + +Play the game and tick each line off out loud. Anything that fails here is a bug to fix now, in this task. + +```bash +./cosmic-tetris --seed 20260917 +``` + +- [ ] playable from start through game over +- [ ] controls feel immediate +- [ ] resizing works +- [ ] hold works +- [ ] ghost works +- [ ] next queue works +- [ ] piece generation is deterministic (`--seed 7` twice, same pieces) +- [ ] game RNG and FX RNG are isolated (`--seed 7` with and without `--no-fx`, same pieces) +- [ ] line clearing is correct +- [ ] gravity increases +- [ ] pause works +- [ ] restart works +- [ ] ASCII fallback works (`--ascii`) +- [ ] no-FX mode works (`--no-fx`) +- [ ] game logic has comprehensive unit tests +- [ ] renderer has representative snapshot tests +- [ ] terminal output does not visibly flicker +- [ ] animations never block input +- [ ] effects never modify game state +- [ ] four-line clears are gloriously excessive +- [ ] game over collapses the universe into a black hole +- [ ] the game is fun with effects disabled +- [ ] the game is **much funnier with effects enabled** + +- [ ] **Step 5: Re-run the §43 coolness acceptance test** + +Within the first 30 seconds: moving starfield, animated border, piece trails, hard-drop impact, particles, mission-control commentary. Within the first completed line: supernova, debris, border reaction. A four-line clear must still produce the §43 reaction. If it does not, tune it — that is a product requirement, not a preference. + +- [ ] **Step 6: Confirm the codebase is still an afternoon's read (§48)** + +```bash +find . -name '*.go' -not -name '*_test.go' | xargs wc -l | sort -n | tail -20 +``` + +If any non-test file has grown past roughly 400 lines, split it along the responsibility line that made it grow. Do not add abstraction to reduce the number; move code. + +- [ ] **Step 7: Commit** + +```bash +git add README.md LICENSE Makefile +git commit -m "docs: ship Cosmic Tetris" +``` + +--- + +## Done when + +- The game boots with a second of drama that any key skips, and boot does not replay on restart. +- Game over takes 1300ms of theatre — `SIGNAL LOST`, an inward collapse, a black hole — before the final card, and `r`/`q` work instantly at every moment of it. +- Pause freezes gameplay and its particles while the stars keep drifting slowly. +- §45's tiny details appear occasionally and never displace a real message. +- Effects scale down on small terminals and back up on large ones. +- Under `--ascii`, no state of the program emits a single non-ASCII byte; under full mode it still looks like Unicode. +- A frame's line count and line widths depend only on the terminal size and mode — nothing flickers. +- Every line of §47 is ticked, and a four-line clear still gets the §43 reaction. diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/N1-recipe-cosmic-12/2026-09-17-cosmic-tetris-01-engine.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/N1-recipe-cosmic-12/2026-09-17-cosmic-tetris-01-engine.md new file mode 100644 index 000000000..971b67ace --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/N1-recipe-cosmic-12/2026-09-17-cosmic-tetris-01-engine.md @@ -0,0 +1,1623 @@ +# Cosmic Tetris — Plan 1 of 3: Deterministic Game Engine Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the complete headless falling-block game engine — pieces, board, 7-bag, movement, rotation with kicks, gravity, locking, line clearing, hold, scoring, game over — as a deterministic, clock-free Go package with comprehensive unit tests. + +**Architecture:** One package, `internal/game`, with no dependencies outside the standard library. The engine is a pure state machine: callers push player actions in via `Input(Action)` and push elapsed time in via `Advance(dt)`; both return a slice of `Event` values describing what happened. Nothing in the package reads a clock or a global RNG. The board is a fixed-size array so state is cheap to copy for events and snapshots. + +**Tech Stack:** Go 1.26, standard library only (`math/rand`, `time` for `time.Duration` as a value type only). + +**Spec:** `design.md` (this plan implements §5–§13, §34, §35, §40, §49.1, §49.2, §49.4 shape/cell semantics, §49.6) + +**Plan sequence:** This is plan 1 of 3. Plan 2 (`2026-09-17-cosmic-tetris-02-terminal.md`) builds the Bubble Tea app and renderer on top of this package. Plan 3 (`2026-09-17-cosmic-tetris-03-cosmic-fx.md`) adds the effects layer. Plan 2 and 3 depend on the exact names and types in this plan's Interfaces blocks — do not rename them later without updating those plans. + +## Global Constraints + +- Go module path: `cosmic-tetris`. Go directive: `go 1.26`. +- `internal/game` imports **only** the standard library. It must never import `charm.land/...`, `internal/render`, `internal/fx`, or `internal/app`. +- **Nothing under `internal/game` calls `time.Now()`, `time.Since()`, `time.Tick`, or `rand` package-level functions** (§49.2). `time` is used only for the `time.Duration` type. Enforced by a test in Task 9. +- The game RNG is a `*rand.Rand` owned by `Game`, seeded from `Seed`, and used for **the 7-bag and nothing else** (§49.6). The FX RNG (plan 3) is separate and must never be crossed with it. +- Board geometry is fixed: `Width = 10`, `Height = 22`, `HiddenRows = 2`, `VisibleRows = 20` (§5). Row 0 is the top; rows 0 and 1 are the hidden spawn rows; rows 2–21 are visible. +- Level 1 gravity interval is `800ms`, scaling `800ms * 0.86^(level-1)`, clamped to a floor of `60ms` (§11). +- Lock delay `500ms`; max lock resets `15` (§12). +- Line values `100 / 300 / 500 / 800`, each `× level` (§13). Combo bonus `50 × (combo - 1) × level` (§49.1). Soft drop `+1/cell`, hard drop `+2/cell` (§11). +- Level increases every 10 cleared lines (§11). +- No networking, no filesystem access, no goroutines, no logging in this package. +- Every task ends with `go test ./...` passing and `gofmt -l .` printing nothing. + +## Review Focus + +These are input classes the spec implies but does not spell out. Each one has a test added to the task that owns the code — listed here so a reviewer can check them in one place. + +1. **A `dt` far larger than one gravity interval** (terminal was suspended, or the frame loop stalled): gravity must step the piece down repeatedly, checking collision at every step, and must terminate. A single subtract-and-drop would teleport a piece through the stack. → Task 6. +2. **Zero, negative, or absurd `dt`**: `Advance(0)` must be a no-op returning no events; a negative `dt` must not run the accumulators backwards. → Task 6. +3. **Hold when the incoming piece cannot spawn** (stack reaches the spawn rows): must end the game the same way a blocked natural spawn does, not panic or leave a piece overlapping locked cells. → Task 7. +4. **Input after game over**: every action and `Advance` must be inert once `Over` is true — no movement, no scoring, no further `GameOver` events. → Task 8. +5. **A piece that locks entirely inside the hidden rows** and a completed row inside the hidden rows: locking above row 2 must still commit cells, and row completion must scan all 22 rows, not just the visible 20. → Task 5. + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `go.mod` | Module `cosmic-tetris`, `go 1.26`. | +| `LICENSE` | MIT, copyright Jesse Vincent. | +| `internal/game/piece.go` | `PieceKind`, `Piece`, the four literal rotation tables per kind, `Cells()`. | +| `internal/game/board.go` | `Board` array type, bounds/occupancy queries, collision, row completion, row clearing and collapse. | +| `internal/game/bag.go` | 7-bag generator over an injected `*rand.Rand`. | +| `internal/game/rules.go` | Tunable constants: spawn offset, kick offset table, lock delay, max resets, gravity interval. | +| `internal/game/scoring.go` | Pure scoring functions: line values, combo bonus, level from lines. | +| `internal/game/events.go` | The `Event` interface and every concrete event struct. | +| `internal/game/game.go` | `Game` state, `New`, `Input`, `Advance`, `GhostY`, spawn/lock/clear sequencing. | +| `internal/game/*_test.go` | Tests, one file per source file above. | + +`events.go` is one file beyond §33's list for this package. It is genuinely necessary: §36 references the type `game.Event` from the app layer, so events are part of `internal/game`'s public surface, and there are ten of them — putting them in `game.go` would bury the state machine. §33's `rules.go` and `scoring.go` are kept as listed. + +--- + +### Task 1: Module bootstrap + +**Files:** +- Create: `go.mod`, `LICENSE`, `.gitignore` + +**Interfaces:** +- Consumes: nothing. +- Produces: a buildable module rooted at `cosmic-tetris`, so all later packages are `cosmic-tetris/internal/...`. + +- [ ] **Step 1: Initialize the module** + +```bash +go mod init cosmic-tetris +``` + +Confirm `go.mod` says `go 1.26` (edit the directive if `go mod init` wrote something older). + +- [ ] **Step 2: Add `LICENSE`** + +MIT license text, `Copyright (c) 2026 Jesse Vincent`. + +- [ ] **Step 3: Add `.gitignore`** + +``` +/cosmic-tetris +/dist/ +``` + +- [ ] **Step 4: Verify the module builds** + +Run: `go build ./... && go vet ./...` +Expected: no output, exit 0. + +- [ ] **Step 5: Commit** + +```bash +git add go.mod LICENSE .gitignore +git commit -m "chore: initialize cosmic-tetris go module" +``` + +--- + +### Task 2: Pieces and rotation tables + +**Files:** +- Create: `internal/game/piece.go` +- Test: `internal/game/piece_test.go` + +**Interfaces:** +- Consumes: nothing. +- Produces: + ```go + type PieceKind uint8 + const ( + Empty PieceKind = iota + I; J; L; O; S; T; Z + ) + func (k PieceKind) String() string // "I", "J", ... ; Empty -> "." + var AllKinds = [7]PieceKind{I, J, L, O, S, T, Z} + + type Piece struct { + Kind PieceKind + Rotation int // 0..3 + X, Y int // top-left of the piece's 4x4 box, in board coordinates + } + + // Cells returns the four occupied board coordinates of p, in row-major + // order of the rotation table. Panics if p.Kind is Empty. + func (p Piece) Cells() [4][2]int + ``` + +The rotation data is the one thing tests cannot derive, so it is pinned here verbatim. Each entry is a 4×4 mask, `X` occupied, `.` empty, row 0 first. Every kind has exactly four literal rotations (§6); `O` repeats. + +- [ ] **Step 1: Write `internal/game/piece.go` with the pinned rotation tables** + +```go +// shapes[kind][rotation][row] is a 4-character mask, 'X' occupied. +var shapes = map[PieceKind][4][4]string{ + I: { + {"....", "XXXX", "....", "...."}, + {"..X.", "..X.", "..X.", "..X."}, + {"....", "....", "XXXX", "...."}, + {".X..", ".X..", ".X..", ".X.."}, + }, + J: { + {"X...", "XXX.", "....", "...."}, + {"XX..", "X...", "X...", "...."}, + {"....", "XXX.", "..X.", "...."}, + {".X..", ".X..", "XX..", "...."}, + }, + L: { + {"..X.", "XXX.", "....", "...."}, + {"X...", "X...", "XX..", "...."}, + {"....", "XXX.", "X...", "...."}, + {"XX..", ".X..", ".X..", "...."}, + }, + O: { + {".XX.", ".XX.", "....", "...."}, + {".XX.", ".XX.", "....", "...."}, + {".XX.", ".XX.", "....", "...."}, + {".XX.", ".XX.", "....", "...."}, + }, + S: { + {".XX.", "XX..", "....", "...."}, + {".X..", ".XX.", "..X.", "...."}, + {"....", ".XX.", "XX..", "...."}, + {"X...", "XX..", ".X..", "...."}, + }, + T: { + {".X..", "XXX.", "....", "...."}, + {".X..", ".XX.", ".X..", "...."}, + {"....", "XXX.", ".X..", "...."}, + {".X..", "XX..", ".X..", "...."}, + }, + Z: { + {"XX..", ".XX.", "....", "...."}, + {"..X.", ".XX.", ".X..", "...."}, + {"....", "XX..", ".XX.", "...."}, + {".X..", "XX..", "X...", "...."}, + }, +} +``` + +Implement `Cells()` by scanning the mask and offsetting by `p.X, p.Y`. Normalize `Rotation` with `((r % 4) + 4) % 4` so a negative rotation from a counter-clockwise turn is safe. + +- [ ] **Step 2: Write the failing tests** + +```go +func TestEveryRotationHasFourCells(t *testing.T) { + for _, k := range AllKinds { + for r := 0; r < 4; r++ { + p := Piece{Kind: k, Rotation: r} + var n int + for _, row := range shapes[k][r] { + n += strings.Count(row, "X") + } + if n != 4 { + t.Errorf("%v rotation %d has %d cells, want 4", k, r, n) + } + if got := len(p.Cells()); got != 4 { + t.Errorf("%v rotation %d Cells() len %d, want 4", k, r, got) + } + } + } +} + +func TestRotationTablesAreFourCharacterRows(t *testing.T) { + for _, k := range AllKinds { + for r := 0; r < 4; r++ { + for i, row := range shapes[k][r] { + if len(row) != 4 { + t.Errorf("%v rot %d row %d = %q, want 4 chars", k, r, i, row) + } + } + } + } +} + +func TestCellsOffsetByPosition(t *testing.T) { + // T rotation 0 is ".X.." / "XXX." -> (1,0) (0,1) (1,1) (2,1) + p := Piece{Kind: T, Rotation: 0, X: 3, Y: 5} + want := [4][2]int{{4, 5}, {3, 6}, {4, 6}, {5, 6}} + if got := p.Cells(); got != want { + t.Errorf("Cells() = %v, want %v", got, want) + } +} + +func TestOIsIdenticalThroughRotation(t *testing.T) { + base := Piece{Kind: O, Rotation: 0, X: 3, Y: 0}.Cells() + for r := 1; r < 4; r++ { + if got := (Piece{Kind: O, Rotation: r, X: 3, Y: 0}).Cells(); got != base { + t.Errorf("O rotation %d = %v, want %v", r, got, base) + } + } +} + +func TestNegativeRotationNormalizes(t *testing.T) { + got := Piece{Kind: T, Rotation: -1, X: 0, Y: 0}.Cells() + want := Piece{Kind: T, Rotation: 3, X: 0, Y: 0}.Cells() + if got != want { + t.Errorf("rotation -1 = %v, want same as rotation 3 %v", got, want) + } +} + +func TestKindString(t *testing.T) { + if got := I.String(); got != "I" { + t.Errorf("I.String() = %q", got) + } + if got := Empty.String(); got != "." { + t.Errorf("Empty.String() = %q", got) + } +} +``` + +- [ ] **Step 3: Run the tests** + +Run: `go test ./internal/game/ -run 'Rotation|Cells|OIsIdentical|KindString' -v` +Expected: PASS (the implementation and the tables were written together; if a table has a typo, the four-cells test names the kind and rotation). + +- [ ] **Step 4: Commit** + +```bash +git add internal/game/piece.go internal/game/piece_test.go +git commit -m "feat(game): tetromino kinds and pinned rotation tables" +``` + +--- + +### Task 3: Board + +**Files:** +- Create: `internal/game/board.go` +- Test: `internal/game/board_test.go` + +**Interfaces:** +- Consumes: `PieceKind`, `Piece` (Task 2). +- Produces: + ```go + const ( + Width = 10 + Height = 22 + HiddenRows = 2 + VisibleRows = Height - HiddenRows // 20 + ) + + // Board is a value type: assignment copies it. Row 0 is the top. + type Board [Height][Width]PieceKind + + func (b Board) At(x, y int) PieceKind // Empty for out-of-range y < 0 + func (b Board) Occupied(x, y int) bool // out of bounds counts as occupied except above the top + func (b *Board) Set(x, y int, k PieceKind) // no-op if out of range + func (b Board) Collides(p Piece) bool // any cell out of bounds or on a filled cell + func (b *Board) Commit(p Piece) // write p's cells as p.Kind + func (b Board) CompleteRows() []int // ascending row indices, all 22 rows scanned + func (b *Board) ClearRows(rows []int) // remove rows, collapse everything above down + ``` + +Pin the out-of-bounds rule: `x < 0`, `x >= Width`, and `y >= Height` collide. `y < 0` does **not** collide — a piece's 4×4 box may hang above the board during a kick, and only its filled cells matter, all of which are inside the box. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestCollidesWithWalls(t *testing.T) { + var b Board + if !b.Collides(Piece{Kind: T, X: -1, Y: 5}) { // T rot0 fills col 0 of its box + t.Error("piece off the left edge should collide") + } + if !b.Collides(Piece{Kind: T, X: Width - 2, Y: 5}) { + t.Error("piece off the right edge should collide") + } + if !b.Collides(Piece{Kind: T, X: 3, Y: Height - 1}) { + t.Error("piece below the floor should collide") + } + if b.Collides(Piece{Kind: T, X: 3, Y: 0}) { + t.Error("piece inside an empty board should not collide") + } +} + +func TestCollidesWithLockedCells(t *testing.T) { + var b Board + b.Set(4, 10, I) + if !b.Collides(Piece{Kind: O, Rotation: 0, X: 3, Y: 9}) { // O fills (4,9),(5,9),(4,10),(5,10) + t.Error("overlapping a locked cell should collide") + } + if b.Collides(Piece{Kind: O, Rotation: 0, X: 6, Y: 9}) { + t.Error("clear of the locked cell should not collide") + } +} + +func TestCommitWritesKind(t *testing.T) { + var b Board + p := Piece{Kind: Z, Rotation: 0, X: 3, Y: 4} + b.Commit(p) + for _, c := range p.Cells() { + if got := b.At(c[0], c[1]); got != Z { + t.Errorf("At(%d,%d) = %v, want Z", c[0], c[1], got) + } + } +} + +func TestCompleteRowsScansHiddenRows(t *testing.T) { + var b Board + for x := 0; x < Width; x++ { + b.Set(x, 1, I) // hidden row + b.Set(x, 21, I) // bottom row + } + b.Set(0, 10, I) // partial row, must not be reported + got := b.CompleteRows() + want := []int{1, 21} + if !reflect.DeepEqual(got, want) { + t.Errorf("CompleteRows() = %v, want %v", got, want) + } +} + +func TestClearRowsCollapsesFromAbove(t *testing.T) { + var b Board + b.Set(0, 19, T) // a marker that must fall two rows + for x := 0; x < Width; x++ { + b.Set(x, 20, I) + b.Set(x, 21, J) + } + b.ClearRows([]int{20, 21}) + if got := b.At(0, 21); got != T { + t.Errorf("marker should have fallen to row 21, At(0,21) = %v", got) + } + if got := b.At(0, 19); got != Empty { + t.Errorf("old marker position should be empty, got %v", got) + } + if rows := b.CompleteRows(); len(rows) != 0 { + t.Errorf("no complete rows should remain, got %v", rows) + } +} + +func TestClearRowsHandlesNonAdjacentRows(t *testing.T) { + var b Board + for x := 0; x < Width; x++ { + b.Set(x, 18, I) + b.Set(x, 20, I) + } + b.Set(0, 19, T) + b.Set(1, 21, Z) + b.ClearRows([]int{18, 20}) + if got := b.At(0, 20); got != T { + t.Errorf("T should land on row 20, got %v", got) + } + if got := b.At(1, 21); got != Z { + t.Errorf("row 21 should be untouched, got %v", got) + } +} + +func TestBoardIsCopiedByAssignment(t *testing.T) { + var b Board + b.Set(0, 0, I) + snapshot := b + b.Set(0, 0, Empty) + if snapshot.At(0, 0) != I { + t.Error("Board assignment must copy, not alias") + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/game/ -run Board -v` (plus `-run 'Collides|Commit|CompleteRows|ClearRows'`) +Expected: FAIL — `undefined: Board`. + +- [ ] **Step 3: Implement `internal/game/board.go`** + +Implement the interface above. `ClearRows` is the only non-obvious one: copy rows bottom-up into a write cursor, skipping cleared rows, then zero the remaining rows at the top. Do not assume the input rows are adjacent or sorted — sort a local copy. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/board.go internal/game/board_test.go +git commit -m "feat(game): board collision, row completion and collapse" +``` + +--- + +### Task 4: 7-bag and rules constants + +**Files:** +- Create: `internal/game/bag.go`, `internal/game/rules.go` +- Test: `internal/game/bag_test.go`, `internal/game/rules_test.go` + +**Interfaces:** +- Consumes: `PieceKind`, `AllKinds` (Task 2). +- Produces: + ```go + // Bag is a 7-bag: it yields a shuffled permutation of all seven kinds, + // refilling when empty. It never allocates after construction. + type Bag struct { /* queue [7]PieceKind; pos int */ } + func NewBag(rng *rand.Rand) Bag + func (b *Bag) Next(rng *rand.Rand) PieceKind + + // rules.go + const ( + SpawnX = 3 + SpawnY = 0 + LockDelay = 500 * time.Millisecond + MaxLockResets = 15 + BaseGravity = 800 * time.Millisecond + MinGravity = 60 * time.Millisecond + GravityDecay = 0.86 + ) + // KickOffsets is tried in order when rotating; the first valid one wins (§7). + var KickOffsets = [8][2]int{{0, 0}, {-1, 0}, {1, 0}, {-2, 0}, {2, 0}, {0, -1}, {-1, -1}, {1, -1}} + func GravityInterval(level int) time.Duration + ``` + +`Bag.Next` takes the RNG as a parameter rather than holding it, so `Game` remains the single owner of the generator (§49.6). + +- [ ] **Step 1: Write the failing tests** + +```go +func TestEachBagContainsAllSevenKindsExactlyOnce(t *testing.T) { + rng := rand.New(rand.NewSource(42)) + b := NewBag(rng) + for round := 0; round < 5; round++ { + seen := map[PieceKind]int{} + for i := 0; i < 7; i++ { + seen[b.Next(rng)]++ + } + for _, k := range AllKinds { + if seen[k] != 1 { + t.Fatalf("round %d: kind %v appeared %d times, want 1", round, k, seen[k]) + } + } + } +} + +func TestSeededBagIsReproducible(t *testing.T) { + draw := func() []PieceKind { + rng := rand.New(rand.NewSource(8675309)) + b := NewBag(rng) + out := make([]PieceKind, 0, 21) + for i := 0; i < 21; i++ { + out = append(out, b.Next(rng)) + } + return out + } + if a, b := draw(), draw(); !reflect.DeepEqual(a, b) { + t.Errorf("same seed produced different sequences:\n%v\n%v", a, b) + } +} + +func TestDifferentSeedsDiffer(t *testing.T) { + draw := func(seed int64) []PieceKind { + rng := rand.New(rand.NewSource(seed)) + b := NewBag(rng) + out := make([]PieceKind, 0, 14) + for i := 0; i < 14; i++ { + out = append(out, b.Next(rng)) + } + return out + } + if reflect.DeepEqual(draw(1), draw(2)) { + t.Error("seeds 1 and 2 produced identical 14-piece sequences") + } +} + +func TestGravityIntervalDecaysAndClamps(t *testing.T) { + if got := GravityInterval(1); got != BaseGravity { + t.Errorf("level 1 = %v, want %v", got, BaseGravity) + } + if a, b := GravityInterval(5), GravityInterval(6); !(a > b) { + t.Errorf("interval must shrink with level: level5=%v level6=%v", a, b) + } + if got := GravityInterval(99); got != MinGravity { + t.Errorf("level 99 = %v, want clamp at %v", got, MinGravity) + } + if got := GravityInterval(0); got != BaseGravity { + t.Errorf("level 0 should be treated as level 1, got %v", got) + } + // 800ms * 0.86^2 = 591.68ms + if got := GravityInterval(3); got < 591*time.Millisecond || got > 592*time.Millisecond { + t.Errorf("level 3 = %v, want ~591.7ms", got) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/game/ -run 'Bag|Gravity|Seed' -v` +Expected: FAIL — `undefined: NewBag`, `undefined: GravityInterval`. + +- [ ] **Step 3: Implement `bag.go` and `rules.go`** + +`NewBag` fills the queue with `AllKinds` and shuffles with `rng.Shuffle`; `Next` returns the piece at `pos`, refilling and reshuffling when `pos` reaches 7. `GravityInterval` uses `math.Pow(GravityDecay, float64(level-1))` with `level` clamped to a minimum of 1, then clamps the result to `MinGravity`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/bag.go internal/game/rules.go internal/game/bag_test.go internal/game/rules_test.go +git commit -m "feat(game): 7-bag generator and gravity rules" +``` + +--- + +### Task 5: Scoring and events + +**Files:** +- Create: `internal/game/scoring.go`, `internal/game/events.go` +- Test: `internal/game/scoring_test.go` + +**Interfaces:** +- Consumes: `Piece`, `PieceKind`, `Board` (Tasks 2–3). +- Produces: + ```go + // scoring.go + func LineScore(count, level int) int // count 0..4; 0 for count<=0 or >4 + func ComboBonus(combo, level int) int // 50*(combo-1)*level, 0 when combo<2 + func LevelFor(lines int) int // 1 + lines/10 + const ( + SoftDropPoints = 1 // per cell + HardDropPoints = 2 // per cell + ) + + // events.go + type Event interface{ isGameEvent() } + + type PieceSpawned struct { gameEvent; Piece Piece } + type PieceMoved struct { gameEvent; Piece Piece; DX, DY int } // DY>0 is downward + type PieceRotated struct { gameEvent; Piece Piece; Dir int } // +1 cw, -1 ccw + type PieceHardDropped struct { gameEvent; Piece Piece; Cells int } // Piece is the landed piece + type PieceLocked struct { gameEvent; Piece Piece } + type HoldUsed struct { gameEvent; Stored, Spawned PieceKind } + type LinesCleared struct { gameEvent; Rows []int; Count int; Before Board } + type ComboChanged struct { gameEvent; Combo int } + type LevelChanged struct { gameEvent; Level int } + type GameOver struct { gameEvent; Score, Lines, Level int } + ``` + +`LinesCleared.Before` is the board **after the piece locked but before the rows were removed**. Plan 3's line-clear animation renders it for 220ms (§19) — without it the renderer cannot show a row that state has already deleted. `Board` is a value type, so this is a 220-byte copy, not an alias. + +`gameEvent` is an unexported empty struct embedded in each event to satisfy `Event`: + +```go +type gameEvent struct{} +func (gameEvent) isGameEvent() {} +``` + +- [ ] **Step 1: Write the failing tests** + +```go +func TestLineScoreValues(t *testing.T) { + cases := []struct{ count, level, want int }{ + {1, 1, 100}, {2, 1, 300}, {3, 1, 500}, {4, 1, 800}, + {1, 7, 700}, {4, 3, 2400}, + {0, 5, 0}, {5, 1, 0}, {-1, 1, 0}, + } + for _, c := range cases { + if got := LineScore(c.count, c.level); got != c.want { + t.Errorf("LineScore(%d,%d) = %d, want %d", c.count, c.level, got, c.want) + } + } +} + +func TestComboBonusStartsAtComboTwo(t *testing.T) { + cases := []struct{ combo, level, want int }{ + {0, 5, 0}, {1, 5, 0}, {2, 1, 50}, {2, 4, 200}, {5, 2, 400}, + } + for _, c := range cases { + if got := ComboBonus(c.combo, c.level); got != c.want { + t.Errorf("ComboBonus(%d,%d) = %d, want %d", c.combo, c.level, got, c.want) + } + } +} + +func TestLevelFor(t *testing.T) { + cases := []struct{ lines, want int }{{0, 1}, {9, 1}, {10, 2}, {19, 2}, {20, 3}, {127, 13}} + for _, c := range cases { + if got := LevelFor(c.lines); got != c.want { + t.Errorf("LevelFor(%d) = %d, want %d", c.lines, got, c.want) + } + } +} + +func TestEventsSatisfyEventInterface(t *testing.T) { + evts := []Event{ + PieceSpawned{}, PieceMoved{}, PieceRotated{}, PieceHardDropped{}, + PieceLocked{}, HoldUsed{}, LinesCleared{}, ComboChanged{}, + LevelChanged{}, GameOver{}, + } + if len(evts) != 10 { + t.Fatalf("expected 10 event types, got %d", len(evts)) + } +} + +// Review Focus 5: completion inside the hidden rows. +func TestCompleteRowsInHiddenRegionAreScored(t *testing.T) { + var b Board + for x := 0; x < Width; x++ { + b.Set(x, 0, I) + } + if rows := b.CompleteRows(); !reflect.DeepEqual(rows, []int{0}) { + t.Errorf("row 0 (hidden) should be complete, got %v", rows) + } + if got := LineScore(1, 1); got != 100 { + t.Errorf("a hidden-row clear still scores: %d", got) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/game/ -run 'LineScore|Combo|LevelFor|Event|Hidden' -v` +Expected: FAIL — `undefined: LineScore`. + +- [ ] **Step 3: Implement `scoring.go` and `events.go`** + +`LineScore` uses a `[5]int{0, 100, 300, 500, 800}` table with a range guard. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/scoring.go internal/game/events.go internal/game/scoring_test.go +git commit -m "feat(game): scoring functions and event types" +``` + +--- + +### Task 6: Game state, spawn, movement, rotation, gravity + +**Files:** +- Create: `internal/game/game.go` +- Test: `internal/game/game_test.go` + +**Interfaces:** +- Consumes: everything from Tasks 2–5. +- Produces: + ```go + const NextQueueLen = 5 // §6: enough future pieces to render the next five + + type Action int + const ( + ActionLeft Action = iota + ActionRight + ActionSoftDrop + ActionHardDrop + ActionRotateCW + ActionRotateCCW + ActionHold + ) + + type Game struct { + Board Board + Active Piece + Hold *PieceKind + CanHold bool + + Next []PieceKind // always NextQueueLen long + Bag Bag + + Score int + Lines int + Level int + Combo int + + GravityAccumulator time.Duration + LockAccumulator time.Duration + LockResets int + Grounded bool + Over bool + + Seed int64 + rng *rand.Rand + events []Event // reused buffer, drained by Input/Advance + } + + func New(seed int64) *Game + func (g *Game) Input(a Action) []Event + func (g *Game) Advance(dt time.Duration) []Event + // GhostY returns the Y the active piece would land at from its current + // position (§10). Equal to Active.Y when the piece is grounded. + func (g *Game) GhostY() int + ``` + +Pinned semantics — the tests below depend on all of these: + +- `New(seed)` sets `Level = 1`, `CanHold = true`, fills `Next` to `NextQueueLen`, and spawns the first piece. The `PieceSpawned` event for the first piece is **not** returned to anyone (nothing has called in yet); it is left in `g.events` and drained by the caller's first `Input`/`Advance`. +- Spawn position is always `SpawnX, SpawnY` at rotation 0 (§6, §9). If the spawned piece collides there, `Over = true` and a `GameOver` event is emitted (§12, §40). +- Movement/rotation while `Grounded` resets `LockAccumulator` to 0 and increments `LockResets`, but only while `LockResets < MaxLockResets`; past that the timer keeps running (§12). +- Becoming ungrounded (e.g. moving off a ledge) sets `LockAccumulator = 0` without consuming a reset. `LockResets` returns to 0 only on spawn. +- Soft drop moves one cell if possible, scores `SoftDropPoints`, and zeroes `GravityAccumulator`. If it cannot move it does nothing and scores nothing. +- Rotation tries `KickOffsets` in order and takes the first position with no collision; if none is valid the rotation fails silently and emits no event (§7). +- `Advance(dt)`: if `Over` or `dt <= 0`, return no events. Otherwise add `dt` to `GravityAccumulator` and **loop** `for g.GravityAccumulator >= GravityInterval(g.Level)`, subtracting and dropping one cell per iteration, so a large `dt` steps rather than teleports (Review Focus 1). When a step cannot descend, set `Grounded` and stop consuming gravity. Then run the lock timer. + +- [ ] **Step 1: Write the failing tests for spawn, movement and rotation** + +```go +func TestNewGameSpawnsCenteredPieceAtLevelOne(t *testing.T) { + g := New(1) + if g.Level != 1 || g.Score != 0 || g.Lines != 0 || g.Combo != 0 { + t.Errorf("fresh game = level %d score %d lines %d combo %d", g.Level, g.Score, g.Lines, g.Combo) + } + if g.Active.X != SpawnX || g.Active.Y != SpawnY || g.Active.Rotation != 0 { + t.Errorf("spawn at (%d,%d) rot %d, want (%d,%d) rot 0", g.Active.X, g.Active.Y, g.Active.Rotation, SpawnX, SpawnY) + } + if len(g.Next) != NextQueueLen { + t.Errorf("Next has %d entries, want %d", len(g.Next), NextQueueLen) + } + if !g.CanHold { + t.Error("CanHold should start true") + } + for _, c := range g.Active.Cells() { + if c[1] >= HiddenRows { + t.Errorf("spawned piece cell %v is not in the hidden rows", c) + } + } +} + +func TestMoveLeftRightAndWalls(t *testing.T) { + g := New(1) + startX := g.Active.X + if evts := g.Input(ActionLeft); !hasEvent[PieceMoved](evts) { + t.Error("ActionLeft should emit PieceMoved") + } + if g.Active.X != startX-1 { + t.Errorf("X = %d, want %d", g.Active.X, startX-1) + } + for i := 0; i < 20; i++ { + g.Input(ActionLeft) + } + blockedX := g.Active.X + if evts := g.Input(ActionLeft); len(evts) != 0 { + t.Errorf("blocked move should emit nothing, got %#v", evts) + } + if g.Active.X != blockedX { + t.Error("piece moved through the left wall") + } +} + +func TestRotationEmitsEventAndFailsWhenBlocked(t *testing.T) { + g := New(1) + g.Active = Piece{Kind: T, Rotation: 0, X: 3, Y: 10} + if evts := g.Input(ActionRotateCW); !hasEvent[PieceRotated](evts) { + t.Error("free rotation should emit PieceRotated") + } + if g.Active.Rotation != 1 { + t.Errorf("rotation = %d, want 1", g.Active.Rotation) + } + + // Box the piece in on all sides so no kick offset can succeed. + g2 := New(1) + g2.Active = Piece{Kind: I, Rotation: 0, X: 3, Y: 10} + for y := 8; y <= 13; y++ { + for x := 0; x < Width; x++ { + if y == 10 && x >= 3 && x <= 6 { + continue + } + g2.Board.Set(x, y, J) + } + } + before := g2.Active + if evts := g2.Input(ActionRotateCW); len(evts) != 0 { + t.Errorf("blocked rotation should emit nothing, got %#v", evts) + } + if g2.Active != before { + t.Errorf("failed rotation must not move the piece: %+v -> %+v", before, g2.Active) + } +} + +func TestRotationWallKicksOffTheLeftEdge(t *testing.T) { + g := New(1) + // I at rotation 1 is a vertical bar in column X+2; put it hard against + // the left wall so rotating to 0 (cols X..X+3) needs a rightward kick. + g.Active = Piece{Kind: I, Rotation: 1, X: -2, Y: 10} + if evts := g.Input(ActionRotateCW); !hasEvent[PieceRotated](evts) { + t.Fatal("rotation against the wall should succeed via a kick") + } + for _, c := range g.Active.Cells() { + if c[0] < 0 || c[0] >= Width { + t.Errorf("kicked piece has out-of-bounds cell %v", c) + } + } +} + +func TestCounterClockwiseRotation(t *testing.T) { + g := New(1) + g.Active = Piece{Kind: T, Rotation: 0, X: 3, Y: 10} + g.Input(ActionRotateCCW) + if g.Active.Rotation != 3 { + t.Errorf("ccw from 0 = %d, want 3", g.Active.Rotation) + } +} +``` + +Add this test helper to `game_test.go` (used by many later tests): + +```go +func hasEvent[E Event](evts []Event) bool { + for _, e := range evts { + if _, ok := e.(E); ok { + return true + } + } + return false +} + +func findEvent[E Event](evts []Event) (E, bool) { + for _, e := range evts { + if v, ok := e.(E); ok { + return v, true + } + } + var zero E + return zero, false +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/game/ -run 'NewGame|Move|Rotation' -v` +Expected: FAIL — `undefined: New`. + +- [ ] **Step 3: Implement `game.go` up to movement and rotation** + +Write `New`, `Input` handling for `ActionLeft`/`ActionRight`/`ActionRotateCW`/`ActionRotateCCW`, plus internal helpers: `spawn()`, `emit(Event)`, `drain() []Event`, `tryMove(dx, dy int) bool`, `tryRotate(dir int) bool`, `refillNext()`, `updateGrounded()`, `resetLockTimer()`. Leave gravity, drops, hold and locking as stubs for the next steps. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/game/ -run 'NewGame|Move|Rotation' -v` +Expected: PASS. + +- [ ] **Step 5: Write the failing gravity tests** + +```go +func TestGravityDropsOneCellPerInterval(t *testing.T) { + g := New(1) + y := g.Active.Y + g.Advance(GravityInterval(1) - time.Millisecond) + if g.Active.Y != y { + t.Error("piece dropped before the interval elapsed") + } + evts := g.Advance(2 * time.Millisecond) + if g.Active.Y != y+1 { + t.Errorf("Y = %d, want %d after one interval", g.Active.Y, y+1) + } + if !hasEvent[PieceMoved](evts) { + t.Error("gravity step should emit PieceMoved") + } +} + +// Review Focus 1: a huge dt must step, not teleport. +func TestLargeDtStepsThroughTheStack(t *testing.T) { + g := New(1) + for x := 0; x < Width; x++ { + g.Board.Set(x, 15, J) // a floor at row 15 + } + g.Advance(30 * time.Second) + for _, c := range g.Active.Cells() { + if g.Board.At(c[0], c[1]) != Empty { + t.Fatalf("active piece cell %v overlaps a locked cell: gravity teleported through the floor", c) + } + if c[1] > 14 { + t.Fatalf("active piece cell %v is at or below the floor at row 15", c) + } + } +} + +// Review Focus 2: degenerate dt. +func TestZeroAndNegativeDtAreNoOps(t *testing.T) { + g := New(1) + before := *g + if evts := g.Advance(0); len(evts) != 0 { + t.Errorf("Advance(0) emitted %#v", evts) + } + if evts := g.Advance(-5 * time.Second); len(evts) != 0 { + t.Errorf("Advance(negative) emitted %#v", evts) + } + if g.Active != before.Active || g.GravityAccumulator != before.GravityAccumulator { + t.Error("degenerate dt changed engine state") + } +} + +func TestGravityAcceleratesWithLevel(t *testing.T) { + g := New(1) + g.Level = 10 + y := g.Active.Y + g.Advance(GravityInterval(10)) + if g.Active.Y != y+1 { + t.Errorf("level 10 piece should drop after %v", GravityInterval(10)) + } +} + +func TestGhostYIsTheLandingRow(t *testing.T) { + g := New(1) + for x := 0; x < Width; x++ { + g.Board.Set(x, 18, J) + } + ghost := g.GhostY() + probe := g.Active + probe.Y = ghost + if g.Board.Collides(probe) { + t.Errorf("GhostY %d collides", ghost) + } + probe.Y = ghost + 1 + if !g.Board.Collides(probe) { + t.Errorf("GhostY %d is not the lowest valid row", ghost) + } + if g.GhostY() < g.Active.Y { + t.Error("GhostY must never be above the active piece") + } +} +``` + +- [ ] **Step 6: Run the gravity tests to verify they fail** + +Run: `go test ./internal/game/ -run 'Gravity|LargeDt|ZeroAndNegative|GhostY' -v` +Expected: FAIL — gravity is still a stub. + +- [ ] **Step 7: Implement gravity and `GhostY`** + +`Advance` guards `Over` and `dt <= 0`, accumulates, then loops one cell per elapsed interval as pinned above. `GhostY` copies `Active`, walks `Y` down while the copy does not collide, returns the last non-colliding `Y`. + +- [ ] **Step 8: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 9: Commit** + +```bash +git add internal/game/game.go internal/game/game_test.go +git commit -m "feat(game): game state, movement, wall kicks, gravity and ghost" +``` + +--- + +### Task 7: Locking, line clearing, scoring integration, hold + +**Files:** +- Modify: `internal/game/game.go` +- Test: `internal/game/lock_test.go`, `internal/game/hold_test.go` + +**Interfaces:** +- Consumes: Task 6's `Game`. +- Produces: `ActionSoftDrop`, `ActionHardDrop`, `ActionHold` behavior, and the post-lock sequence. No new exported names. + +Pinned lock sequence (§12), in this exact order, all inside one `Advance`/`Input` call: + +1. commit the piece to the board, emit `PieceLocked` +2. `Before := g.Board` (post-commit, pre-clear), `rows := g.Board.CompleteRows()` +3. if `len(rows) > 0`: `g.Board.ClearRows(rows)`, `g.Lines += len(rows)`, emit `LinesCleared{Rows: rows, Count: len(rows), Before: Before}` +4. update combo: clearing increments `g.Combo` (first clear → 1), a non-clearing lock sets it to 0; emit `ComboChanged` only when the value changed (§49.1) +5. `g.Score += LineScore(len(rows), g.Level) + ComboBonus(g.Combo, g.Level)` — scored at the level **before** any level-up this placement causes +6. if `LevelFor(g.Lines) != g.Level`: set it and emit `LevelChanged` +7. `g.CanHold = true`, then spawn the next piece (emitting `PieceSpawned`, or `GameOver` if blocked) + +Hard drop: fall to `GhostY()`, add `HardDropPoints × cells`, emit `PieceHardDropped{Piece: landed, Cells: n}`, then run the lock sequence **immediately** — no lock delay. + +Hold (§9): only when `CanHold`. If `Hold` is nil, store `Active.Kind` and spawn from the queue; otherwise swap `Active.Kind` with `*Hold` and respawn at `SpawnX, SpawnY` rotation 0. Set `CanHold = false` and emit `HoldUsed{Stored, Spawned}`. If the incoming piece collides at the spawn position, the game ends exactly as a blocked spawn does (Review Focus 3). + +- [ ] **Step 1: Write the failing lock and clear tests** + +```go +func groundedGame(t *testing.T) *Game { + t.Helper() + g := New(1) + g.Active.Y = g.GhostY() + g.Advance(time.Millisecond) // let the engine notice it is grounded + if !g.Grounded { + t.Fatal("setup: piece should be grounded") + } + return g +} + +func TestLockHappensAfterLockDelay(t *testing.T) { + g := groundedGame(t) + if evts := g.Advance(LockDelay - 2*time.Millisecond); hasEvent[PieceLocked](evts) { + t.Error("locked before the lock delay elapsed") + } + evts := g.Advance(5 * time.Millisecond) + if !hasEvent[PieceLocked](evts) { + t.Fatal("piece should lock after the lock delay") + } + if !hasEvent[PieceSpawned](evts) { + t.Error("a new piece should spawn in the same call") + } +} + +func TestMovementWhileGroundedResetsLockTimer(t *testing.T) { + g := groundedGame(t) + g.Advance(400 * time.Millisecond) + g.Input(ActionLeft) + if g.LockAccumulator != 0 { + t.Errorf("LockAccumulator = %v, want 0 after a grounded move", g.LockAccumulator) + } + if g.LockResets != 1 { + t.Errorf("LockResets = %d, want 1", g.LockResets) + } + if evts := g.Advance(200 * time.Millisecond); hasEvent[PieceLocked](evts) { + t.Error("timer reset should have prevented the lock") + } +} + +func TestLockResetsAreCapped(t *testing.T) { + g := groundedGame(t) + for i := 0; i < MaxLockResets+5; i++ { + g.Advance(10 * time.Millisecond) + g.Input(ActionLeft) + g.Input(ActionRight) + } + if g.LockResets > MaxLockResets { + t.Errorf("LockResets = %d, want <= %d", g.LockResets, MaxLockResets) + } + var locked bool + for i := 0; i < 200; i++ { + g.Advance(10 * time.Millisecond) + if evts := g.Input(ActionLeft); hasEvent[PieceLocked](evts) { + locked = true + break + } + if g.LockAccumulator >= LockDelay { + locked = true + break + } + } + if !locked { + t.Error("a piece must eventually lock even under continuous nudging") + } +} + +func TestSoftDropScoresAndResetsGravity(t *testing.T) { + g := New(1) + y := g.Active.Y + g.Advance(100 * time.Millisecond) + g.Input(ActionSoftDrop) + if g.Active.Y != y+1 { + t.Errorf("Y = %d, want %d", g.Active.Y, y+1) + } + if g.Score != SoftDropPoints { + t.Errorf("Score = %d, want %d", g.Score, SoftDropPoints) + } + if g.GravityAccumulator != 0 { + t.Errorf("GravityAccumulator = %v, want 0", g.GravityAccumulator) + } +} + +func TestSoftDropOnTheFloorScoresNothing(t *testing.T) { + g := groundedGame(t) + before := g.Score + g.Input(ActionSoftDrop) + if g.Score != before { + t.Errorf("Score = %d, want unchanged %d", g.Score, before) + } +} + +func TestHardDropScoresPerCellAndLocksImmediately(t *testing.T) { + g := New(1) + from := g.Active.Y + to := g.GhostY() + evts := g.Input(ActionHardDrop) + hd, ok := findEvent[PieceHardDropped](evts) + if !ok { + t.Fatal("expected PieceHardDropped") + } + if hd.Cells != to-from { + t.Errorf("Cells = %d, want %d", hd.Cells, to-from) + } + if g.Score != HardDropPoints*(to-from) { + t.Errorf("Score = %d, want %d", g.Score, HardDropPoints*(to-from)) + } + if !hasEvent[PieceLocked](evts) { + t.Error("hard drop must lock without waiting for the lock delay") + } +} + +// Review Focus 4 partner test for a grounded hard drop. +func TestHardDropWhileGroundedScoresZeroAndLocks(t *testing.T) { + g := groundedGame(t) + before := g.Score + evts := g.Input(ActionHardDrop) + if g.Score != before { + t.Errorf("Score = %d, want unchanged %d", g.Score, before) + } + if !hasEvent[PieceLocked](evts) { + t.Error("a grounded hard drop should still lock") + } +} + +func TestLineClearScoresAndReportsBeforeBoard(t *testing.T) { + g := New(1) + // Fill row 21 except the two columns the O piece will occupy. + for x := 0; x < Width; x++ { + if x == 4 || x == 5 { + continue + } + g.Board.Set(x, 21, J) + } + g.Active = Piece{Kind: O, Rotation: 0, X: 3, Y: 19} // O fills cols 4,5 + evts := g.Input(ActionHardDrop) + lc, ok := findEvent[LinesCleared](evts) + if !ok { + t.Fatal("expected LinesCleared") + } + if lc.Count != 1 || !reflect.DeepEqual(lc.Rows, []int{21}) { + t.Errorf("LinesCleared = %+v, want 1 row [21]", lc) + } + for x := 0; x < Width; x++ { + if lc.Before.At(x, 21) == Empty { + t.Errorf("Before board should show row 21 full; col %d is empty", x) + } + } + if g.Board.At(0, 21) != Empty { + t.Error("live board should already be cleared") + } + if g.Lines != 1 { + t.Errorf("Lines = %d, want 1", g.Lines) + } + if g.Combo != 1 { + t.Errorf("Combo = %d, want 1 after the first clear", g.Combo) + } + // 100*1 line score + 0 combo bonus + 2 hard drop points per cell. + if g.Score < 100 { + t.Errorf("Score = %d, want at least the 100-point line value", g.Score) + } +} + +func TestComboResetsOnANonClearingPlacement(t *testing.T) { + g := New(1) + g.Combo = 3 + g.Active = Piece{Kind: O, Rotation: 0, X: 3, Y: 0} + evts := g.Input(ActionHardDrop) + if g.Combo != 0 { + t.Errorf("Combo = %d, want 0", g.Combo) + } + if cc, ok := findEvent[ComboChanged](evts); !ok || cc.Combo != 0 { + t.Errorf("expected ComboChanged{0}, got %+v ok=%v", cc, ok) + } +} + +func TestFourLineClearScoresEightHundredTimesLevel(t *testing.T) { + g := New(1) + g.Level = 2 + for y := 18; y <= 21; y++ { + for x := 0; x < Width; x++ { + if x == 4 { + continue + } + g.Board.Set(x, y, J) + } + } + g.Active = Piece{Kind: I, Rotation: 1, X: 2, Y: 18} // vertical I in column 4 + before := g.Score + evts := g.Input(ActionHardDrop) + lc, _ := findEvent[LinesCleared](evts) + if lc.Count != 4 { + t.Fatalf("Count = %d, want 4", lc.Count) + } + if g.Score-before < 1600 { + t.Errorf("score gain = %d, want at least 800*2", g.Score-before) + } +} + +func TestLevelUpEveryTenLines(t *testing.T) { + g := New(1) + g.Lines = 9 + for x := 0; x < Width; x++ { + if x == 4 || x == 5 { + continue + } + g.Board.Set(x, 21, J) + } + g.Active = Piece{Kind: O, Rotation: 0, X: 3, Y: 19} + evts := g.Input(ActionHardDrop) + if g.Level != 2 { + t.Errorf("Level = %d, want 2 at 10 lines", g.Level) + } + if lv, ok := findEvent[LevelChanged](evts); !ok || lv.Level != 2 { + t.Errorf("expected LevelChanged{2}, got %+v ok=%v", lv, ok) + } +} + +// Review Focus 5: a piece that locks entirely in the hidden rows. +func TestLockInHiddenRowsCommitsCells(t *testing.T) { + g := New(1) + for y := 2; y < Height; y++ { + for x := 0; x < Width; x++ { + g.Board.Set(x, y, J) // stack right up to the hidden rows + } + } + g.Active = Piece{Kind: O, Rotation: 0, X: 3, Y: 0} + g.Input(ActionHardDrop) + if g.Board.At(4, 0) == Empty || g.Board.At(4, 1) == Empty { + t.Error("cells locked in the hidden rows must be committed to the board") + } +} +``` + +- [ ] **Step 2: Run the lock tests to verify they fail** + +Run: `go test ./internal/game/ -run 'Lock|SoftDrop|HardDrop|LineClear|Combo|FourLine|LevelUp|Hidden' -v` +Expected: FAIL — drops and locking are stubs. + +- [ ] **Step 3: Implement locking, drops and the post-lock sequence in `game.go`** + +Follow the pinned seven-step sequence exactly, in a private `lockPiece()` used by both the lock-delay path in `Advance` and the hard-drop path in `Input`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Write the failing hold tests** + +```go +func TestFirstHoldStoresActiveAndSpawnsNext(t *testing.T) { + g := New(1) + stored := g.Active.Kind + wantNext := g.Next[0] + evts := g.Input(ActionHold) + if g.Hold == nil || *g.Hold != stored { + t.Errorf("Hold = %v, want %v", g.Hold, stored) + } + if g.Active.Kind != wantNext { + t.Errorf("Active = %v, want the queue head %v", g.Active.Kind, wantNext) + } + if g.CanHold { + t.Error("CanHold should be false after a hold") + } + if hu, ok := findEvent[HoldUsed](evts); !ok || hu.Stored != stored || hu.Spawned != wantNext { + t.Errorf("HoldUsed = %+v ok=%v, want {Stored:%v Spawned:%v}", hu, ok, stored, wantNext) + } + if len(g.Next) != NextQueueLen { + t.Errorf("Next has %d entries, want %d", len(g.Next), NextQueueLen) + } +} + +func TestSecondHoldIsBlockedUntilLock(t *testing.T) { + g := New(1) + g.Input(ActionHold) + held, active := *g.Hold, g.Active.Kind + if evts := g.Input(ActionHold); len(evts) != 0 { + t.Errorf("second hold should emit nothing, got %#v", evts) + } + if *g.Hold != held || g.Active.Kind != active { + t.Error("second hold changed state") + } +} + +func TestHoldIsRestoredAfterLock(t *testing.T) { + g := New(1) + g.Input(ActionHold) + g.Input(ActionHardDrop) + if !g.CanHold { + t.Error("CanHold should be true again after the piece locks") + } +} + +func TestHoldSwapReturnsToSpawnRotation(t *testing.T) { + g := New(1) + g.Input(ActionHold) + g.Input(ActionRotateCW) + g.Input(ActionHardDrop) // clears the hold lock + g.Input(ActionRotateCW) + g.Input(ActionHold) + if g.Active.Rotation != 0 { + t.Errorf("held piece spawned at rotation %d, want 0", g.Active.Rotation) + } + if g.Active.X != SpawnX || g.Active.Y != SpawnY { + t.Errorf("held piece spawned at (%d,%d), want (%d,%d)", g.Active.X, g.Active.Y, SpawnX, SpawnY) + } +} + +// Review Focus 3: holding into a blocked spawn. +func TestHoldIntoABlockedSpawnEndsTheGame(t *testing.T) { + g := New(1) + for y := 0; y < Height; y++ { + for x := 0; x < Width; x++ { + g.Board.Set(x, y, J) + } + } + g.Active = Piece{Kind: T, Rotation: 0, X: SpawnX, Y: SpawnY} + evts := g.Input(ActionHold) + if !g.Over { + t.Error("holding into a fully blocked board should end the game") + } + if !hasEvent[GameOver](evts) { + t.Error("expected a GameOver event") + } +} +``` + +- [ ] **Step 6: Run the hold tests to verify they fail** + +Run: `go test ./internal/game/ -run Hold -v` +Expected: FAIL — hold is a stub. + +- [ ] **Step 7: Implement hold in `game.go`** + +- [ ] **Step 8: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 9: Commit** + +```bash +git add internal/game/game.go internal/game/lock_test.go internal/game/hold_test.go +git commit -m "feat(game): locking, line clears, scoring integration and hold" +``` + +--- + +### Task 8: Game over + +**Files:** +- Modify: `internal/game/game.go` +- Test: `internal/game/gameover_test.go` + +**Interfaces:** +- Consumes: Task 7's lock sequence. +- Produces: `Over bool` semantics — once true, `Input` and `Advance` return `nil` and change nothing. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestBlockedSpawnEndsTheGame(t *testing.T) { + g := New(1) + for y := 0; y < HiddenRows+2; y++ { + for x := 0; x < Width; x++ { + g.Board.Set(x, y, J) + } + } + g.Active = Piece{Kind: O, Rotation: 0, X: SpawnX, Y: 18} + evts := g.Input(ActionHardDrop) + if !g.Over { + t.Fatal("Over should be true when the next piece cannot spawn") + } + go_, ok := findEvent[GameOver](evts) + if !ok { + t.Fatal("expected a GameOver event") + } + if go_.Score != g.Score || go_.Lines != g.Lines || go_.Level != g.Level { + t.Errorf("GameOver = %+v, want score %d lines %d level %d", go_, g.Score, g.Lines, g.Level) + } +} + +// Review Focus 4: everything is inert after game over. +func TestInputAndAdvanceAreInertAfterGameOver(t *testing.T) { + g := New(1) + g.Over = true + before := *g + for _, a := range []Action{ActionLeft, ActionRight, ActionSoftDrop, ActionHardDrop, ActionRotateCW, ActionRotateCCW, ActionHold} { + if evts := g.Input(a); len(evts) != 0 { + t.Errorf("Input(%v) after game over emitted %#v", a, evts) + } + } + if evts := g.Advance(5 * time.Second); len(evts) != 0 { + t.Errorf("Advance after game over emitted %#v", evts) + } + if g.Active != before.Active || g.Score != before.Score || g.Board != before.Board { + t.Error("state changed after game over") + } +} + +func TestGameOverIsEmittedOnlyOnce(t *testing.T) { + g := New(1) + for y := 0; y < Height; y++ { + for x := 0; x < Width; x++ { + g.Board.Set(x, y, J) + } + } + g.Active = Piece{Kind: O, Rotation: 0, X: SpawnX, Y: SpawnY} + first := g.Input(ActionHardDrop) + if !hasEvent[GameOver](first) { + t.Fatal("expected GameOver on the first lock") + } + if evts := g.Advance(time.Second); hasEvent[GameOver](evts) { + t.Error("GameOver emitted a second time") + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/game/ -run 'GameOver|Blocked|Inert' -v` +Expected: FAIL. + +- [ ] **Step 3: Implement the guards** + +Early-return in `Input` and `Advance` when `g.Over`. In `spawn()`, when the new piece collides at the spawn position, set `Over` and emit `GameOver{Score, Lines, Level}`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/game.go internal/game/gameover_test.go +git commit -m "feat(game): game over on blocked spawn, inert engine afterwards" +``` + +--- + +### Task 9: Determinism replay test and clock-free enforcement + +**Files:** +- Create: `internal/game/determinism_test.go`, `internal/game/testdata/replay.golden` +- Create: `internal/game/clockfree_test.go` + +**Interfaces:** +- Consumes: the whole engine. +- Produces: the guarantee plan 2 and 3 rely on — identical seed plus identical `(Action, dt)` stream yields identical state (§35, §49.2). + +- [ ] **Step 1: Write the replay test** + +```go +type step struct { + action Action + hasAct bool + dt time.Duration +} + +// cannedStream is a fixed input script: rotations, walks, soft and hard +// drops interleaved with elapsed time. +func cannedStream() []step { + var out []step + acts := []Action{ActionLeft, ActionRotateCW, ActionRight, ActionSoftDrop, + ActionRotateCCW, ActionHold, ActionHardDrop, ActionRight, ActionLeft} + for i := 0; i < 400; i++ { + out = append(out, step{dt: time.Duration(7+i%13) * time.Millisecond}) + if i%3 == 0 { + out = append(out, step{action: acts[i%len(acts)], hasAct: true}) + } + } + return out +} + +func replay(seed int64) *Game { + g := New(seed) + for _, s := range cannedStream() { + if s.hasAct { + g.Input(s.action) + } else { + g.Advance(s.dt) + } + } + return g +} + +func summarize(g *Game) string { + var sb strings.Builder + fmt.Fprintf(&sb, "score=%d lines=%d level=%d combo=%d over=%v\n", g.Score, g.Lines, g.Level, g.Combo, g.Over) + fmt.Fprintf(&sb, "active=%v rot=%d x=%d y=%d\n", g.Active.Kind, g.Active.Rotation, g.Active.X, g.Active.Y) + hold := "none" + if g.Hold != nil { + hold = g.Hold.String() + } + fmt.Fprintf(&sb, "hold=%s canhold=%v\n", hold, g.CanHold) + fmt.Fprintf(&sb, "next=%v\n", g.Next) + for y := 0; y < Height; y++ { + for x := 0; x < Width; x++ { + sb.WriteString(g.Board.At(x, y).String()) + } + sb.WriteByte('\n') + } + return sb.String() +} + +func TestReplayIsReproducible(t *testing.T) { + a, b := summarize(replay(8675309)), summarize(replay(8675309)) + if a != b { + t.Errorf("same seed and stream produced different states:\n--- a ---\n%s\n--- b ---\n%s", a, b) + } +} + +func TestReplayMatchesGolden(t *testing.T) { + got := summarize(replay(8675309)) + path := filepath.Join("testdata", "replay.golden") + if *update { + if err := os.WriteFile(path, []byte(got), 0o644); err != nil { + t.Fatal(err) + } + } + want, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if got != string(want) { + t.Errorf("replay drifted from the golden state.\n--- got ---\n%s\n--- want ---\n%s", got, want) + } +} + +func TestDifferentSeedsProduceDifferentReplays(t *testing.T) { + if summarize(replay(1)) == summarize(replay(2)) { + t.Error("seeds 1 and 2 produced identical final states") + } +} +``` + +Declare the update flag once in this file: + +```go +var update = flag.Bool("update", false, "rewrite golden files") +``` + +- [ ] **Step 2: Generate the golden file and confirm the test passes** + +Run: `go test ./internal/game/ -run Replay -update && go test ./internal/game/ -run Replay -v` +Expected: `testdata/replay.golden` is created; tests PASS. Inspect the golden file — it should show a plausible board with a non-zero score. If the game ended immediately or the board is empty, the canned stream is wrong, not the engine. + +- [ ] **Step 3: Write the clock-free enforcement test** + +```go +// §49.2: nothing in this package may read a clock or a global RNG. +func TestPackageNeverReadsAClock(t *testing.T) { + banned := []string{"time.Now(", "time.Since(", "time.Tick(", "time.After(", + "rand.Int(", "rand.Intn(", "rand.Float64(", "rand.Shuffle(", "rand.Perm("} + entries, err := os.ReadDir(".") + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + name := e.Name() + if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + src, err := os.ReadFile(name) + if err != nil { + t.Fatal(err) + } + for _, b := range banned { + if strings.Contains(string(src), b) { + t.Errorf("%s uses %s — the engine must take time and randomness as inputs (design.md §49.2, §49.6)", name, b) + } + } + } +} + +func TestGameOwnsItsRNGAndSeedIsRecorded(t *testing.T) { + g := New(4242) + if g.Seed != 4242 { + t.Errorf("Seed = %d, want 4242", g.Seed) + } + if g.rng == nil { + t.Error("Game must own a *rand.Rand") + } +} +``` + +- [ ] **Step 4: Run it** + +Run: `go test ./internal/game/ -run 'Clock|RNG' -v` +Expected: PASS. If it fails, fix the source — not the test. + +- [ ] **Step 5: Run the whole suite with the race detector and vet** + +Run: `go test -race ./... && go vet ./... && gofmt -l .` +Expected: PASS, no vet findings, `gofmt -l` prints nothing. + +- [ ] **Step 6: Commit** + +```bash +git add internal/game/determinism_test.go internal/game/clockfree_test.go internal/game/testdata/replay.golden +git commit -m "test(game): deterministic replay golden and clock-free enforcement" +``` + +--- + +## Done when + +- `go test -race ./...` passes; `go vet ./...` clean; `gofmt -l .` empty. +- Every §40 engine category has tests: board (collision, bounds, completion, removal, collapse), pieces (all rotations, kicks, failed rotation, spawn), bag (complete bags, reproducibility), hold (initial, swap, blocked second, restored), drops (soft, hard, landing, lock), score (line values, combo, drop points, level), game over (blocked spawn, state transition), determinism (replay). +- `internal/game` imports only the standard library, and the clock-free test passes. diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/N1-recipe-cosmic-12/2026-09-17-cosmic-tetris-02-terminal.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/N1-recipe-cosmic-12/2026-09-17-cosmic-tetris-02-terminal.md new file mode 100644 index 000000000..f9d7c2683 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/N1-recipe-cosmic-12/2026-09-17-cosmic-tetris-02-terminal.md @@ -0,0 +1,1597 @@ +# Cosmic Tetris — Plan 2 of 3: Playable Terminal Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Turn the headless engine into a genuinely good, genuinely playable terminal game: Bubble Tea event loop, immediate keyboard control, board and HUD rendering, next queue, hold, ghost, adaptive layout, pause, restart, help, game over, and the full CLI surface — with snapshot tests pinning the layout. + +**Architecture:** Three packages. `internal/render` is pure: it takes a `Frame` value (game pointer, size, mode, overlay) and returns a string, drawing into a `Canvas` of single-width styled cells so plan 3's effects can composite into the same grid. `internal/app` owns the Bubble Tea model, one 60 Hz frame clock, and the keymap; it converts key presses into `game.Action` immediately and elapsed time into `game.Advance(dt)`. `cmd/cosmic-tetris` parses flags and starts the program. Dependencies point one way: `app → render → game`. + +**Tech Stack:** Go 1.26, `charm.land/bubbletea/v2`, `charm.land/lipgloss/v2`, `charm.land/bubbles/v2`. + +**Spec:** `design.md` (this plan implements §4, §8, §10 rendering, §26, §29 skip-to-play stub, §30, §31, §32, §33, §36, §37, §39, §41, §46, §49.3, §49.4) + +**Plan sequence:** Plan 2 of 3. Requires plan 1 (`2026-09-17-cosmic-tetris-01-engine.md`) to be complete and merged — every `game.*` name used here comes from its Interfaces blocks. Plan 3 (`2026-09-17-cosmic-tetris-03-cosmic-fx.md`) adds effects on top of the `Canvas` and `Frame` this plan creates. + +## Global Constraints + +- Module `cosmic-tetris`, Go 1.26. Imports: `charm.land/bubbletea/v2`, `charm.land/lipgloss/v2`, `charm.land/bubbles/v2`. +- **Do not abstract Bubble Tea away behind a homegrown framework** (§3). The model, `Update`, and messages are ordinary Bubble Tea. +- Dependency direction is one-way: `internal/render` must not import `internal/app`; neither may import `internal/fx` in this plan. `internal/game` stays untouched except where a step says otherwise. +- **Rendering must not mutate game state** (§37). `render` receives `*game.Game` and calls only read-only methods (`GhostY`, `Board.At`, field reads). +- One animation clock at ~60 Hz driving accumulated elapsed time; **no second timing loop** (§36). Input is handled the instant the key message arrives, never deferred to a tick (§8, §44). +- Board geometry on screen: each cell is **2 terminal columns × 1 row** (§5). Board box is 22 columns × 22 rows (20 visible rows plus border). +- Minimum usable terminal: **40 columns × 24 rows** (§31). Below that, show the too-small notice. Never crash on resize (§31). +- Every glyph written to a `Canvas` cell must be single-width. A test enforces this over the glyph table. +- Element drop order as height runs out: title, then mission control, then stat labels (§49.3). NEXT never stacks above or below the board; at small sizes it sits beside the board and truncates to 3 pieces. +- Ghost glyph `░░` in full/reduced modes, `··` in ASCII; pieces use filled glyphs (`██`, `[]` in ASCII) with a bright foreground; the active piece renders one step brighter than locked cells (§49.4). +- CLI surface is exactly: no flags, `--seed`, `--ascii`, `--no-fx`, `--reduced-motion`, `--help` (§49.5). Nothing else. +- The §4 mockup is intent, not geometry (§49.7). The ANSI-stripped goldens are the binding layout contract (§41). +- Every task ends with `go test ./...` passing, `go vet ./...` clean, and `gofmt -l .` empty. + +## Review Focus + +Input classes the spec implies but does not spell out. Each has a test in the task that owns the code. + +1. **`View()` before the first `WindowSizeMsg`, and a 0×0 or 1×1 size.** Bubble Tea can call `View` before it reports a size, and some terminals report zero during a resize storm. Any division or `w - 22` arithmetic must survive it: expect the too-small notice, never a panic or a negative-length slice. → Task 4 and Task 6. +2. **Every size in a sweep from 1×1 to 120×60.** No panic, no rendered line wider than the terminal, and never more lines than the terminal has rows — otherwise the terminal scrolls and the frame tears. → Task 8. +3. **A very large terminal (e.g. 300×100).** The board must stay 22 columns wide and centered rather than stretching, and panels must not float absurdly far from the board. → Task 4. +4. **Unmapped keys, mouse events, paste, and focus messages.** A stray mouse scroll or bracketed paste must be ignored, not fall through into a movement action or panic on a type assertion. → Task 6. +5. **Resize into and back out of the too-small state, while paused and after game over.** The game must not be restarted, unpaused, or lose the board when the terminal shrinks below the minimum and grows again. → Task 6. + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `cmd/cosmic-tetris/main.go` | Flag parsing, mode detection, seed selection, `tea.NewProgram`. | +| `internal/render/palette.go` | `Mode`, `DetectMode`, glyph table, all colors, per-mode glyph selection. | +| `internal/render/render.go` | `Canvas` (styled single-width cell grid), `Frame`, `Render`, overlay drawing. | +| `internal/render/layout.go` | `Layout` and `Compute(w, h)`: tiers, positions, drop order. | +| `internal/render/board.go` | Board box, locked cells, ghost, active piece. | +| `internal/render/hud.go` | Title, HOLD, NEXT, stats, mission line, controls line. | +| `internal/app/keys.go` | `KeyMap` built on `bubbles/v2/key`, plus help bindings. | +| `internal/app/messages.go` | `FrameMsg` and the frame-tick command. | +| `internal/app/model.go` | `Options`, `State`, `Model`, `New`, `Init`, `View`. | +| `internal/app/update.go` | `Update`: keys, frame ticks, resize. | +| `internal/render/testdata/*.golden` | ANSI-stripped layout snapshots. | +| `README.md` | What it is, how to build, controls, flags. | + +--- + +### Task 1: Dependencies and a running Bubble Tea program + +**Files:** +- Create: `cmd/cosmic-tetris/main.go`, `internal/app/model.go`, `internal/app/messages.go` +- Modify: `go.mod`, `go.sum` + +**Interfaces:** +- Consumes: `game.New` (plan 1). +- Produces: + ```go + // internal/app + type Options struct { + Seed int64 + SeedFixed bool // true when --seed was given explicitly + ASCII bool + NoFX bool + ReducedMotion bool + } + type State int + const ( + StatePlaying State = iota + StatePaused + StateGameOver + ) + type Model struct { /* fields grow through this plan */ } + func New(opts Options) *Model + + // internal/app/messages.go + type FrameMsg struct{ Now time.Time } + const FrameRate = time.Second / 60 + func frameTick() tea.Cmd // tea.Tick(FrameRate, ...) -> FrameMsg + ``` + +**API note — read this before writing code.** Bubble Tea v2 and Lip Gloss v2 changed several signatures from v1 (`Init` returns a model, key messages are `tea.KeyPressMsg`, the color profile moved). This plan states intent; bind the exact signatures to what the installed version reports. + +- [ ] **Step 1: Add the dependencies** + +```bash +go get charm.land/bubbletea/v2@latest charm.land/lipgloss/v2@latest charm.land/bubbles/v2@latest +``` + +- [ ] **Step 2: Record the actual v2 API surface** + +Run and read: + +```bash +go doc charm.land/bubbletea/v2 Model +go doc charm.land/bubbletea/v2 | grep -iE 'func Tick|KeyPressMsg|WindowSizeMsg|WithAltScreen|NewProgram' +go doc charm.land/lipgloss/v2 | grep -iE 'func NewStyle|func Color|func Width|func JoinVertical' +go doc charm.land/bubbles/v2/key Binding +go doc charm.land/bubbles/v2/help Model +``` + +Expected: you now know whether `Init` returns `(Model, Cmd)`, whether `View` returns `string` or a `fmt.Stringer`, and the exact key-message type name. Use those signatures for the rest of this plan; where a later step names `tea.KeyPressMsg` and your version differs, use your version's name. + +- [ ] **Step 3: Write a smoke test for the model constructor** + +```go +func TestNewModelStartsPlaying(t *testing.T) { + m := New(Options{Seed: 5}) + if m.state != StatePlaying { + t.Errorf("state = %v, want StatePlaying", m.state) + } + if m.game == nil { + t.Fatal("model must hold a game") + } + if m.game.Seed != 5 { + t.Errorf("game seed = %d, want 5", m.game.Seed) + } +} + +func TestViewBeforeAnySizeDoesNotPanic(t *testing.T) { + m := New(Options{Seed: 5}) + _ = m.View() // width and height are still zero +} +``` + +- [ ] **Step 4: Run it to verify it fails** + +Run: `go test ./internal/app/ -v` +Expected: FAIL — `undefined: New`. + +- [ ] **Step 5: Implement `internal/app/model.go` and `messages.go`** + +`Model` holds `opts Options`, `game *game.Game`, `state State`, `width, height int`, `lastFrame time.Time`. `Init` returns the model plus `frameTick()`. For now `View()` returns `"cosmic tetris"` and `Update` handles only quit keys (`q`, `esc`, `ctrl+c`) and `tea.WindowSizeMsg`; a real `Update` arrives in Task 6. + +- [ ] **Step 6: Implement `cmd/cosmic-tetris/main.go`** + +Parse exactly the §49.5 flags with the `flag` package: `--seed int64`, `--ascii`, `--no-fx`, `--reduced-motion`, plus `flag.Usage` for `--help`. When `--seed` is absent, seed from `time.Now().UnixNano()` and leave `SeedFixed` false (main may read the clock; the engine may not). Start with `tea.NewProgram(app.New(opts), tea.WithAltScreen())` and exit non-zero on a run error. + +- [ ] **Step 7: Verify it builds, runs and quits** + +Run: `go build ./... && go test ./... && ./cosmic-tetris --help` +Expected: the flag list prints the five flags and nothing else. Then run `./cosmic-tetris` in a terminal and press `q`: it must clear the alt screen and exit 0. + +- [ ] **Step 8: Commit** + +```bash +git add go.mod go.sum cmd internal/app +git commit -m "feat(app): bubble tea v2 program skeleton and CLI flags" +``` + +--- + +### Task 2: Palette, modes and glyphs + +**Files:** +- Create: `internal/render/palette.go` +- Test: `internal/render/palette_test.go` + +**Interfaces:** +- Consumes: `game.PieceKind`. +- Produces: + ```go + type Mode int + const ( + ModeFull Mode = iota // unicode + truecolor + ModeReduced // unicode + 256 color + ModeASCII // ascii glyphs only + ) + // DetectMode picks a mode from the environment. asciiFlag forces ModeASCII. + func DetectMode(term, colorterm string, asciiFlag bool) Mode + + type Glyphs struct { + Block string // "██" / "[]" + Ghost string // "░░" / "··" + Empty string // " " in every mode + BorderH, BorderV rune + BorderTL, BorderTR, BorderBL, BorderBR rune + MissionPrefix string // "✦" / "*" + Trail [3]rune // "▓▒░" / "|:." (plan 3 uses these) + Stars [6]rune // '.' '·' '˚' '✦' '✧' '*' / ascii fallbacks + } + func GlyphsFor(m Mode) Glyphs + + type Palette struct{ mode Mode } + func NewPalette(m Mode) Palette + func (p Palette) Locked(k game.PieceKind) lipgloss.Style + func (p Palette) Active(k game.PieceKind) lipgloss.Style + func (p Palette) Ghost() lipgloss.Style + func (p Palette) Border(phase float64) lipgloss.Style // phase 0..1 cycles the §25 ramp + func (p Palette) Dim() lipgloss.Style // labels, controls line + func (p Palette) Label() lipgloss.Style // stat labels + func (p Palette) Value() lipgloss.Style // stat values, bright + func (p Palette) Accent() lipgloss.Style + ``` + +Pinned colors (§26 neon space palette, §49.4 bright foreground on filled glyphs): + +| Kind | Locked | Active (one step brighter) | +|---|---|---| +| I plasma cyan | `#22E4F7` | `#7FF4FF` | +| J deep electric blue | `#3B6BFF` | `#86A4FF` | +| L solar orange | `#FF8C1A` | `#FFB469` | +| O stellar gold | `#FFD23F` | `#FFE58A` | +| S alien green | `#4EF07A` | `#9BF7B8` | +| T ultraviolet | `#A05CFF` | `#C79BFF` | +| Z supernova pink | `#FF3D81` | `#FF85AC` | + +Ghost `#4A4A6A`. Dim `#6C6C8A`. Label `#8A8AA8`. Value `#EEF6FF`. Accent `#22E4F7`. Border ramp, in cycle order: `#5B2A86` deep violet → `#22E4F7` electric cyan → `#FF3DF2` magenta → `#2F6BFF` stellar blue → `#EEF6FF` hot white, interpolated in RGB. + +`ModeReduced` does not carry a second palette: Lip Gloss downsamples truecolor to the terminal's profile. `ModeASCII` uses the same hex values but no gradient interpolation — `Border(phase)` returns the ramp's nearest stop instead of a blend, and no background colors are ever set. + +`DetectMode`: `asciiFlag` → `ModeASCII`; else `COLORTERM` containing `truecolor` or `24bit` → `ModeFull`; else `ModeReduced`. A `TERM` of `dumb` or empty → `ModeASCII`. + +The mission prefix is `✦`, not §27's `☄`: U+2604 is double-width in most terminals and would shift every cell on that row by one column. `✦` is single-width and keeps the same visual register. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestDetectMode(t *testing.T) { + cases := []struct { + term, colorterm string + ascii bool + want Mode + }{ + {"xterm-256color", "truecolor", false, ModeFull}, + {"xterm-256color", "24bit", false, ModeFull}, + {"xterm-256color", "", false, ModeReduced}, + {"xterm", "", false, ModeReduced}, + {"dumb", "truecolor", false, ModeASCII}, + {"", "", false, ModeASCII}, + {"xterm-256color", "truecolor", true, ModeASCII}, + } + for _, c := range cases { + if got := DetectMode(c.term, c.colorterm, c.ascii); got != c.want { + t.Errorf("DetectMode(%q,%q,%v) = %v, want %v", c.term, c.colorterm, c.ascii, got, c.want) + } + } +} + +func TestGlyphsPerMode(t *testing.T) { + full := GlyphsFor(ModeFull) + if full.Block != "██" || full.Ghost != "░░" { + t.Errorf("full mode glyphs = %q/%q, want ██/░░", full.Block, full.Ghost) + } + ascii := GlyphsFor(ModeASCII) + if ascii.Block != "[]" || ascii.Ghost != ".." { + t.Errorf("ascii glyphs = %q/%q, want []/..", ascii.Block, ascii.Ghost) + } + if GlyphsFor(ModeReduced).Ghost != "░░" { + t.Error("reduced mode keeps the unicode ghost") + } +} + +func TestEveryCellGlyphIsExactlyTwoColumnsWide(t *testing.T) { + for _, m := range []Mode{ModeFull, ModeReduced, ModeASCII} { + g := GlyphsFor(m) + for name, s := range map[string]string{"Block": g.Block, "Ghost": g.Ghost, "Empty": g.Empty} { + if w := lipgloss.Width(s); w != 2 { + t.Errorf("mode %v %s = %q is %d columns wide, want 2", m, name, s, w) + } + } + } +} + +func TestEverySingleRuneGlyphIsSingleWidth(t *testing.T) { + for _, m := range []Mode{ModeFull, ModeReduced, ModeASCII} { + g := GlyphsFor(m) + runes := []rune{g.BorderH, g.BorderV, g.BorderTL, g.BorderTR, g.BorderBL, g.BorderBR} + runes = append(runes, g.Trail[:]...) + runes = append(runes, g.Stars[:]...) + for _, r := range runes { + if w := lipgloss.Width(string(r)); w != 1 { + t.Errorf("mode %v glyph %q is %d columns wide, want 1 (double-width runes shift the whole grid)", m, r, w) + } + } + if w := lipgloss.Width(g.MissionPrefix); w != 1 { + t.Errorf("mode %v MissionPrefix %q is %d wide, want 1", m, g.MissionPrefix, w) + } + } +} + +func TestActiveIsDistinctFromLockedForEveryKind(t *testing.T) { + p := NewPalette(ModeFull) + for _, k := range game.AllKinds { + if p.Active(k).Render("x") == p.Locked(k).Render("x") { + t.Errorf("kind %v: active and locked render identically", k) + } + } +} + +func TestBorderPhaseWrapsAndIsStable(t *testing.T) { + p := NewPalette(ModeFull) + if p.Border(0).Render("x") != p.Border(1).Render("x") { + t.Error("Border(0) and Border(1) should be the same point on the cycle") + } + for _, ph := range []float64{-3.25, 0.5, 7.75} { + _ = p.Border(ph) // must not panic on out-of-range phase + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/render/ -v` +Expected: FAIL — `undefined: DetectMode`. + +- [ ] **Step 3: Implement `palette.go`** + +Store colors as `lipgloss.Color("#RRGGBB")` in a `[8]` table indexed by `game.PieceKind`. `Border(phase)` normalizes the phase into `[0,1)`, scales by the number of ramp segments, and linearly interpolates the two neighboring stops' RGB bytes (parse the hex once at init into `[3]uint8` triples). + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/palette.go internal/render/palette_test.go +git commit -m "feat(render): neon space palette, mode detection and per-mode glyphs" +``` + +--- + +### Task 3: Canvas + +**Files:** +- Create: `internal/render/render.go` (Canvas portion only) +- Test: `internal/render/canvas_test.go` + +**Interfaces:** +- Consumes: `lipgloss`. +- Produces: + ```go + // StyleID indexes a Canvas's style table. 0 is the zero style. + type StyleID int + // Canvas is a fixed grid of single-width cells. Writes outside the grid + // are silently dropped, so callers never need bounds checks. + type Canvas struct { /* w, h int; runes []rune; styles []StyleID; table []lipgloss.Style */ } + func NewCanvas(w, h int) *Canvas + func (c *Canvas) Size() (w, h int) + func (c *Canvas) Style(s lipgloss.Style) StyleID // registers and returns an id + func (c *Canvas) Set(x, y int, r rune, id StyleID) + // SetString writes s left to right from (x,y). Runes wider than one column + // are replaced with '?' so the grid never drifts. + func (c *Canvas) SetString(x, y int, s string, id StyleID) + func (c *Canvas) Fill(r rune, id StyleID) + // String renders the grid, coalescing runs of equal style into one styled + // span per run, and trimming trailing blanks on each line. + func (c *Canvas) String() string + ``` + +Coalescing matters: styling every cell individually would emit tens of thousands of escape sequences per frame and visibly flicker (§47). + +- [ ] **Step 1: Write the failing tests** + +```go +func plain(s string) string { return stripANSI(s) } // helper lives in golden_test.go, Task 5 + +func TestCanvasStartsBlank(t *testing.T) { + c := NewCanvas(4, 2) + if got := stripANSI(c.String()); got != "\n" { + t.Errorf("blank canvas = %q, want a single empty line plus newline", got) + } +} + +func TestCanvasSetAndRender(t *testing.T) { + c := NewCanvas(5, 2) + id := c.Style(lipgloss.NewStyle().Foreground(lipgloss.Color("#FF0000"))) + c.SetString(1, 0, "abc", id) + c.Set(0, 1, 'z', 0) + lines := strings.Split(stripANSI(c.String()), "\n") + if lines[0] != " abc" { + t.Errorf("line 0 = %q, want %q", lines[0], " abc") + } + if lines[1] != "z" { + t.Errorf("line 1 = %q, want %q", lines[1], "z") + } +} + +func TestCanvasDropsOutOfBoundsWrites(t *testing.T) { + c := NewCanvas(3, 2) + c.Set(-1, 0, 'x', 0) + c.Set(0, -5, 'x', 0) + c.Set(99, 0, 'x', 0) + c.Set(0, 99, 'x', 0) + c.SetString(2, 0, "abcdef", 0) // runs off the right edge + c.SetString(-2, 1, "xy", 0) // starts off the left edge + got := stripANSI(c.String()) + if strings.Contains(got, "cdef") { + t.Errorf("text ran past the right edge: %q", got) + } + lines := strings.Split(got, "\n") + if lines[0] != " a" { + t.Errorf("line 0 = %q, want %q", lines[0], " a") + } + if lines[1] != "" && lines[1] != "y" { + t.Errorf("line 1 = %q, want the clipped remainder or empty", lines[1]) + } +} + +func TestCanvasReplacesWideRunes(t *testing.T) { + c := NewCanvas(4, 1) + c.SetString(0, 0, "a☄b", 0) // ☄ is double-width + got := stripANSI(c.String()) + if lipgloss.Width(got) != len([]rune("a?b")) { + t.Errorf("wide rune was not narrowed: %q (width %d)", got, lipgloss.Width(got)) + } +} + +func TestCanvasCoalescesStyleRuns(t *testing.T) { + c := NewCanvas(10, 1) + id := c.Style(lipgloss.NewStyle().Bold(true)) + c.SetString(0, 0, "aaaaaaaaaa", id) + out := c.String() + if n := strings.Count(out, "\x1b["); n > 4 { + t.Errorf("a single styled run emitted %d escape sequences, want at most 4", n) + } +} + +func TestCanvasZeroAndNegativeSizes(t *testing.T) { + for _, dim := range [][2]int{{0, 0}, {0, 5}, {5, 0}, {-3, -3}} { + c := NewCanvas(dim[0], dim[1]) + c.Set(0, 0, 'x', 0) + c.SetString(0, 0, "hello", 0) + _ = c.String() // must not panic + } +} + +func TestCanvasNeverExceedsItsWidth(t *testing.T) { + c := NewCanvas(7, 3) + c.Fill('#', 0) + for _, line := range strings.Split(stripANSI(c.String()), "\n") { + if w := lipgloss.Width(line); w > 7 { + t.Errorf("line %q is %d columns wide, want <= 7", line, w) + } + } +} +``` + +Add the ANSI stripper now, in `internal/render/strip_test.go`, since Task 5's goldens reuse it: + +```go +var ansiRE = regexp.MustCompile("\x1b\\[[0-9;?]*[a-zA-Z]") + +func stripANSI(s string) string { return ansiRE.ReplaceAllString(s, "") } +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/render/ -run Canvas -v` +Expected: FAIL — `undefined: NewCanvas`. + +- [ ] **Step 3: Implement the Canvas in `render.go`** + +Back the grid with two flat slices of length `w*h`. `NewCanvas` clamps negative dimensions to 0. `String` walks each row, groups consecutive cells with the same `StyleID`, renders each group with `table[id].Render(runs)`, right-trims spaces that carry the zero style, and joins rows with `\n`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/render.go internal/render/canvas_test.go internal/render/strip_test.go +git commit -m "feat(render): styled single-width cell canvas with run coalescing" +``` + +--- + +### Task 4: Adaptive layout + +**Files:** +- Create: `internal/render/layout.go` +- Test: `internal/render/layout_test.go` + +**Interfaces:** +- Consumes: `game.Width`, `game.VisibleRows`. +- Produces: + ```go + const ( + CellCols = 2 // §5: 2 columns per logical cell + BoardW = game.Width * CellCols // 20 + BoardBoxW = BoardW + 2 // 22, with border + BoardBoxH = game.VisibleRows + 2 // 22, with border + MinCols = 40 // §31 + MinRows = 24 + WidePanelW = 12 + CompactPanelW = 14 + WideMinCols = 56 + ) + + type Tier int + const ( + TierCompact Tier = iota // board + one panel column on the right + TierWide // HOLD + stats left, board, NEXT right + ) + + type Layout struct { + ScreenW, ScreenH int + TooSmall bool + Tier Tier + BoardX, BoardY int // top-left of the board's border box + LeftPanelX int // valid on TierWide + RightPanelX int + PanelW int + ShowTitle bool + ShowMission bool + ShowStatLabels bool + NextCount int // 5 wide, 3 compact (§49.3) + TitleY int + MissionY int + ControlsY int + } + func Compute(w, h int) Layout + ``` + +Pinned rules — the tests below assert exactly these: + +- `TooSmall` when `w < MinCols || h < MinRows`. All other fields are zero in that case. +- `Tier = TierWide` when `w >= WideMinCols`, else `TierCompact`. +- `PanelW = WidePanelW` on wide, `min(CompactPanelW, w - BoardBoxW - 1)` on compact. +- `NextCount = 5` on wide, `3` on compact. +- Height cascade (§49.3 order, title first to go): `ShowTitle = h >= 27`, `ShowMission = h >= 26`, `ShowStatLabels = h >= 25`. Board and controls always present. +- Vertical stack, in order: title row (if shown), board box (22 rows), mission row (if shown), controls row. Total height `used`; `BoardY = (h - used)/2 + titleRows`, floored at 0, so the block is vertically centered with any spare row biased to the top. `TitleY = BoardY - titleRows`, `MissionY = BoardY + BoardBoxH` (when shown), `ControlsY = h - 1`. +- Horizontal: wide total `WidePanelW + 1 + BoardBoxW + 1 + WidePanelW = 48`; compact total `BoardBoxW + 1 + PanelW`. `BoardX` centers the total block: `BoardX = (w-total)/2 + leadingPanel`, never negative. So the board never stretches on a huge terminal (Review Focus 3). + +- [ ] **Step 1: Write the failing tests** + +```go +func TestComputeRejectsBelowMinimum(t *testing.T) { + for _, sz := range [][2]int{{0, 0}, {1, 1}, {39, 40}, {80, 23}, {-5, -5}} { + if l := Compute(sz[0], sz[1]); !l.TooSmall { + t.Errorf("Compute(%d,%d).TooSmall = false, want true", sz[0], sz[1]) + } + } + if l := Compute(MinCols, MinRows); l.TooSmall { + t.Errorf("Compute(%d,%d) should be usable", MinCols, MinRows) + } +} + +func TestTiersAndNextCount(t *testing.T) { + if l := Compute(40, 30); l.Tier != TierCompact || l.NextCount != 3 { + t.Errorf("40 cols: tier %v next %d, want compact/3", l.Tier, l.NextCount) + } + if l := Compute(56, 30); l.Tier != TierWide || l.NextCount != 5 { + t.Errorf("56 cols: tier %v next %d, want wide/5", l.Tier, l.NextCount) + } + if l := Compute(55, 30); l.Tier != TierCompact { + t.Error("55 cols should still be compact") + } +} + +func TestHeightDropOrderIsTitleThenMissionThenLabels(t *testing.T) { + cases := []struct { + h int + title, mission, labels bool + }{ + {30, true, true, true}, + {27, true, true, true}, + {26, false, true, true}, + {25, false, false, true}, + {24, false, false, false}, + } + for _, c := range cases { + l := Compute(80, c.h) + if l.ShowTitle != c.title || l.ShowMission != c.mission || l.ShowStatLabels != c.labels { + t.Errorf("h=%d: title=%v mission=%v labels=%v, want %v/%v/%v", + c.h, l.ShowTitle, l.ShowMission, l.ShowStatLabels, c.title, c.mission, c.labels) + } + } +} + +func TestBoardFitsInsideTheScreenAtEverySize(t *testing.T) { + for w := MinCols; w <= 200; w++ { + for h := MinRows; h <= 60; h++ { + l := Compute(w, h) + if l.TooSmall { + t.Fatalf("Compute(%d,%d) unexpectedly too small", w, h) + } + if l.BoardX < 0 || l.BoardX+BoardBoxW > w { + t.Fatalf("Compute(%d,%d): board spans x %d..%d", w, h, l.BoardX, l.BoardX+BoardBoxW) + } + if l.BoardY < 0 || l.BoardY+BoardBoxH > h { + t.Fatalf("Compute(%d,%d): board spans y %d..%d", w, h, l.BoardY, l.BoardY+BoardBoxH) + } + if l.ControlsY >= h || l.ControlsY < l.BoardY+BoardBoxH-1 { + t.Fatalf("Compute(%d,%d): ControlsY %d is not below the board", w, h, l.ControlsY) + } + if l.RightPanelX+l.PanelW > w { + t.Fatalf("Compute(%d,%d): right panel runs off the screen", w, h) + } + } + } +} + +// Review Focus 3: the board must not stretch or drift on a huge terminal. +func TestHugeTerminalKeepsBoardCenteredAndFixedWidth(t *testing.T) { + l := Compute(300, 100) + if l.BoardX < 100 || l.BoardX > 200 { + t.Errorf("BoardX = %d, want the board roughly centered in 300 columns", l.BoardX) + } + gapLeft := l.BoardX - (l.LeftPanelX + l.PanelW) + if gapLeft < 0 || gapLeft > 4 { + t.Errorf("left panel sits %d columns from the board; panels must hug the board", gapLeft) + } +} + +func TestPanelsDoNotOverlapTheBoard(t *testing.T) { + for _, w := range []int{40, 48, 56, 80, 120} { + l := Compute(w, 30) + if l.Tier == TierWide && l.LeftPanelX+l.PanelW > l.BoardX { + t.Errorf("w=%d: left panel overlaps the board", w) + } + if l.RightPanelX < l.BoardX+BoardBoxW { + t.Errorf("w=%d: right panel (x=%d) overlaps the board ending at %d", w, l.RightPanelX, l.BoardX+BoardBoxW) + } + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/render/ -run 'Compute|Tier|Height|Board|Huge|Panels' -v` +Expected: FAIL — `undefined: Compute`. + +- [ ] **Step 3: Implement `layout.go`** + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS. If `TestBoardFitsInsideTheScreenAtEverySize` fails at one specific size, fix the arithmetic — do not relax the test. + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/layout.go internal/render/layout_test.go +git commit -m "feat(render): adaptive layout with pinned small-terminal drop order" +``` + +--- + +### Task 5: Board rendering and the first golden + +**Files:** +- Create: `internal/render/board.go` +- Modify: `internal/render/render.go` (add `Frame`, `Render`) +- Test: `internal/render/render_test.go`, `internal/render/testdata/` + +**Interfaces:** +- Consumes: `Canvas`, `Palette`, `Glyphs`, `Layout`, `game.Game`. +- Produces: + ```go + type OverlayKind int + const ( + OverlayNone OverlayKind = iota + OverlayPause + OverlayHelp + OverlayGameOver + OverlayTooSmall + ) + + type Frame struct { + Game *game.Game + W, H int + Mode Mode + Overlay OverlayKind + Seed int64 + Mission string // mission-control text, without the prefix + Elapsed time.Duration // since program start; drives the border cycle + HelpView string // pre-rendered help body from bubbles/help (Task 7) + } + func Render(f Frame) string + + // board.go + func drawBoard(c *Canvas, l Layout, f Frame, p Palette, g Glyphs) + ``` + +Board drawing order (§37 steps 3–7, with FX steps deferred to plan 3): border box, locked cells, ghost piece, active piece. Ghost is drawn before the active piece and only into cells that are `Empty` on the board, so it can never obscure locked blocks (§10). The active piece is drawn last, so nothing can obscure it (§44). + +Border phase comes from `Elapsed`: `phase = math.Mod(Elapsed.Seconds()/12, 1)` — one full trip round the §25 ramp every 12 seconds, i.e. subtle. + +- [ ] **Step 1: Write the golden-test harness and the wide-layout test** + +```go +// fixtureGame builds a deterministic mid-game state: a seeded game, a canned +// action script, a held piece and a partly built stack. +func fixtureGame(t *testing.T) *game.Game { + t.Helper() + g := game.New(7) + g.Input(game.ActionHold) + script := []game.Action{ + game.ActionLeft, game.ActionLeft, game.ActionLeft, game.ActionHardDrop, + game.ActionRight, game.ActionRight, game.ActionRotateCW, game.ActionHardDrop, + game.ActionLeft, game.ActionHardDrop, + game.ActionRotateCCW, game.ActionRight, game.ActionRight, game.ActionRight, game.ActionHardDrop, + } + for _, a := range script { + g.Input(a) + g.Advance(50 * time.Millisecond) + } + return g +} + +func goldenCheck(t *testing.T, name, got string) { + t.Helper() + path := filepath.Join("testdata", name+".golden") + if *update { + if err := os.MkdirAll("testdata", 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(got), 0o644); err != nil { + t.Fatal(err) + } + } + want, err := os.ReadFile(path) + if err != nil { + t.Fatalf("%v (run: go test ./internal/render/ -update)", err) + } + if got != string(want) { + t.Errorf("%s golden mismatch.\n--- got ---\n%s\n--- want ---\n%s", name, got, want) + } +} + +var update = flag.Bool("update", false, "rewrite golden files") + +func renderPlain(f Frame) string { return stripANSI(Render(f)) } + +func TestGoldenWideLayout(t *testing.T) { + f := Frame{Game: fixtureGame(t), W: 80, H: 30, Mode: ModeFull, Seed: 7, Mission: "NOMINALISH"} + goldenCheck(t, "wide", renderPlain(f)) +} + +func TestBoardBoxHasCorrectDimensions(t *testing.T) { + f := Frame{Game: fixtureGame(t), W: 80, H: 30, Mode: ModeFull, Seed: 7} + l := Compute(f.W, f.H) + lines := strings.Split(renderPlain(f), "\n") + top := []rune(padTo(lines[l.BoardY], f.W)) + g := GlyphsFor(ModeFull) + if top[l.BoardX] != g.BorderTL || top[l.BoardX+BoardBoxW-1] != g.BorderTR { + t.Errorf("board top row = %q, want corners at %d and %d", lines[l.BoardY], l.BoardX, l.BoardX+BoardBoxW-1) + } + bottom := []rune(padTo(lines[l.BoardY+BoardBoxH-1], f.W)) + if bottom[l.BoardX] != g.BorderBL { + t.Error("board bottom-left corner is missing") + } + for y := l.BoardY + 1; y < l.BoardY+BoardBoxH-1; y++ { + row := []rune(padTo(lines[y], f.W)) + if row[l.BoardX] != g.BorderV || row[l.BoardX+BoardBoxW-1] != g.BorderV { + t.Errorf("row %d is missing a side border: %q", y, lines[y]) + } + } +} + +// padTo right-pads a line so indexing by column is safe after trailing-blank trimming. +func padTo(s string, w int) string { + r := []rune(stripANSI(s)) + for len(r) < w { + r = append(r, ' ') + } + return string(r) +} + +func TestGhostIsDrawnAtTheLandingRowAndNotOverLockedCells(t *testing.T) { + g := fixtureGame(t) + f := Frame{Game: g, W: 80, H: 30, Mode: ModeFull, Seed: 7} + l := Compute(f.W, f.H) + lines := strings.Split(renderPlain(f), "\n") + ghostY := g.GhostY() + var found bool + for _, c := range (game.Piece{Kind: g.Active.Kind, Rotation: g.Active.Rotation, X: g.Active.X, Y: ghostY}).Cells() { + if c[1] < game.HiddenRows { + continue + } + row := []rune(padTo(lines[l.BoardY+1+c[1]-game.HiddenRows], f.W)) + col := l.BoardX + 1 + c[0]*CellCols + got := row[col] + if got == '░' { + found = true + } + if g.Board.At(c[0], c[1]) != game.Empty && got == '░' { + t.Errorf("ghost drawn over a locked cell at board (%d,%d)", c[0], c[1]) + } + } + if !found && ghostY != g.Active.Y { + t.Error("no ghost cells were rendered at the landing row") + } +} + +func TestActivePieceIsRenderedAtItsPosition(t *testing.T) { + g := fixtureGame(t) + f := Frame{Game: g, W: 80, H: 30, Mode: ModeFull, Seed: 7} + l := Compute(f.W, f.H) + lines := strings.Split(renderPlain(f), "\n") + for _, c := range g.Active.Cells() { + if c[1] < game.HiddenRows { + continue // hidden rows are not rendered + } + row := []rune(padTo(lines[l.BoardY+1+c[1]-game.HiddenRows], f.W)) + col := l.BoardX + 1 + c[0]*CellCols + if row[col] != '█' { + t.Errorf("active cell (%d,%d) renders %q at column %d, want █", c[0], c[1], row[col], col) + } + } +} + +func TestRenderDoesNotMutateGameState(t *testing.T) { + g := fixtureGame(t) + before := *g + Render(Frame{Game: g, W: 80, H: 30, Mode: ModeFull, Seed: 7}) + if g.Board != before.Board || g.Active != before.Active || g.Score != before.Score || g.Combo != before.Combo { + t.Error("Render mutated game state (design.md §37)") + } +} + +func TestHiddenRowsAreNotRendered(t *testing.T) { + g := game.New(7) + g.Board.Set(0, 0, game.I) // hidden row + f := Frame{Game: g, W: 80, H: 30, Mode: ModeFull, Seed: 7} + l := Compute(f.W, f.H) + lines := strings.Split(renderPlain(f), "\n") + first := []rune(padTo(lines[l.BoardY+1], f.W)) + if first[l.BoardX+1] == '█' { + t.Error("a cell in a hidden spawn row was rendered inside the board") + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/render/ -run 'Golden|Board|Ghost|Active|Mutate|Hidden' -v` +Expected: FAIL — `undefined: Render`. + +- [ ] **Step 3: Implement `Frame`/`Render` and `drawBoard`** + +`Render` computes the layout, allocates a `Canvas(f.W, f.H)`, draws the too-small notice and returns early when `l.TooSmall`, otherwise draws the board (HUD comes in Task 6), then any overlay, then returns `c.String()`. Draw the too-small notice now, centered, matching §31: + +``` +THIS UNIVERSE IS TOO SMALL + +resize terminal to continue + +current: 34 × 19 +needed: approximately 40 × 24 +``` + +(Use `x` rather than `×` when `Mode == ModeASCII`.) + +- [ ] **Step 4: Generate the golden and verify** + +Run: `go test ./internal/render/ -update && go test ./internal/render/ -v` +Expected: PASS. **Read `testdata/wide.golden`.** The board must look like a board: a 22-wide box, a stack at the bottom, an active piece near the top. If it does not, fix the renderer before committing the golden. + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/board.go internal/render/render.go internal/render/render_test.go internal/render/testdata +git commit -m "feat(render): board box, locked cells, ghost, active piece and wide golden" +``` + +--- + +### Task 6: HUD and the full layout goldens + +**Files:** +- Create: `internal/render/hud.go` +- Modify: `internal/render/render.go` +- Test: `internal/render/hud_test.go`, `internal/render/testdata/` + +**Interfaces:** +- Consumes: Task 5's `Frame`, `Layout`. +- Produces: + ```go + func drawTitle(c *Canvas, l Layout, f Frame, p Palette, g Glyphs) + func drawHold(c *Canvas, x, y int, f Frame, p Palette, g Glyphs) + func drawNext(c *Canvas, x, y int, f Frame, p Palette, g Glyphs) + func drawStats(c *Canvas, x, y int, f Frame, l Layout, p Palette) + func drawMission(c *Canvas, l Layout, f Frame, p Palette, g Glyphs) + func drawControls(c *Canvas, l Layout, f Frame, p Palette) + // MiniPiece renders kind k as up to 4 rows of 2-column cells into the canvas. + func drawMiniPiece(c *Canvas, x, y int, k game.PieceKind, p Palette, g Glyphs) + ``` + +Pinned content and formats (§4 mockup for intent, these strings are binding): + +- Title row, only when `ShowTitle`: `╭─ ✦ COSMIC TETRIS ` + `─` fill + ` LOCAL UNIVERSE %04X ─╮`, exactly `ScreenW` columns, where `%04X` is `uint16(f.Seed)`. In ASCII mode use `+-` corners and `-` fill and drop the `✦`. +- Stats: label rows `SCORE` / `LINES` / `LEVEL` (only when `ShowStatLabels`) above value rows `%08d` / `%03d` / `%02d`. When labels are hidden, only the value rows are drawn. +- `HOLD` panel: the label plus the held piece, or an empty well when `Hold` is nil. +- `NEXT` panel: the label plus `l.NextCount` upcoming kinds from `Game.Next`, each in its own 2-row slot. +- On `TierWide`: HOLD and stats in the left panel, NEXT in the right panel. On `TierCompact`: one right panel, top to bottom — `NEXT` (3 pieces), `HOLD` (one slot), then stats. +- Mission row, only when `ShowMission`: ` MISSION CONTROL: `, truncated to `ScreenW`, `Mission` defaulting to `NOMINALISH` when empty. +- Controls row, longest variant that fits `ScreenW`: + 1. `←→ move ↑ rotate ↓ descend SPACE YEET C hold ? help` + 2. `←→ move ↑ rot SPACE yeet C hold ? help` + 3. `? help` + In ASCII mode substitute `<>` for `←→` and `^` for `↑`, `v` for `↓`. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestStatsShowFormattedValues(t *testing.T) { + g := fixtureGame(t) + g.Score, g.Lines, g.Level = 129340, 42, 7 + out := renderPlain(Frame{Game: g, W: 80, H: 30, Mode: ModeFull, Seed: 7}) + for _, want := range []string{"SCORE", "00129340", "LINES", "042", "LEVEL", "07"} { + if !strings.Contains(out, want) { + t.Errorf("output is missing %q", want) + } + } +} + +func TestStatLabelsDropAtTightHeights(t *testing.T) { + g := fixtureGame(t) + g.Score, g.Lines, g.Level = 129340, 42, 7 + out := renderPlain(Frame{Game: g, W: 80, H: 24, Mode: ModeFull, Seed: 7}) + if strings.Contains(out, "LINES") { + t.Error("stat labels should be dropped at h=24 (design.md §49.3)") + } + if !strings.Contains(out, "042") { + t.Error("stat values must survive when labels are dropped") + } +} + +func TestTitleAndMissionDropInOrder(t *testing.T) { + g := fixtureGame(t) + full := renderPlain(Frame{Game: g, W: 80, H: 30, Mode: ModeFull, Seed: 7, Mission: "NOMINALISH"}) + if !strings.Contains(full, "COSMIC TETRIS") || !strings.Contains(full, "LOCAL UNIVERSE 0007") { + t.Errorf("title row missing or mislabelled:\n%s", full) + } + if !strings.Contains(full, "MISSION CONTROL: NOMINALISH") { + t.Error("mission row missing at h=30") + } + noTitle := renderPlain(Frame{Game: g, W: 80, H: 26, Mode: ModeFull, Seed: 7, Mission: "NOMINALISH"}) + if strings.Contains(noTitle, "COSMIC TETRIS") { + t.Error("title should drop first, at h=26") + } + if !strings.Contains(noTitle, "MISSION CONTROL") { + t.Error("mission should still be present at h=26") + } + noMission := renderPlain(Frame{Game: g, W: 80, H: 25, Mode: ModeFull, Seed: 7, Mission: "NOMINALISH"}) + if strings.Contains(noMission, "MISSION CONTROL") { + t.Error("mission should drop second, at h=25") + } +} + +func TestNextQueueLengthPerTier(t *testing.T) { + g := fixtureGame(t) + wide := renderPlain(Frame{Game: g, W: 80, H: 30, Mode: ModeFull, Seed: 7}) + compact := renderPlain(Frame{Game: g, W: 44, H: 30, Mode: ModeFull, Seed: 7}) + if !strings.Contains(wide, "NEXT") || !strings.Contains(compact, "NEXT") { + t.Fatal("NEXT label missing") + } + // Count rendered mini-piece rows to the right of the board in each mode. + if countCellRows(wide, Compute(80, 30)) <= countCellRows(compact, Compute(44, 30)) { + t.Error("wide layout should show more upcoming pieces than compact (5 vs 3)") + } +} + +// countCellRows counts rows right of the board that contain at least one block glyph. +func countCellRows(out string, l Layout) int { + n := 0 + for _, line := range strings.Split(out, "\n") { + r := []rune(line) + for x := l.BoardX + BoardBoxW; x < len(r); x++ { + if r[x] == '█' { + n++ + break + } + } + } + return n +} + +func TestNextNeverStacksAboveOrBelowTheBoard(t *testing.T) { + g := fixtureGame(t) + l := Compute(40, 24) + lines := strings.Split(renderPlain(Frame{Game: g, W: 40, H: 24, Mode: ModeFull, Seed: 7}), "\n") + for y, line := range lines { + if y >= l.BoardY && y < l.BoardY+BoardBoxH { + continue + } + if strings.Contains(line, "NEXT") { + t.Errorf("NEXT appears on row %d, outside the board's rows (design.md §49.3)", y) + } + } +} + +func TestControlsRowShrinksWithWidth(t *testing.T) { + g := fixtureGame(t) + for _, w := range []int{80, 48, 40} { + out := renderPlain(Frame{Game: g, W: w, H: 30, Mode: ModeFull, Seed: 7}) + if !strings.Contains(out, "? help") { + t.Errorf("w=%d: controls row must always offer '? help'", w) + } + for _, line := range strings.Split(out, "\n") { + if lipgloss.Width(line) > w { + t.Errorf("w=%d: line %q is %d columns wide", w, line, lipgloss.Width(line)) + } + } + } +} + +func TestHoldPanelShowsTheHeldPiece(t *testing.T) { + g := fixtureGame(t) + if g.Hold == nil { + t.Fatal("fixture should have a held piece") + } + out := renderPlain(Frame{Game: g, W: 80, H: 30, Mode: ModeFull, Seed: 7}) + if !strings.Contains(out, "HOLD") { + t.Error("HOLD label missing") + } + l := Compute(80, 30) + var found bool + for _, line := range strings.Split(out, "\n") { + r := []rune(padTo(line, 80)) + for x := l.LeftPanelX; x < l.LeftPanelX+l.PanelW && x < len(r); x++ { + if r[x] == '█' { + found = true + } + } + } + if !found { + t.Error("no held piece glyphs found in the left panel") + } +} + +func TestGoldenLayouts(t *testing.T) { + g := fixtureGame(t) + g.Score, g.Lines, g.Level = 129340, 42, 7 + cases := []struct { + name string + w, h int + }{ + {"wide", 80, 30}, + {"medium", 50, 26}, + {"small", 40, 24}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + goldenCheck(t, c.name, renderPlain(Frame{ + Game: g, W: c.w, H: c.h, Mode: ModeFull, Seed: 7, Mission: "GRAVITY REMAINS MOSTLY LEGAL", + })) + }) + } +} + +func TestGoldenTooSmall(t *testing.T) { + goldenCheck(t, "toosmall", renderPlain(Frame{ + Game: fixtureGame(t), W: 34, H: 19, Mode: ModeFull, Seed: 7, Overlay: OverlayTooSmall, + })) +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/render/ -run 'Stats|Title|Next|Controls|Hold|Golden' -v` +Expected: FAIL — the HUD is not drawn yet; `wide.golden` from Task 5 will also mismatch. + +- [ ] **Step 3: Implement `hud.go` and wire it into `Render`** + +- [ ] **Step 4: Regenerate the goldens and read them** + +Run: `go test ./internal/render/ -update && go test ./internal/render/ -v` +Expected: PASS. **Open all four goldens.** Check: nothing overlaps the board, the panels sit beside it, the small golden fits in 40×24, the too-small notice reports `current: 34 × 19`. Fix and regenerate if not. + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/hud.go internal/render/render.go internal/render/hud_test.go internal/render/testdata +git commit -m "feat(render): HUD panels, title, mission and controls rows with layout goldens" +``` + +--- + +### Task 7: Bubble Tea wiring — keys, frame clock, pause, restart, resize + +**Files:** +- Create: `internal/app/keys.go`, `internal/app/update.go` +- Modify: `internal/app/model.go` +- Test: `internal/app/update_test.go`, `internal/app/keys_test.go` + +**Interfaces:** +- Consumes: `render.Render`, `render.DetectMode`, `game.Action`, `FrameMsg`. +- Produces: + ```go + // keys.go + type KeyMap struct { + Left, Right, SoftDrop, RotateCW, RotateCCW, HardDrop, + Hold, Pause, Restart, Help, Quit key.Binding + } + func DefaultKeyMap() KeyMap + func (k KeyMap) ShortHelp() []key.Binding + func (k KeyMap) FullHelp() [][]key.Binding + // ActionFor maps a pressed key to a game action. + func (k KeyMap) ActionFor(msg tea.KeyPressMsg) (game.Action, bool) + + // model.go additions + func (m *Model) SetSize(w, h int) + func (m *Model) restart() + ``` + +Bindings (§8, including the WASD aliases): + +| Binding | Keys | +|---|---| +| Left | `left`, `h`, `a` | +| Right | `right`, `l`, `d` | +| SoftDrop | `down`, `j`, `s` | +| RotateCW | `up`, `k`, `x`, `w` | +| RotateCCW | `z` | +| HardDrop | `space` (`" "`) | +| Hold | `c` | +| Pause | `p` | +| Restart | `r` | +| Help | `?` | +| Quit | `q`, `esc`, `ctrl+c` | + +Pinned `Update` behavior: + +- `tea.WindowSizeMsg`: store `width`/`height`. **Do not touch game state** — a resize below the minimum only changes what `View` draws (Review Focus 5). +- `FrameMsg`: `dt := msg.Now.Sub(m.lastFrame)`, clamped to `[0, 100ms]` so a suspended terminal cannot dump seconds of gravity into one call; `m.lastFrame = msg.Now`; when `m.state == StatePlaying`, `evts := m.game.Advance(dt)` and transition to `StateGameOver` if any event is `game.GameOver`; always return `frameTick()` so the clock never dies. +- A key handled by `ActionFor` is applied **immediately** via `m.game.Input(...)` — never queued for the next tick — and only when `m.state == StatePlaying`. +- `Pause` toggles `StatePlaying` ↔ `StatePaused`. `Help` toggles `m.showHelp` in any state. `Restart` calls `m.restart()` from any state. `Quit` returns `tea.Quit`. +- `restart()`: when `opts.SeedFixed`, reuse `opts.Seed`; otherwise advance the seed with `seed = seed*6364136223846793005 + 1442695040888963407` and store it back, so consecutive runs differ while a `--seed` run stays reproducible. +- Any other message type is ignored (Review Focus 4). +- `View()`: when `width == 0 || height == 0`, return `""`. Otherwise build a `render.Frame`, mapping `m.state` and `m.showHelp` to an `OverlayKind` (help wins over pause, which wins over game over), and pass `render.Compute`-driven overlay `OverlayTooSmall` implicitly via `Render`. + +- [ ] **Step 1: Write the failing key tests** + +```go +func keyPress(s string) tea.KeyPressMsg { /* construct per your bubbletea v2 version */ } + +func TestActionForCoversEveryDocumentedKey(t *testing.T) { + k := DefaultKeyMap() + cases := map[string]game.Action{ + "left": game.ActionLeft, "h": game.ActionLeft, "a": game.ActionLeft, + "right": game.ActionRight, "l": game.ActionRight, "d": game.ActionRight, + "down": game.ActionSoftDrop, "j": game.ActionSoftDrop, "s": game.ActionSoftDrop, + "up": game.ActionRotateCW, "k": game.ActionRotateCW, "x": game.ActionRotateCW, "w": game.ActionRotateCW, + "z": game.ActionRotateCCW, + " ": game.ActionHardDrop, + "c": game.ActionHold, + } + for keyStr, want := range cases { + got, ok := k.ActionFor(keyPress(keyStr)) + if !ok { + t.Errorf("key %q produced no action", keyStr) + continue + } + if got != want { + t.Errorf("key %q -> %v, want %v", keyStr, got, want) + } + } + for _, keyStr := range []string{"p", "r", "?", "q", "F", "1"} { + if _, ok := k.ActionFor(keyPress(keyStr)); ok { + t.Errorf("key %q should not map to a game action", keyStr) + } + } +} +``` + +- [ ] **Step 2: Write the failing update tests** + +```go +func TestKeyPressAppliesImmediatelyWithoutATick(t *testing.T) { + m := New(Options{Seed: 7}) + m.SetSize(80, 30) + x := m.game.Active.X + m.Update(keyPress("left")) + if m.game.Active.X != x-1 { + t.Errorf("X = %d, want %d — input must not wait for a frame tick", m.game.Active.X, x-1) + } +} + +func TestFrameMsgAdvancesGameAndReschedules(t *testing.T) { + m := New(Options{Seed: 7}) + m.SetSize(80, 30) + start := time.Now() + m.Update(FrameMsg{Now: start}) + _, cmd := m.Update(FrameMsg{Now: start.Add(game.GravityInterval(1) + 10*time.Millisecond)}) + if cmd == nil { + t.Error("every FrameMsg must schedule the next tick") + } + if m.game.GravityAccumulator == 0 && m.game.Active.Y == game.SpawnY { + t.Error("elapsed time was not passed to the engine") + } +} + +func TestFrameDtIsClamped(t *testing.T) { + m := New(Options{Seed: 7}) + m.SetSize(80, 30) + start := time.Now() + m.Update(FrameMsg{Now: start}) + m.Update(FrameMsg{Now: start.Add(30 * time.Second)}) // terminal was suspended + if m.game.Lines > 0 || m.game.Score > 500 { + t.Errorf("a 30s stall dumped %d lines / %d points into the game; dt must be clamped", m.game.Lines, m.game.Score) + } +} + +func TestPauseFreezesTheGame(t *testing.T) { + m := New(Options{Seed: 7}) + m.SetSize(80, 30) + m.Update(keyPress("p")) + if m.state != StatePaused { + t.Fatalf("state = %v, want StatePaused", m.state) + } + before := *m.game + start := time.Now() + m.Update(FrameMsg{Now: start}) + m.Update(FrameMsg{Now: start.Add(5 * time.Second)}) + if m.game.Active != before.Active || m.game.GravityAccumulator != before.GravityAccumulator { + t.Error("game advanced while paused") + } + m.Update(keyPress("left")) + if m.game.Active.X != before.Active.X { + t.Error("input was accepted while paused") + } + m.Update(keyPress("p")) + if m.state != StatePlaying { + t.Error("p should resume") + } +} + +func TestRestartResetsTheGame(t *testing.T) { + m := New(Options{Seed: 7, SeedFixed: true}) + m.SetSize(80, 30) + for i := 0; i < 5; i++ { + m.Update(keyPress(" ")) + } + if m.game.Score == 0 { + t.Fatal("setup: expected some score from hard drops") + } + m.Update(keyPress("r")) + if m.game.Score != 0 || m.game.Lines != 0 || m.game.Over { + t.Errorf("after restart: score %d lines %d over %v", m.game.Score, m.game.Lines, m.game.Over) + } + if m.state != StatePlaying { + t.Errorf("state = %v, want StatePlaying", m.state) + } + if m.game.Seed != 7 { + t.Errorf("--seed run should restart on the same seed, got %d", m.game.Seed) + } +} + +func TestRestartWithoutFixedSeedChangesTheSeed(t *testing.T) { + m := New(Options{Seed: 7, SeedFixed: false}) + m.SetSize(80, 30) + m.Update(keyPress("r")) + if m.game.Seed == 7 { + t.Error("without --seed, a restart should pick a new seed") + } +} + +func TestGameOverTransition(t *testing.T) { + m := New(Options{Seed: 7}) + m.SetSize(80, 30) + for i := 0; i < 500 && m.state == StatePlaying; i++ { + m.Update(keyPress(" ")) // hard drop repeatedly until the stack tops out + } + if m.state != StateGameOver { + t.Fatalf("state = %v after 500 hard drops, want StateGameOver", m.state) + } + if !strings.Contains(stripANSI(m.View()), "UNIVERSE EXPIRED") { + t.Error("game over view should show the UNIVERSE EXPIRED card") + } + m.Update(keyPress("r")) + if m.state != StatePlaying { + t.Error("r should reboot the universe from the game over screen") + } +} + +// Review Focus 4: stray messages must be inert. +func TestUnknownMessagesAreIgnored(t *testing.T) { + m := New(Options{Seed: 7}) + m.SetSize(80, 30) + before := *m.game + for _, msg := range []tea.Msg{ + tea.MouseWheelMsg{}, tea.PasteMsg("qqqq"), struct{ Nonsense int }{42}, nil, + } { + m.Update(msg) + } + if m.game.Active != before.Active || m.game.Score != before.Score || m.state != StatePlaying { + t.Error("a stray message changed game state") + } +} + +// Review Focus 5: shrinking below the minimum and growing back must not disturb the game. +func TestResizeBelowMinimumPreservesGameState(t *testing.T) { + m := New(Options{Seed: 7}) + m.SetSize(80, 30) + m.Update(keyPress(" ")) + before := *m.game + m.Update(tea.WindowSizeMsg{Width: 20, Height: 8}) + small := stripANSI(m.View()) + if !strings.Contains(small, "TOO SMALL") { + t.Errorf("expected the too-small notice at 20x8:\n%s", small) + } + m.Update(tea.WindowSizeMsg{Width: 0, Height: 0}) + _ = m.View() + m.Update(tea.WindowSizeMsg{Width: 80, Height: 30}) + if m.game.Board != before.Board || m.game.Score != before.Score || m.state != StatePlaying { + t.Error("resizing below the minimum and back disturbed the game") + } +} + +func TestQuitKeysReturnQuit(t *testing.T) { + for _, keyStr := range []string{"q", "esc", "ctrl+c"} { + m := New(Options{Seed: 7}) + m.SetSize(80, 30) + if _, cmd := m.Update(keyPress(keyStr)); cmd == nil { + t.Errorf("key %q should return a quit command", keyStr) + } + } +} +``` + +`stripANSI` is needed in `internal/app` tests too — add a copy in `internal/app/strip_test.go` (test-only duplication of four lines is cheaper than an exported helper). + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `go test ./internal/app/ -v` +Expected: FAIL — `undefined: DefaultKeyMap`. + +- [ ] **Step 4: Implement `keys.go`, `update.go` and the real `View`** + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `go test ./... -v` +Expected: PASS. + +- [ ] **Step 6: Play it** + +Run: `go build ./cmd/cosmic-tetris && ./cosmic-tetris --seed 1234` +Check by hand: pieces fall; arrows and `hjkl` move; `space` slams; `c` holds; `p` pauses; `r` restarts; ghost tracks the landing row; the next queue advances; resizing the window does not crash; `q` exits. Holding an arrow key down repeats the movement (terminal auto-repeat delivers one key message per repeat — no custom repeat logic is needed or wanted). + +- [ ] **Step 7: Commit** + +```bash +git add internal/app +git commit -m "feat(app): 60Hz frame clock, immediate input, pause, restart, resize" +``` + +--- + +### Task 8: Pause, help and game-over overlays + +**Files:** +- Modify: `internal/render/render.go`, `internal/app/model.go` +- Test: `internal/render/overlay_test.go`, `internal/render/testdata/` + +**Interfaces:** +- Consumes: `OverlayKind` (Task 5), `bubbles/v2/help`. +- Produces: + ```go + func drawOverlay(c *Canvas, l Layout, f Frame, p Palette, g Glyphs) + // Model.helpBody renders the §39 flight manual using bubbles/help + KeyMap. + func (m *Model) helpBody() string + ``` + +Overlays are centered boxes drawn last, over everything. Pinned copy: + +- Pause (§30): title `TEMPORAL SUSPENSION`, body `SPACE IS PAUSED`, footer `p resume`. +- Game over (§28 final card): `UNIVERSE EXPIRED`, then `SCORE `, `LINES `, `LEVEL `, then `r REBOOT UNIVERSE` and `q ACCEPT COSMIC DEATH`, plus the subtitle `CAUSE: EXCESSIVE GEOMETRY`. +- Help (§39): header `FLIGHT MANUAL`, the key rows exactly as §39 lists them, ending with `? close this nonsense`. + +Box borders use the rounded set in unicode modes and `+-|` in ASCII. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestPauseOverlay(t *testing.T) { + out := renderPlain(Frame{Game: fixtureGame(t), W: 80, H: 30, Mode: ModeFull, Seed: 7, Overlay: OverlayPause}) + for _, want := range []string{"TEMPORAL SUSPENSION", "SPACE IS PAUSED", "p resume"} { + if !strings.Contains(out, want) { + t.Errorf("pause overlay missing %q", want) + } + } + goldenCheck(t, "pause", out) +} + +func TestGameOverOverlay(t *testing.T) { + g := fixtureGame(t) + g.Score, g.Lines, g.Level = 483200, 127, 13 + out := renderPlain(Frame{Game: g, W: 80, H: 30, Mode: ModeFull, Seed: 7, Overlay: OverlayGameOver}) + for _, want := range []string{"UNIVERSE EXPIRED", "483,200", "127", "13", "REBOOT UNIVERSE", "ACCEPT COSMIC DEATH", "CAUSE: EXCESSIVE GEOMETRY"} { + if !strings.Contains(out, want) { + t.Errorf("game over overlay missing %q", want) + } + } + goldenCheck(t, "gameover", out) +} + +func TestHelpOverlay(t *testing.T) { + body := strings.Join([]string{ + "← → / h l move spacecraft", + "↓ / j accelerate doom", + "↑ / k / x rotate geometry", + "z rotate other way", + "SPACE YEET", + "c quantum storage", + "p suspend spacetime", + "r reboot universe", + "q abandon mission", + "? close this nonsense", + }, "\n") + out := renderPlain(Frame{Game: fixtureGame(t), W: 80, H: 30, Mode: ModeFull, Seed: 7, Overlay: OverlayHelp, HelpView: body}) + for _, want := range []string{"FLIGHT MANUAL", "YEET", "quantum storage", "close this nonsense"} { + if !strings.Contains(out, want) { + t.Errorf("help overlay missing %q", want) + } + } + goldenCheck(t, "help", out) +} + +func TestOverlaysStayInsideTheScreen(t *testing.T) { + for _, ov := range []OverlayKind{OverlayPause, OverlayHelp, OverlayGameOver} { + for _, sz := range [][2]int{{40, 24}, {46, 25}, {80, 30}, {200, 60}} { + f := Frame{Game: fixtureGame(t), W: sz[0], H: sz[1], Mode: ModeFull, Seed: 7, Overlay: ov, HelpView: "a\nb\nc"} + lines := strings.Split(renderPlain(f), "\n") + if len(lines) > sz[1] { + t.Errorf("overlay %v at %dx%d produced %d lines", ov, sz[0], sz[1], len(lines)) + } + for _, line := range lines { + if lipgloss.Width(line) > sz[0] { + t.Errorf("overlay %v at %dx%d: line %q is %d wide", ov, sz[0], sz[1], line, lipgloss.Width(line)) + } + } + } + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/render/ -run Overlay -v` +Expected: FAIL — overlays are not drawn. + +- [ ] **Step 3: Implement `drawOverlay` and `Model.helpBody`** + +Build overlay boxes with `lipgloss` (border + padding + `JoinVertical`), then write the rendered lines into the canvas with `SetString` at a centered origin, clipping to the screen. `helpBody` uses `bubbles/v2/help` with `KeyMap.FullHelp()`, with the §39 descriptions as each binding's help text. + +- [ ] **Step 4: Generate the goldens and verify** + +Run: `go test ./internal/render/ -update && go test ./... -v` +Expected: PASS. Read `pause.golden`, `gameover.golden`, `help.golden` — each box must be intact and centered, with the board still visible around it. + +- [ ] **Step 5: Commit** + +```bash +git add internal/render internal/app internal/render/testdata +git commit -m "feat(render): pause, help and game over overlays with goldens" +``` + +--- + +### Task 9: ASCII mode, structural sweep, README + +**Files:** +- Test: `internal/render/ascii_test.go`, `internal/render/sweep_test.go`, `internal/render/testdata/ascii.golden` +- Create: `README.md` +- Modify: `cmd/cosmic-tetris/main.go` (wire `DetectMode`) + +**Interfaces:** +- Consumes: everything above. +- Produces: no new API. `--no-fx` and `--reduced-motion` are parsed and carried in `Options` but have nothing to suppress until plan 3 — assert that they are accepted and change nothing here. + +- [ ] **Step 1: Write the failing ASCII and sweep tests** + +```go +func TestASCIIModeUsesNoNonASCIIRunes(t *testing.T) { + g := fixtureGame(t) + g.Score, g.Lines, g.Level = 129340, 42, 7 + for _, ov := range []OverlayKind{OverlayNone, OverlayPause, OverlayGameOver, OverlayTooSmall} { + out := renderPlain(Frame{Game: g, W: 80, H: 30, Mode: ModeASCII, Seed: 7, Overlay: ov, Mission: "MOON NOTIFIED"}) + for i, r := range out { + if r > unicode.MaxASCII { + t.Fatalf("overlay %v: non-ASCII rune %q at offset %d in ASCII mode", ov, r, i) + } + } + } +} + +func TestASCIIModeStillRendersAPlayableBoard(t *testing.T) { + out := renderPlain(Frame{Game: fixtureGame(t), W: 80, H: 30, Mode: ModeASCII, Seed: 7}) + if !strings.Contains(out, "[]") { + t.Error("ASCII mode should draw pieces as []") + } + if !strings.Contains(out, "SCORE") || !strings.Contains(out, "NEXT") { + t.Error("ASCII mode lost the HUD") + } + goldenCheck(t, "ascii", out) +} + +// Review Focus 2: every size must render safely. +func TestSweepEverySizeRendersWithinBounds(t *testing.T) { + g := fixtureGame(t) + for w := 1; w <= 120; w++ { + for h := 1; h <= 60; h++ { + for _, mode := range []Mode{ModeFull, ModeASCII} { + func() { + defer func() { + if r := recover(); r != nil { + t.Fatalf("panic at %dx%d mode %v: %v", w, h, mode, r) + } + }() + out := renderPlain(Frame{Game: g, W: w, H: h, Mode: mode, Seed: 7, Mission: "STRUCTURAL VIBES: QUESTIONABLE"}) + lines := strings.Split(out, "\n") + if len(lines) > h { + t.Fatalf("%dx%d mode %v: %d lines, want <= %d", w, h, mode, len(lines), h) + } + for _, line := range lines { + if lw := lipgloss.Width(line); lw > w { + t.Fatalf("%dx%d mode %v: line %q is %d columns wide", w, h, mode, line, lw) + } + } + }() + } + } + } +} + +func TestSweepWithEveryOverlay(t *testing.T) { + g := fixtureGame(t) + for _, ov := range []OverlayKind{OverlayPause, OverlayHelp, OverlayGameOver, OverlayTooSmall} { + for _, sz := range [][2]int{{1, 1}, {12, 6}, {39, 23}, {40, 24}, {41, 25}, {120, 60}} { + f := Frame{Game: g, W: sz[0], H: sz[1], Mode: ModeFull, Seed: 7, Overlay: ov, HelpView: "x\ny"} + out := renderPlain(f) + if lines := strings.Split(out, "\n"); len(lines) > sz[1] { + t.Errorf("overlay %v at %dx%d: %d lines", ov, sz[0], sz[1], len(lines)) + } + } + } +} +``` + +- [ ] **Step 2: Run them** + +Run: `go test ./internal/render/ -run 'ASCII|Sweep' -v` +Expected: FAIL at first — expect real bugs here (off-by-one clipping, a unicode glyph leaking into ASCII mode). Fix the renderer for each failure; the sweep is the contract. + +- [ ] **Step 3: Generate the ASCII golden** + +Run: `go test ./internal/render/ -update && go test ./... -v` +Expected: PASS; read `ascii.golden` and confirm it is a readable game in pure ASCII. + +- [ ] **Step 4: Wire `DetectMode` into main** + +In `cmd/cosmic-tetris/main.go`, compute `render.DetectMode(os.Getenv("TERM"), os.Getenv("COLORTERM"), *asciiFlag)` and pass the mode through `Options` into the model. Verify by hand: + +```bash +./cosmic-tetris --ascii --seed 1 +./cosmic-tetris --no-fx --seed 1 +./cosmic-tetris --reduced-motion --seed 1 +TERM=dumb ./cosmic-tetris --seed 1 +``` + +Expected: all four run and play. `--ascii` and `TERM=dumb` show `[]` pieces; `--no-fx` and `--reduced-motion` are accepted and behave like the default (they have nothing to suppress yet). + +- [ ] **Step 5: Write `README.md`** + +Cover: what it is (one paragraph, in the spirit of §48), `go build ./cmd/cosmic-tetris`, the §8 control table, the five flags from §49.5, the 40×24 minimum, and a "how it's built" paragraph naming `internal/game` (deterministic, clock-free), `internal/render` (pure), `internal/app` (Bubble Tea). + +- [ ] **Step 6: Final verification** + +Run: `go test -race ./... && go vet ./... && gofmt -l .` +Expected: PASS, clean, empty. + +- [ ] **Step 7: Commit** + +```bash +git add internal/render README.md cmd/cosmic-tetris/main.go +git commit -m "feat(render): ASCII fallback, full-size sweep tests and README" +``` + +--- + +## Done when + +- `./cosmic-tetris` is a genuinely good game: pieces fall, controls respond instantly, ghost and next queue and hold all work, lines clear, gravity increases, pause and restart and quit work, game over shows the card. +- Goldens exist and pass for wide, medium, small, pause, game over, help, ASCII, too-small (§41). +- The size sweep passes: no panic, no line wider than the terminal, never more lines than rows, at every size from 1×1 to 120×60 in two modes. +- All five CLI flags are accepted; `--ascii` visibly changes the glyph set. +- `go test -race ./...` passes, `go vet` is clean, `gofmt -l .` is empty. diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/N1-recipe-cosmic-12/2026-09-17-cosmic-tetris-03-cosmic-fx.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/N1-recipe-cosmic-12/2026-09-17-cosmic-tetris-03-cosmic-fx.md new file mode 100644 index 000000000..f130497f2 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/N1-recipe-cosmic-12/2026-09-17-cosmic-tetris-03-cosmic-fx.md @@ -0,0 +1,2765 @@ +# Cosmic Tetris — Plan 3 of 3: Cosmic Effects Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Wrap the playable game in an outrageous effects layer — starfield, animated border, ion trails, hard-drop impact and shake, supernova line clears, hyperdrive, shockwaves, four-line banners, mission-control commentary, boot sequence, and a game-over black hole — such that a four-line clear produces an immediate "LOL WHAT THE FUCK" and none of it ever touches game state. + +**Architecture:** `internal/fx` is an independent simulation: it *observes* `game.Event` values and a read-only snapshot, integrates its own particles against elapsed time, and exposes a flat list of screen-space overlay cells plus a few scalar readouts (shake offset, border energy, banner, board freeze). It holds its own `*rand.Rand`, never receives a `*game.Game`, and cannot compile against one. `internal/flavor` is the mission-control text channel with the same shape. `internal/render` gains an `FX` field on `Frame` and composites overlay cells into the existing `Canvas` under strict rules: never over the active piece, never outside the viewport. + +**Tech Stack:** Go 1.26, `charm.land/bubbletea/v2`, `charm.land/lipgloss/v2`, `charm.land/bubbles/v2` (spinner for the boot screen). + +**Spec:** `design.md` (this plan implements §14–§25, §27, §28, §29, §30 star drift, §43, §44, §45, §49.5) + +**Plan sequence:** Plan 3 of 3. Requires plan 1 (`...-01-engine.md`) and plan 2 (`...-02-terminal.md`) complete and merged. Every `game.*` and `render.*` name used here comes from their Interfaces blocks. + +## Global Constraints + +- **`internal/fx` and `internal/flavor` may import `internal/game`, and nothing else of ours.** They must not import `internal/render` or `internal/app`. `internal/fx` must never hold a `*game.Game` — it sees only `[]game.Event` and a value snapshot. A test enforces the import list. +- **Effects may never modify game state** (§14, §44). All FX inputs are values or read-only slices. +- **FX randomness uses a separate `*rand.Rand` from the game's** (§35, §49.6). `fx.World` and `flavor.Channel` each own one, seeded from the CLI seed by a fixed offset. Crossing them with the game RNG is the one unforgivable bug in this plan. +- Never obscure the active piece. Never make controls lag. Never delay gameplay for animation. Never make screen shake exceed roughly one cell. Never let particles permanently alter the rendered board (§44). +- Every glyph FX emits must be **single-width**. Plan 2's `TestEverySingleRuneGlyphIsSingleWidth` is extended in Task 1 to cover the new glyph sets. Two spec glyphs are substituted for width safety, documented where they appear: §24's ring set `○ ◌ ◯` becomes `· ∘ o O` (`. o O 0` in ASCII), and §28's black-hole core `●` becomes `██` (`##` in ASCII). +- No goroutine per particle or per frame; no filesystem access or synchronous logging during gameplay; a few hundred particles must be trivial (§38). +- Particle count is hard-capped at `MaxParticles = 600`. Emissions past the cap are dropped, not queued. +- `--no-fx` disables the whole visual simulation: no stars, particles, trails, shake, banners, or shockwaves, and a static board border. Mission-control text and the level-up notice remain — they are information, and the boring mode must still be a good game (§32). +- `--reduced-motion` suppresses **screen shake, hyperdrive acceleration, and shockwaves** and leaves color, trails, and particles alone (§49.5). +- Every task ends with `go test -race ./...` passing, `go vet ./...` clean, `gofmt -l .` empty, and the plan-2 goldens still passing (regenerate deliberately when a task is supposed to change them). + +## Review Focus + +Failure modes the spec implies but does not spell out. Each has a test in the task that owns the code. + +1. **Sustained play and repeated four-line clears.** Particle storage must stay bounded at `MaxParticles` and must not grow its backing slice without limit or allocate per particle per frame — otherwise a long game degrades into a slideshow, violating "never make controls lag". → Task 1. +2. **A `dt` spike from a suspended terminal, and a zero/negative `dt`.** Integrating 30 s in one step sends positions to ±10⁶ or NaN and can make a ring expand forever; the simulation must clamp its step and stay finite. → Task 1. +3. **Overlay cells landing outside the viewport, on the board border, or on the active piece.** All three must be refused by the compositor rather than corrupting the frame or hiding the piece the player is steering (§44). → Task 2. +4. **`--no-fx` and `--reduced-motion` on every path.** No nil-pointer dereference, shake exactly `(0,0)`, no hyperdrive or shockwave cells, and the game still fully playable and readable. → Task 12. +5. **Events arriving while paused, and after game over.** Paused freezes gameplay particles while background stars keep drifting very slowly (§30); after game over nothing may resurrect the normal board or spawn new gameplay particles. → Task 11. + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `internal/fx/particle.go` | `Particle`, the integrator, the fixed-capacity pool. | +| `internal/fx/events.go` | `Config`, `Geometry`, `Snapshot`, `Cell`, `Role`, `Layer`, and the event fan-out in `Observe`. | +| `internal/fx/world.go` | `World`: timers for every effect, `Advance`, `Cells`, `ShakeOffset`, `BorderEnergy`, `Banner`, `Notice`, `BoardFreeze`, game-over phase. | +| `internal/fx/starfield.go` | Three star layers, drift, level scaling, hyperdrive, shooting stars. | +| `internal/flavor/messages.go` | Mission-control message tables and the `Channel` cooldown state machine. | +| `internal/render/fx.go` | `drawFX`: composite overlay cells with clipping and the active-piece guard. | +| `internal/render/render.go` | `Frame.FX`, shake offset applied to the board, banner and notice drawing, boot and collapse overlays. | +| `internal/app/*` | Wire `fx.World` and `flavor.Channel` into the frame loop; boot and game-over states. | + +--- + +### Task 1: Particle simulation + +**Files:** +- Create: `internal/fx/particle.go` +- Test: `internal/fx/particle_test.go` + +**Interfaces:** +- Consumes: nothing outside the standard library. +- Produces: + ```go + const ( + MaxParticles = 600 + MaxStep = 50 * time.Millisecond // integration step ceiling + ) + + type Particle struct { + X, Y float64 // screen cells; Y grows downward + VX, VY float64 // cells per second + AX, AY float64 // cells per second squared + Drag float64 // per-second multiplier, e.g. 0.9 + Life float64 // seconds remaining + MaxLife float64 + Glyph rune + Role Role + Kind game.PieceKind // Empty unless the particle inherits a piece color + Layer Layer + } + + // pool is a fixed-capacity particle store. Dead slots are reused; when the + // pool is full, Spawn drops the request and returns false. + type pool struct { /* items [MaxParticles]Particle; live int */ } + func (p *pool) Spawn(pt Particle) bool + func (p *pool) Advance(dt time.Duration, w, h int) // integrate, age, cull + func (p *pool) Len() int + func (p *pool) Each(fn func(*Particle)) + func (p *pool) Clear() + func (p *pool) ClearLayer(l Layer) + ``` + +Pinned integration (§23), applied per sub-step of at most `MaxStep`: + +``` +position += velocity * dt +velocity += acceleration * dt +velocity *= pow(Drag, dt) // Drag as a per-second factor +life -= dt +``` + +Cull when `Life <= 0`, when `X < -2 || X > w+2 || Y < -2 || Y > h+2`, or when any field is not finite. `Advance` splits a large `dt` into at most 4 sub-steps of `MaxStep` and discards the remainder — a suspended terminal should resume with a clean sky, not simulate 30 seconds (Review Focus 2). + +- [ ] **Step 1: Write the failing tests** + +```go +func TestIntegrationMovesAndAges(t *testing.T) { + var p pool + p.Spawn(Particle{X: 5, Y: 5, VX: 10, VY: -4, Drag: 1, Life: 1, MaxLife: 1, Glyph: '*'}) + p.Advance(100*time.Millisecond, 80, 30) + var got Particle + p.Each(func(q *Particle) { got = *q }) + if math.Abs(got.X-6) > 0.01 || math.Abs(got.Y-4.6) > 0.01 { + t.Errorf("position = (%.3f,%.3f), want (6.0,4.6)", got.X, got.Y) + } + if math.Abs(got.Life-0.9) > 0.001 { + t.Errorf("Life = %.3f, want 0.9", got.Life) + } +} + +func TestAccelerationAndDragApply(t *testing.T) { + var p pool + p.Spawn(Particle{X: 0, Y: 0, VX: 10, AY: 20, Drag: 0.5, Life: 5, MaxLife: 5, Glyph: '*'}) + p.Advance(time.Second, 80, 30) + var got Particle + p.Each(func(q *Particle) { got = *q }) + if got.VY <= 0 { + t.Errorf("VY = %.3f, want positive after gravity", got.VY) + } + if got.VX >= 10 { + t.Errorf("VX = %.3f, want less than 10 after drag", got.VX) + } +} + +func TestDeadParticlesAreCulled(t *testing.T) { + var p pool + p.Spawn(Particle{Life: 0.05, MaxLife: 0.05, Drag: 1, Glyph: '*'}) + p.Advance(100*time.Millisecond, 80, 30) + if p.Len() != 0 { + t.Errorf("Len = %d, want 0 after the particle expired", p.Len()) + } +} + +func TestOffscreenParticlesAreCulled(t *testing.T) { + var p pool + p.Spawn(Particle{X: 79, Y: 5, VX: 500, Drag: 1, Life: 10, MaxLife: 10, Glyph: '*'}) + p.Spawn(Particle{X: 5, Y: 29, VY: 500, Drag: 1, Life: 10, MaxLife: 10, Glyph: '*'}) + p.Advance(100*time.Millisecond, 80, 30) + if p.Len() != 0 { + t.Errorf("Len = %d, want 0 after both left the viewport", p.Len()) + } +} + +// Review Focus 1: bounded storage under sustained emission. +func TestPoolIsCappedAndReusesSlots(t *testing.T) { + var p pool + for i := 0; i < MaxParticles*3; i++ { + p.Spawn(Particle{X: 5, Y: 5, Drag: 1, Life: 10, MaxLife: 10, Glyph: '*'}) + } + if p.Len() != MaxParticles { + t.Errorf("Len = %d, want the cap %d", p.Len(), MaxParticles) + } + if ok := p.Spawn(Particle{Life: 1, MaxLife: 1, Drag: 1}); ok { + t.Error("Spawn should report false when the pool is full") + } + // Age everything out, then confirm slots are reusable. + p.Advance(20*time.Second, 80, 30) + if p.Len() != 0 { + t.Fatalf("Len = %d after aging out, want 0", p.Len()) + } + if ok := p.Spawn(Particle{Life: 1, MaxLife: 1, Drag: 1, Glyph: '*'}); !ok { + t.Error("Spawn should succeed again once slots are free") + } +} + +func TestPoolDoesNotAllocatePerFrame(t *testing.T) { + var p pool + for i := 0; i < 200; i++ { + p.Spawn(Particle{X: 10, Y: 10, VX: 0.1, Drag: 0.99, Life: 100, MaxLife: 100, Glyph: '*'}) + } + allocs := testing.AllocsPerRun(50, func() { p.Advance(16*time.Millisecond, 80, 30) }) + if allocs > 0 { + t.Errorf("Advance allocated %.1f times per frame, want 0", allocs) + } +} + +// Review Focus 2: dt spikes and degenerate dt. +func TestLargeDtIsClampedAndStaysFinite(t *testing.T) { + var p pool + p.Spawn(Particle{X: 40, Y: 15, VX: 3, VY: 3, AY: 30, Drag: 0.9, Life: 100, MaxLife: 100, Glyph: '*'}) + p.Advance(30*time.Second, 80, 30) + p.Each(func(q *Particle) { + for _, v := range []float64{q.X, q.Y, q.VX, q.VY} { + if math.IsNaN(v) || math.IsInf(v, 0) { + t.Fatalf("non-finite particle after a 30s step: %+v", *q) + } + } + }) +} + +func TestZeroAndNegativeDtAreNoOps(t *testing.T) { + var p pool + p.Spawn(Particle{X: 5, Y: 5, VX: 10, Drag: 1, Life: 1, MaxLife: 1, Glyph: '*'}) + p.Advance(0, 80, 30) + p.Advance(-time.Second, 80, 30) + var got Particle + p.Each(func(q *Particle) { got = *q }) + if got.X != 5 || got.Life != 1 { + t.Errorf("degenerate dt changed the particle: %+v", got) + } +} + +func TestClearLayerLeavesOtherLayers(t *testing.T) { + var p pool + p.Spawn(Particle{Layer: LayerBackground, Life: 5, MaxLife: 5, Drag: 1, Glyph: '.'}) + p.Spawn(Particle{Layer: LayerBoard, Life: 5, MaxLife: 5, Drag: 1, Glyph: '*'}) + p.ClearLayer(LayerBoard) + if p.Len() != 1 { + t.Fatalf("Len = %d, want 1", p.Len()) + } + p.Each(func(q *Particle) { + if q.Layer != LayerBackground { + t.Errorf("surviving particle is on layer %v, want LayerBackground", q.Layer) + } + }) +} +``` + +- [ ] **Step 2: Declare `Role` and `Layer` so the tests compile** + +Add to `internal/fx/events.go`: + +```go +type Layer int +const ( + LayerBackground Layer = iota // behind everything: stars + LayerBoard // inside the board box: trails, debris, clear animation + LayerGlobal // over the whole screen: shockwaves, hyperdrive streaks +) + +type Role int +const ( + RoleStarFar Role = iota + RoleStarMid + RoleStarNear + RoleTrail + RoleDebris + RoleImpact + RoleShock + RoleClear + RoleCollapse +) +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `go test ./internal/fx/ -v` +Expected: FAIL — `undefined: pool`. + +- [ ] **Step 4: Implement `particle.go`** + +Back the pool with a fixed `[MaxParticles]Particle` array plus a parallel `alive [MaxParticles]bool` (or a `Life > 0` test). `Advance` guards `dt <= 0`, splits into sub-steps, and compacts nothing — slots are reused in place, which is what keeps allocations at zero. + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `go test ./internal/fx/ -v` +Expected: PASS, including the zero-allocation test. If `TestPoolDoesNotAllocatePerFrame` fails, look for a closure or slice append in the hot path. + +- [ ] **Step 6: Commit** + +```bash +git add internal/fx +git commit -m "feat(fx): bounded particle pool with clamped integration" +``` + +--- + +### Task 2: World, event observation and the render compositor + +**Files:** +- Create: `internal/fx/world.go`, `internal/render/fx.go` +- Modify: `internal/fx/events.go`, `internal/render/render.go`, `internal/render/palette.go`, `internal/app/model.go`, `internal/app/update.go` +- Test: `internal/fx/world_test.go`, `internal/render/fx_test.go` + +**Interfaces:** +- Consumes: Task 1's pool, `game.Event`, plan 2's `Canvas`/`Frame`/`Layout`. +- Produces: + ```go + // internal/fx + type Config struct { + Seed int64 + Enabled bool // false with --no-fx + ReducedMotion bool + ASCII bool + } + type Geometry struct { + ScreenW, ScreenH int + BoardX, BoardY int // top-left of the board's border box + CellCols int // 2 + } + type Snapshot struct { + Level, Combo, Score int + Board game.Board + Active game.Piece + Paused bool + } + type Cell struct { + X, Y int + Glyph rune + Role Role + Brightness float64 // 0..1 + Kind game.PieceKind // Empty unless the cell inherits a piece color + Layer Layer + } + + type World struct{ /* ... */ } + func NewWorld(cfg Config) *World + func (w *World) Resize(g Geometry) + func (w *World) Observe(evts []game.Event, s Snapshot) + func (w *World) Advance(dt time.Duration) + func (w *World) Cells(dst []Cell) []Cell // appends into dst; reuse the slice + func (w *World) ShakeOffset() (dx, dy int) + func (w *World) Count() int + + // internal/render + type FXView interface { + Cells(dst []fx.Cell) []fx.Cell + ShakeOffset() (int, int) + } + // Frame gains: FX FXView (nil means no effects) + func drawFX(c *Canvas, l Layout, f Frame, p Palette, layer fx.Layer, guard func(x, y int) bool) + func (p Palette) FXStyle(role fx.Role, brightness float64, kind game.PieceKind) lipgloss.Style + ``` + +Pinned compositor rules (§37 steps 2, 6, 9 and §44): + +- `drawFX` is called three times per frame: `LayerBackground` before the board, `LayerBoard` after the ghost, `LayerGlobal` after the HUD. +- A cell is dropped when it falls outside the canvas (the `Canvas` already refuses, but `drawFX` must not compute a style for it), when `guard(x, y)` returns false, or when its brightness rounds to zero. +- The board-layer guard refuses any screen cell occupied by the **active piece** or by the **board border**, so effects can never obscure the piece the player is steering, and the border stays a clean frame. +- `Cells(dst)` appends into a caller-owned slice; `render` keeps one `[]fx.Cell` buffer on the `Frame`'s behalf (a package-level `sync.Pool` is not needed — pass a reused slice through `Frame`... simplest: `render` allocates once per `Render` call and the sweep test asserts it does not grow unbounded). + +- [ ] **Step 1: Write the failing fx tests** + +```go +func TestWorldRequiresNoGameReference(t *testing.T) { + // This is a compile-time guarantee documented as a test: Observe takes + // values only, so a *game.Game cannot reach the FX simulation. + w := NewWorld(Config{Seed: 1, Enabled: true}) + w.Resize(Geometry{ScreenW: 80, ScreenH: 30, BoardX: 10, BoardY: 3, CellCols: 2}) + w.Observe([]game.Event{game.PieceLocked{}}, Snapshot{Level: 1}) + w.Advance(16 * time.Millisecond) + _ = w.Cells(nil) +} + +func TestDisabledWorldProducesNothing(t *testing.T) { + w := NewWorld(Config{Seed: 1, Enabled: false}) + w.Resize(Geometry{ScreenW: 80, ScreenH: 30, BoardX: 10, BoardY: 3, CellCols: 2}) + for i := 0; i < 100; i++ { + w.Observe([]game.Event{game.PieceHardDropped{Cells: 9}, game.LinesCleared{Count: 4}}, Snapshot{Level: 3}) + w.Advance(16 * time.Millisecond) + } + if got := w.Cells(nil); len(got) != 0 { + t.Errorf("--no-fx world produced %d cells, want 0", len(got)) + } + if dx, dy := w.ShakeOffset(); dx != 0 || dy != 0 { + t.Errorf("--no-fx shake = (%d,%d), want (0,0)", dx, dy) + } +} + +func TestWorldIsDeterministicForASeed(t *testing.T) { + run := func() string { + w := NewWorld(Config{Seed: 99, Enabled: true}) + w.Resize(Geometry{ScreenW: 80, ScreenH: 30, BoardX: 10, BoardY: 3, CellCols: 2}) + var sb strings.Builder + for i := 0; i < 60; i++ { + w.Observe([]game.Event{game.PieceMoved{DX: 1}}, Snapshot{Level: 2}) + w.Advance(16 * time.Millisecond) + for _, c := range w.Cells(nil) { + fmt.Fprintf(&sb, "%d,%d,%c;", c.X, c.Y, c.Glyph) + } + } + return sb.String() + } + if a, b := run(), run(); a != b { + t.Error("the same FX seed produced different output") + } +} + +func TestFXRNGDoesNotTouchTheGameRNG(t *testing.T) { + // Draw a piece sequence while an FX world consumes plenty of randomness. + seq := func(withFX bool) []game.PieceKind { + g := game.New(1234) + w := NewWorld(Config{Seed: 1234, Enabled: true}) + w.Resize(Geometry{ScreenW: 80, ScreenH: 30, BoardX: 10, BoardY: 3, CellCols: 2}) + var out []game.PieceKind + for i := 0; i < 30; i++ { + out = append(out, g.Active.Kind) + evts := g.Input(game.ActionHardDrop) + if withFX { + w.Observe(evts, Snapshot{Level: g.Level}) + w.Advance(16 * time.Millisecond) + } + } + return out + } + if !reflect.DeepEqual(seq(false), seq(true)) { + t.Error("running the FX world changed the piece order — the RNGs are crossed (design.md §35, §49.6)") + } +} + +func TestCellsAppendsIntoTheProvidedSlice(t *testing.T) { + w := NewWorld(Config{Seed: 1, Enabled: true}) + w.Resize(Geometry{ScreenW: 80, ScreenH: 30, BoardX: 10, BoardY: 3, CellCols: 2}) + w.Advance(100 * time.Millisecond) + buf := make([]Cell, 0, 64) + got := w.Cells(buf) + if cap(got) != cap(buf) && len(got) <= cap(buf) { + t.Error("Cells should append into the caller's slice rather than allocating a new one") + } +} +``` + +- [ ] **Step 2: Write the failing compositor tests** + +```go +type stubFX struct { + cells []fx.Cell + dx, dy int +} + +func (s stubFX) Cells(dst []fx.Cell) []fx.Cell { return append(dst, s.cells...) } +func (s stubFX) ShakeOffset() (int, int) { return s.dx, s.dy } + +// Review Focus 3: the compositor must clip and must protect the active piece. +func TestFXCellsAreClippedToTheScreen(t *testing.T) { + g := fixtureGame(t) + stub := stubFX{cells: []fx.Cell{ + {X: -5, Y: 2, Glyph: '*', Brightness: 1, Layer: fx.LayerGlobal}, + {X: 500, Y: 2, Glyph: '*', Brightness: 1, Layer: fx.LayerGlobal}, + {X: 4, Y: -9, Glyph: '*', Brightness: 1, Layer: fx.LayerGlobal}, + {X: 4, Y: 900, Glyph: '*', Brightness: 1, Layer: fx.LayerGlobal}, + }} + out := renderPlain(Frame{Game: g, W: 80, H: 30, Mode: ModeFull, Seed: 7, FX: stub}) + lines := strings.Split(out, "\n") + if len(lines) > 30 { + t.Errorf("%d lines, want <= 30", len(lines)) + } + for _, line := range lines { + if lipgloss.Width(line) > 80 { + t.Errorf("line %q is %d columns wide", line, lipgloss.Width(line)) + } + } +} + +func TestFXNeverOverwritesTheActivePiece(t *testing.T) { + g := fixtureGame(t) + l := Compute(80, 30) + var cells []fx.Cell + for _, c := range g.Active.Cells() { + if c[1] < game.HiddenRows { + continue + } + y := l.BoardY + 1 + c[1] - game.HiddenRows + x := l.BoardX + 1 + c[0]*CellCols + cells = append(cells, + fx.Cell{X: x, Y: y, Glyph: '*', Brightness: 1, Layer: fx.LayerBoard}, + fx.Cell{X: x + 1, Y: y, Glyph: '*', Brightness: 1, Layer: fx.LayerBoard}) + } + if len(cells) == 0 { + t.Skip("active piece is entirely in the hidden rows") + } + lines := strings.Split(renderPlain(Frame{Game: g, W: 80, H: 30, Mode: ModeFull, Seed: 7, FX: stubFX{cells: cells}}), "\n") + for _, c := range cells { + row := []rune(padTo(lines[c.Y], 80)) + if row[c.X] == '*' { + t.Fatalf("an FX cell overwrote the active piece at (%d,%d) (design.md §44)", c.X, c.Y) + } + } +} + +func TestFXNeverOverwritesTheBoardBorder(t *testing.T) { + g := fixtureGame(t) + l := Compute(80, 30) + cells := []fx.Cell{ + {X: l.BoardX, Y: l.BoardY + 5, Glyph: '*', Brightness: 1, Layer: fx.LayerBoard}, + {X: l.BoardX + BoardBoxW - 1, Y: l.BoardY + 5, Glyph: '*', Brightness: 1, Layer: fx.LayerBoard}, + } + lines := strings.Split(renderPlain(Frame{Game: g, W: 80, H: 30, Mode: ModeFull, Seed: 7, FX: stubFX{cells: cells}}), "\n") + row := []rune(padTo(lines[l.BoardY+5], 80)) + if row[l.BoardX] == '*' || row[l.BoardX+BoardBoxW-1] == '*' { + t.Error("a board-layer FX cell overwrote the board border") + } +} + +func TestBackgroundFXAppearsOutsideTheBoard(t *testing.T) { + g := fixtureGame(t) + l := Compute(80, 30) + x := l.BoardX - 3 + if x < 0 { + t.Skip("no room left of the board") + } + cells := []fx.Cell{{X: x, Y: l.BoardY + 4, Glyph: '✦', Brightness: 1, Layer: fx.LayerBackground}} + lines := strings.Split(renderPlain(Frame{Game: g, W: 80, H: 30, Mode: ModeFull, Seed: 7, FX: stubFX{cells: cells}}), "\n") + row := []rune(padTo(lines[l.BoardY+4], 80)) + if row[x] != '✦' { + t.Errorf("background FX cell not rendered: got %q", row[x]) + } +} + +func TestShakeOffsetsTheBoardByAtMostOneCell(t *testing.T) { + g := fixtureGame(t) + base := Compute(80, 30) + for _, off := range [][2]int{{0, 1}, {-1, 0}, {1, 0}, {0, -1}} { + lines := strings.Split(renderPlain(Frame{ + Game: g, W: 80, H: 30, Mode: ModeFull, Seed: 7, FX: stubFX{dx: off[0], dy: off[1]}, + }), "\n") + y := base.BoardY + off[1] + row := []rune(padTo(lines[y], 80)) + if row[base.BoardX+off[0]] != GlyphsFor(ModeFull).BorderTL { + t.Errorf("shake %v: board top-left corner is not at the shifted position", off) + } + } +} + +func TestNilFXRendersExactlyLikeNoFX(t *testing.T) { + g := fixtureGame(t) + a := renderPlain(Frame{Game: g, W: 80, H: 30, Mode: ModeFull, Seed: 7}) + b := renderPlain(Frame{Game: g, W: 80, H: 30, Mode: ModeFull, Seed: 7, FX: nil}) + if a != b { + t.Error("a nil FX field must be a no-op") + } +} +``` + +- [ ] **Step 3: Run both test sets to verify they fail** + +Run: `go test ./internal/fx/ ./internal/render/ -v` +Expected: FAIL — `undefined: NewWorld`, `Frame has no field FX`. + +- [ ] **Step 4: Implement `world.go`, `render/fx.go` and the `Frame.FX` plumbing** + +`World` holds the pool, `Geometry`, `Config`, its own `rand.New(rand.NewSource(cfg.Seed ^ 0x5FC05_1CE))` — any fixed non-zero constant will do; the point is that the FX stream is derived from the CLI seed yet never shares state with the game's generator — and one timer field per effect (all zero for now). `Observe` is a type switch that will gain a case per task; for now it records the snapshot and ignores events. `Advance` returns early when `!cfg.Enabled`. + +`drawFX` maps each cell through `Palette.FXStyle` and writes it with `Canvas.Set`. Build the active-piece and border guard once per frame as a small `func(x, y int) bool` closure over the layout and the active piece's screen cells. + +`Palette.FXStyle`: `RoleTrail` and `RoleCollapse` use the piece color for `Kind` scaled by brightness; stars use a white-to-slate ramp (`RoleStarFar` dimmest); `RoleDebris`/`RoleImpact` use accent cyan into hot white; `RoleShock` uses magenta; `RoleClear` uses hot white. Brightness scales the color's RGB toward `#000000`. + +Wire `internal/app`: construct `fx.NewWorld(fx.Config{Seed: opts.Seed, Enabled: !opts.NoFX, ReducedMotion: opts.ReducedMotion, ASCII: mode == render.ModeASCII})` in `New`; call `Resize` from `SetSize` and whenever the layout changes; in the `FrameMsg` branch call `w.Observe(evts, snapshot)` then `w.Advance(dt)`; put the world on `Frame.FX`. + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `go test -race ./... -v` +Expected: PASS. Plan 2's goldens must still pass unchanged — FX emits nothing yet. + +- [ ] **Step 6: Commit** + +```bash +git add internal/fx internal/render internal/app +git commit -m "feat(fx): world skeleton, event observation and guarded render compositing" +``` + +--- + +### Task 3: Starfield + +**Files:** +- Create: `internal/fx/starfield.go` +- Modify: `internal/fx/world.go` +- Test: `internal/fx/starfield_test.go` + +**Interfaces:** +- Consumes: `pool`, `Geometry`, `Config`. +- Produces: + ```go + type starfield struct{ /* ... */ } + func newStarfield(rng *rand.Rand, g Geometry, ascii bool) starfield + func (s *starfield) resize(g Geometry) + func (s *starfield) advance(dt time.Duration, level int, speedMul float64, frozen bool) + func (s *starfield) cells(dst []Cell) []Cell + func (s *starfield) boost(extraDensity float64, d time.Duration) // §20 star density bump + ``` + +Pinned behavior (§15, §30, §45): + +- Three depth layers with fixed proportions of the total star budget: far 50 %, mid 33 %, near 17 %. Budget = `ScreenW * ScreenH / 28`, clamped to `[20, 240]`. +- Base downward drift, in cells per second: far `0.6`, mid `1.6`, near `4.0`. +- Level scaling: `speed *= 1 + 0.04*(level-1)`, capped at `2.0×` — "subtly increases" (§15). +- Glyphs: far `.`, mid `·` and `˚`, near `✦` and `✧`, with `*` used occasionally in the near layer. ASCII mode: far `.`, mid `.` and `:`, near `+` and `*`. +- Brightness: far `0.35`, mid `0.6`, near `1.0`. +- A star leaving the bottom respawns at a random column on row `-1`, so density is constant and no allocation happens. +- `frozen` (paused, §30) slows every layer to `0.15×` rather than stopping — "background stars may continue drifting very slowly". +- Shooting star (§45): with probability `0.012` per second, spawn a near-layer particle with a diagonal velocity and a 3-cell trail, lasting ~500 ms. + +- [ ] **Step 1: Write the failing tests** + +```go +func geom() Geometry { return Geometry{ScreenW: 80, ScreenH: 30, BoardX: 29, BoardY: 3, CellCols: 2} } + +func TestStarfieldPopulatesThreeLayers(t *testing.T) { + w := NewWorld(Config{Seed: 3, Enabled: true}) + w.Resize(geom()) + w.Advance(16 * time.Millisecond) + counts := map[Role]int{} + for _, c := range w.Cells(nil) { + counts[c.Role]++ + } + for _, r := range []Role{RoleStarFar, RoleStarMid, RoleStarNear} { + if counts[r] == 0 { + t.Errorf("no stars on layer %v", r) + } + } + if counts[RoleStarFar] <= counts[RoleStarNear] { + t.Errorf("far layer (%d) should be denser than the near layer (%d)", counts[RoleStarFar], counts[RoleStarNear]) + } +} + +func TestStarsDriftDownward(t *testing.T) { + w := NewWorld(Config{Seed: 3, Enabled: true}) + w.Resize(geom()) + w.Advance(16 * time.Millisecond) + before := averageY(w.Cells(nil), RoleStarNear) + w.Advance(500 * time.Millisecond) + after := averageY(w.Cells(nil), RoleStarNear) + if !(after > before) { + t.Errorf("near stars did not drift down: %.2f -> %.2f", before, after) + } +} + +func averageY(cells []Cell, r Role) float64 { + var sum, n float64 + for _, c := range cells { + if c.Role == r { + sum += float64(c.Y) + n++ + } + } + if n == 0 { + return 0 + } + return sum / n +} + +func TestStarDensityIsStableOverTime(t *testing.T) { + w := NewWorld(Config{Seed: 3, Enabled: true}) + w.Resize(geom()) + w.Advance(16 * time.Millisecond) + first := len(w.Cells(nil)) + for i := 0; i < 600; i++ { + w.Advance(16 * time.Millisecond) + } + last := len(w.Cells(nil)) + if last < first/2 { + t.Errorf("star count fell from %d to %d — stars are not respawning", first, last) + } + if last > MaxParticles { + t.Errorf("star count %d exceeds the particle cap", last) + } +} + +func TestHigherLevelsDriftFaster(t *testing.T) { + measure := func(level int) float64 { + w := NewWorld(Config{Seed: 3, Enabled: true}) + w.Resize(geom()) + w.Observe(nil, Snapshot{Level: level}) + w.Advance(16 * time.Millisecond) + before := averageY(w.Cells(nil), RoleStarMid) + for i := 0; i < 30; i++ { + w.Observe(nil, Snapshot{Level: level}) + w.Advance(16 * time.Millisecond) + } + return averageY(w.Cells(nil), RoleStarMid) - before + } + slow, fast := measure(1), measure(12) + if !(fast > slow) { + t.Errorf("level 12 drift %.3f should exceed level 1 drift %.3f", fast, slow) + } + if fast > slow*3 { + t.Errorf("level 12 drift %.3f is more than 3x level 1 %.3f — §15 says subtle", fast, slow) + } +} + +func TestPausedStarsDriftVerySlowly(t *testing.T) { + w := NewWorld(Config{Seed: 3, Enabled: true}) + w.Resize(geom()) + w.Observe(nil, Snapshot{Level: 1, Paused: true}) + w.Advance(16 * time.Millisecond) + before := averageY(w.Cells(nil), RoleStarMid) + for i := 0; i < 30; i++ { + w.Observe(nil, Snapshot{Level: 1, Paused: true}) + w.Advance(16 * time.Millisecond) + } + moved := averageY(w.Cells(nil), RoleStarMid) - before + if moved < 0 { + t.Error("paused stars should still drift downward, if slowly") + } + if moved > 1.0 { + t.Errorf("paused stars moved %.2f rows in half a second; §30 wants very slow drift", moved) + } +} + +func TestStarsStayInsideTheViewport(t *testing.T) { + w := NewWorld(Config{Seed: 3, Enabled: true}) + w.Resize(geom()) + for i := 0; i < 300; i++ { + w.Advance(16 * time.Millisecond) + for _, c := range w.Cells(nil) { + if c.X < 0 || c.X >= 80 || c.Y < 0 || c.Y >= 30 { + t.Fatalf("star at (%d,%d) is outside an 80x30 viewport", c.X, c.Y) + } + } + } +} + +func TestResizeRebuildsTheStarfieldWithoutPanicking(t *testing.T) { + w := NewWorld(Config{Seed: 3, Enabled: true}) + for _, sz := range [][2]int{{80, 30}, {40, 24}, {1, 1}, {0, 0}, {200, 60}, {40, 24}} { + w.Resize(Geometry{ScreenW: sz[0], ScreenH: sz[1], BoardX: 1, BoardY: 1, CellCols: 2}) + w.Advance(50 * time.Millisecond) + for _, c := range w.Cells(nil) { + if c.X < 0 || c.X >= sz[0] || c.Y < 0 || c.Y >= sz[1] { + t.Fatalf("after resize to %dx%d a star sits at (%d,%d)", sz[0], sz[1], c.X, c.Y) + } + } + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ -run Star -v` +Expected: FAIL — no stars are emitted. + +- [ ] **Step 3: Implement `starfield.go` and hook it into `World.Advance`/`Cells`** + +Keep stars in their own fixed array inside `starfield` rather than the shared pool, so a particle storm can never crowd out the sky. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/fx/ -v` +Expected: PASS. + +- [ ] **Step 5: Look at it** + +Run: `go build ./cmd/cosmic-tetris && ./cosmic-tetris --seed 1` +Expected: stars drift behind and around the board, at three visible brightnesses, and the board is still perfectly readable (§15's closing rule). If the sky is too busy, lower the budget divisor — do not lower it below "obviously alive". + +- [ ] **Step 6: Commit** + +```bash +git add internal/fx +git commit -m "feat(fx): three-layer starfield with level scaling and paused drift" +``` + +--- + +### Task 4: Animated board border + +**Files:** +- Modify: `internal/fx/world.go`, `internal/render/board.go`, `internal/render/render.go` +- Test: `internal/fx/border_test.go`, `internal/render/border_test.go` + +**Interfaces:** +- Produces: + ```go + // fx + func (w *World) BorderEnergy() float64 // 0 normal .. 1 major event + func (w *World) BorderPhase() float64 // 0..1 position on the §25 ramp + // render + // Frame.FX gains BorderEnergy/BorderPhase via an extended FXView interface. + ``` + +Pinned behavior (§25): + +- Base phase advances at `1/12` per second — one full trip round the ramp every 12 seconds, subtle. +- `BorderEnergy` decays exponentially with a 400 ms half-life from a value set by events (Task 6 sets it to 1.0 on hard-drop impact; Task 8 to 1.0 on a four-line clear). +- Phase speed is multiplied by `1 + 6*energy`: during a major event the gradient runs rapidly round the border. +- Brightness: the border style is drawn at `Palette.Border(phase)`, with energy blending toward hot white `#EEF6FF`. +- With `--no-fx`, `BorderEnergy` and `BorderPhase` both return 0 and the border draws as a single static color. + +- [ ] **Step 1: Write the failing tests** + +```go +// fx +func TestBorderPhaseAdvancesSlowly(t *testing.T) { + w := NewWorld(Config{Seed: 1, Enabled: true}) + w.Resize(geom()) + start := w.BorderPhase() + w.Advance(time.Second) + if d := w.BorderPhase() - start; d <= 0 || d > 0.2 { + t.Errorf("phase advanced %.4f in one second, want a small positive step (~1/12)", d) + } +} + +func TestBorderEnergyDecays(t *testing.T) { + w := NewWorld(Config{Seed: 1, Enabled: true}) + w.Resize(geom()) + w.Observe([]game.Event{game.PieceHardDropped{Cells: 12}}, Snapshot{Level: 1}) + if e := w.BorderEnergy(); e < 0.5 { + t.Fatalf("energy right after a hard drop = %.2f, want near 1", e) + } + w.Advance(2 * time.Second) + if e := w.BorderEnergy(); e > 0.1 { + t.Errorf("energy after two seconds = %.2f, want near 0", e) + } +} + +func TestBorderPhaseRunsFasterUnderEnergy(t *testing.T) { + measure := func(energize bool) float64 { + w := NewWorld(Config{Seed: 1, Enabled: true}) + w.Resize(geom()) + if energize { + w.Observe([]game.Event{game.PieceHardDropped{Cells: 12}}, Snapshot{Level: 1}) + } + start := w.BorderPhase() + w.Advance(200 * time.Millisecond) + return w.BorderPhase() - start + } + if calm, hot := measure(false), measure(true); !(hot > calm*2) { + t.Errorf("energized phase step %.4f should far exceed calm %.4f", hot, calm) + } +} + +func TestDisabledWorldHasNoBorderAnimation(t *testing.T) { + w := NewWorld(Config{Seed: 1, Enabled: false}) + w.Resize(geom()) + w.Observe([]game.Event{game.PieceHardDropped{Cells: 9}}, Snapshot{Level: 1}) + w.Advance(time.Second) + if w.BorderPhase() != 0 || w.BorderEnergy() != 0 { + t.Errorf("--no-fx border phase/energy = %.2f/%.2f, want 0/0", w.BorderPhase(), w.BorderEnergy()) + } +} +``` + +```go +// render +func TestBorderColorChangesWithPhase(t *testing.T) { + g := fixtureGame(t) + a := Render(Frame{Game: g, W: 80, H: 30, Mode: ModeFull, Seed: 7, FX: stubFX{phase: 0.0}}) + b := Render(Frame{Game: g, W: 80, H: 30, Mode: ModeFull, Seed: 7, FX: stubFX{phase: 0.5}}) + if a == b { + t.Error("the board border should be colored differently at phase 0 and 0.5") + } + if stripANSI(a) != stripANSI(b) { + t.Error("the border animation must change color only, never glyphs or geometry") + } +} +``` + +Extend `stubFX` with `phase, energy float64` and the two new methods. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ ./internal/render/ -run Border -v` +Expected: FAIL — `undefined: (*World).BorderPhase`. + +- [ ] **Step 3: Implement the border animation** + +Add `borderPhase`, `borderEnergy` to `World` and advance them in `Advance`; extend `render.FXView` with `BorderEnergy() float64` and `BorderPhase() float64`; have `drawBoard` use `p.Border(phase)` blended toward hot white by energy, falling back to `p.Border(0)` when `f.FX == nil`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test -race ./... -v` +Expected: PASS, plan 2's ANSI-stripped goldens unchanged (color-only change). + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx internal/render +git commit -m "feat(fx): slow border gradient cycle with event-driven energy" +``` + +--- + +### Task 5: Piece trails + +**Files:** +- Modify: `internal/fx/world.go`, `internal/fx/events.go` +- Test: `internal/fx/trail_test.go` + +**Interfaces:** +- Produces: no new exported API — `Observe` gains cases for `game.PieceMoved` and `game.PieceHardDropped`. + +Pinned behavior (§17): + +- On `PieceMoved`, emit one `RoleTrail` particle per cell of the piece's **previous** position (derive it by subtracting `DX`/`DY` from the event's piece), with `Kind` set to the piece kind, `Life = 0.13s` (inside the 100–160 ms window), zero velocity, `Drag = 1`. +- Brightness ramps the glyph through `▓ ▒ ░` by remaining life fraction (`>0.66`, `>0.33`, else) — ASCII mode uses `| : .`. +- On `PieceHardDropped`, emit a **stronger** vertical trail: every cell the piece crossed between its start row and the landing row, with `Life = 0.22s`. +- Trails are `LayerBoard` and therefore subject to the active-piece guard: the trail is only ever visible where the piece no longer is. +- Trails do not emit at all when the world is disabled. + +- [ ] **Step 1: Write the failing tests** + +```go +func trailCells(w *World) []Cell { + var out []Cell + for _, c := range w.Cells(nil) { + if c.Role == RoleTrail { + out = append(out, c) + } + } + return out +} + +func TestPieceMovedEmitsATrailBehindThePiece(t *testing.T) { + w := NewWorld(Config{Seed: 1, Enabled: true}) + w.Resize(geom()) + p := game.Piece{Kind: game.T, Rotation: 0, X: 4, Y: 8} + w.Observe([]game.Event{game.PieceMoved{Piece: p, DX: 1}}, Snapshot{Level: 1}) + cells := trailCells(w) + if len(cells) == 0 { + t.Fatal("no trail cells emitted") + } + for _, c := range cells { + if c.Kind != game.T { + t.Errorf("trail cell Kind = %v, want T (trails inherit the piece color)", c.Kind) + } + } + // The trail sits one cell to the left of the piece: 2 screen columns. + minX := cells[0].X + for _, c := range cells { + if c.X < minX { + minX = c.X + } + } + pieceX := geom().BoardX + 1 + p.X*geom().CellCols + if minX >= pieceX { + t.Errorf("trail leftmost column %d should be left of the piece at %d", minX, pieceX) + } +} + +func TestTrailsExpireWithinTheSpecWindow(t *testing.T) { + w := NewWorld(Config{Seed: 1, Enabled: true}) + w.Resize(geom()) + w.Observe([]game.Event{game.PieceMoved{Piece: game.Piece{Kind: game.L, X: 4, Y: 8}, DX: -1}}, Snapshot{Level: 1}) + if len(trailCells(w)) == 0 { + t.Fatal("no trail emitted") + } + w.Advance(90 * time.Millisecond) + if len(trailCells(w)) == 0 { + t.Error("trail vanished before 100ms (§17 wants 100-160ms)") + } + w.Advance(120 * time.Millisecond) + if n := len(trailCells(w)); n != 0 { + t.Errorf("%d trail cells alive after 210ms, want 0", n) + } +} + +func TestTrailGlyphsFadeThroughTheRamp(t *testing.T) { + w := NewWorld(Config{Seed: 1, Enabled: true}) + w.Resize(geom()) + w.Observe([]game.Event{game.PieceMoved{Piece: game.Piece{Kind: game.I, X: 3, Y: 8}, DX: -1}}, Snapshot{Level: 1}) + first := trailCells(w)[0].Glyph + w.Advance(100 * time.Millisecond) + cells := trailCells(w) + if len(cells) == 0 { + t.Fatal("trail expired too early") + } + if cells[0].Glyph == first { + t.Errorf("trail glyph stayed %q; it should fade through the ramp", first) + } +} + +func TestHardDropTrailIsLongerThanAMoveTrail(t *testing.T) { + w := NewWorld(Config{Seed: 1, Enabled: true}) + w.Resize(geom()) + w.Observe([]game.Event{game.PieceHardDropped{ + Piece: game.Piece{Kind: game.I, Rotation: 1, X: 3, Y: 18}, Cells: 12, + }}, Snapshot{Level: 1}) + drop := len(trailCells(w)) + + w2 := NewWorld(Config{Seed: 1, Enabled: true}) + w2.Resize(geom()) + w2.Observe([]game.Event{game.PieceMoved{Piece: game.Piece{Kind: game.I, Rotation: 1, X: 3, Y: 18}, DX: 1}}, Snapshot{Level: 1}) + move := len(trailCells(w2)) + + if drop <= move { + t.Errorf("hard-drop trail (%d cells) should be stronger than a move trail (%d)", drop, move) + } +} + +func TestTrailsRespectTheParticleCap(t *testing.T) { + w := NewWorld(Config{Seed: 1, Enabled: true}) + w.Resize(geom()) + for i := 0; i < 2000; i++ { + w.Observe([]game.Event{game.PieceMoved{Piece: game.Piece{Kind: game.O, X: 4, Y: 10}, DX: 1}}, Snapshot{Level: 1}) + } + if n := w.Count(); n > MaxParticles { + t.Errorf("particle count %d exceeds the cap %d", n, MaxParticles) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ -run Trail -v` +Expected: FAIL — no trail cells. + +- [ ] **Step 3: Implement the trail cases in `Observe`** + +Add a `boardCellToScreen(bx, by int) (x, y int, ok bool)` helper on `World` that maps board coordinates to screen columns and reports `false` for hidden rows — every board-local effect from here on uses it. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/fx/ -v` +Expected: PASS. + +- [ ] **Step 5: Look at it** + +Run: `./cosmic-tetris --seed 1` and slide a piece left and right quickly. +Expected: a short ion smear behind the piece in the piece's own color, gone almost immediately. If it looks like a comet tail, shorten the life; if you cannot see it at all, raise brightness — not lifetime. + +- [ ] **Step 6: Commit** + +```bash +git add internal/fx +git commit -m "feat(fx): short-lived ion trails behind moving and dropped pieces" +``` + +--- + +### Task 6: Hard-drop impact — trail, debris, shake, border flash + +**Files:** +- Modify: `internal/fx/world.go`, `internal/render/render.go` +- Test: `internal/fx/impact_test.go` + +**Interfaces:** +- Produces: `ShakeOffset` becomes non-trivial; no new names. + +Pinned behavior (§18, §44): + +- Debris: 14 particles from the landed piece's bottom edge, `Role = RoleImpact`, glyphs cycling `· * ✦ +` (ASCII `. * + '`), initial velocity fanned upward and outward (`VX ∈ [-9, 9]`, `VY ∈ [-14, -4]` cells/s), `AY = 34` (gravity), `Drag = 0.82`, `Life ∈ [0.28, 0.5]s`. +- Screen shake: exactly the §18 pattern `(0,+1), (-1,0), (+1,0), (0,-1), (0,0)`, one entry per 16 ms, total 80 ms. Deterministic — an index into a table, not RNG. Offsets never exceed one cell. +- `--reduced-motion` returns `(0, 0)` from `ShakeOffset` at all times but keeps debris and the border flash (§49.5). +- Border flash: `borderEnergy = 1.0` (Task 4 decays it). +- Shake shifts **only the board box and its contents**, not the HUD or the mission line — a whole-screen shift makes the terminal unreadable (§18's closing rule). + +- [ ] **Step 1: Write the failing tests** + +```go +func TestHardDropEmitsDebris(t *testing.T) { + w := NewWorld(Config{Seed: 1, Enabled: true}) + w.Resize(geom()) + w.Observe([]game.Event{game.PieceHardDropped{ + Piece: game.Piece{Kind: game.T, X: 4, Y: 19}, Cells: 11, + }}, Snapshot{Level: 1}) + var debris int + for _, c := range w.Cells(nil) { + if c.Role == RoleImpact { + debris++ + } + } + if debris < 8 { + t.Errorf("%d debris particles, want at least 8", debris) + } +} + +func TestDebrisFallsBackDown(t *testing.T) { + w := NewWorld(Config{Seed: 1, Enabled: true}) + w.Resize(geom()) + w.Observe([]game.Event{game.PieceHardDropped{Piece: game.Piece{Kind: game.T, X: 4, Y: 19}, Cells: 11}}, Snapshot{Level: 1}) + up := averageY(w.Cells(nil), RoleImpact) + w.Advance(60 * time.Millisecond) + mid := averageY(w.Cells(nil), RoleImpact) + w.Advance(200 * time.Millisecond) + down := averageY(w.Cells(nil), RoleImpact) + if !(mid < up) { + t.Errorf("debris should fly upward first: %.2f -> %.2f", up, mid) + } + if !(down > mid) { + t.Errorf("debris should fall back: %.2f -> %.2f", mid, down) + } +} + +func TestShakeFollowsTheSpecPatternAndEnds(t *testing.T) { + w := NewWorld(Config{Seed: 1, Enabled: true}) + w.Resize(geom()) + w.Observe([]game.Event{game.PieceHardDropped{Piece: game.Piece{Kind: game.T, X: 4, Y: 19}, Cells: 11}}, Snapshot{Level: 1}) + want := [][2]int{{0, 1}, {-1, 0}, {1, 0}, {0, -1}, {0, 0}} + for i, exp := range want { + dx, dy := w.ShakeOffset() + if dx != exp[0] || dy != exp[1] { + t.Errorf("step %d: shake = (%d,%d), want (%d,%d)", i, dx, dy, exp[0], exp[1]) + } + w.Advance(16 * time.Millisecond) + } + if dx, dy := w.ShakeOffset(); dx != 0 || dy != 0 { + t.Errorf("shake = (%d,%d) after 80ms, want (0,0)", dx, dy) + } +} + +func TestShakeNeverExceedsOneCell(t *testing.T) { + w := NewWorld(Config{Seed: 1, Enabled: true}) + w.Resize(geom()) + for i := 0; i < 50; i++ { + w.Observe([]game.Event{ + game.PieceHardDropped{Piece: game.Piece{Kind: game.T, X: 4, Y: 19}, Cells: 11}, + game.LinesCleared{Count: 4}, + }, Snapshot{Level: 9, Combo: 7}) + for j := 0; j < 8; j++ { + dx, dy := w.ShakeOffset() + if dx < -1 || dx > 1 || dy < -1 || dy > 1 { + t.Fatalf("shake = (%d,%d) exceeds one cell (design.md §44)", dx, dy) + } + w.Advance(8 * time.Millisecond) + } + } +} + +func TestReducedMotionSuppressesShakeButKeepsDebris(t *testing.T) { + w := NewWorld(Config{Seed: 1, Enabled: true, ReducedMotion: true}) + w.Resize(geom()) + w.Observe([]game.Event{game.PieceHardDropped{Piece: game.Piece{Kind: game.T, X: 4, Y: 19}, Cells: 11}}, Snapshot{Level: 1}) + for i := 0; i < 10; i++ { + if dx, dy := w.ShakeOffset(); dx != 0 || dy != 0 { + t.Fatalf("reduced motion shake = (%d,%d), want (0,0)", dx, dy) + } + w.Advance(16 * time.Millisecond) + } + var debris int + for _, c := range w.Cells(nil) { + if c.Role == RoleImpact { + debris++ + } + } + if debris == 0 { + t.Error("reduced motion should keep debris particles (§49.5)") + } +} + +func TestHardDropFlashesTheBorder(t *testing.T) { + w := NewWorld(Config{Seed: 1, Enabled: true}) + w.Resize(geom()) + w.Observe([]game.Event{game.PieceHardDropped{Piece: game.Piece{Kind: game.T, X: 4, Y: 19}, Cells: 11}}, Snapshot{Level: 1}) + if e := w.BorderEnergy(); e < 0.8 { + t.Errorf("border energy after impact = %.2f, want near 1", e) + } +} +``` + +Add to `internal/render/fx_test.go`: + +```go +func TestShakeDoesNotMoveTheHUD(t *testing.T) { + g := fixtureGame(t) + g.Score = 129340 + calm := strings.Split(renderPlain(Frame{Game: g, W: 80, H: 30, Mode: ModeFull, Seed: 7}), "\n") + shaken := strings.Split(renderPlain(Frame{Game: g, W: 80, H: 30, Mode: ModeFull, Seed: 7, FX: stubFX{dx: 1, dy: -1}}), "\n") + rowOf := func(lines []string, needle string) int { + for i, l := range lines { + if strings.Contains(l, needle) { + return i + } + } + return -1 + } + if a, b := rowOf(calm, "00129340"), rowOf(shaken, "00129340"); a != b { + t.Errorf("score moved from row %d to %d — shake must only shift the board (§18)", a, b) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ ./internal/render/ -run 'Impact|Debris|Shake|Flash' -v` +Expected: FAIL. + +- [ ] **Step 3: Implement the impact effect and the render-side shake** + +In `render`, apply `dx, dy` only to the board box origin used by `drawBoard` and the board-layer FX guard, clamping so the shifted box still fits on screen. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test -race ./... -v` +Expected: PASS. + +- [ ] **Step 5: Feel it** + +Run: `./cosmic-tetris --seed 1` and hammer space. +Expected: dropping a piece feels like dropping a refrigerator from orbit — a vertical smear, debris kicking up, one cell of shake, the border snapping bright. Then run `./cosmic-tetris --reduced-motion --seed 1` and confirm the screen is completely still while debris still flies. + +- [ ] **Step 6: Commit** + +```bash +git add internal/fx internal/render +git commit -m "feat(fx): hard-drop impact with debris, one-cell shake and border flash" +``` + +--- + +### Task 7: Line-clear supernova + +**Files:** +- Modify: `internal/fx/world.go`, `internal/render/render.go`, `internal/render/board.go` +- Test: `internal/fx/clear_test.go`, `internal/render/clear_test.go` + +**Interfaces:** +- Produces: + ```go + // fx + // BoardFreeze returns the pre-clear board to render for the duration of the + // line-clear animation (§19). ok is false when no animation is running. + func (w *World) BoardFreeze() (game.Board, bool) + func (w *World) ClearRows() []int // rows being animated, board coordinates + // render.FXView gains BoardFreeze() (game.Board, bool) and ClearRows() []int. + ``` + +Pinned behavior (§19, §44): + +- Total animation `220ms`, driven by `LinesCleared`. Gameplay is **never** delayed: the engine has already collapsed the rows, and the animation is purely a rendering overlay fed by `LinesCleared.Before`. +- While the animation runs, `drawBoard` renders `BoardFreeze()`'s locked cells instead of the live board, so the cleared rows are still visible. The active piece, ghost and HUD keep using live state. +- Phase A, `0–70ms` — critical mass: cleared rows render as `▓` (`ModeASCII`: `%`), brightest at the row center. +- Phase B, `70–140ms` — supernova: a bright front expands from the row center outward; cells inside the front render `✦` (`*` in ASCII) at full brightness, cells behind it dim. +- Phase C, `140–220ms` — collapse: the rows become debris. Emit 6 particles per cleared row with `VX` proportional to distance from the row center (`(x - center) * 3.2` cells/s), `VY ∈ [-6, 2]`, `AY = 30`, glyphs `· * ✦`, `Role = RoleClear`. +- After 220 ms the freeze ends and the live board takes over. Debris outlives the freeze and simply falls. +- `--no-fx`: no freeze, no phases, no debris; the clear is instantaneous. + +- [ ] **Step 1: Write the failing fx tests** + +```go +func clearedBoard() (game.Board, []int) { + var b game.Board + for x := 0; x < game.Width; x++ { + b.Set(x, 21, game.I) + b.Set(x, 20, game.J) + } + return b, []int{20, 21} +} + +func TestClearAnimationRunsForTwoTwentyMilliseconds(t *testing.T) { + w := NewWorld(Config{Seed: 1, Enabled: true}) + w.Resize(geom()) + before, rows := clearedBoard() + w.Observe([]game.Event{game.LinesCleared{Rows: rows, Count: 2, Before: before}}, Snapshot{Level: 1}) + if _, ok := w.BoardFreeze(); !ok { + t.Fatal("a clear should start a board freeze") + } + if got := w.ClearRows(); !reflect.DeepEqual(got, rows) { + t.Errorf("ClearRows = %v, want %v", got, rows) + } + w.Advance(210 * time.Millisecond) + if _, ok := w.BoardFreeze(); !ok { + t.Error("freeze ended before 220ms") + } + w.Advance(20 * time.Millisecond) + if _, ok := w.BoardFreeze(); ok { + t.Error("freeze should end at 220ms — gameplay is already past it") + } +} + +func TestClearPhasesProduceDifferentGlyphs(t *testing.T) { + w := NewWorld(Config{Seed: 1, Enabled: true}) + w.Resize(geom()) + before, rows := clearedBoard() + w.Observe([]game.Event{game.LinesCleared{Rows: rows, Count: 2, Before: before}}, Snapshot{Level: 1}) + glyphsAt := func() map[rune]int { + m := map[rune]int{} + for _, c := range w.Cells(nil) { + if c.Role == RoleClear { + m[c.Glyph]++ + } + } + return m + } + phaseA := glyphsAt() + w.Advance(80 * time.Millisecond) + phaseB := glyphsAt() + if len(phaseA) == 0 || len(phaseB) == 0 { + t.Fatalf("no clear cells: A=%v B=%v", phaseA, phaseB) + } + if reflect.DeepEqual(phaseA, phaseB) { + t.Error("phase A and phase B render identically") + } +} + +func TestCollapseDebrisInheritsHorizontalVelocityFromTheCenter(t *testing.T) { + w := NewWorld(Config{Seed: 1, Enabled: true}) + w.Resize(geom()) + before, rows := clearedBoard() + w.Observe([]game.Event{game.LinesCleared{Rows: rows, Count: 2, Before: before}}, Snapshot{Level: 1}) + w.Advance(150 * time.Millisecond) // into phase C + start := w.Cells(nil) + var left, right []Cell + centerX := geom().BoardX + 1 + game.Width // middle of the board in screen columns + for _, c := range start { + if c.Role != RoleClear { + continue + } + if c.X < centerX { + left = append(left, c) + } else { + right = append(right, c) + } + } + if len(left) == 0 || len(right) == 0 { + t.Fatalf("debris on only one side: %d left, %d right", len(left), len(right)) + } + w.Advance(120 * time.Millisecond) + var spread float64 + for _, c := range w.Cells(nil) { + if c.Role == RoleClear { + spread += math.Abs(float64(c.X - centerX)) + } + } + if spread == 0 { + t.Error("debris did not spread away from the row center (§19)") + } +} + +func TestDisabledWorldDoesNotFreezeTheBoard(t *testing.T) { + w := NewWorld(Config{Seed: 1, Enabled: false}) + w.Resize(geom()) + before, rows := clearedBoard() + w.Observe([]game.Event{game.LinesCleared{Rows: rows, Count: 2, Before: before}}, Snapshot{Level: 1}) + if _, ok := w.BoardFreeze(); ok { + t.Error("--no-fx must not freeze the board") + } +} + +func TestASecondClearDuringTheAnimationRestartsIt(t *testing.T) { + w := NewWorld(Config{Seed: 1, Enabled: true}) + w.Resize(geom()) + before, rows := clearedBoard() + w.Observe([]game.Event{game.LinesCleared{Rows: rows, Count: 2, Before: before}}, Snapshot{Level: 1}) + w.Advance(200 * time.Millisecond) + w.Observe([]game.Event{game.LinesCleared{Rows: []int{21}, Count: 1, Before: before}}, Snapshot{Level: 1}) + if got := w.ClearRows(); !reflect.DeepEqual(got, []int{21}) { + t.Errorf("ClearRows = %v, want the newest clear [21]", got) + } + w.Advance(100 * time.Millisecond) + if _, ok := w.BoardFreeze(); !ok { + t.Error("the newest clear should have restarted the 220ms window") + } +} +``` + +- [ ] **Step 2: Write the failing render test** + +```go +func TestFrozenBoardShowsTheClearedRow(t *testing.T) { + g := fixtureGame(t) + var before game.Board + for x := 0; x < game.Width; x++ { + before.Set(x, 21, game.I) + } + f := Frame{Game: g, W: 80, H: 30, Mode: ModeFull, Seed: 7, FX: stubFX{freeze: before, frozen: true, rows: []int{21}}} + l := Compute(80, 30) + lines := strings.Split(renderPlain(f), "\n") + row := []rune(padTo(lines[l.BoardY+1+21-game.HiddenRows], 80)) + var blocks int + for x := l.BoardX + 1; x < l.BoardX+BoardBoxW-1; x++ { + if row[x] == '█' { + blocks++ + } + } + if blocks < game.Width { + t.Errorf("frozen board should still show the full cleared row; found %d block columns", blocks) + } +} + +func TestFreezeDoesNotAffectTheActivePiece(t *testing.T) { + g := fixtureGame(t) + var empty game.Board + f := Frame{Game: g, W: 80, H: 30, Mode: ModeFull, Seed: 7, FX: stubFX{freeze: empty, frozen: true}} + l := Compute(80, 30) + lines := strings.Split(renderPlain(f), "\n") + for _, c := range g.Active.Cells() { + if c[1] < game.HiddenRows { + continue + } + row := []rune(padTo(lines[l.BoardY+1+c[1]-game.HiddenRows], 80)) + if row[l.BoardX+1+c[0]*CellCols] != '█' { + t.Error("the active piece must render from live state even during a freeze") + } + } +} +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `go test ./internal/fx/ ./internal/render/ -run 'Clear|Freeze|Collapse' -v` +Expected: FAIL. + +- [ ] **Step 4: Implement the clear animation and the render freeze** + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `go test -race ./... -v` +Expected: PASS. + +- [ ] **Step 6: See it** + +Run: `./cosmic-tetris --seed 1` and clear a line. +Expected: the row goes hot, blows apart from the center, and rains debris — and the piece you are steering never stutters. + +- [ ] **Step 7: Commit** + +```bash +git add internal/fx internal/render +git commit -m "feat(fx): three-phase supernova line clear with rendered board freeze" +``` + +--- + +### Task 8: Four-line event, hyperdrive, banners, shockwaves and combo escalation + +**Files:** +- Modify: `internal/fx/world.go`, `internal/fx/starfield.go`, `internal/render/render.go` +- Test: `internal/fx/hyperdrive_test.go`, `internal/fx/combo_test.go`, `internal/render/banner_test.go` + +**Interfaces:** +- Produces: + ```go + // fx + func (w *World) Banner() (string, bool) // §20 giant banner text + func (w *World) HUDPulse() float64 // 0..1, §21 combo-4+ HUD pulse + func (w *World) speedMul() float64 // internal: hyperdrive star multiplier + // render.FXView gains Banner() (string, bool) and HUDPulse() float64. + ``` + +Pinned behavior: + +- **Hyperdrive** (§16), triggered by a four-line clear, a combo of 4 or more, or a new high score within the session. Star speed multiplier by elapsed time: `0–50ms → 0.0` (stars pause), `50–100ms → 0.3` (stretch), `100–500ms → ramp 1 → 6`, `500–800ms → 6`, `800–1100ms → ramp 6 → 1`, then over. Suppressed entirely under `--reduced-motion` (§49.5). +- **Four-line clear** (§20) triggers, simultaneously: hyperdrive, `borderEnergy = 1`, a `+60 %` star density boost for 900 ms, an eruption of 40 `RoleDebris` particles from the cleared rows, `HUDPulse` set to 1, and a banner for **700 ms** chosen from the FX RNG: + ``` + ✦ EVENT HORIZON ✦ + QUADRUPLE COSMIC INCIDENT + FOUR ROWS HAVE LEFT THE CHAT + SPACE-TIME HAS FILED A COMPLAINT + ``` + The banner is drawn centered over the board and **must not block input** — it is a rendering overlay only. +- **Combo escalation** (§21), on `ComboChanged`: combo 2 → 8 spark particles; combo 3 → 14 meteor particles with lateral drift; combo 4 → `HUDPulse` pulses for 600 ms; combo 5+ → also a shockwave, and a banner `COMBO n // ` for 700 ms from: + ``` + UNAUTHORIZED ORBITAL MANEUVER + STRUCTURAL REALITY FAILURE + NASA DENIES EVERYTHING + ``` +- **Shockwave** (§24): an expanding ring over ~300 ms, radius `0 → 14` screen columns, drawn as `RoleShock` cells on an ellipse with a 2:1 horizontal stretch (terminal cells are tall), glyph by radius `· ∘ o O` (ASCII `. o O 0`), at most 24 cells per ring. **Used sparingly**: at most one ring per 800 ms, and never under `--reduced-motion`. +- Banner text renders in `ModeASCII` with the `✦` stripped. + +- [ ] **Step 1: Write the failing tests** + +```go +func tetrisEvent() []game.Event { + before, rows := clearedBoard() + return []game.Event{game.LinesCleared{Rows: rows, Count: 4, Before: before}} +} + +func TestHyperdriveTimeline(t *testing.T) { + w := NewWorld(Config{Seed: 1, Enabled: true}) + w.Resize(geom()) + w.Advance(16 * time.Millisecond) + calm := starDrift(t, w, 200*time.Millisecond) + + w2 := NewWorld(Config{Seed: 1, Enabled: true}) + w2.Resize(geom()) + w2.Advance(16 * time.Millisecond) + w2.Observe(tetrisEvent(), Snapshot{Level: 1}) + w2.Advance(30 * time.Millisecond) + paused := starDrift(t, w2, 16*time.Millisecond) + if paused > 0.2 { + t.Errorf("stars moved %.2f rows in the first 50ms; §16 says they pause", paused) + } + w2.Advance(120 * time.Millisecond) // into the acceleration ramp + fast := starDrift(t, w2, 200*time.Millisecond) + if !(fast > calm*2) { + t.Errorf("hyperdrive drift %.2f should far exceed calm drift %.2f", fast, calm) + } + w2.Advance(1200 * time.Millisecond) // past 1100ms + after := starDrift(t, w2, 200*time.Millisecond) + if after > calm*1.6 { + t.Errorf("drift %.2f should be back to normal (~%.2f) after 1100ms", after, calm) + } +} + +// starDrift measures how far mid-layer stars move over d. +func starDrift(t *testing.T, w *World, d time.Duration) float64 { + t.Helper() + before := averageY(w.Cells(nil), RoleStarMid) + w.Advance(d) + return averageY(w.Cells(nil), RoleStarMid) - before +} + +func TestFourLineClearTriggersEverything(t *testing.T) { + w := NewWorld(Config{Seed: 1, Enabled: true}) + w.Resize(geom()) + w.Advance(16 * time.Millisecond) + starsBefore := len(w.Cells(nil)) + w.Observe(tetrisEvent(), Snapshot{Level: 3}) + if _, ok := w.Banner(); !ok { + t.Error("a four-line clear should raise a banner") + } + if w.BorderEnergy() < 0.8 { + t.Errorf("border energy = %.2f, want near 1", w.BorderEnergy()) + } + if w.HUDPulse() < 0.8 { + t.Errorf("HUD pulse = %.2f, want near 1", w.HUDPulse()) + } + if dx, dy := w.ShakeOffset(); dx == 0 && dy == 0 { + t.Error("a four-line clear should shake the screen") + } + w.Advance(50 * time.Millisecond) + if now := len(w.Cells(nil)); now <= starsBefore { + t.Errorf("cell count %d did not rise above the resting %d — no eruption or density boost", now, starsBefore) + } +} + +func TestBannerLastsSevenHundredMilliseconds(t *testing.T) { + w := NewWorld(Config{Seed: 1, Enabled: true}) + w.Resize(geom()) + w.Observe(tetrisEvent(), Snapshot{Level: 1}) + text, ok := w.Banner() + if !ok || text == "" { + t.Fatalf("Banner() = %q, %v", text, ok) + } + w.Advance(650 * time.Millisecond) + if _, ok := w.Banner(); !ok { + t.Error("banner vanished before 700ms") + } + w.Advance(100 * time.Millisecond) + if _, ok := w.Banner(); ok { + t.Error("banner outlived 700ms") + } +} + +func TestBannerTextComesFromTheSpecList(t *testing.T) { + allowed := map[string]bool{ + "✦ EVENT HORIZON ✦": true, "QUADRUPLE COSMIC INCIDENT": true, + "FOUR ROWS HAVE LEFT THE CHAT": true, "SPACE-TIME HAS FILED A COMPLAINT": true, + } + seen := map[string]bool{} + for seed := int64(0); seed < 40; seed++ { + w := NewWorld(Config{Seed: seed, Enabled: true}) + w.Resize(geom()) + w.Observe(tetrisEvent(), Snapshot{Level: 1}) + text, _ := w.Banner() + if !allowed[text] { + t.Fatalf("banner %q is not one of the §20 banners", text) + } + seen[text] = true + } + if len(seen) < 2 { + t.Errorf("40 seeds produced %d distinct banners; the choice should vary", len(seen)) + } +} + +func TestComboEscalation(t *testing.T) { + cases := []struct { + combo int + wantCells bool + wantPulse bool + wantBanner bool + wantShock bool + }{ + {1, false, false, false, false}, + {2, true, false, false, false}, + {3, true, false, false, false}, + {4, true, true, false, false}, + {5, true, true, true, true}, + } + for _, c := range cases { + w := NewWorld(Config{Seed: 1, Enabled: true}) + w.Resize(Geometry{ScreenW: 80, ScreenH: 30, BoardX: 29, BoardY: 3, CellCols: 2}) + w.Observe([]game.Event{game.ComboChanged{Combo: c.combo}}, Snapshot{Level: 2, Combo: c.combo}) + var gameplayCells, shockCells int + for _, cell := range w.Cells(nil) { + switch cell.Role { + case RoleDebris, RoleImpact: + gameplayCells++ + case RoleShock: + shockCells++ + } + } + if c.wantCells && gameplayCells == 0 { + t.Errorf("combo %d: expected sparks or meteors", c.combo) + } + if !c.wantCells && gameplayCells > 0 { + t.Errorf("combo %d: expected no particles, got %d", c.combo, gameplayCells) + } + if got := w.HUDPulse() > 0.5; got != c.wantPulse { + t.Errorf("combo %d: HUD pulse %v, want %v", c.combo, got, c.wantPulse) + } + if _, ok := w.Banner(); ok != c.wantBanner { + t.Errorf("combo %d: banner %v, want %v", c.combo, ok, c.wantBanner) + } + if got := shockCells > 0; got != c.wantShock { + t.Errorf("combo %d: shockwave %v, want %v", c.combo, got, c.wantShock) + } + } +} + +func TestComboBannerNamesTheCombo(t *testing.T) { + w := NewWorld(Config{Seed: 1, Enabled: true}) + w.Resize(geom()) + w.Observe([]game.Event{game.ComboChanged{Combo: 6}}, Snapshot{Level: 2, Combo: 6}) + text, ok := w.Banner() + if !ok || !strings.Contains(text, "COMBO 6") { + t.Errorf("banner = %q, want it to name COMBO 6", text) + } +} + +func TestShockwaveExpandsAndExpires(t *testing.T) { + w := NewWorld(Config{Seed: 1, Enabled: true}) + w.Resize(geom()) + w.Observe([]game.Event{game.ComboChanged{Combo: 5}}, Snapshot{Level: 2, Combo: 5}) + radius := func() float64 { + var maxR float64 + cx, cy := 40.0, 15.0 + for _, c := range w.Cells(nil) { + if c.Role != RoleShock { + continue + } + r := math.Hypot(float64(c.X)-cx, (float64(c.Y)-cy)*2) + if r > maxR { + maxR = r + } + } + return maxR + } + r0 := radius() + w.Advance(150 * time.Millisecond) + r1 := radius() + if !(r1 > r0) { + t.Errorf("ring did not expand: %.1f -> %.1f", r0, r1) + } + w.Advance(200 * time.Millisecond) + for _, c := range w.Cells(nil) { + if c.Role == RoleShock { + t.Error("shockwave outlived 300ms") + break + } + } +} + +func TestShockwavesAreRateLimited(t *testing.T) { + w := NewWorld(Config{Seed: 1, Enabled: true}) + w.Resize(geom()) + for i := 0; i < 10; i++ { + w.Observe([]game.Event{game.ComboChanged{Combo: 6}}, Snapshot{Level: 2, Combo: 6}) + w.Advance(20 * time.Millisecond) + } + rings := map[int]bool{} + for _, c := range w.Cells(nil) { + if c.Role == RoleShock { + rings[c.Y] = true // rough proxy: distinct rows touched by ring cells + } + } + if len(rings) > 24 { + t.Errorf("%d shockwave rows alive at once; §24 says use them sparingly", len(rings)) + } +} + +func TestReducedMotionSuppressesHyperdriveAndShockwaves(t *testing.T) { + w := NewWorld(Config{Seed: 1, Enabled: true, ReducedMotion: true}) + w.Resize(geom()) + w.Advance(16 * time.Millisecond) + calmRef := NewWorld(Config{Seed: 1, Enabled: true, ReducedMotion: true}) + calmRef.Resize(geom()) + calmRef.Advance(16 * time.Millisecond) + calm := starDrift(t, calmRef, 200*time.Millisecond) + + w.Observe(tetrisEvent(), Snapshot{Level: 1}) + w.Observe([]game.Event{game.ComboChanged{Combo: 6}}, Snapshot{Level: 1, Combo: 6}) + w.Advance(200 * time.Millisecond) + if drift := starDrift(t, w, 200*time.Millisecond); drift > calm*1.5 { + t.Errorf("reduced motion drift %.2f should stay near calm %.2f — no hyperdrive (§49.5)", drift, calm) + } + for _, c := range w.Cells(nil) { + if c.Role == RoleShock { + t.Error("reduced motion must not emit shockwaves (§49.5)") + break + } + } + if _, ok := w.Banner(); !ok { + t.Error("reduced motion should keep banners — they are text, not motion") + } +} + +func TestNewHighScoreTriggersHyperdrive(t *testing.T) { + w := NewWorld(Config{Seed: 1, Enabled: true}) + w.Resize(geom()) + w.Advance(16 * time.Millisecond) + w.Observe(nil, Snapshot{Level: 1, Score: 100}) + w.Advance(2 * time.Second) + base := starDrift(t, w, 100*time.Millisecond) + w.Observe(nil, Snapshot{Level: 1, Score: 100000}) // a session high score + w.Advance(150 * time.Millisecond) + if drift := starDrift(t, w, 200*time.Millisecond); !(drift > base) { + t.Errorf("a new high score should trigger hyperdrive: %.2f vs %.2f", drift, base) + } +} +``` + +Render side: + +```go +func TestBannerIsDrawnCenteredOverTheBoard(t *testing.T) { + g := fixtureGame(t) + out := renderPlain(Frame{Game: g, W: 80, H: 30, Mode: ModeFull, Seed: 7, FX: stubFX{banner: "✦ EVENT HORIZON ✦"}}) + if !strings.Contains(out, "EVENT HORIZON") { + t.Fatalf("banner not rendered:\n%s", out) + } + for _, line := range strings.Split(out, "\n") { + if lipgloss.Width(line) > 80 { + t.Errorf("banner pushed line %q to %d columns", line, lipgloss.Width(line)) + } + } +} + +func TestLongBannerIsTruncatedAtNarrowWidths(t *testing.T) { + g := fixtureGame(t) + out := renderPlain(Frame{Game: g, W: 40, H: 24, Mode: ModeFull, Seed: 7, FX: stubFX{banner: "SPACE-TIME HAS FILED A COMPLAINT"}}) + for _, line := range strings.Split(out, "\n") { + if lipgloss.Width(line) > 40 { + t.Errorf("line %q is %d columns wide at w=40", line, lipgloss.Width(line)) + } + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ ./internal/render/ -run 'Hyperdrive|FourLine|Banner|Combo|Shock|HighScore|ReducedMotion' -v` +Expected: FAIL. + +- [ ] **Step 3: Implement hyperdrive, the four-line bundle, combo escalation, shockwaves and banner rendering** + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test -race ./... -v` +Expected: PASS. + +- [ ] **Step 5: Verify the actual product requirement (§43)** + +Run: `./cosmic-tetris --seed 1`, build a well, and clear four rows with a vertical `I`. +Expected: hyperdrive, a bigger shake, the border gradient sprinting, a particle eruption, a HUD flash, denser stars, and a giant banner — all at once, and the next piece is already controllable. §43 wants an immediate "LOL WHAT THE FUCK". If it does not land, turn up particle counts and banner size before adding new effects. + +- [ ] **Step 6: Commit** + +```bash +git add internal/fx internal/render +git commit -m "feat(fx): hyperdrive, four-line spectacle, combo escalation and shockwaves" +``` + +--- + +### Task 9: Mission control + +**Files:** +- Create: `internal/flavor/messages.go` +- Modify: `internal/app/update.go`, `internal/app/model.go` +- Test: `internal/flavor/messages_test.go` + +**Interfaces:** +- Produces: + ```go + package flavor + + type Snapshot struct { + Score, Lines, Level, Combo int + ActiveKind game.PieceKind + } + type Channel struct{ /* ... */ } + func NewChannel(seed int64) *Channel + func (c *Channel) Observe(evts []game.Event, s Snapshot) + func (c *Channel) PlayerActed() // called on any player key press + func (c *Channel) Advance(dt time.Duration) + func (c *Channel) Line() string // current message, never empty + ``` + +Pinned behavior (§27, §45): + +- A posted message holds for at least `MinHold = 2500ms`. A new trigger during the hold is dropped unless its priority is higher. Priorities: game over `3`; four-line clear, level change, combo ≥ 5 → `2`; everything else `1`. This is what "give them time to breathe" means. +- Idle rotation: after `IdleAfter = 12s` with no new message, post an idle line. +- The opening line is `NOMINALISH`. +- Message tables, verbatim from §27 for idle and general events: + ``` + GRAVITY REMAINS MOSTLY LEGAL + TETROMINO INJECTION SUCCESSFUL + STRUCTURAL VIBES: QUESTIONABLE + LOCAL UNIVERSE STABLE* + * DEFINITION OF STABLE UNDER REVIEW + MOON NOTIFIED + ORBITAL OSHA HAS ENTERED THE CHAT + WE HAVE EXCEEDED THE RECOMMENDED NUMBER OF BLOCKS + PHYSICS TEAM SAYS KEEP GOING + ``` +- Event-specific lines: `LevelChanged` → `GRAVITY TAX INCREASED`, `LOCAL PHYSICS UPDATED WITHOUT CONSENT`, or `PLEASE SECURE ALL LOOSE TETROMINOES` (§22); `LinesCleared{Count:4}` → `FOUR ROWS HAVE LEFT THE CHAT`; `GameOver` → `CAUSE: EXCESSIVE GEOMETRY`. +- §45 rarities, each fired only in its specific situation and at most once per 30 s: + - hard-dropping a vertical `I` → `KINETIC ROD DEPLOYED` + - holding an `O` → `CUBE ADJACENT OBJECT SECURED` + - score crossing a power of ten above 10 000 → `NUMBER BECAME BIGGER` + - 10 s with no `PlayerActed` call → `CAPTAIN?` + - probability `0.002` per posted message → `DID YOU KNOW YOU'RE IN A TERMINAL?` +- `Channel` owns its own `*rand.Rand`, seeded `seed ^ 0x1D` — separate from both the game and FX generators. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestOpeningLine(t *testing.T) { + c := NewChannel(1) + if got := c.Line(); got != "NOMINALISH" { + t.Errorf("Line() = %q, want NOMINALISH", got) + } +} + +func TestMessagesGetTimeToBreathe(t *testing.T) { + c := NewChannel(1) + c.Observe([]game.Event{game.PieceLocked{}}, Snapshot{Level: 1}) + first := c.Line() + c.Advance(200 * time.Millisecond) + for i := 0; i < 20; i++ { + c.Observe([]game.Event{game.PieceLocked{}}, Snapshot{Level: 1}) + c.Advance(50 * time.Millisecond) + } + if c.Line() != first { + t.Errorf("message changed to %q within the hold window (§27: do not rotate constantly)", c.Line()) + } + c.Advance(3 * time.Second) + c.Observe([]game.Event{game.PieceLocked{}}, Snapshot{Level: 1}) + if c.Line() == first { + t.Error("message should be replaceable after the hold window") + } +} + +func TestHighPriorityEventPreempts(t *testing.T) { + c := NewChannel(1) + c.Observe([]game.Event{game.PieceLocked{}}, Snapshot{Level: 1}) + low := c.Line() + c.Advance(100 * time.Millisecond) + c.Observe([]game.Event{game.LevelChanged{Level: 4}}, Snapshot{Level: 4}) + if c.Line() == low { + t.Error("a level change should preempt an ordinary message") + } +} + +func TestIdleRotation(t *testing.T) { + c := NewChannel(1) + first := c.Line() + c.Advance(13 * time.Second) + if c.Line() == first { + t.Error("an idle channel should eventually post a new line") + } +} + +func TestEveryLineIsFromTheSpecTables(t *testing.T) { + c := NewChannel(5) + seen := map[string]bool{} + for i := 0; i < 400; i++ { + c.Observe([]game.Event{game.PieceLocked{}, game.LevelChanged{Level: i%9 + 1}}, Snapshot{Level: i%9 + 1}) + c.Advance(3 * time.Second) + seen[c.Line()] = true + } + if len(seen) < 4 { + t.Errorf("only %d distinct lines in 400 posts: %v", len(seen), seen) + } + for line := range seen { + if line != strings.ToUpper(line) { + t.Errorf("mission control line %q should be upper case", line) + } + if len(line) > 60 { + t.Errorf("line %q is %d characters; the mission row is one line", line, len(line)) + } + } +} + +func TestKineticRodForAVerticalIHardDrop(t *testing.T) { + c := NewChannel(1) + c.Observe([]game.Event{game.PieceHardDropped{ + Piece: game.Piece{Kind: game.I, Rotation: 1, X: 3, Y: 18}, Cells: 12, + }}, Snapshot{Level: 1}) + if c.Line() != "KINETIC ROD DEPLOYED" { + t.Errorf("Line() = %q, want KINETIC ROD DEPLOYED (§45)", c.Line()) + } +} + +func TestCubeAdjacentObjectForHoldingAnO(t *testing.T) { + c := NewChannel(1) + c.Observe([]game.Event{game.HoldUsed{Stored: game.O, Spawned: game.T}}, Snapshot{Level: 1}) + if c.Line() != "CUBE ADJACENT OBJECT SECURED" { + t.Errorf("Line() = %q, want CUBE ADJACENT OBJECT SECURED (§45)", c.Line()) + } +} + +func TestCaptainAfterALongIdle(t *testing.T) { + c := NewChannel(1) + c.PlayerActed() + c.Advance(11 * time.Second) + if !strings.Contains(c.Line(), "CAPTAIN?") { + t.Errorf("Line() = %q, want the CAPTAIN? prompt after 10s of no input (§45)", c.Line()) + } + c2 := NewChannel(1) + for i := 0; i < 20; i++ { + c2.PlayerActed() + c2.Advance(time.Second) + } + if strings.Contains(c2.Line(), "CAPTAIN?") { + t.Error("an active player should never see CAPTAIN?") + } +} + +func TestRaritiesDoNotRepeatConstantly(t *testing.T) { + c := NewChannel(1) + var kinetic int + for i := 0; i < 60; i++ { + c.Observe([]game.Event{game.PieceHardDropped{ + Piece: game.Piece{Kind: game.I, Rotation: 1, X: 3, Y: 18}, Cells: 12, + }}, Snapshot{Level: 1}) + c.Advance(3 * time.Second) + if c.Line() == "KINETIC ROD DEPLOYED" { + kinetic++ + } + } + if kinetic > 8 { + t.Errorf("KINETIC ROD DEPLOYED appeared %d times in 180s; §45 says occasional", kinetic) + } +} + +func TestChannelIsDeterministicPerSeed(t *testing.T) { + run := func() []string { + c := NewChannel(77) + var out []string + for i := 0; i < 40; i++ { + c.Observe([]game.Event{game.PieceLocked{}}, Snapshot{Level: 1}) + c.Advance(3 * time.Second) + out = append(out, c.Line()) + } + return out + } + if !reflect.DeepEqual(run(), run()) { + t.Error("the same seed produced different mission-control output") + } +} + +func TestFlavorRNGDoesNotTouchTheGameRNG(t *testing.T) { + seq := func(withFlavor bool) []game.PieceKind { + g := game.New(999) + c := NewChannel(999) + var out []game.PieceKind + for i := 0; i < 25; i++ { + out = append(out, g.Active.Kind) + evts := g.Input(game.ActionHardDrop) + if withFlavor { + c.Observe(evts, Snapshot{Level: g.Level, Score: g.Score}) + c.Advance(3 * time.Second) + } + } + return out + } + if !reflect.DeepEqual(seq(false), seq(true)) { + t.Error("the flavor channel changed the piece order — RNGs are crossed") + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/flavor/ -v` +Expected: FAIL — `undefined: NewChannel`. + +- [ ] **Step 3: Implement `internal/flavor/messages.go`** + +- [ ] **Step 4: Wire it into the app** + +Hold a `*flavor.Channel` on the `Model`; call `PlayerActed()` from the key branch when a key maps to a game action; call `Observe`/`Advance` in the `FrameMsg` branch alongside the FX world; pass `Line()` into `render.Frame.Mission`. Mission control keeps working with `--no-fx`. + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `go test -race ./... -v` +Expected: PASS. + +- [ ] **Step 6: Read it** + +Run: `./cosmic-tetris --seed 1` and play a minute. +Expected: commentary that changes with what you did, sits still long enough to read, and never strobes. + +- [ ] **Step 7: Commit** + +```bash +git add internal/flavor internal/app +git commit -m "feat(flavor): mission control channel with cooldowns and rare lines" +``` + +--- + +### Task 10: Level-up notice and quantum storage + +**Files:** +- Modify: `internal/fx/world.go`, `internal/render/render.go` +- Test: `internal/fx/notice_test.go`, `internal/fx/hold_test.go`, `internal/render/notice_test.go` + +**Interfaces:** +- Produces: + ```go + // fx + // Notice returns a slide-in card and its horizontal offset in cells. + // ok is false when nothing is showing. + func (w *World) Notice() (lines []string, offsetX int, ok bool) + // render.FXView gains Notice() ([]string, int, bool). + ``` + +Pinned behavior: + +- **Level up** (§22): on `LevelChanged`, show a two-line card for `1600ms`: + ``` + GRAVITY ANOMALY DETECTED + LEVEL 08 + ``` + with a subtitle line chosen from `GRAVITY TAX INCREASED`, `LOCAL PHYSICS UPDATED WITHOUT CONSENT`, `PLEASE SECURE ALL LOOSE TETROMINOES`. It **slides in** over the first 200 ms (`offsetX` easing from `+8` to `0`), holds, then fades out over the last 300 ms (brightness only). It never pauses the game. + With `--no-fx` the card still appears — but at `offsetX = 0` for its whole life, no slide. +- **Quantum storage** (§9): on `HoldUsed`, emit for `120ms` a compression streak — 6 `RoleTrail` particles along the row of the outgoing piece with strong `VX` (`±22` cells/s) and `Life = 0.12s` — plus 3 bright `RoleImpact` flash cells at the incoming piece's spawn cells. Gameplay does not wait for it. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestLevelUpNoticeContentAndDuration(t *testing.T) { + w := NewWorld(Config{Seed: 1, Enabled: true}) + w.Resize(geom()) + w.Observe([]game.Event{game.LevelChanged{Level: 8}}, Snapshot{Level: 8}) + lines, _, ok := w.Notice() + if !ok { + t.Fatal("a level change should raise a notice") + } + joined := strings.Join(lines, "\n") + if !strings.Contains(joined, "GRAVITY ANOMALY DETECTED") || !strings.Contains(joined, "LEVEL 08") { + t.Errorf("notice = %q, want the §22 card with a zero-padded level", joined) + } + w.Advance(1500 * time.Millisecond) + if _, _, ok := w.Notice(); !ok { + t.Error("notice vanished before 1600ms") + } + w.Advance(200 * time.Millisecond) + if _, _, ok := w.Notice(); ok { + t.Error("notice outlived 1600ms") + } +} + +func TestLevelUpNoticeSlidesIn(t *testing.T) { + w := NewWorld(Config{Seed: 1, Enabled: true}) + w.Resize(geom()) + w.Observe([]game.Event{game.LevelChanged{Level: 3}}, Snapshot{Level: 3}) + _, first, _ := w.Notice() + w.Advance(250 * time.Millisecond) + _, settled, _ := w.Notice() + if first <= settled { + t.Errorf("notice offset went %d -> %d; it should slide in toward 0", first, settled) + } + if settled != 0 { + t.Errorf("settled offset = %d, want 0", settled) + } +} + +func TestNoticeAppearsWithFXDisabledWithoutSliding(t *testing.T) { + w := NewWorld(Config{Seed: 1, Enabled: false}) + w.Resize(geom()) + w.Observe([]game.Event{game.LevelChanged{Level: 5}}, Snapshot{Level: 5}) + _, off, ok := w.Notice() + if !ok { + t.Error("--no-fx should still announce a level change (it is information)") + } + if off != 0 { + t.Errorf("--no-fx notice offset = %d, want 0", off) + } +} + +func TestHoldEmitsAShortStreak(t *testing.T) { + w := NewWorld(Config{Seed: 1, Enabled: true}) + w.Resize(geom()) + w.Observe([]game.Event{game.HoldUsed{Stored: game.T, Spawned: game.L}}, Snapshot{Level: 1, Active: game.Piece{Kind: game.T, X: 4, Y: 6}}) + var streak int + for _, c := range w.Cells(nil) { + if c.Role == RoleTrail || c.Role == RoleImpact { + streak++ + } + } + if streak == 0 { + t.Fatal("a hold should emit a compression streak (§9)") + } + w.Advance(200 * time.Millisecond) + for _, c := range w.Cells(nil) { + if c.Role == RoleTrail { + t.Error("the hold streak should be gone after ~120ms") + break + } + } +} +``` + +Render side: + +```go +func TestNoticeIsDrawnAndClipped(t *testing.T) { + g := fixtureGame(t) + for _, sz := range [][2]int{{80, 30}, {40, 24}} { + out := renderPlain(Frame{ + Game: g, W: sz[0], H: sz[1], Mode: ModeFull, Seed: 7, + FX: stubFX{notice: []string{"GRAVITY ANOMALY DETECTED", "LEVEL 08"}, noticeOff: 3}, + }) + if !strings.Contains(out, "LEVEL 08") { + t.Errorf("%dx%d: notice not rendered", sz[0], sz[1]) + } + for _, line := range strings.Split(out, "\n") { + if lipgloss.Width(line) > sz[0] { + t.Errorf("%dx%d: notice pushed line %q to %d columns", sz[0], sz[1], line, lipgloss.Width(line)) + } + } + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ ./internal/render/ -run 'Notice|Hold' -v` +Expected: FAIL. + +- [ ] **Step 3: Implement the notice and hold effects** + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test -race ./... -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx internal/render +git commit -m "feat(fx): level-up notice and quantum storage hold effect" +``` + +--- + +### Task 11: Boot sequence and pause behavior + +**Files:** +- Modify: `internal/app/model.go`, `internal/app/update.go`, `internal/render/render.go` +- Test: `internal/app/boot_test.go`, `internal/render/boot_test.go`, `internal/fx/paused_test.go` + +**Interfaces:** +- Produces: + ```go + // app + const StateBoot State = ... // added as the initial state + // render + const OverlayBoot OverlayKind = ... + // Frame gains BootElapsed time.Duration for the boot timeline. + ``` + +Pinned behavior (§29, §30): + +- The program starts in `StateBoot`. The boot overlay reveals its lines on this timeline: title at `0ms`, `INITIALIZING LOCAL UNIVERSE...` at `150ms`, `gravity ........ OK` at `350ms`, `spacetime ...... OK` at `550ms`, `tetrominoes .... QUESTIONABLE` at `750ms`, `UNIVERSE ONLINE` at `900ms`. At `1000ms` the state becomes `StatePlaying` automatically. +- **Any key skips the boot sequence** and is consumed — it must not also move a piece. +- A `bubbles/v2/spinner` runs beside `INITIALIZING LOCAL UNIVERSE...` (this is §3's sanctioned use of Bubbles). +- No gravity runs during boot: `game.Advance` is not called in `StateBoot`. +- Pause (§30): gameplay and gameplay particles freeze; `fx.Snapshot.Paused` is true, so stars keep drifting very slowly (already tested in Task 3) and `LayerBoard`/`LayerGlobal` particles do not advance. + +- [ ] **Step 1: Write the failing tests** + +```go +// app +func TestProgramStartsInBoot(t *testing.T) { + m := New(Options{Seed: 1}) + m.SetSize(80, 30) + if m.state != StateBoot { + t.Errorf("state = %v, want StateBoot", m.state) + } + out := stripANSI(m.View()) + if !strings.Contains(out, "COSMIC") { + t.Errorf("boot screen should show the title:\n%s", out) + } +} + +func TestBootRevealsStepsThenStarts(t *testing.T) { + m := New(Options{Seed: 1}) + m.SetSize(80, 30) + start := time.Now() + m.Update(FrameMsg{Now: start}) + m.Update(FrameMsg{Now: start.Add(400 * time.Millisecond)}) + if out := stripANSI(m.View()); !strings.Contains(out, "gravity") { + t.Errorf("expected the gravity check by 400ms:\n%s", out) + } + for now := start.Add(450 * time.Millisecond); now.Sub(start) < 1100*time.Millisecond; now = now.Add(50 * time.Millisecond) { + m.Update(FrameMsg{Now: now}) + } + if m.state != StatePlaying { + t.Errorf("state = %v after 1.1s, want StatePlaying", m.state) + } +} + +func TestNoGravityDuringBoot(t *testing.T) { + m := New(Options{Seed: 1}) + m.SetSize(80, 30) + y := m.game.Active.Y + start := time.Now() + for i := 0; i < 10; i++ { + m.Update(FrameMsg{Now: start.Add(time.Duration(i) * 90 * time.Millisecond)}) + if m.state != StateBoot { + break + } + } + if m.game.Active.Y != y && m.state == StateBoot { + t.Error("the piece fell during the boot sequence") + } +} + +func TestAnyKeySkipsBootAndIsConsumed(t *testing.T) { + m := New(Options{Seed: 1}) + m.SetSize(80, 30) + x := m.game.Active.X + m.Update(keyPress("left")) + if m.state != StatePlaying { + t.Errorf("state = %v, want StatePlaying after a key press", m.state) + } + if m.game.Active.X != x { + t.Error("the skip key should be consumed, not also applied as a move") + } +} + +func TestQuitStillWorksDuringBoot(t *testing.T) { + m := New(Options{Seed: 1}) + m.SetSize(80, 30) + if _, cmd := m.Update(keyPress("ctrl+c")); cmd == nil { + t.Error("ctrl+c during boot should quit") + } +} +``` + +```go +// render +func TestBootOverlayGolden(t *testing.T) { + f := Frame{ + Game: game.New(7), W: 80, H: 30, Mode: ModeFull, Seed: 7, + Overlay: OverlayBoot, BootElapsed: 800 * time.Millisecond, + } + out := renderPlain(f) + for _, want := range []string{"C O S M I C", "T E T R I S", "INITIALIZING LOCAL UNIVERSE", "gravity", "spacetime", "tetrominoes"} { + if !strings.Contains(out, want) { + t.Errorf("boot overlay missing %q", want) + } + } + goldenCheck(t, "boot", out) +} + +func TestBootOverlayFitsTheMinimumTerminal(t *testing.T) { + f := Frame{Game: game.New(7), W: 40, H: 24, Mode: ModeFull, Seed: 7, Overlay: OverlayBoot, BootElapsed: 900 * time.Millisecond} + for _, line := range strings.Split(renderPlain(f), "\n") { + if lipgloss.Width(line) > 40 { + t.Errorf("line %q is %d columns wide at w=40", line, lipgloss.Width(line)) + } + } +} +``` + +```go +// fx — Review Focus 5 +func TestPausedFreezesGameplayParticles(t *testing.T) { + w := NewWorld(Config{Seed: 1, Enabled: true}) + w.Resize(geom()) + w.Observe([]game.Event{game.PieceHardDropped{Piece: game.Piece{Kind: game.T, X: 4, Y: 19}, Cells: 11}}, Snapshot{Level: 1}) + before := averageY(w.Cells(nil), RoleImpact) + for i := 0; i < 20; i++ { + w.Observe(nil, Snapshot{Level: 1, Paused: true}) + w.Advance(16 * time.Millisecond) + } + after := averageY(w.Cells(nil), RoleImpact) + if math.Abs(after-before) > 0.01 { + t.Errorf("gameplay particles moved while paused: %.3f -> %.3f (§30)", before, after) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/app/ ./internal/render/ ./internal/fx/ -run 'Boot|Paused' -v` +Expected: FAIL. + +- [ ] **Step 3: Implement the boot state, overlay and paused freeze** + +- [ ] **Step 4: Generate the boot golden and verify** + +Run: `go test ./internal/render/ -update && go test -race ./... -v` +Expected: PASS. Read `boot.golden` — it should read like §29's excessive drama. + +- [ ] **Step 5: Watch it** + +Run: `./cosmic-tetris` +Expected: about a second of drama, then the game starts by itself; pressing any key skips straight in without also moving a piece. + +- [ ] **Step 6: Commit** + +```bash +git add internal/app internal/render internal/fx +git commit -m "feat(app): boot sequence, skip-on-keypress and paused particle freeze" +``` + +--- + +### Task 12: Game-over black hole + +**Files:** +- Modify: `internal/fx/world.go`, `internal/render/render.go`, `internal/app/update.go` +- Test: `internal/fx/gameover_test.go`, `internal/render/gameover_test.go` + +**Interfaces:** +- Produces: + ```go + // fx + type CollapsePhase int + const ( + CollapseNone CollapsePhase = iota + CollapseSignalLost // 0-300ms + CollapseInfall // 300-900ms + CollapseSingularity // 900-1300ms + CollapseDone // >=1300ms + ) + func (w *World) Collapse() CollapsePhase + // render.FXView gains Collapse() fx.CollapsePhase. + ``` + +Pinned behavior (§28): + +- On `game.GameOver`, capture `Snapshot.Board` and start the collapse timeline. +- `CollapseSignalLost` (0–300 ms): everything freezes; render the captured board plus a centered `SIGNAL LOST`. +- `CollapseInfall` (300–900 ms): at the phase boundary, convert every filled board cell into a `RoleCollapse` particle carrying its `PieceKind`, with velocity aimed at the board center (magnitude scaled by distance so they arrive together) — and stop drawing the board itself. +- `CollapseSingularity` (900–1300 ms): draw §28's black hole, with the core as `██` (`##` in ASCII — §28's `●` is double-width in many terminals) and the `\ | /` rays and stray `·` `˚` `*` around it. +- `CollapseDone` (≥1300 ms): `app` switches `Frame.Overlay` to `OverlayGameOver` and plan 2's final card appears, now with the `CAUSE: EXCESSIVE GEOMETRY` subtitle already in place. +- `r` and `q` work throughout — the theatre must not block input (§44). `r` cancels the collapse and restarts immediately. +- With `--no-fx`, `Collapse()` returns `CollapseDone` immediately and the final card shows at once. + +- [ ] **Step 1: Write the failing tests** + +```go +// fx +func TestCollapseTimeline(t *testing.T) { + w := NewWorld(Config{Seed: 1, Enabled: true}) + w.Resize(geom()) + var b game.Board + for x := 0; x < game.Width; x++ { + for y := 14; y < game.Height; y++ { + b.Set(x, y, game.J) + } + } + w.Observe([]game.Event{game.GameOver{Score: 1000, Lines: 12, Level: 3}}, Snapshot{Level: 3, Board: b}) + cases := []struct { + at time.Duration + want CollapsePhase + }{ + {0, CollapseSignalLost}, + {200 * time.Millisecond, CollapseSignalLost}, + {400 * time.Millisecond, CollapseInfall}, + {1000 * time.Millisecond, CollapseSingularity}, + {1400 * time.Millisecond, CollapseDone}, + } + var elapsed time.Duration + for _, c := range cases { + w.Advance(c.at - elapsed) + elapsed = c.at + if got := w.Collapse(); got != c.want { + t.Errorf("at %v: phase = %v, want %v", c.at, got, c.want) + } + } +} + +func TestBlocksFallInwardDuringInfall(t *testing.T) { + w := NewWorld(Config{Seed: 1, Enabled: true}) + w.Resize(geom()) + var b game.Board + for x := 0; x < game.Width; x++ { + b.Set(x, 21, game.I) + } + w.Observe([]game.Event{game.GameOver{}}, Snapshot{Board: b}) + w.Advance(350 * time.Millisecond) + centerX := float64(geom().BoardX + 1 + game.Width) + spread := func() float64 { + var s, n float64 + for _, c := range w.Cells(nil) { + if c.Role == RoleCollapse { + s += math.Abs(float64(c.X) - centerX) + n++ + } + } + if n == 0 { + return -1 + } + return s / n + } + first := spread() + if first < 0 { + t.Fatal("no collapse particles were created") + } + w.Advance(300 * time.Millisecond) + if second := spread(); !(second < first) { + t.Errorf("blocks are not converging on the center: %.2f -> %.2f", first, second) + } +} + +func TestCollapseParticlesKeepTheirPieceColors(t *testing.T) { + w := NewWorld(Config{Seed: 1, Enabled: true}) + w.Resize(geom()) + var b game.Board + b.Set(3, 20, game.Z) + w.Observe([]game.Event{game.GameOver{}}, Snapshot{Board: b}) + w.Advance(350 * time.Millisecond) + var found bool + for _, c := range w.Cells(nil) { + if c.Role == RoleCollapse && c.Kind == game.Z { + found = true + } + } + if !found { + t.Error("collapse particles should inherit the locked cell's piece kind") + } +} + +func TestDisabledWorldSkipsTheCollapse(t *testing.T) { + w := NewWorld(Config{Seed: 1, Enabled: false}) + w.Resize(geom()) + w.Observe([]game.Event{game.GameOver{}}, Snapshot{}) + if got := w.Collapse(); got != CollapseDone { + t.Errorf("--no-fx collapse phase = %v, want CollapseDone", got) + } +} +``` + +```go +// render +func TestSignalLostThenSingularityThenCard(t *testing.T) { + g := fixtureGame(t) + signal := renderPlain(Frame{Game: g, W: 80, H: 30, Mode: ModeFull, Seed: 7, FX: stubFX{collapse: fx.CollapseSignalLost}}) + if !strings.Contains(signal, "SIGNAL LOST") { + t.Error("phase 1 should show SIGNAL LOST") + } + sing := renderPlain(Frame{Game: g, W: 80, H: 30, Mode: ModeFull, Seed: 7, FX: stubFX{collapse: fx.CollapseSingularity}}) + if !strings.Contains(sing, "\\ | /") && !strings.Contains(sing, "|") { + t.Errorf("phase 3 should draw the black hole:\n%s", sing) + } + goldenCheck(t, "collapse", sing) + card := renderPlain(Frame{Game: g, W: 80, H: 30, Mode: ModeFull, Seed: 7, Overlay: OverlayGameOver, FX: stubFX{collapse: fx.CollapseDone}}) + if !strings.Contains(card, "UNIVERSE EXPIRED") { + t.Error("the final card should show once the collapse is done") + } +} + +func TestCollapseIsASCIISafe(t *testing.T) { + out := renderPlain(Frame{Game: fixtureGame(t), W: 80, H: 30, Mode: ModeASCII, Seed: 7, FX: stubFX{collapse: fx.CollapseSingularity}}) + for _, r := range out { + if r > unicode.MaxASCII { + t.Fatalf("non-ASCII rune %q in the ASCII-mode collapse", r) + } + } +} +``` + +```go +// app +func TestRestartAndQuitWorkDuringTheCollapse(t *testing.T) { + m := New(Options{Seed: 7}) + m.SetSize(80, 30) + m.Update(keyPress("x")) // skip boot + for i := 0; i < 500 && m.state != StateGameOver; i++ { + m.Update(keyPress(" ")) + } + if m.state != StateGameOver { + t.Fatal("could not reach game over") + } + if _, cmd := m.Update(keyPress("q")); cmd == nil { + t.Error("q must quit during the collapse") + } + m2 := New(Options{Seed: 7}) + m2.SetSize(80, 30) + m2.Update(keyPress("x")) + for i := 0; i < 500 && m2.state != StateGameOver; i++ { + m2.Update(keyPress(" ")) + } + m2.Update(keyPress("r")) + if m2.state != StatePlaying { + t.Error("r must restart during the collapse, without waiting for it to finish") + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ ./internal/render/ ./internal/app/ -run 'Collapse|SignalLost|Infall|Singularity' -v` +Expected: FAIL. + +- [ ] **Step 3: Implement the collapse** + +- [ ] **Step 4: Generate the golden and verify** + +Run: `go test ./internal/render/ -update && go test -race ./... -v` +Expected: PASS; read `collapse.golden` and confirm the black hole looks like §28. + +- [ ] **Step 5: Die on purpose** + +Run: `./cosmic-tetris --seed 3` and top out. +Expected: freeze, `SIGNAL LOST`, the stack falling inward, a black hole, then the score card — and `r` gets you a new universe at any point in that sequence. + +- [ ] **Step 6: Commit** + +```bash +git add internal/fx internal/render internal/app +git commit -m "feat(fx): game-over collapse into a black hole" +``` + +--- + +### Task 13: Mode audit, FX goldens, coolness acceptance + +**Files:** +- Test: `internal/fx/modes_test.go`, `internal/render/fxgolden_test.go`, `internal/render/sweep_test.go`, `internal/render/testdata/*` +- Modify: `README.md` + +**Note on the import boundary:** `modes_test.go` imports `lipgloss` for its width check. That is fine — the boundary rule is about `internal/fx`'s own dependencies, and `TestFXDoesNotImportRenderOrApp` checks the non-test package with `go list -deps`. + +**Interfaces:** +- Consumes: everything. +- Produces: no new API. This task is the acceptance gate for §43, §44 and §47. + +- [ ] **Step 1: Write the FX glyph width and import-boundary tests** + +```go +// internal/fx/modes_test.go +func TestEveryEmittedGlyphIsSingleWidth(t *testing.T) { + for _, ascii := range []bool{false, true} { + w := NewWorld(Config{Seed: 4, Enabled: true, ASCII: ascii}) + w.Resize(geom()) + var b game.Board + for x := 0; x < game.Width; x++ { + b.Set(x, 21, game.I) + } + before, rows := clearedBoard() + script := [][]game.Event{ + {game.PieceMoved{Piece: game.Piece{Kind: game.T, X: 4, Y: 8}, DX: 1}}, + {game.PieceHardDropped{Piece: game.Piece{Kind: game.I, Rotation: 1, X: 3, Y: 18}, Cells: 12}}, + {game.LinesCleared{Rows: rows, Count: 4, Before: before}}, + {game.ComboChanged{Combo: 6}}, + {game.LevelChanged{Level: 9}}, + {game.HoldUsed{Stored: game.O, Spawned: game.I}}, + {game.GameOver{Score: 1, Lines: 1, Level: 1}}, + } + for _, evts := range script { + w.Observe(evts, Snapshot{Level: 4, Combo: 6, Board: b}) + for i := 0; i < 40; i++ { + w.Advance(16 * time.Millisecond) + for _, c := range w.Cells(nil) { + if wdt := lipgloss.Width(string(c.Glyph)); wdt != 1 { + t.Fatalf("ascii=%v: emitted glyph %q is %d columns wide", ascii, c.Glyph, wdt) + } + if ascii && c.Glyph > unicode.MaxASCII { + t.Fatalf("ASCII mode emitted the non-ASCII glyph %q", c.Glyph) + } + } + } + } + } +} + +func TestFXDoesNotImportRenderOrApp(t *testing.T) { + for _, pkg := range []string{"cosmic-tetris/internal/fx", "cosmic-tetris/internal/flavor"} { + out, err := exec.Command("go", "list", "-deps", pkg).Output() + if err != nil { + t.Fatal(err) + } + for _, banned := range []string{"cosmic-tetris/internal/render", "cosmic-tetris/internal/app"} { + if strings.Contains(string(out), banned) { + t.Errorf("%s depends on %s — effects must not know about rendering or the app (design.md §14)", pkg, banned) + } + } + } +} + +// Review Focus 4: every mode combination on every path. +func TestEveryModeCombinationSurvivesAFullScript(t *testing.T) { + before, rows := clearedBoard() + events := []game.Event{ + game.PieceSpawned{}, game.PieceMoved{Piece: game.Piece{Kind: game.T, X: 4, Y: 8}, DX: 1}, + game.PieceRotated{Dir: 1}, game.PieceHardDropped{Piece: game.Piece{Kind: game.I, Rotation: 1, X: 3, Y: 18}, Cells: 12}, + game.PieceLocked{}, game.LinesCleared{Rows: rows, Count: 4, Before: before}, + game.ComboChanged{Combo: 7}, game.LevelChanged{Level: 11}, + game.HoldUsed{Stored: game.O, Spawned: game.S}, game.GameOver{Score: 9, Lines: 9, Level: 9}, + } + for _, enabled := range []bool{true, false} { + for _, reduced := range []bool{true, false} { + for _, ascii := range []bool{true, false} { + w := NewWorld(Config{Seed: 8, Enabled: enabled, ReducedMotion: reduced, ASCII: ascii}) + w.Resize(geom()) + for i := 0; i < 120; i++ { + w.Observe(events, Snapshot{Level: 11, Combo: 7, Score: i * 1000}) + w.Advance(16 * time.Millisecond) + if dx, dy := w.ShakeOffset(); reduced && (dx != 0 || dy != 0) { + t.Fatalf("reduced motion produced shake (%d,%d)", dx, dy) + } + if w.Count() > MaxParticles { + t.Fatalf("particle count %d exceeded the cap", w.Count()) + } + for _, c := range w.Cells(nil) { + if reduced && c.Role == RoleShock { + t.Fatal("reduced motion emitted a shockwave") + } + if !enabled { + t.Fatalf("--no-fx emitted a cell: %+v", c) + } + } + } + } + } + } +} + +// Review Focus 1: a long game must not degrade. +func TestSustainedPlayStaysBounded(t *testing.T) { + w := NewWorld(Config{Seed: 8, Enabled: true}) + w.Resize(geom()) + before, rows := clearedBoard() + for i := 0; i < 4000; i++ { + w.Observe([]game.Event{ + game.PieceHardDropped{Piece: game.Piece{Kind: game.I, Rotation: 1, X: 3, Y: 18}, Cells: 12}, + game.LinesCleared{Rows: rows, Count: 4, Before: before}, + game.ComboChanged{Combo: i%9 + 1}, + }, Snapshot{Level: 12, Combo: i%9 + 1}) + w.Advance(16 * time.Millisecond) + if w.Count() > MaxParticles { + t.Fatalf("iteration %d: particle count %d exceeds the cap", i, w.Count()) + } + } +} + +func BenchmarkWorldFrame(b *testing.B) { + w := NewWorld(Config{Seed: 8, Enabled: true}) + w.Resize(geom()) + before, rows := clearedBoard() + w.Observe([]game.Event{game.LinesCleared{Rows: rows, Count: 4, Before: before}}, Snapshot{Level: 8}) + buf := make([]Cell, 0, MaxParticles) + b.ResetTimer() + for i := 0; i < b.N; i++ { + w.Advance(16 * time.Millisecond) + buf = w.Cells(buf[:0]) + } +} +``` + +- [ ] **Step 2: Run them and fix what they find** + +Run: `go test ./internal/fx/ -run 'Glyph|Import|Mode|Sustained' -v && go test ./internal/fx/ -bench WorldFrame -benchtime 200x` +Expected: PASS. The benchmark should report well under 1 ms per frame; if it does not, the bottleneck is FX arithmetic and needs fixing (§38 says the terminal should be the bottleneck). + +- [ ] **Step 3: Add FX-inclusive goldens** + +```go +// internal/render/fxgolden_test.go +// deterministicFX is a fixed FX state, so the golden pins the compositing +// rules rather than a moment in a live simulation. +func deterministicFX() stubFX { + return stubFX{ + cells: []fx.Cell{ + {X: 2, Y: 2, Glyph: '.', Role: fx.RoleStarFar, Brightness: 0.35, Layer: fx.LayerBackground}, + {X: 6, Y: 8, Glyph: '✦', Role: fx.RoleStarNear, Brightness: 1, Layer: fx.LayerBackground}, + {X: 70, Y: 20, Glyph: '·', Role: fx.RoleStarMid, Brightness: 0.6, Layer: fx.LayerBackground}, + {X: 40, Y: 12, Glyph: '*', Role: fx.RoleDebris, Brightness: 1, Layer: fx.LayerGlobal}, + }, + phase: 0.25, energy: 0.5, + } +} + +func TestGoldenWideWithFX(t *testing.T) { + f := Frame{Game: fixtureGame(t), W: 80, H: 30, Mode: ModeFull, Seed: 7, Mission: "PHYSICS TEAM SAYS KEEP GOING", FX: deterministicFX()} + goldenCheck(t, "wide_fx", renderPlain(f)) +} + +func TestGoldenSmallWithFXAndBanner(t *testing.T) { + s := deterministicFX() + s.banner = "✦ EVENT HORIZON ✦" + f := Frame{Game: fixtureGame(t), W: 40, H: 24, Mode: ModeFull, Seed: 7, Mission: "MOON NOTIFIED", FX: s} + goldenCheck(t, "small_fx", renderPlain(f)) +} + +func TestGoldenASCIIWithFX(t *testing.T) { + s := deterministicFX() + for i := range s.cells { + s.cells[i].Glyph = '*' + } + f := Frame{Game: fixtureGame(t), W: 80, H: 30, Mode: ModeASCII, Seed: 7, Mission: "ORBITAL OSHA HAS ENTERED THE CHAT", FX: s} + goldenCheck(t, "ascii_fx", renderPlain(f)) +} +``` + +Run: `go test ./internal/render/ -update && go test -race ./... -v` +Expected: PASS; read the three new goldens and confirm stars appear only outside the board and the piece is never covered. + +- [ ] **Step 4: Extend the plan-2 sweep to include FX** + +Add to `internal/render/sweep_test.go`: + +```go +func TestSweepWithFX(t *testing.T) { + g := fixtureGame(t) + s := deterministicFX() + for w := 1; w <= 120; w++ { + for h := 1; h <= 60; h += 3 { + out := renderPlain(Frame{Game: g, W: w, H: h, Mode: ModeFull, Seed: 7, FX: s, Mission: "MOON NOTIFIED"}) + lines := strings.Split(out, "\n") + if len(lines) > h { + t.Fatalf("%dx%d with FX: %d lines", w, h, len(lines)) + } + for _, line := range lines { + if lipgloss.Width(line) > w { + t.Fatalf("%dx%d with FX: line %q is %d columns", w, h, line, lipgloss.Width(line)) + } + } + } + } +} +``` + +Run: `go test ./internal/render/ -run SweepWithFX -v` +Expected: PASS. + +- [ ] **Step 5: Run the coolness acceptance test by hand (§43)** + +Run: `./cosmic-tetris --seed 11` and play for 30 seconds, then check off each item: + +- [ ] moving starfield +- [ ] animated board border +- [ ] piece trails +- [ ] hard-drop impact +- [ ] particles +- [ ] mission-control commentary +- [ ] on the first completed line: supernova clear, debris, border reaction +- [ ] on a four-line clear: an immediate "LOL WHAT THE FUCK" +- [ ] output does not visibly flicker +- [ ] controls never lag, even during a four-line clear +- [ ] the active piece is never obscured + +Then run `./cosmic-tetris --no-fx --seed 11` and confirm it is still a good game, and `./cosmic-tetris --reduced-motion --seed 11` and confirm nothing shakes or lurches while the game still looks alive. Fix whatever fails before finishing; unchecked boxes are unfinished work, not notes for later. + +- [ ] **Step 6: Update `README.md`** + +Add a short "cosmic effects" section: what the effects layer does, that it can never touch game state, `--no-fx` and `--reduced-motion` and exactly what each suppresses, and a note that the game RNG and FX RNG are independent. + +- [ ] **Step 7: Final verification** + +Run: `go test -race ./... && go vet ./... && gofmt -l . && go build ./...` +Expected: all pass, `gofmt -l` empty. + +- [ ] **Step 8: Commit** + +```bash +git add internal/fx internal/render README.md +git commit -m "test(fx): mode audit, FX goldens, sweep and coolness acceptance" +``` + +--- + +## Done when + +Walking §47's definition of done, the items this plan owns: + +- Effects never modify game state; `internal/fx` cannot even see a `*game.Game`, and the dependency test proves it does not know about `render` or `app`. +- Game RNG and FX RNG are isolated — proven by the two "does not touch the game RNG" tests. +- Four-line clears are gloriously excessive: hyperdrive, shake, border pulse, eruption, HUD flash, star density boost, banner. +- Game over collapses the universe into a black hole, and `r`/`q` still work throughout. +- ASCII fallback, `--no-fx` and `--reduced-motion` all work, each with tests pinning exactly what they suppress. +- Terminal output does not visibly flicker; animations never block input; particle count is bounded and the frame benchmark is well under one millisecond. +- The game is fun with effects disabled and much funnier with them enabled. diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/N1-recipe-ledgerlite-51.plan.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/N1-recipe-ledgerlite-51.plan.md new file mode 100644 index 000000000..d68f932bd --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/N1-recipe-ledgerlite-51.plan.md @@ -0,0 +1,707 @@ +# ledgerlite Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build `ledgerlite`, a standard-library Python package with a `report` command that reads a transactions CSV, categorizes each row from a rules file, and prints per-category totals plus the closing balance. + +**Architecture:** Six small modules with one responsibility each, wired together only at the CLI layer: `parse` turns CSV text into `Transaction` objects (raising `ParseError` with a line number), `rules` turns rules text into ordered `(substring, category)` pairs and answers `categorize()`, `balance` orders by date and computes the closing balance, `report` formats amounts and assembles the report string, `cli` does the file I/O, error messages, and exit codes. Every function takes and returns plain values (text in, text out), so all tests are pure and no test needs a temp file except the CLI's. + +**Tech Stack:** Python 3.11+, standard library only (`csv`, `decimal`, `datetime`, `dataclasses`, `argparse`, `pathlib`), `unittest` for tests. + +**Spec:** `design.md` (repo root) + +## Global Constraints + +- Python 3.11+. **Standard library only** — no third-party packages, no new dependency files. +- Money is `decimal.Decimal` everywhere. Never `float`, not even in a test. +- Tests live at the repo root as `test_.py` and run with `python3 -m unittest`. +- Amounts are printed with exactly two fractional digits, a leading `-` for negatives, and no thousands separators: `-12.50`, `0.00`, `1200.00`. +- Error text is exact, to stderr, one line, no traceback: + - unreadable file: `ledgerlite: cannot read : ` → exit 1 + - malformed line: `ledgerlite: :: ` → exit 2 + - `` is the path exactly as the user typed it on the command line. +- Success prints the report to stdout and exits 0. On exit 2 nothing at all is printed to stdout. +- `--opening` defaults to `0`; `--rules` is optional. + +**Assumptions this plan makes where the spec is silent** (each is pinned by a test; flag them if you disagree): + +1. The header row is validated. A file whose first row is not `date,amount,description` is a malformed file at line 1 — otherwise a headerless CSV silently loses its first transaction. +2. The rules file gets the same error contract as the transactions file: unreadable → exit 1, a line that is not `=` → exit 2 with that line number. +3. A category is listed only if at least one transaction has it, so `uncategorized` appears only when something is uncategorized. "Always listed last" is about ordering, not presence. +4. The package gets a two-line `ledgerlite/__main__.py` (not in the spec's layout) so the tool is runnable as `python3 -m ledgerlite report ...`. + +## Review Focus + +Five things the spec implies that a careless implementation gets wrong; each has a named test in the task that owns the code. + +1. `Decimal("NaN")` and `Decimal("Infinity")` succeed — an amount column containing `NaN` must be rejected as malformed, not silently become a poison value that infects every total (Task 1). +2. A transactions file with no header row, or an empty file, must be rejected at line 1 rather than quietly dropping the first data row (Task 1). +3. A category whose amounts cancel out, or an input amount of `-0.00`, must print `0.00`, never `-0.00` (Task 4). +4. A rules file that cannot be read, or a rules line missing its `=`, must produce a real error with the rules path in it — not a traceback and not a silent "nothing matched" report (Task 2 for the line, Task 5 for the read failure). +5. A valid file with a header and zero data rows must still print a report: no category lines, a blank line, then `closing balance: ` (Task 4). + +--- + +### Task 1: Transaction model and CSV parsing + +**Files:** +- Create: `ledgerlite/__init__.py` (empty), `ledgerlite/model.py`, `ledgerlite/parse.py` +- Test: `test_parse.py` + +**Interfaces:** +- Consumes: nothing. +- Produces: + - `ledgerlite.model.Transaction` — frozen dataclass with fields `date: datetime.date`, `amount: decimal.Decimal`, `description: str`, in that order. + - `ledgerlite.parse.ParseError(Exception)` with `__init__(self, line: int, message: str)` storing `self.line` and `self.message`. + - `ledgerlite.parse.parse_amount(raw: str) -> Decimal` — raises `ValueError` whose `str()` is the ready-to-print reason. + - `ledgerlite.parse.parse_transactions(text: str) -> list[Transaction]` — file order preserved, raises `ParseError`. + +- [ ] **Step 1: Write the failing happy-path tests in `test_parse.py`** + +```python +import datetime +import unittest +from decimal import Decimal + +from ledgerlite.model import Transaction +from ledgerlite.parse import ParseError, parse_amount, parse_transactions + +HEADER = "date,amount,description\n" + + +class ParseTransactionsTests(unittest.TestCase): + def test_parses_rows_in_file_order(self): + text = HEADER + "2026-03-04,-7.50,Coffee shop\n2026-03-01,2500.00,Salary\n" + self.assertEqual( + parse_transactions(text), + [ + Transaction(datetime.date(2026, 3, 4), Decimal("-7.50"), "Coffee shop"), + Transaction(datetime.date(2026, 3, 1), Decimal("2500.00"), "Salary"), + ], + ) + + def test_header_only_file_has_no_transactions(self): + self.assertEqual(parse_transactions(HEADER), []) + + def test_accepts_fewer_than_two_fractional_digits(self): + text = HEADER + "2026-03-04,1.5,a\n2026-03-05,2,b\n" + self.assertEqual( + [t.amount for t in parse_transactions(text)], + [Decimal("1.5"), Decimal("2")], + ) + + def test_quoted_description_may_contain_a_comma(self): + text = HEADER + '2026-03-04,-1.00,"Cafe, downtown"\n' + self.assertEqual(parse_transactions(text)[0].description, "Cafe, downtown") + + +class ParseAmountTests(unittest.TestCase): + def test_parses_negative_two_digit_amount(self): + self.assertEqual(parse_amount("-12.50"), Decimal("-12.50")) + + def test_rejects_three_fractional_digits(self): + with self.assertRaises(ValueError): + parse_amount("1.005") + + def test_rejects_values_that_are_not_finite_numbers(self): + for raw in ["", "abc", "NaN", "Infinity", "-inf"]: + with self.subTest(raw=raw), self.assertRaises(ValueError): + parse_amount(raw) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_parse -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite'` + +- [ ] **Step 3: Create the package and implement `Transaction`, `ParseError`, `parse_amount`, `parse_transactions`** + +- `ledgerlite/__init__.py`: empty. +- `ledgerlite/model.py`: `@dataclass(frozen=True)` `Transaction` with the fields from the Interfaces block. +- `ledgerlite/parse.py`: read the CSV with `csv.reader(io.StringIO(text))`. Take the row's line number from `reader.line_num` (it counts physical lines, so the first data row is 2 and quoted embedded newlines stay correct — do **not** use `enumerate`). Parse dates with `datetime.date.fromisoformat` on the stripped field. Build the list in reader order; do not sort here. +- `parse_amount` is the one place that decides what a valid amount is: + +```python +def parse_amount(raw: str) -> Decimal: + try: + value = Decimal(raw.strip()) + except InvalidOperation: + raise ValueError(f"invalid amount: {raw!r}") from None + if not value.is_finite(): + raise ValueError(f"invalid amount: {raw!r}") + if value.as_tuple().exponent < -2: + raise ValueError(f"amount has more than two fractional digits: {raw!r}") + return value +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_parse -v` +Expected: PASS + +- [ ] **Step 5: Add the failing malformed-input tests to `test_parse.py`** + +```python +class MalformedFileTests(unittest.TestCase): + def assert_parse_error(self, text, line, message): + with self.assertRaises(ParseError) as ctx: + parse_transactions(text) + self.assertEqual((ctx.exception.line, ctx.exception.message), (line, message)) + + def test_missing_header_row(self): + self.assert_parse_error( + "2026-03-04,-7.50,Coffee\n", 1, "expected header date,amount,description" + ) + + def test_empty_file(self): + self.assert_parse_error("", 1, "expected header date,amount,description") + + def test_too_few_columns(self): + self.assert_parse_error(HEADER + "2026-03-04,-7.50\n", 2, "expected 3 columns, got 2") + + def test_too_many_columns(self): + self.assert_parse_error(HEADER + "2026-03-04,-7.50,a,b\n", 2, "expected 3 columns, got 4") + + def test_unparseable_date(self): + self.assert_parse_error( + HEADER + "2026-13-01,-7.50,Coffee\n", 2, "invalid date: '2026-13-01'" + ) + + def test_line_number_counts_the_header(self): + text = HEADER + "2026-03-04,-7.50,Coffee\n2026-03-05,seven,Tea\n" + self.assert_parse_error(text, 3, "invalid amount: 'seven'") + + def test_amount_with_three_fractional_digits(self): + self.assert_parse_error( + HEADER + "2026-03-04,1.005,Coffee\n", + 2, + "amount has more than two fractional digits: '1.005'", + ) + + def test_non_finite_amount_is_rejected(self): + self.assert_parse_error(HEADER + "2026-03-04,NaN,Coffee\n", 2, "invalid amount: 'NaN'") +``` + +- [ ] **Step 6: Run the tests to verify the new ones fail** + +Run: `python3 -m unittest test_parse -v` +Expected: FAIL on the `MalformedFileTests` cases + +- [ ] **Step 7: Add validation to `parse_transactions`** + +Header check: compare `[c.strip().lower() for c in header]` to `["date", "amount", "description"]`; a missing first row (`StopIteration`) and a wrong first row both raise `ParseError(1, "expected header date,amount,description")`. Then per row: column count, then date, then amount — reusing `parse_amount` and passing `str(exc)` through as the `ParseError` message. + +- [ ] **Step 8: Run the tests to verify they pass** + +Run: `python3 -m unittest test_parse -v` +Expected: PASS + +- [ ] **Step 9: Commit** + +```bash +git add ledgerlite/__init__.py ledgerlite/model.py ledgerlite/parse.py test_parse.py +git commit -m "feat: parse transactions CSV into Transaction records" +``` + +--- + +### Task 2: Rules file and categorization + +**Files:** +- Create: `ledgerlite/rules.py` +- Test: `test_rules.py` + +**Interfaces:** +- Consumes: `ledgerlite.parse.ParseError`. +- Produces: + - `ledgerlite.rules.Rule` — `NamedTuple` with `substring: str` (stored lowercased) and `category: str`; compares equal to a plain 2-tuple. + - `ledgerlite.rules.parse_rules(text: str) -> list[Rule]` — file order preserved, raises `ParseError`. + - `ledgerlite.rules.categorize(description: str, rules: Sequence[Rule]) -> str | None`. + +- [ ] **Step 1: Write the failing tests in `test_rules.py`** + +```python +import unittest + +from ledgerlite.parse import ParseError +from ledgerlite.rules import categorize, parse_rules + + +class ParseRulesTests(unittest.TestCase): + def test_parses_rules_in_file_order(self): + self.assertEqual( + parse_rules("coffee=food\nrent=housing\n"), + [("coffee", "food"), ("rent", "housing")], + ) + + def test_lowercases_the_substring_and_strips_whitespace(self): + self.assertEqual(parse_rules(" Coffee = food \n"), [("coffee", "food")]) + + def test_blank_lines_are_ignored_but_still_counted(self): + with self.assertRaises(ParseError) as ctx: + parse_rules("coffee=food\n\noops\n") + self.assertEqual(ctx.exception.line, 3) + + def test_splits_on_the_first_equals_only(self): + self.assertEqual(parse_rules("a=b=c\n"), [("a", "b=c")]) + + def test_line_without_equals_is_an_error(self): + with self.assertRaises(ParseError) as ctx: + parse_rules("coffee food\n") + self.assertEqual( + (ctx.exception.line, ctx.exception.message), + (1, "rule must be ="), + ) + + def test_empty_substring_or_category_is_an_error(self): + for text in ["=food\n", "coffee=\n"]: + with self.subTest(text=text), self.assertRaises(ParseError): + parse_rules(text) + + +class CategorizeTests(unittest.TestCase): + def test_matches_substring_case_insensitively(self): + rules = parse_rules("coffee=food\n") + self.assertEqual(categorize("BLUE BOTTLE COFFEE", rules), "food") + + def test_first_matching_rule_wins(self): + rules = parse_rules("coffee=food\ncoffee=treats\n") + self.assertEqual(categorize("Coffee shop", rules), "food") + + def test_returns_none_when_nothing_matches(self): + self.assertIsNone(categorize("Salary", parse_rules("coffee=food\n"))) + + def test_returns_none_with_no_rules(self): + self.assertIsNone(categorize("Coffee shop", [])) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_rules -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.rules'` + +- [ ] **Step 3: Implement `Rule`, `parse_rules`, and `categorize` in `ledgerlite/rules.py`** + +Iterate with `enumerate(text.splitlines(), start=1)` so the line number is the physical line. Skip lines that are empty after `strip()`. Split with `line.split("=", 1)`; strip both halves; either half empty (or no `=`) raises `ParseError(line, "rule must be =")`. `categorize` lowercases the description once, then returns the first rule whose substring is `in` it, else `None`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_rules -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/rules.py test_rules.py +git commit -m "feat: parse rules file and categorize descriptions" +``` + +--- + +### Task 3: Date ordering and closing balance + +**Files:** +- Create: `ledgerlite/balance.py` +- Test: `test_balance.py` + +**Interfaces:** +- Consumes: `ledgerlite.model.Transaction`. +- Produces: + - `ledgerlite.balance.order_by_date(transactions: Sequence[Transaction]) -> list[Transaction]` — stable, so same-date rows keep input order. + - `ledgerlite.balance.closing_balance(transactions: Sequence[Transaction], opening: Decimal) -> Decimal`. + +- [ ] **Step 1: Write the failing tests in `test_balance.py`** + +```python +import datetime +import unittest +from decimal import Decimal + +from ledgerlite.balance import closing_balance, order_by_date +from ledgerlite.model import Transaction + + +def txn(day, amount, description="x"): + return Transaction(datetime.date(2026, 3, day), Decimal(amount), description) + + +class OrderByDateTests(unittest.TestCase): + def test_sorts_by_date(self): + rows = [txn(4, "1.00", "b"), txn(1, "2.00", "a")] + self.assertEqual([t.description for t in order_by_date(rows)], ["a", "b"]) + + def test_same_date_keeps_input_order(self): + rows = [txn(1, "1.00", "first"), txn(1, "2.00", "second")] + self.assertEqual( + [t.description for t in order_by_date(rows)], ["first", "second"] + ) + + def test_does_not_mutate_its_input(self): + rows = [txn(4, "1.00", "b"), txn(1, "2.00", "a")] + order_by_date(rows) + self.assertEqual([t.description for t in rows], ["b", "a"]) + + +class ClosingBalanceTests(unittest.TestCase): + def test_adds_every_amount_to_the_opening_balance(self): + rows = [txn(4, "-7.50"), txn(1, "2500.00"), txn(2, "-900.00")] + self.assertEqual(closing_balance(rows, Decimal("100")), Decimal("1692.50")) + + def test_no_transactions_returns_the_opening_balance(self): + self.assertEqual(closing_balance([], Decimal("100")), Decimal("100")) + + def test_arithmetic_is_exact(self): + rows = [txn(1, "0.10"), txn(2, "0.20")] + self.assertEqual(closing_balance(rows, Decimal("0")), Decimal("0.30")) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_balance -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.balance'` + +- [ ] **Step 3: Implement `order_by_date` and `closing_balance` in `ledgerlite/balance.py`** + +`order_by_date` returns `sorted(transactions, key=lambda t: t.date)` (Python's sort is stable, and `sorted` leaves the caller's list alone). `closing_balance` starts at `opening` and accumulates the amounts of `order_by_date(transactions)` in order. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_balance -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/balance.py test_balance.py +git commit -m "feat: date-ordered running balance and closing balance" +``` + +--- + +### Task 4: Category totals and report formatting + +**Files:** +- Create: `ledgerlite/report.py` +- Test: `test_report.py` + +**Interfaces:** +- Consumes: `Transaction`, `Rule`, `categorize`, `closing_balance`. +- Produces: + - `ledgerlite.report.UNCATEGORIZED: str = "uncategorized"` + - `ledgerlite.report.format_amount(amount: Decimal) -> str` + - `ledgerlite.report.category_totals(transactions: Sequence[Transaction], rules: Sequence[Rule]) -> list[tuple[str, Decimal]]` — alphabetical, `UNCATEGORIZED` last, categories with no transactions absent. + - `ledgerlite.report.format_report(transactions: Sequence[Transaction], rules: Sequence[Rule], opening: Decimal) -> str` — the whole report, ending in exactly one `"\n"`. + +- [ ] **Step 1: Write the failing tests in `test_report.py`** + +```python +import datetime +import unittest +from decimal import Decimal + +from ledgerlite.model import Transaction +from ledgerlite.report import category_totals, format_amount, format_report +from ledgerlite.rules import parse_rules + +RULES = parse_rules("coffee=food\nrent=housing\n") +EXAMPLE = [ + Transaction(datetime.date(2026, 3, 4), Decimal("-7.50"), "Coffee shop"), + Transaction(datetime.date(2026, 3, 1), Decimal("2500.00"), "Salary"), + Transaction(datetime.date(2026, 3, 2), Decimal("-900.00"), "Rent March"), +] + + +class FormatAmountTests(unittest.TestCase): + def test_formats_two_fractional_digits(self): + cases = [ + ("-12.5", "-12.50"), + ("0", "0.00"), + ("1200", "1200.00"), + ("1234567.89", "1234567.89"), + ] + for raw, expected in cases: + with self.subTest(raw=raw): + self.assertEqual(format_amount(Decimal(raw)), expected) + + def test_negative_zero_prints_as_zero(self): + self.assertEqual(format_amount(Decimal("-0.00")), "0.00") + + +class CategoryTotalsTests(unittest.TestCase): + def test_alphabetical_with_uncategorized_last(self): + rules = parse_rules("coffee=food\nsalary=zzz\n") + self.assertEqual( + category_totals(EXAMPLE, rules), + [("food", Decimal("-7.50")), ("zzz", Decimal("2500.00")), + ("uncategorized", Decimal("-900.00"))], + ) + + def test_sums_amounts_within_a_category(self): + rows = EXAMPLE + [Transaction(datetime.date(2026, 3, 5), Decimal("-2.50"), "coffee")] + self.assertEqual(dict(category_totals(rows, RULES))["food"], Decimal("-10.00")) + + def test_no_transactions_means_no_categories(self): + self.assertEqual(category_totals([], RULES), []) + + def test_uncategorized_absent_when_everything_matches(self): + rows = [EXAMPLE[0]] + self.assertEqual([name for name, _ in category_totals(rows, RULES)], ["food"]) + + +class FormatReportTests(unittest.TestCase): + def test_matches_the_design_example(self): + self.assertEqual( + format_report(EXAMPLE, RULES, Decimal("100")), + "food: -7.50\n" + "housing: -900.00\n" + "uncategorized: 2500.00\n" + "\n" + "closing balance: 1692.50\n", + ) + + def test_no_transactions_reports_the_opening_balance(self): + self.assertEqual( + format_report([], RULES, Decimal("100")), "\nclosing balance: 100.00\n" + ) + + def test_cancelling_category_prints_zero_not_negative_zero(self): + rows = [ + Transaction(datetime.date(2026, 3, 1), Decimal("-5.00"), "Coffee"), + Transaction(datetime.date(2026, 3, 2), Decimal("5.00"), "Coffee refund"), + ] + self.assertEqual( + format_report(rows, RULES, Decimal("0")), + "food: 0.00\n\nclosing balance: 0.00\n", + ) + + def test_no_rules_means_everything_is_uncategorized(self): + self.assertEqual( + format_report(EXAMPLE, [], Decimal("0")), + "uncategorized: 1592.50\n\nclosing balance: 1592.50\n", + ) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_report -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.report'` + +- [ ] **Step 3: Implement `format_amount`, `category_totals`, and `format_report` in `ledgerlite/report.py`** + +```python +def format_amount(amount: Decimal) -> str: + value = amount.quantize(Decimal("0.01")) + if value == 0: # kills "-0.00" + value = abs(value) + return f"{value:f}" # ":f" never adds thousands separators +``` + +`category_totals`: accumulate into a `dict[str, Decimal]` keyed by `categorize(...) or UNCATEGORIZED`, then return the non-`UNCATEGORIZED` names `sorted()` followed by `UNCATEGORIZED` if it is present. `format_report`: one `": "` line per pair, then `""`, then `f"closing balance: {format_amount(closing_balance(transactions, opening))}"`, joined with `"\n"` plus a trailing `"\n"`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_report -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/report.py test_report.py +git commit -m "feat: per-category totals and report formatting" +``` + +--- + +### Task 5: CLI, exit codes, and error messages + +**Files:** +- Create: `ledgerlite/cli.py`, `ledgerlite/__main__.py` +- Test: `test_cli.py` + +**Interfaces:** +- Consumes: `parse_amount`, `parse_transactions`, `ParseError`, `parse_rules`, `format_report`. +- Produces: + - `ledgerlite.cli.build_parser() -> argparse.ArgumentParser` + - `ledgerlite.cli.main(argv: Sequence[str] | None = None) -> int` + +- [ ] **Step 1: Write the failing tests in `test_cli.py`** + +```python +import contextlib +import errno +import io +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +from ledgerlite.cli import main + +TRANSACTIONS = ( + "date,amount,description\n" + "2026-03-04,-7.50,Coffee shop\n" + "2026-03-01,2500.00,Salary\n" + "2026-03-02,-900.00,Rent March\n" +) +EXPECTED = ( + "food: -7.50\n" + "housing: -900.00\n" + "uncategorized: 2500.00\n" + "\n" + "closing balance: 1692.50\n" +) + + +class CliTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.dir = Path(self.tmp.name) + + def write(self, name, text): + path = self.dir / name + path.write_text(text, encoding="utf-8") + return str(path) + + def run_cli(self, *argv): + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = main(list(argv)) + return code, out.getvalue(), err.getvalue() + + def test_reports_the_design_example(self): + txns = self.write("t.csv", TRANSACTIONS) + rules = self.write("r.txt", "coffee=food\nrent=housing\n") + self.assertEqual( + self.run_cli("report", txns, "--rules", rules, "--opening", "100"), + (0, EXPECTED, ""), + ) + + def test_without_rules_everything_is_uncategorized(self): + txns = self.write("t.csv", TRANSACTIONS) + code, out, err = self.run_cli("report", txns) + self.assertEqual((code, err), (0, "")) + self.assertEqual(out, "uncategorized: 1592.50\n\nclosing balance: 1592.50\n") + + def test_missing_transactions_file(self): + missing = str(self.dir / "nope.csv") + code, out, err = self.run_cli("report", missing) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertEqual( + err, f"ledgerlite: cannot read {missing}: {os.strerror(errno.ENOENT)}\n" + ) + + def test_missing_rules_file(self): + txns = self.write("t.csv", TRANSACTIONS) + missing = str(self.dir / "nope.txt") + code, out, err = self.run_cli("report", txns, "--rules", missing) + self.assertEqual((code, out), (1, "")) + self.assertEqual( + err, f"ledgerlite: cannot read {missing}: {os.strerror(errno.ENOENT)}\n" + ) + + def test_malformed_row_prints_nothing_to_stdout(self): + txns = self.write("t.csv", "date,amount,description\n2026-03-04,1.005,Coffee\n") + code, out, err = self.run_cli("report", txns) + self.assertEqual((code, out), (2, "")) + self.assertEqual( + err, + f"ledgerlite: {txns}:2: amount has more than two fractional digits: '1.005'\n", + ) + + def test_malformed_rules_line(self): + txns = self.write("t.csv", TRANSACTIONS) + rules = self.write("r.txt", "coffee=food\noops\n") + code, out, err = self.run_cli("report", txns, "--rules", rules) + self.assertEqual((code, out), (2, "")) + self.assertEqual(err, f"ledgerlite: {rules}:2: rule must be =\n") + + def test_undecodable_transactions_file_cannot_be_read(self): + path = self.dir / "bad.csv" + path.write_bytes(b"date,amount,description\n2026-03-04,-1.00,\xff\xfe\n") + code, out, err = self.run_cli("report", str(path)) + self.assertEqual((code, out), (1, "")) + self.assertTrue(err.startswith(f"ledgerlite: cannot read {path}: "), err) + + def test_bad_opening_amount(self): + txns = self.write("t.csv", TRANSACTIONS) + code, out, err = self.run_cli("report", txns, "--opening", "1.005") + self.assertEqual((code, out), (2, "")) + self.assertIn("1.005", err) + + def test_runs_as_a_module(self): + txns = self.write("t.csv", TRANSACTIONS) + proc = subprocess.run( + [sys.executable, "-m", "ledgerlite", "report", txns, "--opening", "100"], + capture_output=True, + text=True, + cwd=str(Path(__file__).parent), + ) + self.assertEqual(proc.returncode, 0, proc.stderr) + self.assertEqual(proc.stdout, "uncategorized: 1592.50\n\nclosing balance: 1692.50\n") +``` + +Note on the last test: it passes no `--rules`, so every transaction is uncategorized (`1592.50`) while the closing balance also includes the opening `100`, giving `1692.50`. `cwd` is the repo root (where this test file lives) so that `-m ledgerlite` resolves. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_cli -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.cli'` + +- [ ] **Step 3: Implement `build_parser` and `main` in `ledgerlite/cli.py`** + +- `build_parser`: `ArgumentParser(prog="ledgerlite")` with `add_subparsers(dest="command", required=True)` and one `report` subparser holding positional `transactions` (metavar `TRANSACTIONS`), `--rules` (default `None`), and `--opening` with `default=Decimal("0")` and `type=_opening`, where `_opening` calls `parse_amount` and re-raises `ValueError` as `argparse.ArgumentTypeError(str(exc))`. +- `main` must return an int for every path, including argparse's: + +```python +def main(argv=None) -> int: + try: + args = build_parser().parse_args(argv) + except SystemExit as exc: # argparse already printed the message + return int(exc.code or 0) +``` + +- Then, for the transactions path and (if given) the rules path: read with `Path(p).read_text(encoding="utf-8")`, catching `(OSError, UnicodeDecodeError)` and printing `f"ledgerlite: cannot read {p}: {reason}"` to stderr and returning 1. `reason` is `exc.strerror` when it is set, otherwise `str(exc)` — put that in one `_reason(exc) -> str` helper. +- Then parse each text, catching `ParseError` and printing `f"ledgerlite: {p}:{exc.line}: {exc.message}"` to stderr and returning 2. Read and parse the transactions file before touching the rules file. +- Nothing may be written to stdout before both files have parsed cleanly. Finish with `sys.stdout.write(format_report(transactions, rules, args.opening))` and `return 0`. + +- [ ] **Step 4: Create `ledgerlite/__main__.py`** + +```python +import sys + +from ledgerlite.cli import main + +if __name__ == "__main__": + sys.exit(main()) +``` + +- [ ] **Step 5: Run the CLI tests to verify they pass** + +Run: `python3 -m unittest test_cli -v` +Expected: PASS + +- [ ] **Step 6: Run the whole suite** + +Run: `python3 -m unittest -v` +Expected: PASS, every `test_*.py` collected, zero failures and zero errors + +- [ ] **Step 7: Check the real command by hand** + +```bash +printf 'date,amount,description\n2026-03-04,-7.50,Coffee shop\n2026-03-01,2500.00,Salary\n2026-03-02,-900.00,Rent March\n' > /tmp/t.csv +printf 'coffee=food\nrent=housing\n' > /tmp/r.txt +python3 -m ledgerlite report /tmp/t.csv --rules /tmp/r.txt --opening 100 +``` + +Expected: exactly the example block from `design.md`, and `echo $?` prints 0. + +- [ ] **Step 8: Commit** + +```bash +git add ledgerlite/cli.py ledgerlite/__main__.py test_cli.py +git commit -m "feat: add report command with exit codes and error messages" +``` diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/N1-recipe-ledgerlite-52.plan.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/N1-recipe-ledgerlite-52.plan.md new file mode 100644 index 000000000..65477eb1e --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/N1-recipe-ledgerlite-52.plan.md @@ -0,0 +1,774 @@ +# ledgerlite Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build `ledgerlite`, a standard-library-only Python CLI that reads a bank-transaction CSV, categorizes each row from a rules file, and prints per-category totals plus the closing balance. + +**Architecture:** A small pure-function library (`model` → `parse` → `rules` → `balance` → `report`) with all I/O, argument parsing, and exit codes confined to `cli.py`. Every function below the CLI takes already-read text lines or in-memory objects, so every behavior in the spec is unit-testable without touching the filesystem. Money is `decimal.Decimal` end to end. + +**Tech Stack:** Python 3.11+, standard library only (`csv`, `decimal`, `datetime`, `argparse`, `dataclasses`, `re`, `unittest`). + +**Spec:** `design.md` (in the repo root, alongside this plan) + +## Global Constraints + +- Python 3.11+. **Standard library only** — no third-party imports, no dependency files. +- Money is `decimal.Decimal` everywhere. **Never** construct a `Decimal` from a `float`, and never convert an amount to `float`. +- Amounts print with exactly two fractional digits, a leading `-` for negatives, and no thousands separators: `-12.50`, `0.00`, `1200.00`. +- Exit codes: `0` success, `1` a named file cannot be read, `2` malformed input. +- Exact stderr formats (single line, no trailing period): + - `ledgerlite: cannot read : ` + - `ledgerlite: :: ` +- On exit 2, **nothing** is written to stdout — the whole file is rejected. +- Rule matching is case-insensitive on the description; the **first** matching rule wins. +- Package lives in `ledgerlite/`. Tests live at the repo root as `test_.py` and run with `python3 -m unittest`. +- Work directly on `main`; this is a local scratch repo with no remote. Commit at the end of every task. + +## Review Focus + +Five input classes the spec implies but does not spell out. Each already has a test assigned to the task that owns the code; the assignment is listed so a reviewer can find it. + +1. **A transactions file with no usable header** — a zero-byte file, or a headerless file whose first data row would be silently swallowed as a header, must be rejected rather than quietly dropping a transaction. Same class: a UTF-8 BOM or CRLF line endings must not corrupt the header or the last field. → Task 1 (header validation) and Task 5 (`utf-8-sig`, `newline=""` reading). +2. **Amount spellings `Decimal()` accepts but the spec does not** — `NaN`, `Infinity`, `1e3`, `1_000` must be reported as invalid amounts, not folded into a total (a `NaN` amount would poison every total silently). → Task 1 (`parse_amount` regex). +3. **A total that is exactly zero but carries a negative sign** — `Decimal("-0.00")` formats as `-0.00`; the spec says zero prints `0.00`. → Task 4 (`format_amount`). +4. **A rules file that is unreadable or contains a line without `=`** — a typo like `coffee food` would otherwise match nothing and the user would see unexplained `uncategorized` totals. The spec gives no rules-file error path, so this plan reuses the two it defines: unreadable rules file → exit 1 `cannot read`; malformed rule line → exit 2 `:: `. → Task 2 (rule-line errors) and Task 5 (read failure). +5. **Category names whose ordering or identity is ambiguous** — "alphabetically" must not put `Food` before `bank` on a capitalization technicality, and a rule whose category is literally `uncategorized` must merge into the uncategorized bucket and still print last. → Task 4 (ordering tests). + +## File Structure + +| File | Responsibility | +| --- | --- | +| `ledgerlite/__init__.py` | Empty package marker. | +| `ledgerlite/model.py` | `Transaction` frozen dataclass. No logic. | +| `ledgerlite/parse.py` | `ParseError`, `parse_amount`, `parse_transactions`. Owns every "this row is malformed" decision for the CSV. | +| `ledgerlite/rules.py` | `parse_rules`, `categorize`. Owns rule-line syntax and case-insensitive matching. | +| `ledgerlite/balance.py` | `order_by_date`, `closing_balance`. Owns the spec's ordering rule. | +| `ledgerlite/report.py` | `UNCATEGORIZED`, `format_amount`, `category_totals`, `format_report`. Owns all output text. | +| `ledgerlite/cli.py` | `main(argv) -> int`: argparse, file reading, error messages, exit codes. | +| `ledgerlite/__main__.py` | Two-line shim so `python3 -m ledgerlite` works. (Not in the spec's layout list; without it the tool cannot be run, only imported.) | +| `test_parse.py`, `test_rules.py`, `test_balance.py`, `test_report.py`, `test_cli.py` | Root-level unittest modules, one per implementation module. | + +`rules.py` imports `ParseError` from `parse.py` (the spec assigns `ParseError` to `parse.py`, and `parse.py` imports nothing from `rules.py`, so there is no cycle). + +--- + +### Task 1: Package skeleton, `Transaction`, and CSV parsing + +**Files:** +- Create: `ledgerlite/__init__.py` (empty), `ledgerlite/model.py`, `ledgerlite/parse.py` +- Test: `test_parse.py` + +**Interfaces:** +- Consumes: nothing. +- Produces: + - `ledgerlite.model.Transaction` — frozen dataclass with fields `date: datetime.date`, `amount: decimal.Decimal`, `description: str`, in that order. + - `ledgerlite.parse.ParseError(line: int, message: str)` — exception with attributes `.line: int` and `.message: str`. + - `ledgerlite.parse.parse_amount(text: str) -> decimal.Decimal` — raises `ValueError` whose `str()` is the ready-to-print `` text. + - `ledgerlite.parse.parse_transactions(lines: Iterable[str]) -> list[Transaction]` — raises `ParseError`. Returns transactions in **input order** (ordering is Task 3's job). + - `ledgerlite.parse.HEADER = ["date", "amount", "description"]` + +- [ ] **Step 1: Write the failing happy-path test in `test_parse.py`** + +```python +import unittest +from datetime import date +from decimal import Decimal + +from ledgerlite.model import Transaction +from ledgerlite.parse import ParseError, parse_amount, parse_transactions + + +class ParseTransactionsTest(unittest.TestCase): + def test_parses_rows_in_input_order(self): + lines = [ + "date,amount,description\n", + "2026-03-05,-7.50,Coffee Bar\n", + "2026-03-04,2500.00,ACME Salary\n", + ] + self.assertEqual( + parse_transactions(lines), + [ + Transaction(date(2026, 3, 5), Decimal("-7.50"), "Coffee Bar"), + Transaction(date(2026, 3, 4), Decimal("2500.00"), "ACME Salary"), + ], + ) + + def test_header_only_file_has_no_transactions(self): + self.assertEqual(parse_transactions(["date,amount,description\n"]), []) + + def test_blank_lines_are_skipped(self): + lines = ["date,amount,description\n", "\n", "2026-03-04,1.5,Tip\n", "\n"] + self.assertEqual( + parse_transactions(lines), + [Transaction(date(2026, 3, 4), Decimal("1.5"), "Tip")], + ) + + def test_one_and_two_fractional_digits_both_parse(self): + self.assertEqual(parse_amount("1.5"), Decimal("1.5")) + self.assertEqual(parse_amount("1.50"), Decimal("1.50")) + self.assertEqual(parse_amount("-12"), Decimal("-12")) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `python3 -m unittest test_parse -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite'` + +- [ ] **Step 3: Create `ledgerlite/__init__.py` (empty) and `Transaction` in `ledgerlite/model.py`** + +Use `@dataclasses.dataclass(frozen=True)` so instances compare by value (the tests above rely on `==`). + +- [ ] **Step 4: Write the failing rejection tests in `test_parse.py`** + +Add to the same file. `assertRaises(...)` as a context manager, asserting both `.line` and `.message`, since the CLI prints both. + +```python +class ParseRejectionTest(unittest.TestCase): + def _error(self, lines): + with self.assertRaises(ParseError) as caught: + parse_transactions(lines) + return caught.exception + + def test_empty_file_is_rejected(self): + error = self._error([]) + self.assertEqual(error.line, 1) + self.assertEqual(error.message, 'expected header "date,amount,description"') + + def test_missing_header_is_rejected_not_swallowed(self): + error = self._error(["2026-03-04,1.00,Tip\n"]) + self.assertEqual(error.line, 1) + self.assertEqual(error.message, 'expected header "date,amount,description"') + + def test_wrong_column_count(self): + error = self._error(["date,amount,description\n", "2026-03-04,1.00\n"]) + self.assertEqual(error.line, 2) + self.assertEqual(error.message, "expected 3 columns, got 2") + + def test_unparseable_date(self): + error = self._error(["date,amount,description\n", "04/03/2026,1.00,Tip\n"]) + self.assertEqual(error.line, 2) + self.assertEqual(error.message, 'invalid date "04/03/2026"') + + def test_too_many_fractional_digits(self): + error = self._error(["date,amount,description\n", "2026-03-04,1.005,Tip\n"]) + self.assertEqual(error.line, 2) + self.assertEqual( + error.message, 'amount "1.005" has more than two fractional digits' + ) + + def test_reports_the_second_bad_row_by_its_own_line_number(self): + lines = [ + "date,amount,description\n", + "2026-03-04,1.00,Tip\n", + "2026-03-05,nope,Tip\n", + ] + self.assertEqual(self._error(lines).line, 3) + + def test_amount_spellings_decimal_accepts_but_spec_does_not(self): + for value in ["NaN", "Infinity", "1e3", "1_000", "1.2.3", ""]: + with self.subTest(value=value): + with self.assertRaises(ValueError): + parse_amount(value) +``` + +- [ ] **Step 5: Run the tests to verify every one fails for the same reason** + +Run: `python3 -m unittest test_parse -v` +Expected: 11 tests FAIL — `ImportError: cannot import name 'ParseError' from 'ledgerlite.parse'` (or `ModuleNotFoundError`, since `parse.py` does not exist yet) + +- [ ] **Step 6: Implement `parse_amount(text: str) -> Decimal` in `ledgerlite/parse.py`** + +Validate with a module-level regex before touching `Decimal`, because `Decimal()` accepts `NaN`, `Infinity`, `1e3`, and `1_000`, none of which the spec allows: + +```python +_AMOUNT_RE = re.compile(r"^[+-]?\d*\.?\d+$") +``` + +- `text.strip()` first. +- No regex match → `raise ValueError(f'invalid amount "{text}"')`. +- More than two digits after the `.` → `raise ValueError(f'amount "{text}" has more than two fractional digits')`. +- Otherwise return `Decimal(stripped)`. + +- [ ] **Step 7: Implement `ParseError` and `parse_transactions` in `ledgerlite/parse.py`** + +`ParseError.__init__(self, line, message)` stores `self.line` and `self.message` and calls `super().__init__(f"line {line}: {message}")`. + +`parse_transactions` walks `csv.reader(lines)` and uses `reader.line_num` as the line number (it counts physical lines, so it stays correct for quoted fields containing newlines). Per row: +- Row is `[]` (a blank line) → skip, wherever it appears. +- First non-blank row is the header: compare `[field.strip().lower() for field in row]` against `HEADER`; mismatch → `ParseError(line, 'expected header "date,amount,description"')`. Do not emit a transaction for it. +- Not exactly 3 fields → `ParseError(line, f"expected 3 columns, got {len(row)}")`. +- Date: `datetime.datetime.strptime(value.strip(), "%Y-%m-%d").date()`; `ValueError` → `ParseError(line, f'invalid date "{value}"')`. Use `strptime`, not `date.fromisoformat`, which also accepts `20260304`. +- Amount: `parse_amount(value)`; catch `ValueError as exc` → `ParseError(line, str(exc))`. +- Description: used verbatim, not stripped. + +Raise on the first bad row; the caller prints nothing to stdout. + +- [ ] **Step 8: Run the tests to verify they pass** + +Run: `python3 -m unittest test_parse -v` +Expected: PASS (11 tests) + +- [ ] **Step 9: Commit** + +```bash +git add ledgerlite/__init__.py ledgerlite/model.py ledgerlite/parse.py test_parse.py +git commit -m "feat: parse transactions CSV into Transaction objects" +``` + +--- + +### Task 2: Rules parsing and categorization + +**Files:** +- Create: `ledgerlite/rules.py` +- Test: `test_rules.py` + +**Interfaces:** +- Consumes: `ledgerlite.parse.ParseError(line, message)`. +- Produces: + - `ledgerlite.rules.Rule = tuple[str, str]` — `(substring, category)`, stored as written in the file after stripping. + - `ledgerlite.rules.parse_rules(lines: Iterable[str]) -> list[Rule]` — raises `ParseError`. Preserves file order, which is match precedence. + - `ledgerlite.rules.categorize(description: str, rules: Sequence[Rule]) -> str | None` — `None` when no rule matches. + +- [ ] **Step 1: Write the failing test in `test_rules.py`** + +```python +import unittest + +from ledgerlite.parse import ParseError +from ledgerlite.rules import categorize, parse_rules + + +class ParseRulesTest(unittest.TestCase): + def test_parses_rules_in_file_order(self): + lines = ["coffee=food\n", "rent=housing\n"] + self.assertEqual(parse_rules(lines), [("coffee", "food"), ("rent", "housing")]) + + def test_blank_lines_are_skipped_and_whitespace_stripped(self): + lines = ["\n", " coffee = food \n", " \n"] + self.assertEqual(parse_rules(lines), [("coffee", "food")]) + + def test_only_the_first_equals_sign_splits(self): + self.assertEqual(parse_rules(["a=b=c\n"]), [("a", "b=c")]) + + def test_line_without_equals_is_rejected(self): + with self.assertRaises(ParseError) as caught: + parse_rules(["coffee=food\n", "rent housing\n"]) + self.assertEqual(caught.exception.line, 2) + self.assertEqual(caught.exception.message, 'rule has no "="') + + def test_empty_substring_is_rejected(self): + with self.assertRaises(ParseError) as caught: + parse_rules(["=food\n"]) + self.assertEqual(caught.exception.line, 1) + self.assertEqual(caught.exception.message, "rule has an empty substring") + + def test_empty_category_is_rejected(self): + with self.assertRaises(ParseError) as caught: + parse_rules(["coffee=\n"]) + self.assertEqual(caught.exception.line, 1) + self.assertEqual(caught.exception.message, "rule has an empty category") + + +class CategorizeTest(unittest.TestCase): + RULES = [("coffee", "food"), ("rent", "housing")] + + def test_matches_case_insensitively_on_a_substring(self): + self.assertEqual(categorize("MORNING COFFEE BAR", self.RULES), "food") + self.assertEqual(categorize("Rent March", self.RULES), "housing") + + def test_first_matching_rule_wins(self): + rules = [("bar", "drinks"), ("coffee bar", "food")] + self.assertEqual(categorize("Coffee Bar", rules), "drinks") + + def test_no_match_returns_none(self): + self.assertIsNone(categorize("ACME Salary", self.RULES)) + + def test_no_rules_returns_none(self): + self.assertIsNone(categorize("Coffee", [])) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `python3 -m unittest test_rules -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.rules'` + +- [ ] **Step 3: Implement `parse_rules` and `categorize` in `ledgerlite/rules.py`** + +`parse_rules` numbers lines 1-based with `enumerate(lines, start=1)`, skips lines that are empty after `.strip()`, splits with `line.split("=", 1)`, and strips both halves. `categorize` returns the category of the first rule whose `substring.lower()` is `in description.lower()`. + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `python3 -m unittest test_rules -v` +Expected: PASS (10 tests) + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/rules.py test_rules.py +git commit -m "feat: parse rules file and categorize descriptions" +``` + +--- + +### Task 3: Date ordering and closing balance + +**Files:** +- Create: `ledgerlite/balance.py` +- Test: `test_balance.py` + +**Interfaces:** +- Consumes: `ledgerlite.model.Transaction`. +- Produces: + - `ledgerlite.balance.order_by_date(transactions: Sequence[Transaction]) -> list[Transaction]` + - `ledgerlite.balance.closing_balance(opening: Decimal, transactions: Sequence[Transaction]) -> Decimal` + +Note for the implementer: summing is order-independent, so `closing_balance` would produce the same number without sorting. `order_by_date` exists because the spec makes the ordering rule a requirement of this module and Task 4 has nothing to do with it; keep `closing_balance` folding over `order_by_date` so the rule has exactly one home. + +- [ ] **Step 1: Write the failing test in `test_balance.py`** + +```python +import unittest +from datetime import date +from decimal import Decimal + +from ledgerlite.balance import closing_balance, order_by_date +from ledgerlite.model import Transaction + + +def tx(day, amount, description): + return Transaction(date(2026, 3, day), Decimal(amount), description) + + +class OrderByDateTest(unittest.TestCase): + def test_sorts_by_date(self): + later, earlier = tx(5, "1.00", "b"), tx(4, "2.00", "a") + self.assertEqual(order_by_date([later, earlier]), [earlier, later]) + + def test_ties_keep_input_order(self): + first, second = tx(4, "1.00", "first"), tx(4, "2.00", "second") + self.assertEqual(order_by_date([first, second]), [first, second]) + self.assertEqual(order_by_date([second, first]), [second, first]) + + def test_does_not_mutate_its_argument(self): + transactions = [tx(5, "1.00", "b"), tx(4, "2.00", "a")] + order_by_date(transactions) + self.assertEqual(transactions[0].description, "b") + + +class ClosingBalanceTest(unittest.TestCase): + def test_opening_plus_every_amount(self): + transactions = [tx(5, "-7.50", "c"), tx(4, "2500.00", "s"), tx(6, "-900.00", "r")] + self.assertEqual( + closing_balance(Decimal("100"), transactions), Decimal("1592.50") + ) + + def test_no_transactions_returns_the_opening_amount(self): + self.assertEqual(closing_balance(Decimal("100"), []), Decimal("100")) + + def test_result_is_exact_not_floating_point(self): + transactions = [tx(4, "0.10", "a"), tx(4, "0.20", "b")] + self.assertEqual(closing_balance(Decimal("0"), transactions), Decimal("0.30")) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `python3 -m unittest test_balance -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.balance'` + +- [ ] **Step 3: Implement `order_by_date` and `closing_balance` in `ledgerlite/balance.py`** + +`order_by_date` returns `sorted(transactions, key=lambda t: t.date)` — Python's sort is stable, which is exactly the spec's "ties keep input order", and `sorted` returns a new list. + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `python3 -m unittest test_balance -v` +Expected: PASS (6 tests) + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/balance.py test_balance.py +git commit -m "feat: add date ordering and closing balance" +``` + +--- + +### Task 4: Per-category totals and report formatting + +**Files:** +- Create: `ledgerlite/report.py` +- Test: `test_report.py` + +**Interfaces:** +- Consumes: `ledgerlite.model.Transaction`, `ledgerlite.rules.Rule`/`categorize`, `ledgerlite.balance.closing_balance`. +- Produces: + - `ledgerlite.report.UNCATEGORIZED = "uncategorized"` + - `ledgerlite.report.format_amount(value: Decimal) -> str` + - `ledgerlite.report.category_totals(transactions: Sequence[Transaction], rules: Sequence[Rule]) -> dict[str, Decimal]` — keys are category names, uncategorized transactions under `UNCATEGORIZED`; categories with no transactions do not appear. + - `ledgerlite.report.format_report(transactions: Sequence[Transaction], rules: Sequence[Rule], opening: Decimal) -> str` — the full report, ending in exactly one `"\n"`. + +- [ ] **Step 1: Write the failing test in `test_report.py`** + +```python +import unittest +from datetime import date +from decimal import Decimal + +from ledgerlite.model import Transaction +from ledgerlite.report import UNCATEGORIZED, category_totals, format_amount, format_report + +RULES = [("coffee", "food"), ("rent", "housing")] + + +def tx(day, amount, description): + return Transaction(date(2026, 3, day), Decimal(amount), description) + + +class FormatAmountTest(unittest.TestCase): + def test_two_fractional_digits_and_leading_minus(self): + self.assertEqual(format_amount(Decimal("-12.5")), "-12.50") + self.assertEqual(format_amount(Decimal("0")), "0.00") + self.assertEqual(format_amount(Decimal("1200")), "1200.00") + + def test_no_thousands_separators(self): + self.assertEqual(format_amount(Decimal("1234567.89")), "1234567.89") + + def test_negative_zero_prints_without_a_sign(self): + self.assertEqual(format_amount(Decimal("-0.00")), "0.00") + + +class CategoryTotalsTest(unittest.TestCase): + def test_sums_each_category_and_uncategorized(self): + transactions = [ + tx(4, "-7.50", "Coffee Bar"), + tx(5, "-2.50", "COFFEE beans"), + tx(6, "-900.00", "Rent March"), + tx(7, "2500.00", "ACME Salary"), + ] + self.assertEqual( + category_totals(transactions, RULES), + { + "food": Decimal("-10.00"), + "housing": Decimal("-900.00"), + UNCATEGORIZED: Decimal("2500.00"), + }, + ) + + def test_no_rules_puts_everything_in_uncategorized(self): + self.assertEqual( + category_totals([tx(4, "-7.50", "Coffee Bar")], []), + {UNCATEGORIZED: Decimal("-7.50")}, + ) + + def test_no_transactions_has_no_categories(self): + self.assertEqual(category_totals([], RULES), {}) + + +class FormatReportTest(unittest.TestCase): + def test_matches_the_spec_example(self): + transactions = [ + tx(4, "-7.50", "Coffee Bar"), + tx(5, "-900.00", "Rent March"), + tx(6, "2500.00", "ACME Salary"), + ] + self.assertEqual( + format_report(transactions, RULES, Decimal("100")), + "food: -7.50\nhousing: -900.00\nuncategorized: 2500.00\n\n" + "closing balance: 1692.50\n", + ) + + def test_categories_are_alphabetical_ignoring_case(self): + rules = [("a", "Food"), ("b", "bank"), ("c", "Travel")] + transactions = [tx(4, "1.00", "a"), tx(4, "2.00", "b"), tx(4, "4.00", "c")] + report = format_report(transactions, rules, Decimal("0")) + self.assertEqual( + report.splitlines()[:3], ["bank: 2.00", "Food: 1.00", "Travel: 4.00"] + ) + + def test_uncategorized_is_last_even_though_z_sorts_after_it(self): + rules = [("zoo", "zoo")] + transactions = [tx(4, "1.00", "zoo trip"), tx(4, "2.00", "mystery")] + self.assertEqual( + format_report(transactions, rules, Decimal("0")).splitlines()[:2], + ["zoo: 1.00", "uncategorized: 2.00"], + ) + + def test_a_rule_category_named_uncategorized_merges_and_stays_last(self): + rules = [("coffee", "uncategorized"), ("rent", "housing")] + transactions = [ + tx(4, "-1.00", "Coffee"), + tx(4, "-2.00", "mystery"), + tx(4, "-3.00", "Rent"), + ] + self.assertEqual( + format_report(transactions, rules, Decimal("0")).splitlines()[:2], + ["housing: -3.00", "uncategorized: -3.00"], + ) + + def test_no_transactions_reports_only_the_opening_balance(self): + self.assertEqual( + format_report([], RULES, Decimal("100")), "\nclosing balance: 100.00\n" + ) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `python3 -m unittest test_report -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.report'` + +- [ ] **Step 3: Implement `format_amount` in `ledgerlite/report.py`** + +Quantize to two places with `value.quantize(Decimal("0.01"))`, then drop the sign when the result is zero (`if quantized == 0: quantized = abs(quantized)`), then format with `f"{quantized:f}"`. Do not use `:,` — no thousands separators. + +- [ ] **Step 4: Implement `category_totals` in `ledgerlite/report.py`** + +Accumulate into a plain `dict`, keyed by `rules.categorize(t.description, rules) or UNCATEGORIZED`, starting each new key at `Decimal("0")`. + +- [ ] **Step 5: Implement `format_report` in `ledgerlite/report.py`** + +Order the names with `sorted(name for name in totals if name != UNCATEGORIZED, key=lambda n: (n.lower(), n))` and append `UNCATEGORIZED` last if it is present. Emit one `f"{name}: {format_amount(total)}"` line per name, then an empty line, then `f"closing balance: {format_amount(balance.closing_balance(opening, transactions))}"`. Join with `"\n"` and end with a single `"\n"` — with zero categories that yields a leading blank line, which is the literal reading of the spec's "one line per category, then a blank line, then the closing balance". + +- [ ] **Step 6: Run the test to verify it passes** + +Run: `python3 -m unittest test_report -v` +Expected: PASS (11 tests) + +- [ ] **Step 7: Commit** + +```bash +git add ledgerlite/report.py test_report.py +git commit -m "feat: format per-category totals and closing balance report" +``` + +--- + +### Task 5: CLI, file reading, and exit codes + +**Files:** +- Create: `ledgerlite/cli.py`, `ledgerlite/__main__.py` +- Test: `test_cli.py` + +**Interfaces:** +- Consumes: `parse.parse_transactions`, `parse.parse_amount`, `parse.ParseError`, `rules.parse_rules`, `report.format_report`. +- Produces: `ledgerlite.cli.main(argv: Sequence[str] | None = None) -> int` — returns the process exit code and never calls `sys.exit` itself (except through argparse's own errors). + +- [ ] **Step 1: Write the failing test in `test_cli.py`** + +```python +import contextlib +import io +import os +import tempfile +import unittest + +from ledgerlite.cli import main + +TRANSACTIONS = ( + "date,amount,description\n" + "2026-03-06,2500.00,ACME Salary\n" + "2026-03-04,-7.50,Coffee Bar\n" + "2026-03-05,-900.00,Rent March\n" +) +RULES = "coffee=food\nrent=housing\n" + +EXPECTED = ( + "food: -7.50\n" + "housing: -900.00\n" + "uncategorized: 2500.00\n" + "\n" + "closing balance: 1692.50\n" +) + + +class CliTestCase(unittest.TestCase): + def setUp(self): + self.dir = tempfile.TemporaryDirectory() + self.addCleanup(self.dir.cleanup) + + def write(self, name, text, encoding="utf-8"): + path = os.path.join(self.dir.name, name) + with open(path, "w", encoding=encoding, newline="") as handle: + handle.write(text) + return path + + def run_cli(self, argv): + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = main(argv) + return code, out.getvalue(), err.getvalue() + + +class ReportCommandTest(CliTestCase): + def test_spec_example_end_to_end(self): + transactions = self.write("t.csv", TRANSACTIONS) + rules = self.write("r.txt", RULES) + code, out, err = self.run_cli( + ["report", transactions, "--rules", rules, "--opening", "100"] + ) + self.assertEqual((code, out, err), (0, EXPECTED, "")) + + def test_opening_defaults_to_zero(self): + transactions = self.write("t.csv", "date,amount,description\n2026-03-04,1.50,Tip\n") + code, out, _ = self.run_cli(["report", transactions]) + self.assertEqual(code, 0) + self.assertEqual(out, "uncategorized: 1.50\n\nclosing balance: 1.50\n") + + def test_without_rules_everything_is_uncategorized(self): + transactions = self.write("t.csv", TRANSACTIONS) + code, out, _ = self.run_cli(["report", transactions, "--opening", "100"]) + self.assertEqual(code, 0) + self.assertEqual(out, "uncategorized: 1592.50\n\nclosing balance: 1692.50\n") + + def test_bom_and_crlf_do_not_break_parsing(self): + transactions = self.write( + "t.csv", "\ufeff" + TRANSACTIONS.replace("\n", "\r\n") + ) + rules = self.write("r.txt", RULES) + code, out, err = self.run_cli( + ["report", transactions, "--rules", rules, "--opening", "100"] + ) + self.assertEqual((code, out, err), (0, EXPECTED, "")) + + +class UnreadableFileTest(CliTestCase): + def test_missing_transactions_file_exits_1(self): + path = os.path.join(self.dir.name, "nope.csv") + code, out, err = self.run_cli(["report", path]) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertEqual(err, f"ledgerlite: cannot read {path}: No such file or directory\n") + + def test_missing_rules_file_exits_1(self): + transactions = self.write("t.csv", TRANSACTIONS) + path = os.path.join(self.dir.name, "nope.txt") + code, out, err = self.run_cli(["report", transactions, "--rules", path]) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertEqual(err, f"ledgerlite: cannot read {path}: No such file or directory\n") + + +class MalformedInputTest(CliTestCase): + def test_malformed_row_exits_2_and_prints_nothing_to_stdout(self): + transactions = self.write( + "t.csv", + "date,amount,description\n2026-03-04,1.00,Tip\n2026-03-05,1.005,Fee\n", + ) + code, out, err = self.run_cli(["report", transactions]) + self.assertEqual(code, 2) + self.assertEqual(out, "") + self.assertEqual( + err, + f'ledgerlite: {transactions}:3: amount "1.005" has more than two ' + "fractional digits\n", + ) + + def test_malformed_rule_line_exits_2(self): + transactions = self.write("t.csv", TRANSACTIONS) + rules = self.write("r.txt", "coffee=food\nrent housing\n") + code, out, err = self.run_cli(["report", transactions, "--rules", rules]) + self.assertEqual(code, 2) + self.assertEqual(out, "") + self.assertEqual(err, f'ledgerlite: {rules}:2: rule has no "="\n') + + def test_invalid_opening_amount_is_rejected(self): + transactions = self.write("t.csv", TRANSACTIONS) + for value in ["1.005", "abc"]: + with self.subTest(value=value): + with self.assertRaises(SystemExit) as caught: + with contextlib.redirect_stderr(io.StringIO()): + main(["report", transactions, "--opening", value]) + self.assertEqual(caught.exception.code, 2) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `python3 -m unittest test_cli -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.cli'` + +- [ ] **Step 3: Implement `main(argv)` in `ledgerlite/cli.py`** + +Structure: +- `_read_lines(path: str) -> list[str]` — `open(path, encoding="utf-8-sig", newline="")` and `readlines()`. `utf-8-sig` strips a BOM; `newline=""` is what the `csv` module requires. It also makes `\r\n` files parse cleanly. +- `_opening(text: str) -> Decimal` — the `type=` callable for `--opening`; wraps `parse.parse_amount` and re-raises `ValueError` as `argparse.ArgumentTypeError(str(exc))` so argparse prints usage and exits 2. +- Parser: `ArgumentParser(prog="ledgerlite")` with `add_subparsers(dest="command", required=True)`; the `report` subparser takes positional `transactions`, `--rules` (default `None`), and `--opening` (`type=_opening`, `default=Decimal("0")`). +- Read and parse the transactions file, then the rules file if `--rules` was given (empty rules list otherwise). Wrap both in one helper so the error handling is written once: + +```python +def _load(path, parse_fn): + """Returns (value, None) or (None, error_line_to_print).""" +``` + + where the error line is `f"ledgerlite: cannot read {path}: {reason}"` (exit 1) or `f"ledgerlite: {path}:{exc.line}: {exc.message}"` (exit 2). `reason` is `exc.strerror or str(exc)` for `OSError`, and `"invalid UTF-8"` for `UnicodeDecodeError`. +- On any error: `print(line, file=sys.stderr)` and return `1` or `2` — before anything reaches stdout. +- On success: `print(report.format_report(transactions, rules, args.opening), end="")` and return `0`. + +- [ ] **Step 4: Create `ledgerlite/__main__.py`** + +```python +import sys + +from ledgerlite.cli import main + +if __name__ == "__main__": + sys.exit(main()) +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `python3 -m unittest test_cli -v` +Expected: PASS (9 tests) + +- [ ] **Step 6: Run the whole suite** + +Run: `python3 -m unittest -v` +Expected: PASS, 47 tests, no errors + +- [ ] **Step 7: Verify the spec example through the real command line** + +```bash +printf 'date,amount,description\n2026-03-06,2500.00,ACME Salary\n2026-03-04,-7.50,Coffee Bar\n2026-03-05,-900.00,Rent March\n' > /tmp/t.csv +printf 'coffee=food\nrent=housing\n' > /tmp/r.txt +python3 -m ledgerlite report /tmp/t.csv --rules /tmp/r.txt --opening 100; echo "exit=$?" +python3 -m ledgerlite report /tmp/missing.csv; echo "exit=$?" +``` + +Expected, in order: + +``` +food: -7.50 +housing: -900.00 +uncategorized: 2500.00 + +closing balance: 1692.50 +exit=0 +ledgerlite: cannot read /tmp/missing.csv: No such file or directory +exit=1 +``` + +- [ ] **Step 8: Commit** + +```bash +git add ledgerlite/cli.py ledgerlite/__main__.py test_cli.py +git commit -m "feat: add ledgerlite report CLI" +``` diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/N1-recipe-ledgerlite-53.plan.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/N1-recipe-ledgerlite-53.plan.md new file mode 100644 index 000000000..3e660d8e6 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/N1-recipe-ledgerlite-53.plan.md @@ -0,0 +1,938 @@ +# ledgerlite Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build `ledgerlite`, a stdlib-only Python CLI that reads a bank-transaction CSV, categorizes each row with a rules file, and prints per-category totals plus the closing balance. + +**Architecture:** Six small modules with one responsibility each: `model` (the record type), `parse` (bytes on disk → `list[Transaction]`, or a `ParseError` naming the offending line), `rules` (rules text → ordered `(substring, category)` pairs, plus matching), `balance` (date ordering and the running/closing balance), `report` (per-category totals and text formatting), `cli` (argparse, exit codes, stderr messages). Data flows one way — `cli` → `parse`/`rules` → `balance`/`report` — so every module below `cli` is pure and directly unit-testable, and `cli` owns all I/O error translation. + +**Tech Stack:** Python 3.11+, standard library only (`argparse`, `csv`, `dataclasses`, `datetime`, `decimal`, `re`, `unittest`). + +**Spec:** `design.md` + +## Global Constraints + +- Python 3.11+. Standard library only — no third-party packages, no `pyproject.toml`, no dependencies. +- Money is `decimal.Decimal` everywhere. Never `float`. Never `f"{x:.2f}"` on a float, never `float(...)`, never `round(...)` on a float. +- Package lives at `ledgerlite/` with exactly the modules the spec's layout names: `__init__.py`, `model.py`, `parse.py`, `rules.py`, `balance.py`, `report.py`, `cli.py`. No other modules. +- Tests live at the repo root as `test_.py` and run with `python3 -m unittest` from the repo root. +- Exit codes: `0` success, `1` a file could not be read, `2` the transactions file had a malformed row. +- Error messages go to stderr, prefixed `ledgerlite: `. Report output goes to stdout. +- Amounts print with exactly two fractional digits, a leading `-` only for negatives, no thousands separators. +- This is a local scratch repo with no remote. Work directly on `main`. Commit after every task; never `git push`. + +## Review Focus + +Five things the spec implies but does not spell out, most likely to bite a real user first. Each already has a test assigned to the task that owns the code. + +1. **A CSV exported from a spreadsheet starts with a UTF-8 BOM** — `date` must still be recognized as the header, not reported as a missing header row. (Task 2) +2. **A file that is not valid UTF-8 at all** (a `café` description in latin-1) must exit 1 with `cannot read`, not crash with a `UnicodeDecodeError` traceback. (Tasks 2 and 6) +3. **`--rules` pointing at a file that does not exist** must exit 1 with the same `cannot read` message shape as a missing transactions file, not a traceback. The spec only names the transactions file; the same treatment is the only non-surprising answer. (Task 6) +4. **A rules file that maps something to the literal category `uncategorized`** must merge into the single `uncategorized` bucket printed last, not produce two lines or an alphabetically-placed one. (Task 5) +5. **A zero total printing as `-0.00`** (from an amount written `-0.00`) must print `0.00`; the spec's format list shows `0.00` and no negative zero. (Task 5) + +Also pinned, in the tasks that own them: blank lines inside the CSV are skipped without shifting reported line numbers (Task 2); `2026-02-30` and `20260304` are rejected as dates (Task 2); `1e2` and `1,5` are rejected as amounts (Task 1); a rule with an empty substring does not match everything (Task 3); `--opening 1.005` is rejected instead of silently truncated (Task 6). + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `ledgerlite/__init__.py` | Empty package marker. | +| `ledgerlite/model.py` | `Transaction` frozen dataclass. Nothing else. | +| `ledgerlite/parse.py` | `parse_amount` (the one place the amount grammar lives), `ParseError`, `parse_transactions`. | +| `ledgerlite/rules.py` | `parse_rules`, `load_rules`, `categorize`. | +| `ledgerlite/balance.py` | `sort_by_date`, `closing_balance`. | +| `ledgerlite/report.py` | `UNCATEGORIZED`, `format_amount`, `category_totals`, `format_report`. | +| `ledgerlite/cli.py` | `main(argv)`; argparse wiring, exit codes, stderr messages. | +| `test_model.py` | Task 1. | +| `test_parse.py` | Tasks 1–2. | +| `test_rules.py` | Task 3. | +| `test_balance.py` | Task 4. | +| `test_report.py` | Task 5. | +| `test_cli.py` | Task 6. | + +### Decisions the spec leaves open, resolved once here + +Every task must honor these; they are the reason the tests below assert what they assert. + +- **Amount grammar:** `re.fullmatch(r"[+-]?(?:\d+(?:\.\d+)?|\.\d+)", stripped)`. So `1`, `1.5`, `1.50`, `-12.50`, `+2500`, `.5` are decimal numbers; `1e2`, `1,5`, `1.`, `NaN`, `Infinity`, `""`, `abc` are not. A value that matches but has 3+ digits after the `.` is a *different* error than a value that does not match, because the spec names those two faults separately. +- **Date grammar:** `re.fullmatch(r"\d{4}-\d{2}-\d{2}", stripped)` and then `datetime.date.fromisoformat`. ISO 8601 basic form (`20260304`) and week dates are rejected even though 3.11's `fromisoformat` accepts them; the spec's example is the extended form and one accepted shape keeps errors predictable. +- **Whitespace:** the `date` and `amount` fields are stripped before parsing. `description` is used verbatim. +- **Header row:** required. Its three fields, stripped and lowercased, must be `date`, `amount`, `description` in that order. An empty file is a missing header row (line 1). A header-only file is zero transactions. +- **Blank CSV rows:** a row with no fields, or whose fields are all empty after stripping, is skipped anywhere in the file — a trailing blank line should not reject a whole export. +- **Line numbers** in `ParseError` come from `csv.reader.line_num`, so they are physical file lines and are unaffected by skipped blanks. +- **Encoding:** files open with `encoding="utf-8-sig"` (strips a BOM if present) and `newline=""` for the CSV. +- **Rules file:** each line is stripped; empty lines are ignored; a line with no `=` is ignored; the split is on the *first* `=`; substring and category are each stripped; a rule whose substring or category is empty after stripping is ignored. Matching lowercases both sides. +- **Category ordering:** plain string `sorted()` (codepoint order, so `Food` sorts before `food`). `uncategorized` is moved to last regardless. +- **Uncategorized line:** printed only when at least one transaction lands in that bucket. +- **`--opening`** is parsed with the same `parse_amount` grammar; a bad value is an argparse error (usage on stderr, exit 2). +- **Unreadable file** covers both `OSError` and `UnicodeDecodeError`; the reason text is `e.strerror` when it is set, otherwise `str(e)`. + +--- + +## Task 1: Package skeleton, `Transaction`, and the amount grammar + +**Files:** +- Create: `ledgerlite/__init__.py`, `ledgerlite/model.py`, `ledgerlite/parse.py` +- Test: `test_model.py`, `test_parse.py` + +**Interfaces:** +- Consumes: nothing. +- Produces: + - `ledgerlite.model.Transaction` — frozen dataclass, fields in order `date: datetime.date`, `amount: decimal.Decimal`, `description: str`. + - `ledgerlite.parse.parse_amount(raw: str) -> Decimal` — raises `ValueError`. + - `ledgerlite.parse.ParseError(path: str, line: int, message: str)` — `Exception` subclass with attributes `path`, `line`, `message`; `str(err) == f"{path}:{line}: {message}"`. + +- [ ] **Step 1: Write the failing tests** + +Create `test_model.py`: + +```python +import unittest +from dataclasses import FrozenInstanceError +from datetime import date +from decimal import Decimal + +from ledgerlite.model import Transaction + + +class TransactionTest(unittest.TestCase): + def test_holds_date_amount_description(self): + txn = Transaction(date(2026, 3, 4), Decimal("-7.50"), "Morning Coffee") + self.assertEqual(txn.date, date(2026, 3, 4)) + self.assertEqual(txn.amount, Decimal("-7.50")) + self.assertEqual(txn.description, "Morning Coffee") + + def test_is_frozen(self): + txn = Transaction(date(2026, 3, 4), Decimal("1.00"), "x") + with self.assertRaises(FrozenInstanceError): + txn.amount = Decimal("2.00") + + def test_equality_is_by_value(self): + self.assertEqual( + Transaction(date(2026, 3, 4), Decimal("1.00"), "x"), + Transaction(date(2026, 3, 4), Decimal("1.00"), "x"), + ) + + +if __name__ == "__main__": + unittest.main() +``` + +And `test_parse.py`: + +```python +import unittest +from decimal import Decimal + +from ledgerlite.parse import ParseError, parse_amount + + +class ParseAmountTest(unittest.TestCase): + def test_accepts_decimal_numbers(self): + self.assertEqual(parse_amount("1.5"), Decimal("1.5")) + self.assertEqual(parse_amount("1.50"), Decimal("1.50")) + self.assertEqual(parse_amount("-12.50"), Decimal("-12.50")) + self.assertEqual(parse_amount("+2500"), Decimal("2500")) + self.assertEqual(parse_amount("0"), Decimal("0")) + self.assertEqual(parse_amount(".5"), Decimal("0.5")) + + def test_strips_surrounding_whitespace(self): + self.assertEqual(parse_amount(" -7.50 "), Decimal("-7.50")) + + def test_rejects_more_than_two_fractional_digits(self): + with self.assertRaises(ValueError) as ctx: + parse_amount("1.005") + self.assertEqual( + str(ctx.exception), "amount has more than two fractional digits: 1.005" + ) + + def test_rejects_values_that_are_not_decimal_numbers(self): + for raw in ("", "abc", "1e2", "1,5", "1.", "1.2.3", "NaN", "Infinity", "$1.00"): + with self.subTest(raw=raw): + with self.assertRaises(ValueError) as ctx: + parse_amount(raw) + self.assertEqual( + str(ctx.exception), f"amount is not a decimal number: {raw}" + ) + + +class ParseErrorTest(unittest.TestCase): + def test_carries_path_line_message(self): + err = ParseError("txns.csv", 4, "wrong column count: 2") + self.assertEqual((err.path, err.line, err.message), ("txns.csv", 4, "wrong column count: 2")) + self.assertEqual(str(err), "txns.csv:4: wrong column count: 2") + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_model test_parse -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite'` + +- [ ] **Step 3: Create the package and `Transaction`** + +Create empty `ledgerlite/__init__.py`. In `ledgerlite/model.py`, define `Transaction` with `@dataclass(frozen=True)` and the three annotated fields in the order given in Interfaces. + +- [ ] **Step 4: Implement `parse_amount(raw: str) -> Decimal` and `ParseError` in `ledgerlite/parse.py`** + +`parse_amount` strips `raw`, and if it does not `re.fullmatch(r"[+-]?(?:\d+(?:\.\d+)?|\.\d+)", stripped)` raises `ValueError(f"amount is not a decimal number: {stripped}")`. Otherwise, if the text after the first `.` is longer than two characters, raises `ValueError(f"amount has more than two fractional digits: {stripped}")`. Otherwise returns `Decimal(stripped)`. + +`ParseError.__init__` stores `path`, `line`, `message` and calls `super().__init__(f"{path}:{line}: {message}")`. + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `python3 -m unittest test_model test_parse -v` +Expected: PASS, all green + +- [ ] **Step 6: Commit** + +```bash +git add ledgerlite/__init__.py ledgerlite/model.py ledgerlite/parse.py test_model.py test_parse.py +git commit -m "feat: add Transaction model and strict amount parsing" +``` + +--- + +## Task 2: `parse_transactions` + +**Files:** +- Modify: `ledgerlite/parse.py` +- Test: `test_parse.py` (append) + +**Interfaces:** +- Consumes: `Transaction`, `parse_amount`, `ParseError` from Task 1. +- Produces: `ledgerlite.parse.parse_transactions(path: str) -> list[Transaction]` — returns rows in **file order** (it does not sort). Raises `ParseError` for a malformed file. Lets `OSError` and `UnicodeDecodeError` propagate to the caller unchanged. + +- [ ] **Step 1: Write the failing tests** + +Append to `test_parse.py`, adding to its imports: `import os`, `import shutil`, `import tempfile`, `from datetime import date`, `from ledgerlite.model import Transaction`, and `parse_transactions` alongside the existing names from `ledgerlite.parse`: + +```python +HEADER = "date,amount,description\n" # module level, above the new class + + +class TransactionsFileTest(unittest.TestCase): # insert before the `if __name__` block + def write(self, text, encoding="utf-8"): + return self.write_bytes(text.encode(encoding)) + + def write_bytes(self, data): + directory = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, directory) + path = os.path.join(directory, "txns.csv") + with open(path, "wb") as handle: + handle.write(data) + return path + + def test_parses_rows_in_file_order(self): + path = self.write( + HEADER + + "2026-03-10,2500.00,Salary\n" + + "2026-03-04,-7.50,Morning Coffee\n" + ) + self.assertEqual( + parse_transactions(path), + [ + Transaction(date(2026, 3, 10), Decimal("2500.00"), "Salary"), + Transaction(date(2026, 3, 4), Decimal("-7.50"), "Morning Coffee"), + ], + ) + + def test_header_only_file_has_no_transactions(self): + self.assertEqual(parse_transactions(self.write(HEADER)), []) + + def test_accepts_quoted_description_containing_a_comma(self): + path = self.write(HEADER + '2026-03-04,-7.50,"Coffee, large"\n') + self.assertEqual(parse_transactions(path)[0].description, "Coffee, large") + + def test_accepts_crlf_line_endings(self): + path = self.write("date,amount,description\r\n2026-03-04,-7.50,Coffee\r\n") + self.assertEqual(len(parse_transactions(path)), 1) + + def test_accepts_a_utf8_bom(self): + path = self.write(HEADER + "2026-03-04,-7.50,Coffee\n", encoding="utf-8-sig") + self.assertEqual(parse_transactions(path)[0].amount, Decimal("-7.50")) + + def test_skips_blank_rows_without_shifting_line_numbers(self): + path = self.write( + HEADER + + "2026-03-04,-7.50,Coffee\n" + + "\n" + + "2026-03-05,nope,Rent\n" + ) + with self.assertRaises(ParseError) as ctx: + parse_transactions(path) + self.assertEqual(ctx.exception.line, 4) + + def test_empty_file_is_a_missing_header(self): + with self.assertRaises(ParseError) as ctx: + parse_transactions(self.write("")) + self.assertEqual(ctx.exception.line, 1) + self.assertEqual(ctx.exception.message, "missing header row") + + def test_wrong_header_is_rejected(self): + with self.assertRaises(ParseError) as ctx: + parse_transactions(self.write("amount,date,description\n")) + self.assertEqual(ctx.exception.line, 1) + self.assertEqual( + ctx.exception.message, "expected header row date,amount,description" + ) + + def test_wrong_column_count_is_rejected(self): + path = self.write(HEADER + "2026-03-04,-7.50\n") + with self.assertRaises(ParseError) as ctx: + parse_transactions(path) + self.assertEqual(ctx.exception.line, 2) + self.assertEqual(ctx.exception.message, "wrong column count: 2") + + def test_unparseable_dates_are_rejected(self): + for raw in ("2026-02-30", "20260304", "04/03/2026", "March 4"): + with self.subTest(raw=raw): + path = self.write(HEADER + f"{raw},-7.50,Coffee\n") + with self.assertRaises(ParseError) as ctx: + parse_transactions(path) + self.assertEqual(ctx.exception.line, 2) + self.assertEqual( + ctx.exception.message, + f"date is not a valid ISO 8601 date: {raw}", + ) + + def test_bad_amount_is_reported_with_the_amount_message(self): + path = self.write(HEADER + "2026-03-04,1.005,Coffee\n") + with self.assertRaises(ParseError) as ctx: + parse_transactions(path) + self.assertEqual(ctx.exception.line, 2) + self.assertEqual( + ctx.exception.message, "amount has more than two fractional digits: 1.005" + ) + + def test_error_names_the_path(self): + path = self.write(HEADER + "2026-03-04,abc,Coffee\n") + with self.assertRaises(ParseError) as ctx: + parse_transactions(path) + self.assertEqual(ctx.exception.path, path) + + def test_first_bad_row_wins(self): + path = self.write(HEADER + "bad,-7.50,A\n" + "2026-03-05,bad,B\n") + with self.assertRaises(ParseError) as ctx: + parse_transactions(path) + self.assertEqual(ctx.exception.line, 2) + + def test_undecodable_bytes_propagate(self): + path = self.write_bytes( + HEADER.encode() + b"2026-03-04,-7.50,caf\xe9\n" + ) + with self.assertRaises(UnicodeDecodeError): + parse_transactions(path) + + def test_missing_file_propagates_oserror(self): + with self.assertRaises(FileNotFoundError): + parse_transactions("/nonexistent/txns.csv") +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_parse -v` +Expected: FAIL — `ImportError: cannot import name 'parse_transactions'` + +- [ ] **Step 3: Implement `parse_transactions(path: str) -> list[Transaction]` in `ledgerlite/parse.py`** + +Open with `open(path, newline="", encoding="utf-8-sig")` and wrap the handle in `csv.reader`. Pull the first row: if there is none, raise `ParseError(path, 1, "missing header row")`; if its fields stripped and lowercased are not `["date", "amount", "description"]`, raise `ParseError(path, 1, "expected header row date,amount,description")`. Then for each remaining row, using `reader.line_num` as the line number: + +- skip it if every field is empty after stripping (including the no-field case); +- raise `ParseError(path, line, f"wrong column count: {len(row)}")` unless it has exactly 3 fields; +- validate the stripped date against `re.fullmatch(r"\d{4}-\d{2}-\d{2}", ...)` and `date.fromisoformat`, raising `ParseError(path, line, f"date is not a valid ISO 8601 date: {stripped}")` if either rejects it; +- call `parse_amount` and re-raise any `ValueError` as `ParseError(path, line, str(exc))`; +- append `Transaction(parsed_date, amount, description)` with the description verbatim. + +Return the list. Do not catch `OSError` or `UnicodeDecodeError`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_parse -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/parse.py test_parse.py +git commit -m "feat: parse transactions CSV with per-line error reporting" +``` + +--- + +## Task 3: Rules parsing and categorization + +**Files:** +- Create: `ledgerlite/rules.py` +- Test: `test_rules.py` + +**Interfaces:** +- Consumes: nothing. +- Produces: + - `ledgerlite.rules.parse_rules(text: str) -> list[tuple[str, str]]` — `(substring, category)` pairs in file order. + - `ledgerlite.rules.load_rules(path: str) -> list[tuple[str, str]]` — reads the file with `encoding="utf-8-sig"`; lets `OSError`/`UnicodeDecodeError` propagate. + - `ledgerlite.rules.categorize(description: str, rules: list[tuple[str, str]]) -> str | None` + +- [ ] **Step 1: Write the failing tests** + +Create `test_rules.py`: + +```python +import os +import shutil +import tempfile +import unittest + +from ledgerlite.rules import categorize, load_rules, parse_rules + + +class ParseRulesTest(unittest.TestCase): + def test_parses_one_rule_per_line_in_order(self): + self.assertEqual( + parse_rules("coffee=food\nrent=housing\n"), + [("coffee", "food"), ("rent", "housing")], + ) + + def test_ignores_blank_lines(self): + self.assertEqual(parse_rules("\n \ncoffee=food\n\n"), [("coffee", "food")]) + + def test_ignores_lines_without_a_separator(self): + self.assertEqual(parse_rules("nonsense\ncoffee=food\n"), [("coffee", "food")]) + + def test_splits_on_the_first_separator(self): + self.assertEqual(parse_rules("a=b=c\n"), [("a", "b=c")]) + + def test_strips_whitespace_around_both_parts(self): + self.assertEqual(parse_rules(" coffee = food \n"), [("coffee", "food")]) + + def test_ignores_rules_with_an_empty_substring_or_category(self): + self.assertEqual(parse_rules("=food\ncoffee=\n = \n"), []) + + def test_handles_text_without_a_trailing_newline(self): + self.assertEqual(parse_rules("coffee=food"), [("coffee", "food")]) + + +class CategorizeTest(unittest.TestCase): + RULES = [("coffee", "food"), ("rent", "housing")] + + def test_matches_a_substring_case_insensitively(self): + self.assertEqual(categorize("MORNING COFFEE", self.RULES), "food") + self.assertEqual(categorize("Rent March", self.RULES), "housing") + + def test_matches_when_the_rule_is_uppercase(self): + self.assertEqual(categorize("morning coffee", [("COFFEE", "food")]), "food") + + def test_first_matching_rule_wins(self): + rules = [("coffee", "food"), ("coffee shop", "treats")] + self.assertEqual(categorize("Coffee Shop", rules), "food") + + def test_returns_none_when_nothing_matches(self): + self.assertIsNone(categorize("Salary", self.RULES)) + + def test_returns_none_with_no_rules(self): + self.assertIsNone(categorize("Anything", [])) + + +class LoadRulesTest(unittest.TestCase): + def write(self, text, encoding="utf-8"): + directory = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, directory) + path = os.path.join(directory, "rules.txt") + with open(path, "w", encoding=encoding) as handle: + handle.write(text) + return path + + def test_reads_and_parses_a_file(self): + path = self.write("coffee=food\nrent=housing\n") + self.assertEqual(load_rules(path), [("coffee", "food"), ("rent", "housing")]) + + def test_tolerates_a_utf8_bom(self): + path = self.write("coffee=food\n", encoding="utf-8-sig") + self.assertEqual(load_rules(path), [("coffee", "food")]) + + def test_missing_file_propagates_oserror(self): + with self.assertRaises(FileNotFoundError): + load_rules("/nonexistent/rules.txt") + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_rules -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.rules'` + +- [ ] **Step 3: Implement `parse_rules`, `load_rules`, and `categorize` in `ledgerlite/rules.py`** + +`parse_rules` walks `text.splitlines()`, strips each line, skips it when empty or when `"="` is absent, splits once on `"="`, strips both halves, and keeps the pair only when both halves are non-empty. + +`categorize` lowercases the description once, then returns the category of the first rule whose lowercased substring is contained in it, else `None`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_rules -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/rules.py test_rules.py +git commit -m "feat: add rules parsing and case-insensitive categorization" +``` + +--- + +## Task 4: Date ordering and closing balance + +**Files:** +- Create: `ledgerlite/balance.py` +- Test: `test_balance.py` + +**Interfaces:** +- Consumes: `Transaction` from Task 1. +- Produces: + - `ledgerlite.balance.sort_by_date(transactions: list[Transaction]) -> list[Transaction]` — a new list, stable so same-date rows keep input order; the argument is not mutated. + - `ledgerlite.balance.closing_balance(opening: Decimal, transactions: list[Transaction]) -> Decimal` — the running balance after the last transaction in date order, or `opening` when there are none. + +- [ ] **Step 1: Write the failing tests** + +Create `test_balance.py`: + +```python +import unittest +from datetime import date +from decimal import Decimal + +from ledgerlite.balance import closing_balance, sort_by_date +from ledgerlite.model import Transaction + + +def txn(day, amount, description): + return Transaction(date(2026, 3, day), Decimal(amount), description) + + +class SortByDateTest(unittest.TestCase): + def test_orders_by_date(self): + rows = [txn(10, "2500.00", "Salary"), txn(4, "-7.50", "Coffee")] + self.assertEqual( + [t.description for t in sort_by_date(rows)], ["Coffee", "Salary"] + ) + + def test_ties_keep_input_order(self): + rows = [txn(4, "-1.00", "second-in-file"), txn(4, "-2.00", "third-in-file")] + self.assertEqual( + [t.description for t in sort_by_date(rows)], + ["second-in-file", "third-in-file"], + ) + + def test_does_not_mutate_the_input(self): + rows = [txn(10, "1.00", "b"), txn(4, "1.00", "a")] + sort_by_date(rows) + self.assertEqual([t.description for t in rows], ["b", "a"]) + + def test_empty_list(self): + self.assertEqual(sort_by_date([]), []) + + +class ClosingBalanceTest(unittest.TestCase): + def test_no_transactions_returns_the_opening_amount(self): + self.assertEqual(closing_balance(Decimal("100"), []), Decimal("100")) + + def test_adds_every_amount(self): + rows = [ + txn(10, "2500.00", "Salary"), + txn(4, "-7.50", "Coffee"), + txn(5, "-900.00", "Rent"), + ] + self.assertEqual(closing_balance(Decimal("100"), rows), Decimal("1692.50")) + + def test_stays_exact_with_decimals(self): + rows = [txn(4, "0.10", "a"), txn(5, "0.20", "b")] + self.assertEqual(closing_balance(Decimal("0"), rows), Decimal("0.30")) + + def test_result_is_a_decimal(self): + self.assertIsInstance(closing_balance(Decimal("0"), [txn(4, "1.00", "a")]), Decimal) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_balance -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.balance'` + +- [ ] **Step 3: Implement `sort_by_date` and `closing_balance` in `ledgerlite/balance.py`** + +`sort_by_date` returns `sorted(transactions, key=...)` on the date (Python's sort is stable, which is exactly the tie rule). `closing_balance` starts at `opening` and accumulates each amount walking `sort_by_date(transactions)` — walk the ordered list rather than summing the raw input, so the code says what the spec says. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_balance -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/balance.py test_balance.py +git commit -m "feat: add date ordering and closing balance" +``` + +--- + +## Task 5: Per-category totals and report formatting + +**Files:** +- Create: `ledgerlite/report.py` +- Test: `test_report.py` + +**Interfaces:** +- Consumes: `Transaction` (Task 1), `categorize` (Task 3), `sort_by_date` and `closing_balance` (Task 4). +- Produces: + - `ledgerlite.report.UNCATEGORIZED: str = "uncategorized"` + - `ledgerlite.report.format_amount(amount: Decimal) -> str` + - `ledgerlite.report.category_totals(transactions: list[Transaction], rules: list[tuple[str, str]]) -> list[tuple[str, Decimal]]` — display order: categories sorted, then `uncategorized` last if present. + - `ledgerlite.report.format_report(transactions: list[Transaction], rules: list[tuple[str, str]], opening: Decimal) -> str` — the whole report, ending in exactly one `"\n"`. + +- [ ] **Step 1: Write the failing tests** + +Create `test_report.py`: + +```python +import unittest +from datetime import date +from decimal import Decimal + +from ledgerlite.model import Transaction +from ledgerlite.report import UNCATEGORIZED, category_totals, format_amount, format_report + +RULES = [("coffee", "food"), ("rent", "housing")] + + +def txn(day, amount, description): + return Transaction(date(2026, 3, day), Decimal(amount), description) + + +EXAMPLE = [ + txn(10, "2500.00", "Salary"), + txn(4, "-7.50", "Morning Coffee"), + txn(5, "-900.00", "Rent March"), +] + + +class FormatAmountTest(unittest.TestCase): + def test_two_fractional_digits(self): + self.assertEqual(format_amount(Decimal("-12.50")), "-12.50") + self.assertEqual(format_amount(Decimal("0")), "0.00") + self.assertEqual(format_amount(Decimal("1200")), "1200.00") + self.assertEqual(format_amount(Decimal("2500.5")), "2500.50") + + def test_no_thousands_separators(self): + self.assertEqual(format_amount(Decimal("1234567.89")), "1234567.89") + + def test_negative_zero_prints_as_zero(self): + self.assertEqual(format_amount(Decimal("-0.00")), "0.00") + + +class CategoryTotalsTest(unittest.TestCase): + def test_sums_per_category_alphabetically_with_uncategorized_last(self): + self.assertEqual( + category_totals(EXAMPLE, RULES), + [ + ("food", Decimal("-7.50")), + ("housing", Decimal("-900.00")), + (UNCATEGORIZED, Decimal("2500.00")), + ], + ) + + def test_sums_several_transactions_in_one_category(self): + rows = [txn(4, "-7.50", "Coffee"), txn(6, "-2.50", "coffee beans")] + self.assertEqual(category_totals(rows, RULES), [("food", Decimal("-10.00"))]) + + def test_omits_uncategorized_when_everything_matched(self): + self.assertEqual( + category_totals([txn(4, "-7.50", "Coffee")], RULES), + [("food", Decimal("-7.50"))], + ) + + def test_a_rule_named_uncategorized_merges_into_the_last_bucket(self): + rows = [txn(4, "-7.50", "Mystery Fee"), txn(5, "2500.00", "Salary")] + self.assertEqual( + category_totals(rows, [("mystery", "uncategorized")]), + [(UNCATEGORIZED, Decimal("2492.50"))], + ) + + def test_no_rules_means_everything_is_uncategorized(self): + self.assertEqual( + category_totals(EXAMPLE, []), [(UNCATEGORIZED, Decimal("1592.50"))] + ) + + def test_empty_input_has_no_categories(self): + self.assertEqual(category_totals([], RULES), []) + + def test_category_names_sort_by_codepoint(self): + rows = [txn(4, "-1.00", "Aaa"), txn(5, "-2.00", "bbb")] + rules = [("aaa", "food"), ("bbb", "Food")] + self.assertEqual( + category_totals(rows, rules), + [("Food", Decimal("-2.00")), ("food", Decimal("-1.00"))], + ) + + +class FormatReportTest(unittest.TestCase): + def test_matches_the_design_example(self): + self.assertEqual( + format_report(EXAMPLE, RULES, Decimal("100")), + "food: -7.50\n" + "housing: -900.00\n" + "uncategorized: 2500.00\n" + "\n" + "closing balance: 1692.50\n", + ) + + def test_no_transactions_prints_only_the_closing_balance(self): + self.assertEqual( + format_report([], RULES, Decimal("0")), "\nclosing balance: 0.00\n" + ) + + def test_uses_the_opening_amount(self): + self.assertEqual( + format_report([txn(4, "-7.50", "Coffee")], RULES, Decimal("10")), + "food: -7.50\n\nclosing balance: 2.50\n", + ) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_report -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.report'` + +- [ ] **Step 3: Implement `format_amount(amount: Decimal) -> str` in `ledgerlite/report.py`** + +Return `format(amount, ".2f")` — `Decimal.__format__`, never `float` — after replacing a value equal to zero with its absolute value so `-0.00` cannot reach the output. + +- [ ] **Step 4: Implement `category_totals(transactions, rules) -> list[tuple[str, Decimal]]`** + +Accumulate each transaction's amount into a dict keyed by `categorize(t.description, rules) or UNCATEGORIZED`, starting each key at `Decimal("0.00")`. Then return the non-`UNCATEGORIZED` keys `sorted()` with their totals, followed by the `UNCATEGORIZED` entry if that key exists. + +- [ ] **Step 5: Implement `format_report(transactions, rules, opening) -> str`** + +One `f"{name}: {format_amount(total)}\n"` line per entry from `category_totals`, then `"\n"`, then `f"closing balance: {format_amount(closing_balance(opening, sort_by_date(transactions)))}\n"`. + +- [ ] **Step 6: Run the tests to verify they pass** + +Run: `python3 -m unittest test_report -v` +Expected: PASS + +- [ ] **Step 7: Commit** + +```bash +git add ledgerlite/report.py test_report.py +git commit -m "feat: add per-category totals and report formatting" +``` + +--- + +## Task 6: CLI entry point + +**Files:** +- Create: `ledgerlite/cli.py` +- Test: `test_cli.py` + +**Interfaces:** +- Consumes: `parse_transactions`, `parse_amount`, `ParseError` (Tasks 1–2); `load_rules` (Task 3); `format_report` (Task 5). +- Produces: `ledgerlite.cli.main(argv: list[str] | None = None) -> int` — `argv` excludes the program name and defaults to `sys.argv[1:]`. + +- [ ] **Step 1: Write the failing tests** + +Create `test_cli.py`: + +```python +import contextlib +import io +import os +import shutil +import tempfile +import unittest + +from ledgerlite.cli import main + +TXNS = ( + "date,amount,description\n" + "2026-03-10,2500.00,Salary\n" + "2026-03-04,-7.50,Morning Coffee\n" + "2026-03-05,-900.00,Rent March\n" +) +EXPECTED = ( + "food: -7.50\n" + "housing: -900.00\n" + "uncategorized: 2500.00\n" + "\n" + "closing balance: 1692.50\n" +) + + +class CliTest(unittest.TestCase): + def setUp(self): + self.directory = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.directory) + + def write(self, name, text): + path = os.path.join(self.directory, name) + with open(path, "w", encoding="utf-8", newline="") as handle: + handle.write(text) + return path + + def run_cli(self, argv): + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = main(argv) + return code, out.getvalue(), err.getvalue() + + def test_reports_with_rules_and_opening(self): + txns = self.write("txns.csv", TXNS) + rules = self.write("rules.txt", "coffee=food\nrent=housing\n") + code, out, err = self.run_cli(["report", txns, "--rules", rules, "--opening", "100"]) + self.assertEqual((code, out, err), (0, EXPECTED, "")) + + def test_without_rules_everything_is_uncategorized(self): + txns = self.write("txns.csv", TXNS) + code, out, err = self.run_cli(["report", txns]) + self.assertEqual(code, 0) + self.assertEqual(out, "uncategorized: 1592.50\n\nclosing balance: 1592.50\n") + + def test_opening_defaults_to_zero(self): + txns = self.write("txns.csv", "date,amount,description\n") + code, out, _ = self.run_cli(["report", txns]) + self.assertEqual((code, out), (0, "\nclosing balance: 0.00\n")) + + def test_missing_transactions_file_exits_1(self): + path = os.path.join(self.directory, "nope.csv") + code, out, err = self.run_cli(["report", path]) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertEqual(err, f"ledgerlite: cannot read {path}: No such file or directory\n") + + def test_missing_rules_file_exits_1(self): + txns = self.write("txns.csv", TXNS) + rules = os.path.join(self.directory, "nope.txt") + code, out, err = self.run_cli(["report", txns, "--rules", rules]) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertEqual(err, f"ledgerlite: cannot read {rules}: No such file or directory\n") + + def test_undecodable_transactions_file_exits_1(self): + path = os.path.join(self.directory, "latin1.csv") + with open(path, "wb") as handle: + handle.write(b"date,amount,description\n2026-03-04,-7.50,caf\xe9\n") + code, out, err = self.run_cli(["report", path]) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertTrue(err.startswith(f"ledgerlite: cannot read {path}: "), err) + self.assertTrue(err.endswith("\n")) + + def test_malformed_row_exits_2_with_nothing_on_stdout(self): + txns = self.write( + "txns.csv", "date,amount,description\n2026-03-04,-7.50,Coffee\n2026-03-05,-900.00\n" + ) + code, out, err = self.run_cli(["report", txns]) + self.assertEqual(code, 2) + self.assertEqual(out, "") + self.assertEqual(err, f"ledgerlite: {txns}:3: wrong column count: 2\n") + + def test_bad_opening_amount_is_a_usage_error(self): + txns = self.write("txns.csv", TXNS) + with contextlib.redirect_stderr(io.StringIO()): + with self.assertRaises(SystemExit) as ctx: + main(["report", txns, "--opening", "1.005"]) + self.assertEqual(ctx.exception.code, 2) + + def test_missing_subcommand_is_a_usage_error(self): + with contextlib.redirect_stderr(io.StringIO()): + with self.assertRaises(SystemExit) as ctx: + main([]) + self.assertEqual(ctx.exception.code, 2) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_cli -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.cli'` + +- [ ] **Step 3: Implement `main(argv: list[str] | None = None) -> int` in `ledgerlite/cli.py`** + +Build an `ArgumentParser(prog="ledgerlite")` with `add_subparsers(dest="command", required=True)` and one `report` subparser taking a positional `transactions`, an optional `--rules`, and `--opening` with `type=parse_amount` and `default=Decimal("0")`. (`argparse` turns the `ValueError` from `parse_amount` into its own usage error and exits 2, which is what the bad-`--opening` test expects.) + +Then: + +```python +try: + transactions = parse_transactions(args.transactions) + rules = load_rules(args.rules) if args.rules else [] +except ParseError as exc: + print(f"ledgerlite: {exc}", file=sys.stderr) + return 2 +except (OSError, UnicodeDecodeError) as exc: + path = getattr(exc, "filename", None) or args.transactions + reason = getattr(exc, "strerror", None) or str(exc) + print(f"ledgerlite: cannot read {path}: {reason}", file=sys.stderr) + return 1 +print(format_report(transactions, rules, args.opening), end="") +return 0 +``` + +(The `filename` lookup is what makes the missing-`--rules` message name the rules file. `UnicodeDecodeError` has no `filename`, so it falls back to the transactions path.) + +End the module with `if __name__ == "__main__": sys.exit(main())`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_cli -v` +Expected: PASS + +- [ ] **Step 5: Run the whole suite and the tool end to end** + +Run: `python3 -m unittest -v` +Expected: PASS, all tests from all five test files. + +Then, from the repo root: + +```bash +printf 'date,amount,description\n2026-03-10,2500.00,Salary\n2026-03-04,-7.50,Morning Coffee\n2026-03-05,-900.00,Rent March\n' > /tmp/txns.csv +printf 'coffee=food\nrent=housing\n' > /tmp/rules.txt +python3 -m ledgerlite.cli report /tmp/txns.csv --rules /tmp/rules.txt --opening 100 +``` + +Expected: exactly the report from `design.md`: + +``` +food: -7.50 +housing: -900.00 +uncategorized: 2500.00 + +closing balance: 1692.50 +``` + +- [ ] **Step 6: Commit** + +```bash +git add ledgerlite/cli.py test_cli.py +git commit -m "feat: add ledgerlite report CLI with exit codes" +``` diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/P1-proportion-cosmic-51/2026-09-17-cosmic-tetris-1-engine.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/P1-proportion-cosmic-51/2026-09-17-cosmic-tetris-1-engine.md new file mode 100644 index 000000000..e5f466f46 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/P1-proportion-cosmic-51/2026-09-17-cosmic-tetris-1-engine.md @@ -0,0 +1,856 @@ +# Cosmic Tetris — Plan 1: Deterministic Game Engine Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the complete headless, deterministic falling-block game engine under `internal/game` — pieces, board, 7-bag, movement, rotation with wall kicks, gravity, lock delay, line clearing, hold, scoring, game over — with comprehensive unit tests and a seeded replay test. + +**Architecture:** One package, `internal/game`, with no dependencies outside the standard library. It never touches a terminal, never renders, and never reads a clock: the caller drives it with `Advance(dt time.Duration)` plus explicit input methods, and every mutation returns a `[]Event` slice describing what happened. The engine owns its own `*rand.Rand` (seeded from a CLI seed) which drives only the 7-bag. Board coordinates are `y=0` at the top (hidden spawn rows 0–1), `y=21` at the bottom, `x=0` at the left. + +**Tech Stack:** Go 1.26 standard library only (`math`, `math/rand`, `time`, `testing`). No third-party dependencies in this plan — Bubble Tea / Lip Gloss / Bubbles arrive in Plan 2. + +**Spec:** `design.md` (this plan implements §5–§13, §34, §35, §40 Board/Pieces/Bag/Hold/Drop/Score/Game-over/Determinism, §42 Phase 1, and pinned decisions §49.1, §49.2, §49.6) + +## Global Constraints + +- Language: Go. Module path: `cosmic-tetris`. Go directive: `go 1.26`. +- Repository layout follows `design.md` §33 exactly. Do not add packages beyond that tree except where this plan says so explicitly. +- Nothing under `internal/game` may call `time.Now()`, read the environment, touch the filesystem, spawn goroutines, or print. Timing enters only as `dt time.Duration` (§49.2). +- `Game` owns `rng *rand.Rand` (unexported) which drives the 7-bag and nothing else. `Seed int64` is recorded for display and restart only (§49.6). FX gets a separate RNG in Plan 3; the two never share. +- Board geometry, fixed: width 10, height 22, visible rows 20, hidden spawn rows 2 (§5). +- Seven piece kinds `I J L O S T Z`, four rotations each, `O` visually identical through rotation (§6). +- Wall-kick offsets, tested in exactly this order: `(0,0) (-1,0) (1,0) (-2,0) (2,0) (0,-1) (-1,-1) (1,-1)`. First valid wins; if none is valid the rotation fails (§7). +- Gravity: level 1 interval 800ms; `interval = 800ms * 0.86^(level-1)`; clamped at a 60ms floor. Level increases every 10 cleared lines (§11). +- Lock delay 500ms; a successful move or rotation while grounded resets it; maximum 15 resets per piece (§12). +- Line values `100/300/500/800 × level`; soft drop `+1`/cell; hard drop `+2`/cell (§11, §13). +- Combo: first clearing placement sets combo to 1; a placement clearing nothing resets it to 0; `bonus = 50 × (combo - 1) × level` (§49.1). +- Do not build: networking, profiles, achievements, a plugin system, a database, or persistence of any kind (§2). +- Commit after every task. Every task ends with `go test ./...` green and `go vet ./...` clean. + +## Review Focus + +Five things the spec requires by implication that no obvious task test covers. Each has a test added to the task that owns the code. + +1. **A `dt` larger than the drop interval** (a stalled frame, a backgrounded terminal) must apply every whole gravity step it earned and stop cleanly at the floor — not swallow the extra time and not spin forever. → Task 5. +2. **Wall-kick candidates that leave the board** — the `(0,-1)` family kicks a piece upward, and near `y=0` that is out of bounds. Out-of-bounds candidates must be rejected as invalid, not panic on a negative index. → Task 4. +3. **Line clears that include the hidden spawn rows** (rows 0–1) must clear and collapse like any other row; the collapse loop must not read above row 0. → Task 2. +4. **Gravity interval at absurd levels** — level 40+ drives `0.86^39` toward zero; the interval must clamp at 60ms and never reach zero, which would make `Advance`'s gravity loop non-terminating. → Task 5. +5. **Input after game over** — every input method and `Advance` must be a no-op once the game is over, returning no events and mutating nothing. → Task 8. + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `go.mod` | Module `cosmic-tetris`, `go 1.26`. | +| `internal/game/piece.go` | `PieceKind`, `Piece`, base layouts, the precomputed rotation table, spawn offsets. | +| `internal/game/board.go` | `Cell`, `Board`, bounds/collision queries, locking cells, row completion, row clearing and collapse. | +| `internal/game/bag.go` | `Bag`: 7-bag shuffle/consume/refill over an injected `*rand.Rand`. | +| `internal/game/event.go` | `EventKind`, `Event`. (One file beyond §33's tree: the FX-side `fx/events.go` is a different type in a different package; the engine needs its own.) | +| `internal/game/rules.go` | Tunable constants and pure rule functions: kick offsets, `DropInterval`, `LevelForLines`, lock-delay constants. | +| `internal/game/scoring.go` | `LineScore`, `ComboBonus`, drop-point helpers. | +| `internal/game/game.go` | `Game`: state, `New`, input methods, `Advance`, spawn, hold, the lock pipeline, `Over`, `Ghost`, `Restart`, `Snapshot`. | +| `internal/game/*_test.go` | One test file per source file above. | +| `internal/game/testdata/replay.golden` | Recorded final-state snapshot for the determinism replay test. | + +--- + +## Task 1: Module scaffolding, piece geometry, rotation table + +**Files:** +- Create: `go.mod`, `internal/game/piece.go`, `internal/game/piece_test.go`, `.gitignore`, `README.md`, `LICENSE` + +**Interfaces:** +- Consumes: nothing. +- Produces: + ```go + type PieceKind uint8 + const (KindI PieceKind = iota; KindJ; KindL; KindO; KindS; KindT; KindZ) + const KindCount = 7 + + func (k PieceKind) String() string // "I","J","L","O","S","T","Z" + func (k PieceKind) BoxSize() int // 4 for I and O, 3 otherwise + func KindByName(s string) (PieceKind, bool) + + type Offset struct{ DX, DY int } + + // Offsets returns the four occupied cells of kind k at rotation rot, + // relative to the top-left of the piece's box. rot is taken mod 4. + func Offsets(k PieceKind, rot int) [4]Offset + + type Piece struct { + Kind PieceKind + Rotation int + X, Y int // board coords of the piece box's top-left corner + } + + func (p Piece) Cells() [4]Offset // absolute board coordinates + func SpawnPiece(k PieceKind) Piece // Rotation 0, X=3, Y=0 + ``` + +**Design notes for the implementer:** Do not hand-write 28 rotation layouts. Store one base layout per kind and derive the other three by rotating the box clockwise: for a box of size `n`, `(x, y) -> (n-1-y, x)`. Build the full `[KindCount][4][4]Offset` table once in an `init()` (or a package-level `var` built by a function) and have `Offsets` index it. Sort each derived rotation's offsets by `(DY, DX)` so the table is comparison-stable in tests. + +Base layouts (rotation 0), `#` = occupied: + +```text +I (box 4) J (box 3) L (box 3) O (box 4) +.... #.. ..# .... +#### ### ### .##. +.... ... ... .##. +.... .... + +S (box 3) T (box 3) Z (box 3) +.## .#. ##. +##. ### .## +... ... ... +``` + +- [ ] **Step 1: Initialize the module and repo boilerplate** + +```bash +go mod init cosmic-tetris +printf '/cosmic-tetris\n' > .gitignore +``` + +Write a two-paragraph `README.md` (what the game is, how to run it once Plan 2 lands) and an MIT `LICENSE` with `Copyright (c) 2026`. + +- [ ] **Step 2: Write the failing tests** + +`internal/game/piece_test.go` — five tests, table-driven where noted: + +```go +func TestOffsetsAlwaysFourDistinctCellsInsideBox(t *testing.T) +// for each kind, for rot 0..3: exactly 4 offsets, all distinct, +// 0 <= DX,DY < kind.BoxSize() + +func TestFourRotationsReturnToBase(t *testing.T) +// for each kind: Offsets(k, 4) == Offsets(k, 0), and Offsets(k, -1) == Offsets(k, 3) + +func TestOIsRotationInvariant(t *testing.T) +// Offsets(KindO, rot) equal for all rot in 0..3 + +func TestIRotatesBetweenRowAndColumn(t *testing.T) +// rot 0 and 2: all four DY equal (horizontal); rot 1 and 3: all four DX equal (vertical) + +func TestSpawnPieceStartsInHiddenRows(t *testing.T) +// for each kind: p := SpawnPiece(k); p.Rotation == 0; p.X == 3; p.Y == 0; +// every Cells() entry has DY < 2 and 0 <= DX < 10 +``` + +- [ ] **Step 3: Run the tests and confirm they fail** + +Run: `go test ./internal/game/ -run 'TestOffsets|TestFour|TestO|TestI|TestSpawn' -v` +Expected: build failure — `undefined: Offsets`, `undefined: SpawnPiece`. + +- [ ] **Step 4: Implement `piece.go`** + +Base layouts as `[KindCount][]Offset`, a `rotateCW(in []Offset, n int) []Offset`, a package-level table built at init, plus the accessors from the Interfaces block. `SpawnPiece` returns `Piece{Kind: k, Rotation: 0, X: 3, Y: 0}` — X=3 centers both the 3-box (columns 3–5) and the 4-box (columns 3–6) closely enough, and Y=0 puts the piece in the hidden rows. + +- [ ] **Step 5: Run the tests and confirm they pass** + +Run: `go test ./internal/game/ -v` then `go vet ./...` +Expected: PASS, no vet output. + +- [ ] **Step 6: Commit** + +```bash +git add go.mod .gitignore README.md LICENSE internal/game/piece.go internal/game/piece_test.go +git commit -m "feat(game): piece kinds, derived rotation table, spawn placement" +``` + +--- + +## Task 2: Board — bounds, collision, locking, row clearing + +**Files:** +- Create: `internal/game/board.go`, `internal/game/board_test.go` + +**Interfaces:** +- Consumes: `Piece`, `PieceKind`, `Offset` (Task 1). +- Produces: + ```go + const ( + BoardWidth = 10 + BoardHeight = 22 + VisibleRows = 20 + HiddenRows = 2 // rows 0 and 1 + ) + + type Cell struct { + Filled bool + Kind PieceKind + } + + type Board struct { + Cells [BoardHeight][BoardWidth]Cell + } + + func (b *Board) InBounds(x, y int) bool + func (b *Board) At(x, y int) Cell // zero Cell when out of bounds + func (b *Board) Occupied(x, y int) bool // true when out of bounds + func (b *Board) Collides(p Piece) bool // any cell occupied or out of bounds + func (b *Board) Lock(p Piece) // writes p's cells as Filled with p.Kind + func (b *Board) CompleteRows() []int // ascending row indices, nil when none + func (b *Board) ClearRows(rows []int) // removes rows, collapses everything above + func (b *Board) RowFilledCount(y int) int + func (b *Board) Clear() // empties every cell + ``` + +**Design notes:** `Occupied` returning `true` out of bounds is what makes `Collides` a one-liner and keeps floor, walls, and ceiling uniform. `ClearRows` must tolerate unsorted input and duplicate-free indices; implement it as a downward copy with a write cursor starting at the bottom, then zero the rows the cursor never reached — that pattern handles hidden rows without a special case. + +- [ ] **Step 1: Write the failing tests** + +`internal/game/board_test.go`: + +```go +func TestInBoundsAndOccupiedTreatOutsideAsSolid(t *testing.T) +// (-1,5), (10,5), (0,-1), (0,22) => InBounds false, Occupied true; (0,0) empty board => Occupied false + +func TestCollidesWithFloorWallsAndLockedCells(t *testing.T) +// subtests: piece at bottom row +1 collides; piece pushed to X=-1 collides; +// piece overlapping a manually filled cell collides; free piece does not + +func TestLockWritesKindIntoEveryCell(t *testing.T) +// after Lock(SpawnPiece(KindT)): exactly 4 cells Filled, each Kind == KindT + +func TestCompleteRowsFindsFullRowsAscending(t *testing.T) +// fill rows 21 and 19 fully, row 20 with 9 cells => CompleteRows() == []int{19, 21} + +func TestCompleteRowsNilWhenNothingFull(t *testing.T) + +func TestClearRowsCollapsesStackDownward(t *testing.T) +// fill row 21 fully; put a single marker cell at (0,20); ClearRows([]int{21}) +// => marker now at (0,21), row 20 empty, total filled count == 1 + +func TestClearFourRowsAtOnce(t *testing.T) +// fill rows 18..21; marker at (3,17); ClearRows([]int{18,19,20,21}) +// => marker at (3,21), all other cells empty + +// Review Focus #3 +func TestClearRowsIncludingHiddenSpawnRows(t *testing.T) +// fill rows 0 and 1 (the hidden rows) fully plus a marker at (5,3); +// ClearRows([]int{0,1}) => marker at (5,3) is untouched (nothing above it moved +// into it), rows 0..1 empty, no panic, total filled count == 1 + +func TestClearRowsAcceptsUnsortedInput(t *testing.T) +// same expectation as the four-row case with rows passed as []int{20,18,21,19} +``` + +- [ ] **Step 2: Run the tests and confirm they fail** + +Run: `go test ./internal/game/ -run TestBoard -v; go test ./internal/game/ -v` +Expected: build failure — `undefined: Board`. + +- [ ] **Step 3: Implement `board.go`** + +Per the Interfaces block. Keep `Cells` a value array, not a slice of slices — `Board` copies cheaply, which the snapshot and replay tests will use. + +- [ ] **Step 4: Run the tests and confirm they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/board.go internal/game/board_test.go +git commit -m "feat(game): board collision, row completion, clear and collapse" +``` + +--- + +## Task 3: 7-bag piece generator and next queue + +**Files:** +- Create: `internal/game/bag.go`, `internal/game/bag_test.go` + +**Interfaces:** +- Consumes: `PieceKind`, `KindCount` (Task 1). +- Produces: + ```go + const NextQueueLen = 5 // §6: render the next five + + type Bag struct { + pending []PieceKind + } + + // Next draws the next kind, refilling and reshuffling from rng when empty. + func (b *Bag) Next(rng *rand.Rand) PieceKind + func (b *Bag) Remaining() int + ``` + +**Design notes:** `Bag` takes the RNG as a parameter rather than storing it, so `Game` stays the single owner of randomness (§49.6). Refill by appending all seven kinds and shuffling with `rng.Shuffle`. + +- [ ] **Step 1: Write the failing tests** + +`internal/game/bag_test.go`: + +```go +func TestEveryBagContainsAllSevenKindsExactlyOnce(t *testing.T) +// draw 70 kinds from rand.New(rand.NewSource(1)); for each group of 7, +// assert the group is a permutation of all seven kinds + +func TestSeededBagIsReproducible(t *testing.T) +// two bags with rand.New(rand.NewSource(8675309)) produce identical 70-kind sequences + +func TestDifferentSeedsDiverge(t *testing.T) +// seeds 1 and 2 produce different 70-kind sequences + +func TestRemainingCountsDownWithinABag(t *testing.T) +// fresh bag: Remaining()==0; after one Next: 6; after seven: 0 +``` + +- [ ] **Step 2: Run the tests and confirm they fail** + +Run: `go test ./internal/game/ -run TestEveryBag -v` +Expected: build failure — `undefined: Bag`. + +- [ ] **Step 3: Implement `bag.go`** + +- [ ] **Step 4: Run the tests and confirm they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/bag.go internal/game/bag_test.go +git commit -m "feat(game): seeded 7-bag generator" +``` + +--- + +## Task 4: Game construction, movement, rotation with wall kicks, ghost + +**Files:** +- Create: `internal/game/event.go`, `internal/game/rules.go`, `internal/game/game.go`, `internal/game/game_test.go`, `internal/game/rules_test.go` +- Test: `internal/game/game_test.go` + +**Interfaces:** +- Consumes: `Piece`, `Board`, `Bag` (Tasks 1–3). +- Produces: + ```go + // event.go + type EventKind uint8 + const ( + EventPieceMoved EventKind = iota + EventPieceRotated + EventPieceHardDropped + EventPieceLocked + EventHoldUsed + EventLinesCleared + EventComboChanged + EventLevelChanged + EventGameOver + ) + func (k EventKind) String() string + + type Event struct { + Kind EventKind + Piece Piece // the piece involved, where meaningful + Rows []int // EventLinesCleared: cleared rows, ascending + Distance int // EventPieceHardDropped: cells fallen + Level int // EventLevelChanged: the new level + Combo int // EventComboChanged: the new combo + Score int // score after the event + } + + // rules.go + var KickOffsets = [8]Offset{{0,0},{-1,0},{1,0},{-2,0},{2,0},{0,-1},{-1,-1},{1,-1}} + const ( + BaseDropInterval = 800 * time.Millisecond + MinDropInterval = 60 * time.Millisecond + GravityFactor = 0.86 + LockDelay = 500 * time.Millisecond + MaxLockResets = 15 + LinesPerLevel = 10 + ) + func DropInterval(level int) time.Duration + func LevelForLines(lines int) int // lines/10 + 1 + + // game.go + type Game struct { + Board Board + Active Piece + Hold *PieceKind + CanHold bool + Next []PieceKind // always NextQueueLen long while playing + Bag Bag + + Score, Lines, Level, Combo int + + GravityAccumulator time.Duration + LockAccumulator time.Duration + + Seed int64 + + lockResets int + grounded bool + over bool + rng *rand.Rand + } + + func New(seed int64) *Game + func (g *Game) Over() bool + func (g *Game) Ghost() Piece // Active dropped to its landing row + func (g *Game) MoveLeft() []Event + func (g *Game) MoveRight() []Event + func (g *Game) RotateCW() []Event + func (g *Game) RotateCCW() []Event + ``` + +**Design notes:** `New` seeds `rng` from `seed`, records `Seed`, fills `Next` to `NextQueueLen`, sets `Level = 1`, `CanHold = true`, and spawns the first active piece. Movement helpers share one private `tryMove(dx, dy int) bool`. Rotation walks `KickOffsets` in order and takes the first candidate where `!Board.Collides(candidate)` — since `Collides` reports out-of-bounds as occupied (Task 2), an upward kick past `y=0` is rejected automatically, with no bounds arithmetic in the rotation code. A successful move or rotation while `grounded` calls a private `resetLockTimer()`; Task 5 gives that method its body — here it may be a stub that does nothing but must already be called from the move/rotate paths. + +- [ ] **Step 1: Write the failing tests** + +`internal/game/rules_test.go`: + +```go +func TestDropIntervalCurve(t *testing.T) +// level 1 == 800ms; level 2 within 1ms of 688ms; strictly decreasing for levels 1..20 + +func TestDropIntervalClampsAtFloor(t *testing.T) +// Review Focus #4: for level in {30, 40, 100, 1000}: DropInterval(level) == MinDropInterval +// and DropInterval(level) > 0 + +func TestLevelForLines(t *testing.T) +// 0 -> 1, 9 -> 1, 10 -> 2, 19 -> 2, 20 -> 3, 127 -> 13 +``` + +`internal/game/game_test.go`: + +```go +func TestNewGameIsPlayableAndSeedIsRecorded(t *testing.T) +// New(42): Seed==42, Level==1, Score==0, Lines==0, Combo==0, CanHold true, +// Hold nil, len(Next)==NextQueueLen, !Over() + +func TestMoveLeftRightEmitsPieceMovedAndStopsAtWalls(t *testing.T) +// move left until it stops: X decreases, each success emits one EventPieceMoved; +// the blocked attempt emits no events and leaves X unchanged. Same rightward. + +func TestRotateEmitsPieceRotatedAndCyclesRotation(t *testing.T) +// four RotateCW on a T in open space: Rotation goes 1,2,3,0; each emits EventPieceRotated + +func TestRotateCCWGoesBackwards(t *testing.T) +// RotateCCW from rotation 0 lands on rotation 3 + +func TestRotationWallKicksOffTheLeftWall(t *testing.T) +// place a vertical I at X=-1 (set g.Active directly), RotateCW, assert the piece +// ends up fully in bounds and !Board.Collides(g.Active) + +func TestRotationFailsWhenNoKickWorks(t *testing.T) +// fill the whole board except the 4 cells of the active piece; RotateCW returns +// no events and leaves Rotation and X,Y unchanged + +// Review Focus #2 +func TestRotationNeverKicksAboveTheCeiling(t *testing.T) +// active piece at Y=0 with rows 2..21 filled solid so the only free space is the +// hidden rows; RotateCW must either fail or land with every cell Y >= 0. +// The test must not panic. + +func TestGhostLandsOnTheStackWithoutMovingActive(t *testing.T) +// empty board: Ghost().Y is the lowest non-colliding Y and Active.Y is unchanged; +// with row 21 filled, the ghost rests one row higher; Ghost().Kind == Active.Kind +// and Ghost().Rotation == Active.Rotation +``` + +- [ ] **Step 2: Run the tests and confirm they fail** + +Run: `go test ./internal/game/ -v` +Expected: build failure — `undefined: New`, `undefined: DropInterval`. + +- [ ] **Step 3: Implement `event.go`, `rules.go`, and the movement half of `game.go`** + +`DropInterval` uses `math.Pow(GravityFactor, float64(level-1))` on the base interval and clamps to `MinDropInterval`. + +- [ ] **Step 4: Run the tests and confirm they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/event.go internal/game/rules.go internal/game/game.go internal/game/game_test.go internal/game/rules_test.go +git commit -m "feat(game): game state, movement, kick-based rotation, ghost" +``` + +--- + +## Task 5: Gravity, drops, lock delay, and the lock pipeline + +**Files:** +- Modify: `internal/game/game.go` +- Test: `internal/game/gravity_test.go` (create) + +**Interfaces:** +- Consumes: everything from Task 4. +- Produces: + ```go + // Advance applies dt of elapsed time: gravity steps and, when the active piece + // is grounded, the lock timer. Returns every event produced, in order. + func (g *Game) Advance(dt time.Duration) []Event + + func (g *Game) SoftDrop() []Event // one cell down, +1 point per cell moved + func (g *Game) HardDrop() []Event // to the landing row, +2 points per cell, locks immediately + func (g *Game) Grounded() bool + ``` + +**Design notes on ordering.** `Advance` does, in this order: + +1. If `Over()`, return nil. +2. Add `dt` to `GravityAccumulator`. While `GravityAccumulator >= DropInterval(Level)`: subtract the interval and take one gravity step. A gravity step moves the piece down one cell if it can (emitting `EventPieceMoved`, no score — gravity is free), and does nothing if it cannot. The loop terminates because `DropInterval` has a 60ms floor. +3. If the piece cannot move down, set `grounded` and add `dt` to `LockAccumulator`; when `LockAccumulator >= LockDelay`, run the lock pipeline. If the piece *can* move down, clear `grounded`, zero `LockAccumulator`, and leave `lockResets` alone. + +`resetLockTimer()` (stubbed in Task 4, filled in here): if `grounded && lockResets < MaxLockResets`, zero `LockAccumulator` and increment `lockResets`. + +The lock pipeline, in the order §12 fixes: + +1. `Board.Lock(Active)`, emit `EventPieceLocked`. +2. `rows := Board.CompleteRows()`. +3. If `len(rows) > 0`: `Board.ClearRows(rows)`, emit `EventLinesCleared{Rows: rows}`. +4. Update score, lines, combo, level (Task 7 supplies the arithmetic; here just call into it) and emit `EventComboChanged` / `EventLevelChanged` when those values actually change. +5. Reset `CanHold = true`, `lockResets = 0`, `grounded = false`, both accumulators to zero. +6. Spawn the next piece (Task 8 adds the blocked-spawn game-over branch). + +Note for the FX layer: `EventLinesCleared` carries the row indices *before* the clear, which is what the supernova animation in Plan 3 needs to know where to draw. + +- [ ] **Step 1: Write the failing tests** + +`internal/game/gravity_test.go`: + +```go +func TestAdvanceBelowIntervalDoesNothing(t *testing.T) +// Advance(700ms) at level 1: Active.Y unchanged, no events + +func TestAdvanceOneIntervalDropsOneRow(t *testing.T) +// Advance(800ms): Y increases by exactly 1, one EventPieceMoved + +func TestAdvanceKeepsTheRemainder(t *testing.T) +// Advance(500ms) then Advance(400ms): total one drop, GravityAccumulator == 100ms + +// Review Focus #1 +func TestLargeDtAppliesEveryEarnedStepAndStopsAtTheFloor(t *testing.T) +// Advance(5s) at level 1: piece falls at most 6 rows (5s/800ms) and never further; +// then Advance(1*time.Minute) on an empty board: the piece rests on the floor, +// Board.Collides(Active) is false, and the call returns (does not hang). +// Guard the second call with a 5s test deadline via a done-channel or t.Deadline. + +func TestGroundedPieceLocksAfterLockDelay(t *testing.T) +// drop to the floor, then Advance(499ms): no lock; Advance(2ms): EventPieceLocked +// present and the board has 4 filled cells + +func TestMovementWhileGroundedResetsLockTimer(t *testing.T) +// ground the piece, Advance(400ms), MoveLeft(), Advance(400ms): still not locked + +func TestLockResetsAreCappedAtFifteen(t *testing.T) +// ground the piece, then loop 20×{Advance(400ms); MoveLeft() or MoveRight()}: +// a lock happens; assert EventPieceLocked was emitted within those 20 iterations + +func TestSoftDropScoresOnePointPerCell(t *testing.T) +// three SoftDrop calls on an empty board: Score == 3, Y increased by 3, +// each emits EventPieceMoved + +func TestSoftDropAtTheFloorScoresNothing(t *testing.T) +// with the piece grounded: SoftDrop returns no events and Score is unchanged + +func TestHardDropScoresTwoPerCellAndLocksImmediately(t *testing.T) +// from spawn on an empty board: events contain EventPieceHardDropped with +// Distance == cells fallen, then EventPieceLocked; Score == 2*Distance; +// board has 4 filled cells; a new active piece is present + +func TestHardDropOnAGroundedPieceLocksWithZeroDistance(t *testing.T) +// ground the piece first: EventPieceHardDropped has Distance 0, Score unchanged, +// EventPieceLocked emitted +``` + +- [ ] **Step 2: Run the tests and confirm they fail** + +Run: `go test ./internal/game/ -run 'TestAdvance|TestLarge|TestGrounded|TestMovementWhile|TestLockResets|TestSoft|TestHard' -v` +Expected: build failure — `undefined: (*Game).Advance`. + +- [ ] **Step 3: Implement `Advance`, `SoftDrop`, `HardDrop`, `resetLockTimer`, and the lock pipeline** + +Score updates in step 4 of the pipeline may temporarily inline `100/300/500/800 × level` — Task 7 replaces that with `scoring.go` and adds the combo bonus. + +- [ ] **Step 4: Run the tests and confirm they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/game.go internal/game/gravity_test.go +git commit -m "feat(game): elapsed-time gravity, soft/hard drop, lock delay pipeline" +``` + +--- + +## Task 6: Hold + +**Files:** +- Modify: `internal/game/game.go` +- Test: `internal/game/hold_test.go` (create) + +**Interfaces:** +- Consumes: Task 5's spawn helper and lock pipeline. +- Produces: + ```go + // Hold swaps the active piece with the held piece, or stores it and spawns the + // next when the hold slot is empty. It is a no-op unless CanHold is true. + func (g *Game) Hold() []Event + ``` + +Note: the field is `Game.Hold *PieceKind` per §34 and the method would collide with it, so the field stays `Hold` and the method is named **`HoldPiece`**. Use `HoldPiece()` everywhere — including Plan 2's key handler. + +- [ ] **Step 1: Write the failing tests** + +`internal/game/hold_test.go`: + +```go +func TestFirstHoldStoresActiveAndSpawnsNext(t *testing.T) +// record Active.Kind and Next[0]; HoldPiece(): *Hold == old active kind, +// Active.Kind == old Next[0], len(Next) == NextQueueLen, one EventHoldUsed + +func TestSecondHoldSwapsBack(t *testing.T) +// HoldPiece, lock the piece (HardDrop), then HoldPiece again: the previously held +// kind becomes active and the just-active kind is held + +func TestSecondHoldBeforeLockIsBlocked(t *testing.T) +// HoldPiece twice in a row: the second returns no events and changes nothing + +func TestHoldIsRestoredAfterLock(t *testing.T) +// HoldPiece, HardDrop (locks), then CanHold is true again + +func TestHeldPieceReturnsAtSpawnRotationAndPosition(t *testing.T) +// rotate the active piece twice, HoldPiece, HardDrop, HoldPiece: +// the returning piece has Rotation 0, X 3, Y 0 +``` + +- [ ] **Step 2: Run the tests and confirm they fail** + +Run: `go test ./internal/game/ -run TestHold -v; go test ./internal/game/ -run TestFirst -v` +Expected: build failure — `undefined: (*Game).HoldPiece`. + +- [ ] **Step 3: Implement `HoldPiece`** + +Set `CanHold = false`, emit `EventHoldUsed`, and reset the lock/gravity accumulators and `lockResets` for the newly active piece. + +- [ ] **Step 4: Run the tests and confirm they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/game.go internal/game/hold_test.go +git commit -m "feat(game): quantum storage (hold) with once-per-piece rule" +``` + +--- + +## Task 7: Scoring, combo, level progression + +**Files:** +- Create: `internal/game/scoring.go`, `internal/game/scoring_test.go` +- Modify: `internal/game/game.go` (lock pipeline step 4 calls into `scoring.go`) + +**Interfaces:** +- Consumes: `LevelForLines` (Task 4), the lock pipeline (Task 5). +- Produces: + ```go + // LineScore returns the base value for clearing n lines at the given level. + // n outside 1..4 returns 0. + func LineScore(n, level int) int + + // ComboBonus implements §49.1: 50 * (combo-1) * level. combo <= 1 yields 0. + func ComboBonus(combo, level int) int + ``` + +- [ ] **Step 1: Write the failing tests** + +`internal/game/scoring_test.go`: + +```go +func TestLineScoreValues(t *testing.T) +// level 1: 100/300/500/800 for n=1..4; level 7: 700/2100/3500/5600; n=0 and n=5 => 0 + +func TestComboBonusStartsAtComboTwo(t *testing.T) +// (0,1)=>0, (1,1)=>0, (2,1)=>50, (3,1)=>100, (2,7)=>350 +``` + +`internal/game/gravity_test.go` (append — these exercise the pipeline, not the pure helpers): + +```go +func TestSingleClearScoresBaseValueWithNoComboBonus(t *testing.T) +// helper fillRowExcept(t, g, 21, gapX) then drop a piece into the gap: +// Score == 100, Lines == 1, Combo == 1, no combo bonus in the total + +func TestConsecutiveClearsAccumulateComboBonus(t *testing.T) +// two clears back to back at level 1: second placement adds 100 + 50 + +func TestNonClearingPlacementResetsCombo(t *testing.T) +// clear once (Combo==1), then hard-drop a piece that clears nothing: +// Combo == 0 and an EventComboChanged with Combo 0 is emitted + +func TestLevelRisesEveryTenLines(t *testing.T) +// drive Lines to 10 via cleared rows: Level == 2 and an EventLevelChanged +// with Level 2 is emitted exactly once + +func TestEventsCarryScoreAfterTheEvent(t *testing.T) +// the EventLinesCleared from a single clear has Score == g.Score +``` + +**Test helper to write once in `gravity_test.go` and reuse:** + +```go +// fillRowExcept fills row y of g.Board with locked KindI cells except column gapX. +func fillRowExcept(t *testing.T, g *Game, y, gapX int) + +// forceActive replaces the active piece so a test can aim a drop precisely. +func forceActive(g *Game, k PieceKind, rot, x, y int) +``` + +- [ ] **Step 2: Run the tests and confirm they fail** + +Run: `go test ./internal/game/ -run 'TestLineScore|TestCombo|TestSingleClear|TestConsecutive|TestNonClearing|TestLevelRises' -v` +Expected: FAIL — `undefined: LineScore`, and the pipeline tests fail on the missing combo bonus. + +- [ ] **Step 3: Implement `scoring.go` and wire it into the lock pipeline** + +Pipeline step 4 becomes: if lines were cleared, `Combo++`, `Lines += len(rows)`, `Score += LineScore(len(rows), Level) + ComboBonus(Combo, Level)`; else if `Combo != 0`, `Combo = 0` and emit `EventComboChanged`. Then recompute `Level = LevelForLines(Lines)` and emit `EventLevelChanged` if it moved. Emit `EventComboChanged` whenever `Combo` changes value, in both branches. + +- [ ] **Step 4: Run the tests and confirm they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/scoring.go internal/game/scoring_test.go internal/game/game.go internal/game/gravity_test.go +git commit -m "feat(game): line values, combo bonus, level progression" +``` + +--- + +## Task 8: Game over, restart, snapshot, determinism replay + +**Files:** +- Modify: `internal/game/game.go` +- Create: `internal/game/determinism_test.go`, `internal/game/testdata/replay.golden` + +**Interfaces:** +- Consumes: everything above. +- Produces: + ```go + // Restart resets every field to a fresh game with the given seed, reusing g. + func (g *Game) Restart(seed int64) + + // Snapshot renders the full logical state as a stable multi-line string: + // seed, score, lines, level, combo, hold, next queue, active piece, and the + // 22 board rows as '.' / kind letters. Used by the determinism test and by + // debugging; it must not include any timing or FX state. + func (g *Game) Snapshot() string + ``` + +**Design notes:** game over happens when the piece spawned by the lock pipeline (or by `HoldPiece`) collides at its spawn position. Set `over = true`, emit `EventGameOver`, and leave the colliding piece in `Active` so the renderer has something to freeze and collapse in Plan 3. Once `over` is set, `Advance`, `MoveLeft`, `MoveRight`, `RotateCW`, `RotateCCW`, `SoftDrop`, `HardDrop`, and `HoldPiece` all return `nil` immediately. + +- [ ] **Step 1: Write the failing tests** + +`internal/game/determinism_test.go`: + +```go +func TestBlockedSpawnEndsTheGame(t *testing.T) +// fill rows 0..3 solid except a landing pocket, hard-drop into it, and assert +// the returned events contain EventGameOver and Over() is true + +// Review Focus #5 +func TestInputAfterGameOverIsANoOp(t *testing.T) +// reach game over, capture Snapshot(), then call MoveLeft, MoveRight, RotateCW, +// RotateCCW, SoftDrop, HardDrop, HoldPiece, Advance(10*time.Second): +// every call returns nil and Snapshot() is byte-identical to the capture + +func TestRestartClearsEverything(t *testing.T) +// play a few placements, Restart(99): Seed==99, Score/Lines/Combo==0, Level==1, +// Hold nil, CanHold true, board empty, !Over() + +func TestSnapshotIsStableAcrossIdenticalRuns(t *testing.T) +// replay(seed) twice (helper below) and compare Snapshot() strings + +func TestReplayMatchesGolden(t *testing.T) +// replay(8675309) and compare Snapshot() to testdata/replay.golden, +// regenerated with -update + +func TestDifferentSeedGivesDifferentReplay(t *testing.T) +// replay(8675309) and replay(11) snapshots differ +``` + +**The replay helper — the heart of §35 and §49.2:** + +```go +// step is one canned input plus the elapsed time that follows it. +type step struct { + input string // "", "left", "right", "cw", "ccw", "soft", "hard", "hold" + dt time.Duration +} + +// replaySteps is a fixed 60-step script mixing every input with dt values that +// straddle the level-1 drop interval (e.g. 120ms, 800ms, 60ms, 1500ms). +// Write it out literally; do not generate it randomly. +var replaySteps = []step{ /* 60 literal entries */ } + +func replay(seed int64) *Game { + g := New(seed) + for _, s := range replaySteps { + switch s.input { + case "left": g.MoveLeft() + // ... one case per input + } + g.Advance(s.dt) + } + return g +} +``` + +Add `var update = flag.Bool("update", false, "rewrite golden files")` and have `TestReplayMatchesGolden` write `testdata/replay.golden` with `os.WriteFile` when set. + +- [ ] **Step 2: Run the tests and confirm they fail** + +Run: `go test ./internal/game/ -run 'TestBlockedSpawn|TestInputAfter|TestRestart|TestSnapshot|TestReplay|TestDifferentSeedGives' -v` +Expected: FAIL — `undefined: (*Game).Snapshot`, missing golden file. + +- [ ] **Step 3: Implement game over, `Restart`, and `Snapshot`** + +- [ ] **Step 4: Record the golden file and run the whole suite** + +Run: `go test ./internal/game/ -run TestReplayMatchesGolden -update && go test ./... -v` +Expected: golden written, all tests PASS. Open `testdata/replay.golden` and sanity-check it: a plausible score, a board with locked cells, no all-empty board. + +- [ ] **Step 5: Confirm the engine is clock-free and dependency-free** + +Run: +```bash +grep -rn "time.Now\|os\.\|fmt.Print" internal/game --include='*.go' | grep -v _test.go +go list -deps ./internal/game | grep -v '^\(internal/\|[a-z]*$\|[a-z]*/\)' | grep '\.' || echo "no external deps" +``` +Expected: the grep prints nothing (a `time.Duration` import is fine; `time.Now` is not), and no third-party dependency appears. + +- [ ] **Step 6: Commit** + +```bash +git add internal/game/game.go internal/game/determinism_test.go internal/game/testdata/replay.golden +git commit -m "feat(game): game over, restart, snapshot, seeded replay determinism test" +``` + +--- + +## Done when + +- `go test ./... -v` passes and `go vet ./...` is silent. +- `internal/game` imports nothing outside the standard library and never calls `time.Now()`. +- Every §40 engine category has tests: board collision/bounds/completion/removal/collapse, every rotation plus kicks plus failure plus spawn, bag contents and reproducibility, all four hold rules, soft/hard drop and landing and lock, line values and combo and drop scoring and level progression, blocked-spawn game over, and the seeded replay. +- `Game.Snapshot()` of a replayed game is byte-identical across runs with the same seed. diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/P1-proportion-cosmic-51/2026-09-17-cosmic-tetris-2-terminal.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/P1-proportion-cosmic-51/2026-09-17-cosmic-tetris-2-terminal.md new file mode 100644 index 000000000..ab2391194 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/P1-proportion-cosmic-51/2026-09-17-cosmic-tetris-2-terminal.md @@ -0,0 +1,922 @@ +# Cosmic Tetris — Plan 2: Playable Terminal Game Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Turn the headless engine from Plan 1 into a genuinely good, immediately playable terminal game: Bubble Tea event loop, responsive keys, board/ghost/hold/next/HUD rendering, adaptive layout down to 40×24, pause, restart, help, game over, ASCII fallback, and ANSI-stripped golden layout tests. + +**Architecture:** Three packages on top of `internal/game`. `internal/app` owns the Bubble Tea `Model` — one 60 Hz frame clock, elapsed time handed to `game.Advance(dt)`, keys handled the instant they arrive. `internal/render` is a pure function of a `render.Scene` value to a string; it never sees the `Model` and never mutates anything, which is what makes the golden tests trivial. `cmd/cosmic-tetris` parses flags and starts the program. No FX yet — that is Plan 3 — but the seams FX plugs into (a `Scene.FX` field, a status line, an intensity notion) are placed here. + +**Tech Stack:** Go 1.26, `charm.land/bubbletea/v2 v2.0.9`, `charm.land/lipgloss/v2 v2.0.6`, `charm.land/bubbles/v2 v2.2.1`, `github.com/charmbracelet/x/ansi` (test-only, for stripping ANSI in golden tests). + +**Spec:** `design.md` (this plan implements §4, §5 rendering, §8, §10 rendering, §25 static border, §26, §28 final panel, §30, §31, §32, §33, §34, §36, §37, §39, §41, §42 Phase 2, §46, and pinned decisions §49.3, §49.4, §49.7) + +## Global Constraints + +- Use these import paths, no others, for the TUI: `charm.land/bubbletea/v2`, `charm.land/lipgloss/v2`, `charm.land/bubbles/v2`. +- Bubble Tea v2 API facts, verified against v2.0.9 — do not write v1 code: + - `Model` is `Init() tea.Cmd`, `Update(tea.Msg) (tea.Model, tea.Cmd)`, `View() tea.View`. + - `tea.View` is a struct. Build it with `v := tea.NewView(s)`, then `v.AltScreen = true`. There is **no** `tea.WithAltScreen` option in v2. + - Key presses arrive as `tea.KeyPressMsg` (not `tea.KeyMsg`, which is an interface in v2). It has a `String()` method, so `key.Matches(msg, binding)` from `bubbles/v2/key` works directly. + - Resize arrives as `tea.WindowSizeMsg{Width, Height int}`. + - Available program options include `tea.WithFPS(int)`, `tea.WithInput`, `tea.WithOutput`, `tea.WithColorProfile`, `tea.WithWindowSize`, `tea.WithoutRenderer`. + - `lipgloss.Color("#RRGGBB")` returns a `color.Color`; `Style.Foreground` takes a `color.Color`. +- Do not abstract Bubble Tea behind a homegrown framework (§3). `internal/app` is a Bubble Tea model, plainly. +- Rendering never mutates game state (§37). `internal/render` takes values, returns a string, and holds no mutable package state. +- One animation clock. §36 says "prefer one animation clock and accumulated elapsed time rather than spawning multiple timing loops" — so there is a single `FrameMsg` at ~60 Hz and **no** separate `GravityMsg` loop; gravity comes from the same `dt`. Input is handled in `Update` on arrival and never waits for a tick (§8, §44). +- One logical block is 2 terminal columns × 1 terminal row (§5). Every glyph string the renderer emits for a cell is exactly 2 columns wide, in every mode. +- Glyphs are pinned (§49.4): blocks `██` (`[]` in ASCII), ghost `░░` (`··` in ASCII). Pieces use a bright foreground on filled glyphs — never a foreground+background pair. The active piece renders one step brighter than locked cells. +- Piece color identities (§26): I plasma cyan, J deep electric blue, L solar orange, O stellar gold, S alien green, T ultraviolet, Z supernova pink/red. +- Minimum usable terminal 40×24. Below it, show the too-small notice with current and needed sizes (§31). Resizing must never panic (§31). +- Small-terminal drop order (§49.3), in order as height runs out: title border, then mission control, then stat labels (values stay). NEXT never stacks above or below the board — it sits beside the board and truncates to 3 pieces at small sizes. Board and controls are the last two elements standing. +- CLI surface, exactly (§49.5): `cosmic-tetris`, `--seed N`, `--ascii`, `--no-fx`, `--reduced-motion`, `--help`. Nothing else. +- The §4 wide-layout mockup is intent, not geometry (§49.7). The ANSI-stripped golden tests are the binding layout contract. +- Do not build: networking, profiles, achievements, plugins, a database, high-score persistence. +- Commit after every task. Every task ends with `go test ./...` green and `go vet ./...` clean. + +## Review Focus + +Five things a real player will hit that no obvious task test covers. Each has a test added to the task that owns the code. + +1. **A zero size before the first `WindowSizeMsg`.** Bubble Tea's first `View()` can run at 0×0; centering math there divides and subtracts into negatives. It must render the too-small notice, not panic. → Task 5. +2. **The first frame's `dt`.** A zero-valued `LastFrame` makes the first elapsed time enormous (year 1 to now), which would instantly slam the first piece to the floor. The first `FrameMsg` must seed the clock and every `dt` must be clamped. → Task 6. +3. **Keys arriving faster than frames.** Three lefts inside one 16ms frame must all move the piece — the key path must not be gated on, batched into, or debounced by the frame clock. → Task 6. +4. **A score wider than its HUD column.** `9,999,999` and `LINES 1234` must not widen the stats column and shove the board sideways or off the right edge. → Task 4. +5. **Odd leftover space when centering.** Widths and heights that don't divide evenly (41 columns, 25 rows) must still place the board fully inside the frame with no line exceeding the terminal width. → Task 5. + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `cmd/cosmic-tetris/main.go` | Flag parsing, `Options`, mode detection, program start, exit codes. | +| `internal/app/keys.go` | `KeyMap` of `bubbles/v2/key.Binding`s, including WASD aliases and the help bindings. | +| `internal/app/messages.go` | `FrameMsg`, the frame-tick command, frame interval and dt-clamp constants. | +| `internal/app/model.go` | `Model`, `AppState`, `NewModel`, `Init`, `View`, and the `Model → render.Scene` projection. | +| `internal/app/update.go` | `Update`: key dispatch, frame advance, resize, pause/restart/help/quit state transitions. | +| `internal/render/palette.go` | `Mode`, `DetectMode`, `Glyphs`, `Palette`, per-kind styles, HUD/border/text styles. | +| `internal/render/board.go` | Board panel: locked cells, ghost, active piece, border box. | +| `internal/render/hud.go` | HOLD box, NEXT queue, stats block, mission-control line, controls line, overlay panels. | +| `internal/render/layout.go` | `Layout`, `Plan(width, height)`, breakpoints, drop order, too-small notice. | +| `internal/render/render.go` | `Scene`, `Overlay`, `Render(Scene) string` — assembles the panels per §37's order. | +| `internal/render/testdata/*.golden` | Golden ANSI-stripped frames for the seven §41 scenarios. | + +--- + +## Task 1: CLI, options, and a Bubble Tea program that runs and quits + +**Files:** +- Create: `cmd/cosmic-tetris/main.go`, `cmd/cosmic-tetris/main_test.go`, `internal/app/keys.go`, `internal/app/keys_test.go`, `internal/app/messages.go`, `internal/app/model.go`, `internal/app/update.go`, `internal/app/update_test.go` +- Modify: `go.mod` (add the three Charm modules) + +**Interfaces:** +- Consumes: `game.New`, `game.Game` (Plan 1). +- Produces: + ```go + // cmd/cosmic-tetris + type Options struct { + Seed int64 // 0 means "pick one from the clock at startup" + ASCII bool + NoFX bool + ReducedMotion bool + } + + // ParseArgs parses argv (without the program name). done==true means the + // program should exit 0 immediately (--help was requested). + func ParseArgs(args []string, out io.Writer) (opts Options, done bool, err error) + + // internal/app + type AppState uint8 + const ( + StatePlaying AppState = iota + StatePaused + StateGameOver + ) + + type Options struct { // app's copy; main converts into it + Seed int64 + ASCII bool + NoFX bool + ReducedMotion bool + } + + type Model struct { + Game *game.Game + Opts Options + + Width, Height int + State AppState + ShowHelp bool + + LastFrame time.Time + Keys KeyMap + Help help.Model + Mode render.Mode + + Status string // mission-control text; Plan 3 drives it + } + + func NewModel(o Options, mode render.Mode) Model + func (m Model) Init() tea.Cmd + func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) + func (m Model) View() tea.View + + type KeyMap struct { + Left, Right, SoftDrop, RotateCW, RotateCCW, HardDrop, + Hold, Pause, Restart, Help, Quit key.Binding + } + func DefaultKeyMap() KeyMap + func (k KeyMap) ShortHelp() []key.Binding + func (k KeyMap) FullHelp() [][]key.Binding + + // messages.go + const ( + FrameInterval = 16 * time.Millisecond // ~60 Hz (§36) + MaxFrameDelta = 100 * time.Millisecond // dt clamp (Review Focus #2) + ) + type FrameMsg struct{ Now time.Time } + func frameTick() tea.Cmd // tea.Tick(FrameInterval, func(t time.Time) tea.Msg { return FrameMsg{t} }) + ``` + +**Key bindings (§8), exact key strings:** + +| Binding | Keys | Help text | +|---|---|---| +| Left | `left`, `h`, `a` | `move spacecraft` | +| Right | `right`, `l`, `d` | (shares the row with Left) | +| SoftDrop | `down`, `j`, `s` | `accelerate doom` | +| RotateCW | `up`, `k`, `x`, `w` | `rotate geometry` | +| RotateCCW | `z` | `rotate other way` | +| HardDrop | `space` (`" "`) | `YEET` | +| Hold | `c` | `quantum storage` | +| Pause | `p` | `suspend spacetime` | +| Restart | `r` | `reboot universe` | +| Help | `?` | `close this nonsense` | +| Quit | `q`, `esc`, `ctrl+c` | `abandon mission` | + +Repeat movement while a key is held (§8) needs nothing special: terminals send auto-repeat as a stream of `KeyPressMsg`, and because Task 6 handles keys on arrival rather than on the tick, repeats already flow through. Do not implement a key-repeat timer. + +- [ ] **Step 1: Add the dependencies** + +```bash +go get charm.land/bubbletea/v2@v2.0.9 charm.land/lipgloss/v2@v2.0.6 charm.land/bubbles/v2@v2.2.1 +``` + +- [ ] **Step 2: Write the failing tests** + +`cmd/cosmic-tetris/main_test.go`: + +```go +func TestParseArgsDefaults(t *testing.T) +// ParseArgs(nil, io.Discard): no error, done false, all bools false, Seed == 0 + +func TestParseArgsEveryFlag(t *testing.T) +// []string{"--seed","1234","--ascii","--no-fx","--reduced-motion"} => +// Seed 1234, ASCII, NoFX, ReducedMotion all true + +func TestParseArgsHelpExitsQuietly(t *testing.T) +// []string{"--help"}: done true, err nil, and the buffer mentions +// "--seed", "--ascii", "--no-fx", "--reduced-motion" + +func TestParseArgsRejectsUnknownFlag(t *testing.T) +// []string{"--warp-drive"}: err != nil + +func TestParseArgsRejectsNonNumericSeed(t *testing.T) +// []string{"--seed","banana"}: err != nil +``` + +`internal/app/keys_test.go`: + +```go +func TestEveryDocumentedKeyMatchesItsBinding(t *testing.T) +// table of {key string, want *key.Binding} covering all keys in the table above; +// build tea.KeyPressMsg{Code: 'h'} style values (use tea.KeyPressMsg{Code: rune} +// for letters, and Code: tea.KeyLeft etc. for named keys) and assert +// key.Matches(msg, binding) is true + +func TestWASDAliasesMatchTheirDirections(t *testing.T) +// 'a'->Left, 'd'->Right, 's'->SoftDrop, 'w'->RotateCW + +func TestFullHelpCoversEveryBinding(t *testing.T) +// flattening FullHelp() yields every binding in KeyMap exactly once +``` + +`internal/app/update_test.go`: + +```go +func TestQuitKeyReturnsQuitCommand(t *testing.T) +// m.Update(tea.KeyPressMsg{Code: 'q'}) returns a non-nil Cmd whose result is tea.QuitMsg +``` + +- [ ] **Step 3: Run the tests and confirm they fail** + +Run: `go test ./cmd/... ./internal/app/ -v` +Expected: build failures — `undefined: ParseArgs`, `undefined: DefaultKeyMap`. + +- [ ] **Step 4: Implement** + +- `main.go`: `ParseArgs` with a `flag.FlagSet` in `ContinueOnError` mode and a custom `Usage` that prints the §46 CLI surface. `main` calls `ParseArgs(os.Args[1:], os.Stderr)`, exits 0 on `done`, exits 2 with the error on failure, resolves `Seed == 0` to `time.Now().UnixNano()` (the *only* clock read outside `app`), calls `render.DetectMode` (Task 2 — stub it as `render.ModeFull` for now and wire it in Task 2), builds the model, and runs `tea.NewProgram(m, tea.WithFPS(60))`. +- `keys.go`: `DefaultKeyMap` per the table, `ShortHelp`/`FullHelp`. +- `messages.go`: constants and `frameTick`. +- `model.go`: `NewModel`, `Init` returning `frameTick()`, and a `View` that for now returns `tea.NewView("cosmic tetris")` with `AltScreen = true`. +- `update.go`: handle `tea.WindowSizeMsg` (store size), `tea.KeyPressMsg` for Quit only, and `FrameMsg` by re-arming `frameTick()`. Everything else falls through unchanged. + +- [ ] **Step 5: Run the tests and confirm they pass** + +Run: `go test ./... -v && go vet ./... && go build ./...` +Expected: PASS, clean, builds. + +- [ ] **Step 6: See it run** + +Run: `go run ./cmd/cosmic-tetris` — expect an alt-screen with `cosmic tetris`, and `q` exits cleanly leaving the terminal usable. Then `go run ./cmd/cosmic-tetris --help` and `--seed 1234 --ascii`. + +- [ ] **Step 7: Commit** + +```bash +git add go.mod go.sum cmd internal/app +git commit -m "feat(app): CLI flags, key map, Bubble Tea skeleton with one frame clock" +``` + +--- + +## Task 2: Palette, glyphs, and render-mode detection + +**Files:** +- Create: `internal/render/palette.go`, `internal/render/palette_test.go` +- Modify: `cmd/cosmic-tetris/main.go` (call the real `DetectMode`) + +**Interfaces:** +- Consumes: `game.PieceKind`. +- Produces: + ```go + type Mode uint8 + const ( + ModeFull Mode = iota // Unicode + truecolor + ModeReduced // Unicode + 256 color + ModeASCII // ASCII glyphs, limited color + ) + func (m Mode) String() string + + // DetectMode picks a mode from the environment. forceASCII wins outright. + // COLORTERM of "truecolor" or "24bit" => ModeFull; a TERM containing + // "256color" => ModeReduced; TERM of "dumb" or empty => ModeASCII; + // anything else => ModeReduced. + func DetectMode(getenv func(string) string, forceASCII bool) Mode + + type Glyphs struct { + Block string // "██" / "[]" + Ghost string // "░░" / "··" + Empty string // " " in every mode + Border lipgloss.Border // DoubleBorder in Unicode modes, ASCIIBorder in ASCII + } + func GlyphsFor(m Mode) Glyphs + + type Palette struct{ mode Mode } + func PaletteFor(m Mode) Palette + + // Piece returns the style for a cell of kind k; active cells render one step + // brighter than locked ones (§49.4). + func (p Palette) Piece(k game.PieceKind, active bool) lipgloss.Style + func (p Palette) Ghost() lipgloss.Style + func (p Palette) Border() lipgloss.Style + func (p Palette) Label() lipgloss.Style // dim HUD labels + func (p Palette) Value() lipgloss.Style // bright HUD numbers + func (p Palette) Title() lipgloss.Style + func (p Palette) Status() lipgloss.Style // mission-control line + func (p Palette) Dim() lipgloss.Style // controls line + ``` + +**Colors (locked/active hex pairs, neon space palette per §26):** + +```text +I #22d3ee / #a5f3fc plasma cyan +J #3b82f6 / #93c5fd deep electric blue +L #f97316 / #fdba74 solar orange +O #fbbf24 / #fde68a stellar gold +S #22c55e / #86efac alien green +T #a855f7 / #d8b4fe ultraviolet +Z #f43f5e / #fda4af supernova pink/red +ghost #4b5563 dim slate +border #7c3aed deep violet (Plan 3 animates this) +``` + +In `ModeASCII`, `Piece` returns styles from a two-color set (bright white for active, plain default for locked) so nothing depends on 256-color support. + +- [ ] **Step 1: Write the failing tests** + +`internal/render/palette_test.go`: + +```go +func TestDetectMode(t *testing.T) +// table: {COLORTERM:"truecolor"}=>ModeFull; {TERM:"xterm-256color"}=>ModeReduced; +// {TERM:"dumb"}=>ModeASCII; {}=>ModeASCII; forceASCII=true with truecolor=>ModeASCII + +func TestEveryCellGlyphIsTwoColumnsWide(t *testing.T) +// for each mode: lipgloss.Width(g.Block)==2, Ghost==2, Empty==2 + +func TestASCIIGlyphsAreASCIIOnly(t *testing.T) +// GlyphsFor(ModeASCII): every rune in Block, Ghost, Empty is < 128 + +func TestPieceStyleDiffersPerKindAndBrightness(t *testing.T) +// in ModeFull: the rendered Block for all seven kinds yields seven distinct +// strings; Piece(k,true) differs from Piece(k,false) for every kind + +func TestStyledBlockKeepsItsWidth(t *testing.T) +// for each mode and kind: lipgloss.Width(Piece(k,true).Render(g.Block)) == 2 +``` + +- [ ] **Step 2: Run the tests and confirm they fail** + +Run: `go test ./internal/render/ -v` +Expected: build failure — `undefined: DetectMode`. + +- [ ] **Step 3: Implement `palette.go`, and call `DetectMode(os.Getenv, opts.ASCII)` from `main.go`** + +- [ ] **Step 4: Run the tests and confirm they pass** + +Run: `go test ./... -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/palette.go internal/render/palette_test.go cmd/cosmic-tetris/main.go +git commit -m "feat(render): neon palette, glyph sets, render-mode detection" +``` + +--- + +## Task 3: Board panel — locked cells, ghost, active piece, border + +**Files:** +- Create: `internal/render/board.go`, `internal/render/board_test.go` + +**Interfaces:** +- Consumes: `Palette`, `Glyphs`, `Mode` (Task 2); `game.Board`, `game.Piece`, `game.VisibleRows`, `game.HiddenRows` (Plan 1). +- Produces: + ```go + // BoardCells renders the 20 visible rows as plain cell rows (no border), + // each exactly BoardInnerWidth columns wide. Ghost cells are drawn only where + // the board is empty and the active piece is not (§10: the ghost must never + // obscure locked blocks or the active piece). + func BoardCells(b game.Board, active, ghost game.Piece, showPiece bool, p Palette, g Glyphs) []string + + const ( + BoardInnerWidth = game.BoardWidth * 2 // 20 + BoardInnerHeight = game.VisibleRows // 20 + BoardOuterWidth = BoardInnerWidth + 2 // 22, with border + BoardOuterHeight = BoardInnerHeight + 2 + ) + + // BoardPanel wraps BoardCells in the board border box. + func BoardPanel(rows []string, p Palette, g Glyphs) string + ``` + +**Design notes:** iterate visible rows `y = game.HiddenRows .. game.BoardHeight-1`. Precedence per cell: active piece (when `showPiece`) > locked cell > ghost > empty. Build each row into a `strings.Builder`; reuse one builder across rows. + +- [ ] **Step 1: Write the failing tests** + +`internal/render/board_test.go`: + +```go +func TestBoardCellsDimensions(t *testing.T) +// empty board, ModeFull: len(rows)==20 and lipgloss.Width(row)==20 for every row + +func TestBoardCellsDimensionsInEveryMode(t *testing.T) +// same assertion for ModeReduced and ModeASCII + +func TestLockedCellsAppearInTheRightPlace(t *testing.T) +// fill (0,21) and (9,21): stripped last row starts with the block glyph, +// ends with the block glyph, and the 16 middle columns are spaces + +func TestHiddenRowsAreNotRendered(t *testing.T) +// fill row 1 (hidden) only: every rendered row is blank + +func TestGhostDoesNotOverwriteLockedCells(t *testing.T) +// lock cells where the ghost would land, then render: the stripped output +// contains no ghost glyph at those columns + +func TestActiveDrawsOverGhost(t *testing.T) +// active and ghost at the same Y: those rows show block glyphs, not ghost glyphs + +func TestShowPieceFalseHidesActiveButKeepsLocked(t *testing.T) + +func TestBoardPanelIsTwentyTwoByTwentyTwo(t *testing.T) +// stripped BoardPanel: 22 lines, each 22 columns wide +``` + +Add a shared test helper (used again in Tasks 5 and 7): + +```go +// boardFromASCII builds a game.Board from 22 strings of 10 characters, +// '.' empty and any of "IJLOSTZ" a locked cell of that kind. +func boardFromASCII(t *testing.T, rows []string) game.Board +``` + +- [ ] **Step 2: Run the tests and confirm they fail** + +Run: `go test ./internal/render/ -run TestBoard -v` +Expected: build failure — `undefined: BoardCells`. + +- [ ] **Step 3: Implement `board.go`** + +- [ ] **Step 4: Run the tests and confirm they pass** + +Run: `go test ./... -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/board.go internal/render/board_test.go +git commit -m "feat(render): board panel with ghost and active-piece precedence" +``` + +--- + +## Task 4: HUD — hold, next, stats, status, controls, overlays + +**Files:** +- Create: `internal/render/hud.go`, `internal/render/hud_test.go` + +**Interfaces:** +- Consumes: Tasks 2–3. +- Produces: + ```go + const ( + SideColumnWidth = 10 // HOLD / NEXT / stats columns are exactly this wide + MiniGridWidth = 8 // a 4x2 piece preview: 8 columns, 2 rows + MiniGridHeight = 2 + ) + + // MiniPiece renders kind k as a MiniGridWidth × MiniGridHeight preview at + // spawn rotation, left-aligned and padded to exact size. + func MiniPiece(k game.PieceKind, p Palette, g Glyphs) []string + + // HoldBox renders the HOLD label (when showLabel) plus the held piece, or an + // empty grid when hold is nil. + func HoldBox(hold *game.PieceKind, showLabel bool, p Palette, g Glyphs) []string + + // NextBox renders up to count upcoming pieces (§49.3 truncates to 3 at small + // sizes) with a blank row between previews. + func NextBox(next []game.PieceKind, count int, showLabel bool, p Palette, g Glyphs) []string + + // StatsBox renders SCORE / LINES / LEVEL. With showLabels false, only the + // values are rendered (§49.3). Values are clamped to SideColumnWidth columns. + func StatsBox(score, lines, level int, showLabels bool, p Palette) []string + + // StatusLine renders the mission-control channel: "☄ MISSION CONTROL: TEXT", + // truncated to width. In ASCII mode the comet becomes ">". + func StatusLine(text string, width int, p Palette, m Mode) string + + // ControlsLine renders the one-line control hint, dropping items from the + // right until it fits width. + func ControlsLine(width int, p Palette, m Mode) string + + // TitleBar renders "╭─ ✦ COSMIC TETRIS ── LOCAL UNIVERSE ─╮" style + // chrome at the given width (ASCII mode uses "+-" characters). + func TitleBar(seed int64, width int, p Palette, g Glyphs, m Mode) string + + // Panel renders a centered bordered overlay from title plus body lines. + func Panel(title string, body []string, p Palette, g Glyphs) string + + func PausePanel(p Palette, g Glyphs) string // §30 copy + func GameOverPanel(score, lines, level int, p Palette, g Glyphs) string // §28 copy + func HelpPanel(body string, p Palette, g Glyphs) string // §39, body from bubbles help + ``` + +**Copy, verbatim from the spec:** +- Pause panel: title `TEMPORAL SUSPENSION`, body `SPACE IS PAUSED`, blank, `p resume`. +- Game over panel: title `UNIVERSE EXPIRED`, then `SCORE `, `LINES `, `LEVEL `, blank, `r REBOOT UNIVERSE`, `q ACCEPT COSMIC DEATH`, and the subtitle `CAUSE: EXCESSIVE GEOMETRY`. Format the score with thousands separators (`483,200`). +- Help panel title: `FLIGHT MANUAL`, body from `help.Model.View(KeyMap)` in the app layer — `HelpPanel` just frames a pre-rendered string. +- Controls line: `←→ move ↑ rotate ↓ descend SPACE YEET C hold ? help` (ASCII mode: `<> move ^ rotate v descend SPACE YEET C hold ? help`). +- Status line default text: `NOMINALISH`. + +- [ ] **Step 1: Write the failing tests** + +`internal/render/hud_test.go`: + +```go +func TestMiniPieceIsExactlyEightByTwo(t *testing.T) +// for all seven kinds in all three modes: 2 rows, each 8 columns wide + +func TestHoldBoxEmptyKeepsItsFootprint(t *testing.T) +// HoldBox(nil, true, ...) and HoldBox(&kindT, true, ...) have equal line counts +// and equal per-line widths + +func TestNextBoxHonoursCount(t *testing.T) +// count 5 shows five previews; count 3 shows three; a next slice shorter than +// count renders only what exists without panicking + +func TestStatsBoxLabelsCanBeDropped(t *testing.T) +// showLabels true contains "SCORE"; false does not but still contains the digits + +// Review Focus #4 +func TestStatsBoxWidthIsFixedForHugeValues(t *testing.T) +// score 9_999_999, lines 1234, level 99: every stripped line is at most +// SideColumnWidth columns wide, in both label modes + +func TestStatusLineTruncatesToWidth(t *testing.T) +// a 200-character message at width 30: stripped width == 30 exactly + +func TestStatusLineIsASCIIInASCIIMode(t *testing.T) +// every rune < 128 + +func TestControlsLineNeverExceedsWidth(t *testing.T) +// widths 20, 30, 40, 64, 120: stripped width <= the requested width, and at +// width 40+ the output still mentions "YEET" + +func TestPanelsContainTheirSpecCopy(t *testing.T) +// PausePanel contains "TEMPORAL SUSPENSION" and "SPACE IS PAUSED"; +// GameOverPanel(483200,127,13,...) contains "UNIVERSE EXPIRED", "483,200", +// "127", "13", "REBOOT UNIVERSE", "ACCEPT COSMIC DEATH", "CAUSE: EXCESSIVE GEOMETRY" + +func TestPanelsAreRectangular(t *testing.T) +// for each panel: all stripped lines have equal width +``` + +- [ ] **Step 2: Run the tests and confirm they fail** + +Run: `go test ./internal/render/ -run 'TestMini|TestHold|TestNext|TestStats|TestStatus|TestControls|TestPanels' -v` +Expected: build failure — `undefined: MiniPiece`. + +- [ ] **Step 3: Implement `hud.go`** + +- [ ] **Step 4: Run the tests and confirm they pass** + +Run: `go test ./... -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/hud.go internal/render/hud_test.go +git commit -m "feat(render): HUD boxes, status and controls lines, overlay panels" +``` + +--- + +## Task 5: Layout planner and the full frame assembler + +**Files:** +- Create: `internal/render/layout.go`, `internal/render/layout_test.go`, `internal/render/render.go`, `internal/render/render_test.go` + +**Interfaces:** +- Consumes: Tasks 2–4. +- Produces: + ```go + type LayoutKind uint8 + const ( + LayoutTooSmall LayoutKind = iota + LayoutSmall + LayoutMedium + LayoutWide + ) + + const ( + MinWidth = 40 + MinHeight = 24 + ) + + type Layout struct { + Kind LayoutKind + ShowTitle bool // §49.3 drop 1 + ShowMissionControl bool // §49.3 drop 2 + ShowStatLabels bool // §49.3 drop 3 + ShowHold bool + NextCount int // 5 wide/medium, 3 small + SideBySideStats bool // stats beside the board (wide) vs above it (medium/small) + Width, Height int + } + + // Plan chooses a layout for the terminal size. Breakpoints: + // width < MinWidth || height < MinHeight => LayoutTooSmall + // width >= 64 && height >= 28 => LayoutWide + // width >= 48 && height >= 26 => LayoutMedium + // otherwise => LayoutSmall + // Then elements are dropped by §49.3 order until the content fits Height: + // title first, then mission control, then stat labels. + func Plan(width, height int) Layout + + // TooSmallNotice renders the §31 notice with the current and needed sizes. + func TooSmallNotice(width, height int, p Palette) string + + type Overlay uint8 + const ( + OverlayNone Overlay = iota + OverlayPause + OverlayGameOver + OverlayHelp + ) + + type Scene struct { + Board game.Board + Active game.Piece + Ghost game.Piece + ShowPiece bool + + Hold *game.PieceKind + Next []game.PieceKind + + Score, Lines, Level, Combo int + Seed int64 + + Status string + Overlay Overlay + HelpBody string // pre-rendered by the app from bubbles help + + Mode Mode + Width, Height int + } + + // Render produces the whole frame. It follows §37's order, never mutates its + // input, and guarantees: no line wider than Scene.Width, and no more lines + // than Scene.Height. + func Render(s Scene) string + ``` + +**Design notes:** compute the required height as `BoardOuterHeight (22) + 1 controls + optional title + optional mission control`; drop in §49.3 order while it exceeds `Height`. NEXT and HOLD always live beside the board, never above or below it (§49.3). Assemble with `lipgloss.JoinHorizontal`/`JoinVertical` and center the board block with `lipgloss.Place`. Overlays are drawn last, centered over the assembled frame — Plan 3 keeps this seam by compositing FX before overlays. + +**A note on the `FX` seam:** Plan 3 adds one field, `FX *fx.Overlay`, to `Scene` and one compositing step to `Render`. Do not add it now, but keep `Render` structured as "build panels → join → composite → overlay" so that step has a place to go. + +- [ ] **Step 1: Write the failing tests** + +`internal/render/layout_test.go`: + +```go +func TestPlanBreakpoints(t *testing.T) +// table: (100,40)=>LayoutWide; (64,28)=>LayoutWide; (50,26)=>LayoutMedium; +// (40,24)=>LayoutSmall; (39,24)=>LayoutTooSmall; (40,23)=>LayoutTooSmall + +// Review Focus #1 +func TestPlanAtZeroSizeIsTooSmall(t *testing.T) +// (0,0) and (0,50) and (50,0) => LayoutTooSmall, no panic + +func TestSmallLayoutDropsElementsInSpecOrder(t *testing.T) +// at (40,24): ShowTitle false, ShowMissionControl false, NextCount 3. +// at (48,26): ShowStatLabels true. Assert the drop order by walking heights +// 24..30 at width 64 and checking that ShowTitle never becomes true before +// ShowMissionControl does, which never becomes true before ShowStatLabels does. + +func TestTooSmallNoticeStatesBothSizes(t *testing.T) +// TooSmallNotice(34,19,...) contains "THIS UNIVERSE IS TOO SMALL", +// "34", "19", "40", "24" +``` + +`internal/render/render_test.go`: + +```go +func TestRenderFitsTheFrame(t *testing.T) +// helper demoScene(w,h) built from boardFromASCII; for sizes +// (40,24),(48,26),(64,28),(80,30),(120,50),(200,60) and all three modes: +// every stripped line width <= w, and the stripped line count <= h + +// Review Focus #5 +func TestRenderFitsOddSizes(t *testing.T) +// same assertions for (41,25),(43,27),(65,29),(81,31),(99,45) + +func TestRenderNeverPanicsAcrossEverySize(t *testing.T) +// nested loop w in 1..120, h in 1..48, ModeFull: call Render and assert no panic +// and the frame-fitting invariants. Keep it fast: no golden comparison. + +func TestRenderBelowMinimumShowsTheNotice(t *testing.T) +// (30,15): output contains "TOO SMALL" and does not contain the board border glyph + +func TestRenderContainsTheBoardAtEverySize(t *testing.T) +// for each size at or above the minimum, the stripped output contains a run of +// 20 board-interior columns (assert BoardOuterWidth appears by locating a line +// with the border corner glyph and checking its width) + +func TestRenderDoesNotMutateItsScene(t *testing.T) +// snapshot the Scene (including a deep compare of Board and Next) before and +// after Render; assert equality with reflect.DeepEqual + +func TestOverlaysReplaceNothingBelowThem(t *testing.T) +// with OverlayPause: output still has the same line count as OverlayNone +// and contains "TEMPORAL SUSPENSION" +``` + +- [ ] **Step 2: Run the tests and confirm they fail** + +Run: `go test ./internal/render/ -run 'TestPlan|TestTooSmall|TestRender|TestOverlays' -v` +Expected: build failure — `undefined: Plan`, `undefined: Render`. + +- [ ] **Step 3: Implement `layout.go` and `render.go`** + +- [ ] **Step 4: Run the tests and confirm they pass** + +Run: `go test ./... -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/layout.go internal/render/layout_test.go internal/render/render.go internal/render/render_test.go +git commit -m "feat(render): adaptive layout planner and frame assembler" +``` + +--- + +## Task 6: Wire the model — play, pause, restart, help, game over + +**Files:** +- Modify: `internal/app/model.go`, `internal/app/update.go` +- Test: `internal/app/update_test.go` (extend) + +**Interfaces:** +- Consumes: `render.Render`, `render.Scene`, `render.Plan` (Task 5); the engine's input methods (Plan 1). +- Produces: + ```go + // Scene projects the model into a render.Scene. Pure: no mutation. + func (m Model) Scene() render.Scene + + // advance applies clamped elapsed time to the game and returns the events. + // Plan 3 feeds those events to the FX world here. + func (m *Model) advance(now time.Time) []game.Event + ``` + +**Update behavior, exactly:** + +- `tea.WindowSizeMsg`: store `Width`/`Height`. No other side effects. +- `tea.KeyPressMsg`, handled immediately, before any tick bookkeeping: + - Quit → `tea.Quit`. + - Help → toggle `ShowHelp`. While `ShowHelp` is true, only Help, Quit, and Pause are live. + - Pause → toggle `StatePlaying` ⇄ `StatePaused` (ignored in `StateGameOver`). + - Restart → `m.Game.Restart(newSeed)` where `newSeed` is the current `m.Game.Seed` (a deterministic reboot of the same universe; the seed is a CLI contract), then `State = StatePlaying`, clear `ShowHelp`. + - Movement / rotation / drops / hold → only in `StatePlaying`; call the matching engine method and keep the returned events (Plan 3 consumes them). +- `FrameMsg`: + 1. If `LastFrame.IsZero()`, set `LastFrame = msg.Now` and re-arm the tick without advancing anything (Review Focus #2). + 2. `dt := msg.Now.Sub(m.LastFrame)`; clamp to `[0, MaxFrameDelta]`; `LastFrame = msg.Now`. + 3. In `StatePlaying`, `events := m.Game.Advance(dt)`; if any event is `EventGameOver`, set `State = StateGameOver`. + 4. Re-arm `frameTick()`. +- `View`: if `ShowHelp`, overlay `OverlayHelp` with `m.Help.View(m.Keys)` as the body; else pick the overlay from `State`. Always `AltScreen = true`. + +- [ ] **Step 1: Write the failing tests** + +Extend `internal/app/update_test.go`. Helper: + +```go +// press feeds a key to the model and returns the updated Model. +func press(t *testing.T, m Model, code rune) Model +// frame feeds a FrameMsg at m.LastFrame + d. +func frame(t *testing.T, m Model, d time.Duration) Model +// newTestModel returns a model sized 80x30 with a fixed seed whose clock is +// already seeded (one zero-delta frame already applied). +func newTestModel(t *testing.T) Model +``` + +```go +// Review Focus #2 +func TestFirstFrameSeedsTheClockWithoutAdvancing(t *testing.T) +// NewModel then Update(FrameMsg{time.Now()}): Game.Snapshot() is unchanged from +// a fresh game and LastFrame is non-zero + +func TestFrameDeltaIsClamped(t *testing.T) +// a FrameMsg 10 seconds after the previous one advances the game by no more +// than MaxFrameDelta worth of gravity: assert the active piece's Y moved by 0 +// (100ms < the 800ms level-1 interval) + +// Review Focus #3 +func TestThreeKeysInsideOneFrameAllApply(t *testing.T) +// record Active.X, press left three times with no FrameMsg between them: +// X decreased by exactly 3 + +func TestMovementKeysDriveTheEngine(t *testing.T) +// left/right change X; 'x' changes Rotation; 'z' rotates the other way; +// down increases Score by 1; space locks a piece (board gains 4 filled cells) + +func TestHoldKeyStoresAPiece(t *testing.T) +// 'c': Game.Hold becomes non-nil + +func TestPauseFreezesGameplay(t *testing.T) +// 'p' then a 1-second-worth sequence of clamped frames: Game.Snapshot() unchanged; +// 'p' again then frames: the piece falls + +func TestPausedInputIsIgnored(t *testing.T) +// while paused, left/right/space/hold change nothing + +func TestHelpTogglesAndBlocksGameplayKeys(t *testing.T) +// '?' sets ShowHelp; left does not move the piece; '?' clears it; left works again + +func TestRestartResetsToAFreshUniverse(t *testing.T) +// play some frames and a hard drop, then 'r': Score 0, Lines 0, empty board, +// State StatePlaying, same Seed + +func TestGameOverTransitionsStateAndStopsGameplay(t *testing.T) +// force game over by filling the model's board (m.Game.Board = boardFromASCII-like +// solid rows) and driving frames: State becomes StateGameOver; further left +// presses change nothing; 'r' restarts + +func TestViewIsAltScreenAndNonEmptyInEveryState(t *testing.T) +// for StatePlaying, StatePaused, StateGameOver and ShowHelp: View().AltScreen is +// true and View().Content is non-empty + +func TestViewAtZeroSizeDoesNotPanic(t *testing.T) +// a model with Width 0, Height 0: View() returns the too-small notice +``` + +- [ ] **Step 2: Run the tests and confirm they fail** + +Run: `go test ./internal/app/ -v` +Expected: FAIL — keys not wired, no clamping, `undefined: (Model).Scene`. + +- [ ] **Step 3: Implement the model wiring** + +- [ ] **Step 4: Run the tests and confirm they pass** + +Run: `go test ./... -v && go vet ./...` +Expected: PASS, clean. + +- [ ] **Step 5: Play it** + +Run: `go run ./cmd/cosmic-tetris --seed 1234`. Confirm by hand: pieces fall and speed up as levels rise, all documented keys work, held keys repeat, hard drop lands instantly, ghost shows the landing spot, a completed row disappears, `p` pauses, `?` opens the manual, `r` restarts, `q` exits cleanly. Resize the window while playing — repeatedly, including down past 40×24 and back — and confirm it never garbles or crashes. Then `go run ./cmd/cosmic-tetris --ascii` and confirm it is playable with ASCII glyphs. + +- [ ] **Step 6: Commit** + +```bash +git add internal/app +git commit -m "feat(app): playable game loop with pause, restart, help, game over" +``` + +--- + +## Task 7: Golden layout snapshot tests + +**Files:** +- Create: `internal/render/golden_test.go`, `internal/render/testdata/*.golden` + +**Interfaces:** +- Consumes: `Render`, `Scene` (Task 5). +- Produces: the binding layout contract for §41 (and for Plan 3, which must not break it). + +**Design notes:** golden tests compare `ansi.Strip(Render(scene))` so they test geometry and copy, not color. Every scene is built from a fixed board literal — never from a live game — so they are stable. Add `-update` to rewrite. + +The seven scenarios from §41: `wide`, `medium`, `small`, `pause`, `gameover`, `help`, `ascii`. + +- [ ] **Step 1: Write the failing tests** + +`internal/render/golden_test.go`: + +```go +var updateGolden = flag.Bool("update", false, "rewrite golden files") + +// fixtureScene returns a deterministic scene: a board with a partial stack +// (built with boardFromASCII from Task 3), an active T at (4,8) with its ghost +// at the landing row, a held O, a next queue of I,J,L,S,Z, score 129340, +// lines 42, level 7, combo 3, seed 0x7F3A, status "GRAVITY TAX INCREASED". +func fixtureScene(mode Mode, w, h int, overlay Overlay) Scene + +func TestGoldenLayouts(t *testing.T) +// table of the seven cases: +// wide ModeFull 100x36 OverlayNone +// medium ModeFull 56x27 OverlayNone +// small ModeFull 40x24 OverlayNone +// pause ModeFull 100x36 OverlayPause +// gameover ModeFull 100x36 OverlayGameOver +// help ModeFull 100x36 OverlayHelp (HelpBody: a fixed 6-line string) +// ascii ModeASCII 100x36 OverlayNone +// each subtest: got := ansi.Strip(Render(scene)); compare to +// testdata/.golden, writing it when -update is set. +// Then assert the structural invariants on `got` regardless of the golden: +// - no line exceeds the scene width +// - line count does not exceed the scene height +// - the board's 22-column border box appears intact (find the line containing +// the top-left border corner and assert its width and that the matching +// bottom corner line exists exactly 21 lines later) +// - the HUD does not intrude into the board interior: for every board row, +// the 20 interior columns contain only block, ghost, or space glyphs +``` + +- [ ] **Step 2: Run the tests and confirm they fail** + +Run: `go test ./internal/render/ -run TestGoldenLayouts -v` +Expected: FAIL — golden files missing. + +- [ ] **Step 3: Record the goldens and read every one of them** + +Run: `go test ./internal/render/ -run TestGoldenLayouts -update && go test ./internal/render/ -run TestGoldenLayouts -v` + +Then open all seven files and check them by eye against §4's intent: the board is the visual center, nothing overlaps, the HUD reads cleanly, the small layout has dropped the title and mission control, the ASCII one contains no Unicode. Fix the renderer and re-record if any of that is wrong — a golden file you have not read is not a test. + +- [ ] **Step 4: Confirm the goldens are stable** + +Run: `go test ./internal/render/ -count=3 -v` +Expected: PASS all three runs (catches map-iteration or time-dependent output). + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/golden_test.go internal/render/testdata +git commit -m "test(render): golden ANSI-stripped layout snapshots for seven scenarios" +``` + +--- + +## Done when + +- `go test ./... -v` passes, `go vet ./...` is silent, `go build ./...` succeeds. +- `cosmic-tetris`, `--seed N`, `--ascii`, `--no-fx`, `--reduced-motion`, and `--help` all run. (`--no-fx` and `--reduced-motion` parse and are stored but have nothing to suppress until Plan 3 — that is expected.) +- The game is playable start to game over: gravity accelerates, hold works once per piece, the ghost is accurate, the next queue shows five, lines clear, pause and restart work, the help overlay opens. +- Controls feel immediate: a key press moves the piece without waiting for the next frame. +- Resizing live — including below 40×24 and back — never panics and never garbles the frame. +- Seven golden layout files exist, have been read by a human, and pass three consecutive runs. diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/P1-proportion-cosmic-51/2026-09-17-cosmic-tetris-3-cosmic-fx.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/P1-proportion-cosmic-51/2026-09-17-cosmic-tetris-3-cosmic-fx.md new file mode 100644 index 000000000..b31b06c02 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/P1-proportion-cosmic-51/2026-09-17-cosmic-tetris-3-cosmic-fx.md @@ -0,0 +1,1190 @@ +# Cosmic Tetris — Plan 3: Cosmic Effects Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the universe lose its shit around the player — starfield, animated border, ion trails, hard-drop impact, particle physics, supernova line clears, shockwaves, hyperdrive, four-line spectacle, combo escalation, level-up anomalies, mission control commentary, boot sequence, and a game-over black hole — without a single effect touching game state or delaying input. + +**Architecture:** A new package `internal/fx` runs an independent simulation: it observes `game.Event` values and elapsed `dt`, and produces an `fx.Overlay` — two sparse glyph layers (one board-local, one frame-global) plus a handful of scalars (shake offset, border phase and energy, banner text). `internal/render` composites that overlay; it is the only place FX becomes pixels. FX owns a second `*rand.Rand`, never the game's. `internal/flavor` is a separate tiny state machine for the mission-control channel. Data flows one way: `game → events → fx → overlay → render`. Nothing flows back. + +**Tech Stack:** Go 1.26, `charm.land/lipgloss/v2` (color math in render only), `charm.land/bubbles/v2` (boot spinner, help), `charm.land/bubbletea/v2`. + +**Spec:** `design.md` (this plan implements §9 visual effect, §14–§29, §30 star drift, §43, §44, §45, §42 Phases 3–5, and pinned decisions §49.4, §49.5) + +## Global Constraints + +- **FX may observe game events. It may never modify `GameState`** (§14). `internal/fx` never accepts a `*game.Game`; it takes `game.Event` values and read-only copies. There is a grep-based check for this in Task 1. +- **Two RNGs, never crossed** (§35, §49.6). `fx.World` and `flavor.Channel` each own an independent `*rand.Rand` seeded from the FX seed. Nothing in `internal/fx` or `internal/flavor` may reach the game's generator, and the game's logical state must be identical with FX on and FX off. +- Import direction is fixed: `render` imports `fx`; `fx` must never import `render`. FX therefore carries its own `ASCII bool` in `Config` rather than reusing `render.Mode`. +- The restraint rules in §44 are hard requirements, each with a test: never obscure the active piece; never make controls lag; never delay gameplay for animation; never require reading flavor text; never let random effects alter gameplay; never let screen shake exceed roughly one cell; never let particles permanently alter the rendered board; never let comedy overwhelm playability. +- Performance rules (§38): no goroutine per particle or per frame, no filesystem access during gameplay, no synchronous per-frame logging. A few hundred particles must be trivial; `MaxParticles = 600` is the hard cap and emitters drop overflow rather than growing the slice. +- Board readability is sacred (§15, §21). Background starfield never draws inside the board interior; board-local FX never covers the active piece. +- Glyph pinning (§49.4) still holds, and every FX glyph must have an ASCII fallback — nothing in `ModeASCII` may emit a rune ≥ 128. +- `--no-fx` disables the FX world entirely (`Compose()` returns nil): no starfield, particles, trails, shake, banners, or hyperdrive. Mission control and the static board border remain, because they are flavor and chrome, not motion. The boring mode must still be a good game (§32). +- `--reduced-motion` suppresses exactly three things (§49.5): screen shake, hyperdrive acceleration, and shockwaves. Color, trails, and particles stay. +- Effect timings are spec constants, not taste: hold streak ~120ms (§9), trails 100–160ms (§17), shake 80ms (§18), line clear 220ms total with phases at 70/150/220ms (§19), four-line banner 700ms (§20), shockwave 300ms (§24), hyperdrive envelope 0/50/100/500/800/1100ms (§16), game over 0–300/300–900/900–1300ms (§28), boot ~1s (§29). +- The golden layout tests from Plan 2 must keep passing unchanged with FX off. Do not renegotiate that layout contract. +- Commit after every task. Every task ends with `go test ./...` green and `go vet ./...` clean. + +## Review Focus + +Five failure modes this layer invites that no obvious task test covers. Each has a test added to the task that owns the code. + +1. **A particle at a nonsense coordinate** — a NaN velocity, a position of 1e9, a negative row — must be dropped at compositing time, not indexed into a layer slice. → Task 1. +2. **Resize while animations are running.** Layers sized to the old frame, particles beyond the new bounds, a banner centered on a width that no longer exists: resizing must not panic and must not leak the old buffers. → Task 1. +3. **A huge `dt`** (a backgrounded terminal, a suspended process) must not run a thousand simulation substeps or spawn a starfield's worth of new stars. FX clamps `dt` itself, independent of the app's clamp. → Task 1. +4. **FX drawing over the active piece.** §44's first rule needs an actual test: with the board layer deliberately filled everywhere, the composited frame must still show the active piece's cells. → Task 3. +5. **FX changing the game.** Two runs with the same seed and the same canned input/dt script — one with FX enabled, one with `--no-fx` — must produce byte-identical `game.Snapshot()` output. → Task 9. + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `internal/fx/world.go` | `Config`, `World`, `NewWorld`, `Resize`, `Handle`, `Advance`, `Compose`, `Intensity`, the dt clamp, the particle cap. | +| `internal/fx/layer.go` | `Cell`, `Layer` (sparse glyph buffer with bounds-safe writes), `Overlay`. | +| `internal/fx/particle.go` | `Particle`, the physics step, emitters (burst, debris, eruption), glyph sets. | +| `internal/fx/starfield.go` | Three-layer starfield, level scaling, hyperdrive envelope, shooting stars. | +| `internal/fx/events.go` | Event ingestion: which `game.Event` triggers which effect, with the trigger tables. | +| `internal/fx/trails.go` | Piece ion trails and the hard-drop vertical trail. | +| `internal/fx/clear.go` | Line-clear supernova phases, shockwaves, four-line sequence, banners. | +| `internal/fx/shake.go` | Deterministic screen-shake pattern and the border energy envelope. | +| `internal/fx/anomaly.go` | Level-up notice, combo escalation state, HUD pulse. | +| `internal/fx/gameover.go` | Freeze, inward collapse, black-hole art, panel gating. | +| `internal/flavor/messages.go` | `Channel`: mission-control text, priorities, dwell times, easter eggs. | +| `internal/render/composite.go` | Applies an `fx.Overlay` to the assembled frame: layers, shake, border gradient, banners. | +| `internal/render/boot.go` | Boot-sequence screen (§29). | +| `internal/app/*` | Wiring: FX world lifecycle, event forwarding, `StateBoot`, flavor channel. | + +--- + +## Task 1: FX core — config, layers, overlay, world clock, particle physics + +**Files:** +- Create: `internal/fx/world.go`, `internal/fx/layer.go`, `internal/fx/particle.go`, `internal/fx/layer_test.go`, `internal/fx/world_test.go`, `internal/fx/particle_test.go` + +**Interfaces:** +- Consumes: `game.Event`, `game.Piece`, `game.PieceKind` (Plan 1); `render.BoardInnerWidth`/`VisibleRows` values are *not* imported — FX takes board dimensions as parameters. +- Produces: + ```go + type Config struct { + Enabled bool // false for --no-fx + ReducedMotion bool // --reduced-motion + ASCII bool // --ascii or detected ASCII mode + } + + type Cell struct { + Rune rune // zero value means "nothing here" + Hex string // "#rrggbb"; empty means "use the default FX color" + Bold bool + } + + type Layer struct{ /* w, h, cells []Cell */ } + func NewLayer(w, h int) *Layer + func (l *Layer) Size() (w, h int) + func (l *Layer) Resize(w, h int) // reallocates only when the size changed; clears + func (l *Layer) Clear() + func (l *Layer) InBounds(x, y int) bool + func (l *Layer) Set(x, y int, c Cell) // no-op outside bounds + func (l *Layer) SetF(x, y float64, c Cell) // rounds; no-op on NaN/Inf/out of bounds + func (l *Layer) SetPair(cellX, y int, pair string, hex string, bold bool) // 2-column block + func (l *Layer) At(x, y int) Cell + + type Overlay struct { + Board *Layer // board interior: boardW terminal columns × boardH rows + Global *Layer // whole frame + + ShakeX, ShakeY int // always within [-1,1] (§44) + BorderPhase float64 // 0..1, slow hue walk (§25) + BorderEnergy float64 // 0..1, event excitement + HUDPulse float64 // 0..1, combo pulse (§21) + + Banner string // §20 four-line banner, "" when none + BannerSub string + Notice []string // §22 level-up notice lines, nil when none + + ShowGameOverPanel bool // §28: the final panel waits for the collapse + Frozen bool // §28 phase 1: the board is held still + } + + const ( + MaxParticles = 600 + MaxFXDelta = 100 * time.Millisecond // FX's own dt clamp (Review Focus #3) + ParticleDrag = 0.94 // per 1/60s + ParticleGravity = 14.0 // rows per second squared + ) + + type Space uint8 + const (SpaceBoard Space = iota; SpaceFrame) + + type Particle struct { + X, Y float64 + VX, VY float64 + AX, AY float64 + Life float64 // seconds remaining + MaxLife float64 + Glyph rune + Hex string + Brightness float64 // 0..1, recomputed from Life/MaxLife each step + Space Space + } + + func (w *World) Emit(p Particle) // dropped silently when at MaxParticles + func (w *World) ParticleCount() int + func stepParticles(ps []Particle, dt float64) []Particle // in place, compacting dead ones + + type World struct{ /* unexported */ } + func NewWorld(seed int64, cfg Config) *World + func (w *World) Resize(frameW, frameH, boardW, boardH int) + func (w *World) Advance(dt time.Duration) + func (w *World) Handle(ev game.Event) // Task 4 onward fill in the trigger table + func (w *World) Compose() *Overlay // nil when !cfg.Enabled + func (w *World) Intensity() float64 // 0..1: how excited the universe currently is + func (w *World) Elapsed() time.Duration + ``` + +**Design notes:** `Advance` clamps `dt` to `MaxFXDelta`, accumulates elapsed time, steps particles once with the clamped `dt` (a single step — this is a terminal, not a physics engine), then advances the per-effect timers that later tasks add. `Compose` clears both layers and redraws from current state every frame; it never accumulates, which is how §44's "particles never permanently alter the rendered board" is satisfied structurally. `stepParticles` implements §23 exactly: `pos += v*dt; v += a*dt; v *= drag^(dt*60); life -= dt`, dropping particles whose life is spent, whose coordinates are non-finite, or that are far outside the viewport. + +- [ ] **Step 1: Write the failing tests** + +`internal/fx/layer_test.go`: + +```go +func TestLayerWritesAndReads(t *testing.T) +func TestLayerSetOutsideBoundsIsANoOp(t *testing.T) +// (-1,0),(0,-1),(w,0),(0,h),(1000,1000): no panic, layer stays empty + +// Review Focus #1 +func TestLayerSetFRejectsNonFiniteAndAbsurdCoordinates(t *testing.T) +// math.NaN(), math.Inf(1), math.Inf(-1), 1e9, -1e9 in either coordinate: +// no panic, nothing written + +func TestLayerSetPairWritesTwoColumns(t *testing.T) +// SetPair(3, 5, "██", "#ffffff", true): columns 6 and 7 of row 5 are set + +func TestLayerSetPairAtTheRightEdgeClips(t *testing.T) +// a layer of odd width: SetPair on the last cell writes what fits, no panic + +// Review Focus #2 +func TestLayerResizeReallocatesAndClears(t *testing.T) +// write, Resize to a bigger size: empty and correctly sized; Resize smaller: +// empty and correctly sized; Resize to the same size: still valid and cleared; +// Resize(0,0) then Set: no panic +``` + +`internal/fx/particle_test.go`: + +```go +func TestParticleMovesUnderVelocityAndGravity(t *testing.T) +// one particle, VY 0, gravity accel: after 0.5s Y increased and VY > 0 + +func TestParticleDragReducesSpeed(t *testing.T) +// VX 10, no accel: after 1s |VX| < 10 + +func TestParticlesDieWhenLifeRunsOut(t *testing.T) +// Life 0.1: after stepping 0.2s the slice is empty + +func TestParticlesFarOutsideTheViewportAreDropped(t *testing.T) +// Y = 10000: dropped on the next step + +func TestNonFiniteParticlesAreDropped(t *testing.T) +// VX NaN: dropped rather than propagating NaN into X + +func TestBrightnessTracksRemainingLife(t *testing.T) +// Brightness ~1 at spawn, < 0.5 past the halfway point, >= 0 + +func TestEmitRespectsTheParticleCap(t *testing.T) +// emit 5000: ParticleCount() == MaxParticles, no panic, no reallocation loop +``` + +`internal/fx/world_test.go`: + +```go +func TestDisabledWorldComposesNothing(t *testing.T) +// Config{Enabled:false}: Compose() == nil after Advance and Handle calls + +func TestComposeSizesLayersToTheFrame(t *testing.T) +// Resize(100,40,20,20) then Compose(): Global size 100x40, Board size 20x20 + +// Review Focus #2 +func TestResizeDuringAnimationIsSafe(t *testing.T) +// emit 200 particles across the frame, Resize(20,10,20,20), Advance(16ms), +// Compose(): no panic, layer sizes updated + +// Review Focus #3 +func TestHugeDtIsClamped(t *testing.T) +// Advance(10*time.Second): Elapsed() advanced by at most MaxFXDelta, and the +// call completes within a 1s test deadline + +func TestFXNeverTakesAPointerToGame(t *testing.T) +// go/parser over internal/fx/*.go: no *game.Game appears in any signature. +// Simplest form: read every .go file and assert !strings.Contains(src, "*game.Game") + +func TestTwoWorldsWithTheSameSeedAgree(t *testing.T) +// two worlds, same seed, same Advance/Handle script: Compose() layers are +// cell-for-cell equal +``` + +- [ ] **Step 2: Run the tests and confirm they fail** + +Run: `go test ./internal/fx/ -v` +Expected: build failure — `undefined: NewLayer`, `undefined: NewWorld`. + +- [ ] **Step 3: Implement `layer.go`, `particle.go`, and the `world.go` shell** + +`Handle` may be an empty switch for now; later tasks add cases. `Intensity` returns 0 for now. + +- [ ] **Step 4: Run the tests and confirm they pass** + +Run: `go test ./internal/fx/ -v && go vet ./...` +Expected: PASS, clean. + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx +git commit -m "feat(fx): overlay layers, particle physics, world clock with dt clamp" +``` + +--- + +## Task 2: Starfield with depth layers, level scaling, and hyperdrive + +**Files:** +- Create: `internal/fx/starfield.go`, `internal/fx/starfield_test.go` +- Modify: `internal/fx/world.go` (own a starfield, advance it, draw it in `Compose`) + +**Interfaces:** +- Produces: + ```go + type depth uint8 + const (depthFar depth = iota; depthMid; depthNear) + + // Densities: one star per N frame cells — far 1/60, mid 1/120, near 1/300. + // Speeds in rows per second: far 0.6, mid 1.6, near 4.0 (§15). + // Glyphs: far ".", mid "·˚", near "✦✧" (ASCII: far ".", mid ":", near "*+"). + + func (w *World) StarCount() int + + // Hyperdrive (§16). Triggered by four-line clears, large combos, and new high + // scores. The envelope, by elapsed time since the trigger: + // 0–50ms factor 0.0 (stars pause) + // 50–100ms factor 0.3 stretched glyphs ("|" / near stars elongate) + // 100–500ms factor ramps 0.3 → 8.0 + // 500–800ms factor 8.0 (peak) + // 800–1100ms factor decays 8.0 → 1.0 + // after factor 1.0 + func hyperFactor(since time.Duration) float64 + func (w *World) TriggerHyperdrive() + func (w *World) HyperActive() bool + + // Level scaling (§15): star speed multiplier 1 + 0.06*(level-1), capped at 2.5. + func (w *World) SetLevel(level int) + + // Shooting star (§45): a rare diagonal streak, at most one at a time, + // expected roughly once every 25 seconds of play. + func (w *World) ShootingStarActive() bool + ``` + +**Design notes:** stars live in frame coordinates and drift downward, wrapping to the top with a fresh random X when they pass the bottom. `Resize` rebuilds the star population for the new area, preserving nothing — that is fine and invisible. Under `ReducedMotion`, `hyperFactor` returns 1.0 always (§49.5 suppresses hyperdrive acceleration) but the starfield still drifts. The starfield draws into `Overlay.Global` only; the board interior is masked at composite time in Task 3, so the starfield is free to write anywhere. + +- [ ] **Step 1: Write the failing tests** + +`internal/fx/starfield_test.go`: + +```go +func TestStarCountScalesWithArea(t *testing.T) +// Resize(80,24,...) gives more stars than Resize(40,12,...); both > 0 + +func TestStarsDriftDownwardAndWrap(t *testing.T) +// record star rows, Advance(1s): rows increased (mod wrap); every star stays +// inside the frame after Advance(30s worth of clamped steps) + +func TestThreeDepthLayersExistWithDistinctSpeeds(t *testing.T) +// after Advance(1s) from a known state, the mean row delta for near > mid > far + +func TestHigherLevelsMoveStarsFaster(t *testing.T) +// SetLevel(1) vs SetLevel(10): mean row delta over 1s is larger at level 10, +// and SetLevel(100) is not more than 2.5× the level-1 delta + +func TestHyperFactorEnvelope(t *testing.T) +// table over the §16 timeline: 0ms => 0; 25ms => 0; 75ms => ~0.3; +// 500ms => 8.0; 700ms => 8.0; 1100ms => 1.0; 5s => 1.0; +// and the ramp is monotonically increasing across 100→500ms + +func TestHyperdriveIsSuppressedUnderReducedMotion(t *testing.T) +// ReducedMotion world: TriggerHyperdrive then Advance(500ms): the mean star +// row delta matches the non-triggered baseline within 10% + +func TestASCIIStarfieldUsesASCIIGlyphsOnly(t *testing.T) +// Config{ASCII:true}: every non-zero rune in the composed Global layer is < 128 + +func TestShootingStarIsRare(t *testing.T) +// advance 60 seconds of clamped frames with a fixed seed and count activations: +// at least 1 and at most 8 +``` + +- [ ] **Step 2: Run the tests and confirm they fail** + +Run: `go test ./internal/fx/ -run 'TestStar|TestThree|TestHigher|TestHyper|TestASCIIStar|TestShooting' -v` +Expected: build failure — `undefined: hyperFactor`. + +- [ ] **Step 3: Implement `starfield.go` and hook it into `Advance`/`Compose`** + +- [ ] **Step 4: Run the tests and confirm they pass** + +Run: `go test ./... -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/starfield.go internal/fx/starfield_test.go internal/fx/world.go +git commit -m "feat(fx): three-layer starfield with level scaling and hyperdrive envelope" +``` + +--- + +## Task 3: Composite the overlay into the frame and wire FX into the app + +**Files:** +- Create: `internal/render/composite.go`, `internal/render/composite_test.go` +- Modify: `internal/render/render.go` (add `Scene.FX`, call the compositor), `internal/fx/world.go` (add `AdvancePaused`), `internal/app/model.go`, `internal/app/update.go` +- Test: `internal/app/update_test.go` (extend), `internal/fx/world_test.go` (extend) + +**Interfaces:** +- Consumes: `fx.Overlay`, `fx.World` (Tasks 1–2); `render.Render` (Plan 2). +- Produces: + ```go + // Scene gains exactly one field: + // FX *fx.Overlay // nil when effects are off + + // AdvancePaused steps only what §30 permits while the game is suspended: + // background stars drift very slowly (a 0.15 speed factor) and everything + // else — gameplay particles, trails, shake, banners, timers — is frozen. + func (w *World) AdvancePaused(dt time.Duration) + + // CompositeGlobal draws overlay.Global over the assembled frame, skipping any + // frame cell that is not blank — the starfield fills gaps and never paints + // over the board box, HUD, or text. + func CompositeGlobal(frame string, o *fx.Overlay, p Palette) string + + // CompositeBoard draws overlay.Board over the 20 board-interior columns. + // Cells occupied by the active piece are never overwritten (§44). + func CompositeBoard(rows []string, o *fx.Overlay, active game.Piece, showPiece bool, p Palette) []string + + // ApplyShake shifts the given block by the overlay's shake offset, keeping the + // block's outer dimensions unchanged (it clips, it does not grow). + func ApplyShake(block string, dx, dy int) string + + // BorderStyleAt returns the style for border position i of total, given the + // overlay's phase and energy: a slow hue walk through deep violet, electric + // cyan, magenta, stellar blue, hot white (§25). energy 0 is the calm walk; + // energy 1 races a bright gradient around the ring. + func BorderStyleAt(i, total int, phase, energy float64, p Palette) lipgloss.Style + ``` + + ```go + // internal/app + type Model struct { + // ... Plan 2 fields ... + FX *fx.World + } + ``` + +**Design notes:** `Render`'s order is now exactly §37: layout → starfield (global layer, composited into blanks) → locked board → ghost → active → board-local FX → border → HUD → global FX → banners → mission control → controls. The compositor is where "blank cells only" is enforced for the global layer; the board compositor is where the active-piece mask is enforced. + +App wiring: `NewModel` builds `fx.NewWorld(seed^0x5EED, fx.Config{...})` — a derived seed so the FX generator is reproducible but distinct from the game's. On every `tea.WindowSizeMsg`, call `FX.Resize`. On every input and every `FrameMsg`, forward the returned `[]game.Event` to `FX.Handle` and call `FX.Advance(dt)` with the same clamped `dt` the game got. In `StatePaused` the game does not advance and the world gets `AdvancePaused(dt)` instead (§30). `Scene()` sets `FX: m.FX.Compose()`. + +- [ ] **Step 1: Write the failing tests** + +`internal/render/composite_test.go`: + +```go +func TestCompositeGlobalFillsOnlyBlankCells(t *testing.T) +// a frame of "ABC\nDEF" with a full overlay layer: every original character +// survives; only blanks are replaced + +func TestCompositeGlobalKeepsFrameDimensions(t *testing.T) +// stripped line count and widths unchanged after compositing + +// Review Focus #4 +func TestCompositeBoardNeverCoversTheActivePiece(t *testing.T) +// fill overlay.Board with '#' in every cell; composite over board rows that show +// an active T piece: the stripped result still contains the piece's block glyphs +// at the piece's exact columns + +func TestCompositeBoardDrawsIntoEmptyCells(t *testing.T) +// a single overlay cell over an empty board position appears in the output + +func TestApplyShakeKeepsDimensions(t *testing.T) +// for dx,dy in {-1,0,1}²: stripped dimensions equal the input's + +func TestApplyShakeMovesContent(t *testing.T) +// dx=1: the first column becomes blank and content shifted right + +func TestBorderStyleAtIsDeterministicAndVaries(t *testing.T) +// same inputs give the same style; positions around the ring at energy 1 give +// at least three distinct rendered colors + +func TestRenderWithNilFXMatchesPlan2Goldens(t *testing.T) +// the golden tests from Plan 2 pass unchanged with Scene.FX nil — assert here by +// rendering fixtureScene with FX nil and comparing to testdata/wide.golden +``` + +`internal/app/update_test.go` (extend): + +```go +func TestFXReceivesGameEvents(t *testing.T) +// press space (hard drop) then one frame: m.FX.ParticleCount() > 0 +// (this becomes meaningful in Task 5; assert only that Handle was reached by +// checking FX.Compose() is non-nil and Elapsed() advanced) + +func TestFXIsNilWhenDisabled(t *testing.T) +// NewModel with Opts.NoFX: m.Scene().FX == nil, and the game still plays +// (a frame still advances the piece) + +func TestResizeResizesTheFXWorld(t *testing.T) +// send WindowSizeMsg{120,50}: Compose().Global size is 120x50 + +func TestPausedFramesDriftStarsButFreezeEverythingElse(t *testing.T) +// §30: press 'p', then a second's worth of frames: Game.Snapshot() is unchanged, +// the Board layer is empty (gameplay particles frozen), and the Global layer's +// non-zero cells have moved — slowly: fewer rows than the same second unpaused +``` + +`internal/fx/world_test.go` (extend): + +```go +func TestAdvancePausedDriftsStarsOnly(t *testing.T) +// emit particles and a trail, then AdvancePaused(1s worth of clamped steps): +// ParticleCount() and TrailCount() are unchanged, Elapsed() is unchanged, and +// star rows moved by roughly 15% of what Advance would have moved them +``` + +- [ ] **Step 2: Run the tests and confirm they fail** + +Run: `go test ./internal/render/ ./internal/app/ -v` +Expected: build failure — `undefined: CompositeGlobal`, `Scene has no field FX`. + +- [ ] **Step 3: Implement the compositor and the app wiring** + +- [ ] **Step 4: Run the tests and confirm they pass** + +Run: `go test ./... -v` +Expected: PASS — including Plan 2's golden tests, unchanged. + +- [ ] **Step 5: See the universe** + +Run: `go run ./cmd/cosmic-tetris --seed 1234`. Stars should drift behind and around the board, and the board must remain perfectly readable. Then `--no-fx` (no stars at all, still a good game) and `--ascii` (ASCII stars). + +- [ ] **Step 6: Commit** + +```bash +git add internal/render/composite.go internal/render/composite_test.go internal/render/render.go internal/app +git commit -m "feat(render): composite FX overlay; wire the FX world into the app loop" +``` + +--- + +## Task 4: Animated border and piece ion trails + +**Files:** +- Create: `internal/fx/trails.go`, `internal/fx/trails_test.go`, `internal/fx/shake.go`, `internal/fx/shake_test.go`, `internal/fx/events.go` +- Modify: `internal/fx/world.go`, `internal/render/board.go` (border uses `BorderStyleAt`) + +**Interfaces:** +- Produces: + ```go + // events.go — the trigger table. Handle routes each game event to effects: + // EventPieceMoved -> trail sample + // EventPieceRotated -> trail sample + small border energy bump + // EventPieceHardDropped -> Task 5 + // EventPieceLocked -> Task 5 + // EventHoldUsed -> quantum-storage streak (Task 7) + // EventLinesCleared -> Task 6 + // EventComboChanged -> Task 7 + // EventLevelChanged -> Task 7 (also SetLevel for star speed) + // EventGameOver -> Task 8 + func (w *World) Handle(ev game.Event) + + // trails.go — §17: ██ ▓▓ ▒▒ ░░ by age (ASCII: [] ## ** ..), 100–160ms life. + const TrailLife = 140 * time.Millisecond + func (w *World) sampleTrail(p game.Piece) // records the piece's cells + func (w *World) emitDropTrail(p game.Piece, dist int) // Task 5 uses this + func (w *World) TrailCount() int + func trailGlyph(age time.Duration, ascii bool) string // "" once expired + + // shake.go — §18 deterministic pattern, §44 one-cell cap. + const ShakeDuration = 80 * time.Millisecond + var shakePattern = [5][2]int{{0, 1}, {-1, 0}, {1, 0}, {0, -1}, {0, 0}} + func (w *World) TriggerShake(d time.Duration) + func shakeOffset(since, total time.Duration) (dx, dy int) + + // border energy: bumped by events, decays exponentially back to 0 over ~600ms. + func (w *World) BumpBorder(amount float64) + ``` + +**Design notes:** trails are stored as `{cellX, row int, born time.Duration}` in board-cell coordinates, drawn with `Layer.SetPair`, and pruned when older than `TrailLife`. `BorderPhase` walks a full cycle every 24 seconds — subtle, per §25. `shakeOffset` indexes `shakePattern` by `since / (total/5)`, so it is deterministic and never exceeds one cell; under `ReducedMotion` it returns `(0,0)`. + +- [ ] **Step 1: Write the failing tests** + +`internal/fx/trails_test.go`: + +```go +func TestMoveEventLeavesATrail(t *testing.T) +// Handle(EventPieceMoved with a piece): TrailCount() > 0 and the composed Board +// layer has non-zero cells at the piece's columns + +func TestTrailsExpire(t *testing.T) +// after Advance past TrailLife (in clamped steps): TrailCount() == 0 and the +// Board layer is empty + +func TestTrailGlyphFadesWithAge(t *testing.T) +// trailGlyph at 0ms, 50ms, 100ms, 130ms returns four different non-empty pairs; +// at 200ms returns "" + +func TestTrailGlyphsAreASCIIInASCIIMode(t *testing.T) +// every rune < 128 for each age + +func TestTrailsAreCappedNotUnbounded(t *testing.T) +// 10,000 move events in one frame: TrailCount() stays bounded (<= 4*40) +``` + +`internal/fx/shake_test.go`: + +```go +func TestShakeOffsetFollowsTheSpecPattern(t *testing.T) +// sample at 0, 20, 40, 60, 79ms of an 80ms shake: the five pattern entries in order + +func TestShakeNeverExceedsOneCell(t *testing.T) +// for since in 0..200ms in 1ms steps and totals of 80ms and 200ms: +// |dx| <= 1 and |dy| <= 1 + +func TestShakeEndsAtZero(t *testing.T) +// past the duration: (0,0) + +func TestReducedMotionSuppressesShake(t *testing.T) +// ReducedMotion world: TriggerShake then Advance(16ms): Compose().ShakeX == 0 +// and ShakeY == 0 at every sample across the duration + +func TestBorderPhaseWalksSlowly(t *testing.T) +// Advance 1s: BorderPhase moved by less than 0.1 (a 24s cycle) + +func TestBorderEnergyDecays(t *testing.T) +// BumpBorder(1.0), then after 1s of clamped advances: BorderEnergy < 0.2 +``` + +- [ ] **Step 2: Run the tests and confirm they fail** + +Run: `go test ./internal/fx/ -run 'TestMove|TestTrail|TestShake|TestReducedMotionSuppressesShake|TestBorder' -v` +Expected: build failure — `undefined: trailGlyph`, `undefined: shakeOffset`. + +- [ ] **Step 3: Implement `events.go`, `trails.go`, `shake.go`, and switch the board border to `BorderStyleAt`** + +- [ ] **Step 4: Run the tests and confirm they pass** + +Run: `go test ./... -v` +Expected: PASS (Plan 2 goldens still pass — the border color changes, the glyphs do not). + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx internal/render/board.go +git commit -m "feat(fx): ion trails, deterministic shake pattern, animated board border" +``` + +--- + +## Task 5: Hard-drop impact — trail, debris, shake, border flash + +**Files:** +- Modify: `internal/fx/trails.go`, `internal/fx/particle.go`, `internal/fx/events.go` +- Test: `internal/fx/impact_test.go` (create) + +**Interfaces:** +- Produces: + ```go + // §18. On EventPieceHardDropped (Distance cells crossed), then EventPieceLocked: + // 1. a vertical ion trail through every crossed cell, fading upward + // 2. an impact debris burst from the contact row, glyphs · * ✦ + (ASCII . * + x) + // 3. an 80ms screen shake + // 4. a border flash (BumpBorder(1.0)) + func (w *World) EmitImpact(p game.Piece, distance int) + + // EmitBurst places n particles at (x,y) in the given space with radial + // velocities, upward bias, and lives spread over [minLife, maxLife]. + func (w *World) EmitBurst(space Space, x, y float64, n int, speed float64, minLife, maxLife float64) + ``` + +Scale the burst with distance: `n = 6 + distance` particles, capped at 40 — a one-cell tap is a puff, an orbital refrigerator is a mess. + +- [ ] **Step 1: Write the failing tests** + +`internal/fx/impact_test.go`: + +```go +func TestHardDropEmitsParticlesShakeAndBorderFlash(t *testing.T) +// Handle(EventPieceHardDropped{Piece: p, Distance: 12}) then Advance(16ms): +// ParticleCount() > 0, Compose().ShakeX or ShakeY non-zero at some sample within +// 80ms, BorderEnergy > 0.5 + +func TestHardDropDrawsAVerticalTrailAboveTheLandingRow(t *testing.T) +// the composed Board layer has non-zero cells in rows above the piece's final +// row, in the piece's columns + +func TestBiggerDropsMakeMoreDebris(t *testing.T) +// Distance 1 vs Distance 18: the larger drop emits strictly more particles, +// and neither exceeds 40 new particles + +func TestImpactDebrisFadesToNothing(t *testing.T) +// after 1.5s of clamped advances: ParticleCount() == 0 and the Board layer is empty + +func TestImpactIsASCIISafe(t *testing.T) +// Config{ASCII:true}: every composed rune < 128 + +func TestImpactParticlesStayInsideTheirLayer(t *testing.T) +// no panic and no out-of-range writes across 3s of advances at a 20x20 board +``` + +- [ ] **Step 2: Run the tests and confirm they fail** + +Run: `go test ./internal/fx/ -run 'TestHardDrop|TestBigger|TestImpact' -v` +Expected: build failure — `undefined: (*World).EmitImpact`. + +- [ ] **Step 3: Implement the impact effect** + +- [ ] **Step 4: Run the tests and confirm they pass** + +Run: `go test ./... -v` +Expected: PASS. + +- [ ] **Step 5: Feel it** + +Run: `go run ./cmd/cosmic-tetris` and hard-drop repeatedly. It must feel like dropping a refrigerator from orbit, and the frame must not become unreadable. Then `--reduced-motion`: debris and trail stay, the shake is gone. + +- [ ] **Step 6: Commit** + +```bash +git add internal/fx +git commit -m "feat(fx): hard-drop impact with ion trail, debris, shake, border flash" +``` + +--- + +## Task 6: Line-clear supernova, shockwaves, and the four-line event + +**Files:** +- Create: `internal/fx/clear.go`, `internal/fx/clear_test.go` +- Modify: `internal/fx/events.go`, `internal/render/composite.go` (draw `Overlay.Banner`) + +**Interfaces:** +- Produces: + ```go + // §19, 220ms total, three phases by elapsed time since the clear: + // Phase A 0–70ms "critical mass": the cleared rows become ▓ with a + // bright core spreading from the center + // Phase B 70–150ms "supernova": a center-outward ✦ explosion front + // Phase C 150–220ms "collapse": the row becomes debris particles whose + // horizontal velocity comes from their distance to center + const ( + ClearPhaseA = 70 * time.Millisecond + ClearPhaseB = 150 * time.Millisecond + ClearTotal = 220 * time.Millisecond + ) + func (w *World) EmitLineClear(rows []int, count int) + func (w *World) ClearAnimActive() bool + + // §24 shockwave: expanding rings of · ○ ◌ ◯ (ASCII . o O 0) over 300ms. + // Suppressed entirely under ReducedMotion (§49.5). Used sparingly: four-line + // clears and combo 5+ only. + const ShockwaveDuration = 300 * time.Millisecond + func (w *World) EmitShockwave(space Space, x, y float64) + func (w *World) ShockwaveCount() int + + // §20 four-line sequence, fired together: hyperdrive, a longer shake (160ms, + // still one cell), a border gradient pulse, a particle eruption, a HUD flash, + // a temporary star-density increase, and a 700ms banner. + const BannerDuration = 700 * time.Millisecond + var fourLineBanners = []string{ + "✦ EVENT HORIZON ✦", + "QUADRUPLE COSMIC INCIDENT", + "FOUR ROWS HAVE LEFT THE CHAT", + "SPACE-TIME HAS FILED A COMPLAINT", + } + func (w *World) TriggerFourLine() + func (w *World) BannerActive() bool + ``` + +**A note on coordinates:** the engine has already collapsed the board by the time FX sees `EventLinesCleared` (§19 allows exactly this: "Gameplay state may already know the result, but rendering gets a short animation"). The animation draws at the row indices carried in the event, over the already-collapsed board. Do not try to reconstruct the pre-clear board — 220ms of bright light covers it, and reconstructing state in FX would violate §14's one-way flow. + +In ASCII mode the banner text drops the `✦` characters. + +- [ ] **Step 1: Write the failing tests** + +`internal/fx/clear_test.go`: + +```go +func TestSingleClearRunsThroughThreePhases(t *testing.T) +// Handle(EventLinesCleared{Rows: []int{21}}): sample the Board layer at 30ms, +// 100ms, 180ms — each sample has non-zero cells, and the glyph sets at 30ms and +// 100ms differ + +func TestClearAnimationEndsAt220ms(t *testing.T) +// after 220ms + one frame: ClearAnimActive() false; after particles die the +// Board layer is empty + +func TestPhaseCEmitsDebrisWithOutwardVelocity(t *testing.T) +// advance into phase C: particles exist; those left of the row center have +// VX < 0 and those right of it have VX > 0 + +func TestFourLineClearFiresTheWholeCircus(t *testing.T) +// Handle(EventLinesCleared with 4 rows): BannerActive() true and Banner is one +// of fourLineBanners; HyperActive() true; ShockwaveCount() > 0; +// Compose().BorderEnergy > 0.8; a shake offset appears within 160ms; +// StarCount() is higher than the pre-event count + +func TestBannerExpiresAfter700ms(t *testing.T) +// BannerActive() false and Overlay.Banner == "" after 700ms + one frame + +func TestSingleClearDoesNotFireTheBanner(t *testing.T) +// one row: BannerActive() false, HyperActive() false + +func TestShockwavesAreSuppressedUnderReducedMotion(t *testing.T) +// ReducedMotion: TriggerFourLine then Advance: ShockwaveCount() == 0, but the +// clear animation and particles still run + +func TestShockwaveExpires(t *testing.T) +// after ShockwaveDuration + a frame: ShockwaveCount() == 0 + +func TestClearGlyphsAreASCIISafe(t *testing.T) +// ASCII config, sampled across all three phases: every rune < 128 + +func TestBannerIsASCIISafe(t *testing.T) +// ASCII config: every rune in Overlay.Banner < 128 +``` + +- [ ] **Step 2: Run the tests and confirm they fail** + +Run: `go test ./internal/fx/ -run 'TestSingleClear|TestClear|TestPhaseC|TestFourLine|TestBanner|TestShockwave' -v` +Expected: build failure — `undefined: (*World).EmitLineClear`. + +- [ ] **Step 3: Implement `clear.go`, route `EventLinesCleared`, and render the banner** + +Banner rendering goes in `CompositeGlobal`'s caller: a centered, bright, one-or-two-line block drawn over the frame above the board's vertical center. It must not shift any other element (§20: it must not block gameplay input, and per §44 it must not obscure the active piece — draw it in the upper third of the board area). + +- [ ] **Step 4: Run the tests and confirm they pass** + +Run: `go test ./... -v` +Expected: PASS. + +- [ ] **Step 5: The acceptance test that matters** + +Run: `go run ./cmd/cosmic-tetris --seed 1234`, build a well, and clear four rows. §43 requires an immediate "LOL WHAT THE FUCK" reaction. If it is merely nice, tune amplitudes and durations (not the spec's timings) and try again. + +- [ ] **Step 6: Commit** + +```bash +git add internal/fx internal/render/composite.go +git commit -m "feat(fx): supernova line clears, shockwaves, four-line spectacle" +``` + +--- + +## Task 7: Combo escalation, level-up anomaly, hold streak, mission control + +**Files:** +- Create: `internal/fx/anomaly.go`, `internal/fx/anomaly_test.go`, `internal/flavor/messages.go`, `internal/flavor/messages_test.go` +- Modify: `internal/fx/events.go`, `internal/app/model.go`, `internal/app/update.go`, `internal/render/composite.go` (draw `Overlay.Notice`, apply `HUDPulse`) + +**Interfaces:** +- Produces: + ```go + // fx/anomaly.go — §21 combo escalation: + // combo 2 small sparks + // combo 3 meteor particles (longer-lived, horizontal drift) + // combo 4 HUDPulse begins oscillating + // combo 5+ everything above plus a shockwave and maximum border energy + func (w *World) SetCombo(combo int) + func (w *World) ComboLevel() int + + // §22 level-up notice, ~1200ms, slides out by fading; never pauses the game. + const NoticeDuration = 1200 * time.Millisecond + var levelSubtitles = []string{ + "GRAVITY TAX INCREASED", + "LOCAL PHYSICS UPDATED WITHOUT CONSENT", + "PLEASE SECURE ALL LOOSE TETROMINOES", + } + func (w *World) TriggerLevelNotice(level int) + func (w *World) NoticeActive() bool + + // §9 quantum storage, ~120ms: the outgoing piece compresses, streaks sideways, + // and vanishes while the incoming piece flashes in. Gameplay never waits. + const HoldStreakDuration = 120 * time.Millisecond + func (w *World) EmitHoldStreak(p game.Piece) + ``` + + ```go + // internal/flavor — §27 and §45. + type Priority uint8 + const (PriorityIdle Priority = iota; PriorityEvent; PriorityMajor) + + type Channel struct{ /* unexported: rng, current, dwell, sinceLastMove, ... */ } + func NewChannel(seed int64) *Channel + + // Handle picks a message for a game event, respecting priorities: a major + // message is never displaced by an idle one, and no message is replaced before + // MinDwell has passed (§27: "give them time to breathe"). + func (c *Channel) Handle(ev game.Event) + func (c *Channel) Advance(dt time.Duration) + func (c *Channel) Text() string // e.g. "NOMINALISH" + + const ( + MinDwell = 2500 * time.Millisecond + IdleInterval = 8 * time.Second + FirstMoveIdle = 20 * time.Second // §45 "CAPTAIN?" + ) + ``` + +**Message content (§27 idle pool, verbatim):** `GRAVITY REMAINS MOSTLY LEGAL`, `TETROMINO INJECTION SUCCESSFUL`, `STRUCTURAL VIBES: QUESTIONABLE`, `LOCAL UNIVERSE STABLE*`, `* DEFINITION OF STABLE UNDER REVIEW`, `MOON NOTIFIED`, `ORBITAL OSHA HAS ENTERED THE CHAT`, `WE HAVE EXCEEDED THE RECOMMENDED NUMBER OF BLOCKS`, `PHYSICS TEAM SAYS KEEP GOING`, `NOMINALISH`. + +**Event messages:** combo 5/6/7 use `COMBO 5 // UNAUTHORIZED ORBITAL MANEUVER`, `COMBO 6 // STRUCTURAL REALITY FAILURE`, `COMBO 7 // NASA DENIES EVERYTHING` (combo 8+ reuses the 7 line with the real number). Level up uses `GRAVITY ANOMALY DETECTED`. Four-line clears use the banner text. + +**Easter eggs (§45), each rare and each triggered, never random noise:** hard-dropping a vertical `I` → `KINETIC ROD DEPLOYED`; holding an `O` → `CUBE ADJACENT OBJECT SECURED`; the score crossing a new power of ten at or above 100000 → `NUMBER BECAME BIGGER`; 20 seconds with no input at the start of a game → `CAPTAIN?`; and with probability 1/100 an idle pick becomes `DID YOU KNOW YOU'RE IN A TERMINAL?`. + +`Channel` owns its own `*rand.Rand`, seeded from the FX seed. It never touches the game's RNG. + +- [ ] **Step 1: Write the failing tests** + +`internal/fx/anomaly_test.go`: + +```go +func TestComboTwoMakesSparks(t *testing.T) +// SetCombo(2) then Advance: ParticleCount() > 0 + +func TestComboThreeMakesLongerLivedParticles(t *testing.T) +// combo 3 particles outlive combo 2 particles (still alive after 0.6s) + +func TestComboFourPulsesTheHUD(t *testing.T) +// SetCombo(4): Compose().HUDPulse changes across successive frames and stays in [0,1] + +func TestComboFivePlusMaxesTheBorderAndAddsAShockwave(t *testing.T) +// SetCombo(5): BorderEnergy > 0.9 and ShockwaveCount() > 0 + +func TestComboResetCalmsEverything(t *testing.T) +// SetCombo(0) then 2s of advances: HUDPulse == 0, ParticleCount() == 0 + +func TestLevelNoticeAppearsAndExpires(t *testing.T) +// TriggerLevelNotice(8): Overlay.Notice contains "GRAVITY ANOMALY DETECTED" and +// "LEVEL 08", plus one of levelSubtitles; after NoticeDuration it is nil + +func TestLevelNoticeDoesNotFreezeAnything(t *testing.T) +// with a notice active, stars still move and particles still step + +func TestHoldStreakDrawsAndExpires(t *testing.T) +// EmitHoldStreak: Board layer non-empty; after 120ms + a frame the streak cells +// are gone (particles may linger) +``` + +`internal/flavor/messages_test.go`: + +```go +func TestDefaultTextIsPresentImmediately(t *testing.T) +// NewChannel(1).Text() is non-empty + +func TestEventMessagesOverrideIdleChatter(t *testing.T) +// Handle(EventLevelChanged{Level: 5}): Text() mentions GRAVITY ANOMALY + +func TestMessagesDwellBeforeBeingReplaced(t *testing.T) +// after an event message, Handle of a lower-priority event within MinDwell does +// not change Text() + +func TestMajorMessagesDisplaceEventMessages(t *testing.T) +// a four-line clear replaces a fresh move-level message immediately + +func TestIdleMessagesRotateSlowly(t *testing.T) +// across 60s of 16ms advances with no events, the text changes at least twice +// and at most 10 times + +func TestKineticRodOnVerticalIHardDrop(t *testing.T) +// Handle(EventPieceHardDropped with a vertical I): Text() == "KINETIC ROD DEPLOYED" + +func TestCubeAdjacentOnHoldingAnO(t *testing.T) +// Handle(EventHoldUsed with Piece.Kind == KindO) + +func TestNumberBecameBiggerOnPowerOfTenCrossing(t *testing.T) +// scores 99_000 -> 120_000 triggers it; 120_000 -> 130_000 does not + +func TestCaptainAfterLongIdle(t *testing.T) +// Advance(FirstMoveIdle + 1s) with no events: Text() == "CAPTAIN?" + +func TestChannelIsSeededAndReproducible(t *testing.T) +// two channels with the same seed produce the same 60s text sequence + +func TestEveryMessageFitsAndIsASCII(t *testing.T) +// every message in every pool is <= 60 characters and ASCII-only, so the +// mission-control line survives ASCII mode and small terminals +``` + +- [ ] **Step 2: Run the tests and confirm they fail** + +Run: `go test ./internal/fx/ ./internal/flavor/ -v` +Expected: build failures — `undefined: SetCombo`, `undefined: NewChannel`. + +- [ ] **Step 3: Implement, then wire the channel into the app** + +`Model` gains `Flavor *flavor.Channel`; `Update` forwards every event to both `FX.Handle` and `Flavor.Handle`, and advances both with the same clamped `dt`; `Scene()` sets `Status: m.Flavor.Text()`. The channel runs even with `--no-fx` (it is text, not motion). + +- [ ] **Step 4: Run the tests and confirm they pass** + +Run: `go test ./... -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx internal/flavor internal/app internal/render/composite.go +git commit -m "feat(fx): combo escalation, level anomalies, quantum storage, mission control" +``` + +--- + +## Task 8: Boot sequence and the game-over black hole + +**Files:** +- Create: `internal/render/boot.go`, `internal/render/boot_test.go`, `internal/fx/gameover.go`, `internal/fx/gameover_test.go` +- Modify: `internal/app/model.go`, `internal/app/update.go`, `internal/app/update_test.go` + +**Interfaces:** +- Produces: + ```go + // render/boot.go — §29, about one second of excessive drama. + const ( + BootTotal = 1050 * time.Millisecond + // checklist reveals: gravity 250ms, spacetime 450ms, tetrominoes 650ms, + // "UNIVERSE ONLINE" 900ms + ) + // BootScreen renders the boot screen for the given elapsed time, including the + // spinner frame supplied by the app (bubbles/v2/spinner). + func BootScreen(elapsed time.Duration, spinner string, w, h int, p Palette, m Mode) string + + // fx/gameover.go — §28. + const ( + GameOverFreeze = 300 * time.Millisecond // "SIGNAL LOST", everything frozen + GameOverCollapse = 900 * time.Millisecond // blocks fall inward + GameOverHole = 1300 * time.Millisecond // black hole art, then the panel + ) + // TriggerGameOver takes a read-only copy of the final board so the collapse can + // animate the blocks that were there. It never retains a pointer to the game. + func (w *World) TriggerGameOver(final game.Board) + func (w *World) GameOverPhase() int // 0 none, 1 freeze, 2 collapse, 3 hole, 4 panel + ``` + + ```go + // internal/app: AppState gains StateBoot as the initial state. + const StateBoot AppState = 3 // added to the existing StatePlaying/Paused/GameOver + ``` + +**Behavior:** in `StateBoot` the game does not advance and no gravity applies; the FX starfield does. Any key press skips straight to `StatePlaying` (§29). After `BootTotal` elapses, the app transitions on its own. There is no menu. + +Game over: `EventGameOver` routes to `TriggerGameOver(m.Game.Board)`. Phase 1 sets `Overlay.Frozen` and shows `SIGNAL LOST`; phase 2 converts each filled board cell into a particle accelerating toward the board center; phase 3 draws the §28 black-hole art centered on the board; phase 4 sets `Overlay.ShowGameOverPanel`, which is when `render` draws the §28 final panel. `r` restarts at any phase; with `--no-fx` the panel shows immediately. + +- [ ] **Step 1: Write the failing tests** + +`internal/render/boot_test.go`: + +```go +func TestBootScreenRevealsChecklistOverTime(t *testing.T) +// at 100ms: contains "COSMIC" and "INITIALIZING LOCAL UNIVERSE" but not "gravity"; +// at 300ms: contains "gravity"; at 500ms: "spacetime"; at 700ms: "tetrominoes" +// and "QUESTIONABLE"; at 1000ms: "UNIVERSE ONLINE" + +func TestBootScreenFitsTheFrame(t *testing.T) +// sizes (40,24),(80,30),(120,50) and all modes: no line exceeds width, +// line count <= height + +func TestBootScreenIsASCIISafeInASCIIMode(t *testing.T) +``` + +`internal/fx/gameover_test.go`: + +```go +func TestGameOverPhasesFollowTheTimeline(t *testing.T) +// TriggerGameOver(board): phase 1 at 100ms, 2 at 500ms, 3 at 1000ms, 4 at 1400ms + +func TestFreezePhaseSetsFrozenAndShowsSignalLost(t *testing.T) +// Overlay.Frozen true and the Global layer (or Banner) contains "SIGNAL LOST" + +func TestCollapsePhaseMovesBlocksInward(t *testing.T) +// a board with cells at both edges: after 300ms of collapse, particle X values +// have moved toward the board center + +func TestHolePhaseDrawsTheBlackHole(t *testing.T) +// the Board layer contains the '●' glyph (ASCII mode: '@' or 'O') near center + +func TestPanelIsGatedUntilTheCollapseFinishes(t *testing.T) +// ShowGameOverPanel false before 1300ms, true after + +func TestGameOverDoesNotRetainTheBoard(t *testing.T) +// mutate the caller's board copy after TriggerGameOver: the animation is +// unaffected (assert the composed output at 500ms is identical either way) +``` + +`internal/app/update_test.go` (extend): + +```go +func TestModelStartsInBootAndDoesNotAdvanceTheGame(t *testing.T) +// fresh model: State == StateBoot; frames totaling 500ms leave Game.Snapshot() +// identical to a fresh game + +func TestAnyKeySkipsBoot(t *testing.T) +// press 'x' during boot: State == StatePlaying + +func TestBootEndsOnItsOwn(t *testing.T) +// frames totaling BootTotal + one frame: State == StatePlaying + +func TestGameOverPanelWaitsForTheCollapseWithFX(t *testing.T) +// force game over: immediately after, View().Content does not contain +// "UNIVERSE EXPIRED"; after 1400ms of frames it does + +func TestGameOverPanelIsImmediateWithNoFX(t *testing.T) +// with Opts.NoFX: the panel appears on the first frame after game over + +func TestRestartWorksDuringTheCollapse(t *testing.T) +// press 'r' 400ms into the collapse: State StatePlaying, board empty +``` + +- [ ] **Step 2: Run the tests and confirm they fail** + +Run: `go test ./internal/render/ ./internal/fx/ ./internal/app/ -v` +Expected: build failures — `undefined: BootScreen`, `undefined: TriggerGameOver`, `undefined: StateBoot`. + +- [ ] **Step 3: Implement** + +- [ ] **Step 4: Run the tests and confirm they pass** + +Run: `go test ./... -v` +Expected: PASS. + +- [ ] **Step 5: Watch a universe be born and die** + +Run: `go run ./cmd/cosmic-tetris --seed 1234`. Confirm: about a second of drama, then play begins; pressing a key skips it. Then top the stack out deliberately and confirm the freeze, the inward collapse, the black hole, and the final panel — in that order — followed by a working `r`. + +- [ ] **Step 6: Commit** + +```bash +git add internal/render internal/fx internal/app +git commit -m "feat: boot sequence and game-over black hole collapse" +``` + +--- + +## Task 9: Mode gating, FX snapshots, and the coolness acceptance pass + +**Files:** +- Create: `internal/fx/gating_test.go`, `internal/app/determinism_test.go`, `internal/render/testdata/fx-wide.golden` +- Modify: `internal/render/golden_test.go` (one FX-on golden), `README.md` + +**Interfaces:** +- Consumes: everything. +- Produces: the guarantees in §44, §47, and the acceptance criteria in §43. + +- [ ] **Step 1: Write the failing tests** + +`internal/fx/gating_test.go` — every §44 restraint and every mode flag, as one table where possible: + +```go +func TestNoFXProducesNoOverlayForAnyEvent(t *testing.T) +// Config{Enabled:false}: fire every EventKind (moves, hard drop, 4-line clear, +// combo 7, level up, hold, game over) and advance 3s: Compose() stays nil + +func TestReducedMotionSuppressesExactlyThreeThings(t *testing.T) +// ReducedMotion: after a four-line clear and a hard drop and combo 5, +// ShakeX == ShakeY == 0 at every sample, +// star speed matches the non-hyperdrive baseline, +// ShockwaveCount() == 0, +// but ParticleCount() > 0 and the Board layer has trail/clear content +// (color, trails, particles survive — §49.5) + +func TestASCIIModeEmitsNoWideRunes(t *testing.T) +// ASCII config: fire every event, advance across 3s in 16ms steps, and assert +// every non-zero rune in both layers, the Banner, and the Notice is < 128 + +func TestParticleCountStaysBoundedUnderAbuse(t *testing.T) +// 2000 events of every kind interleaved with advances: ParticleCount() <= +// MaxParticles at all times and the composed layers never exceed their sizes + +func TestOverlayNeverExceedsOneCellOfShake(t *testing.T) +// across a 5s abuse run: |ShakeX| <= 1 and |ShakeY| <= 1 at every frame +``` + +`internal/app/determinism_test.go`: + +```go +// Review Focus #5 — the §35/§47 guarantee, end to end. +func TestGameStateIsIdenticalWithFXOnAndOff(t *testing.T) +// a canned script of ~200 {key, dt} steps (reuse the shape of Plan 1's +// replaySteps, driven through Model.Update instead of the engine directly): +// run it twice with the same seed, once with Opts.NoFX true and once false, +// and assert the two Game.Snapshot() strings are byte-identical + +func TestSameSeedSameScriptIsReproducibleThroughTheApp(t *testing.T) +// two identical FX-on runs produce identical Game.Snapshot() strings + +func TestFXWorldIsAlsoReproducible(t *testing.T) +// the same two FX-on runs produce identical Compose() layers +``` + +- [ ] **Step 2: Run the tests and confirm they fail** + +Run: `go test ./internal/fx/ ./internal/app/ -v` +Expected: FAIL wherever gating is incomplete. Fix the implementation, not the tests. + +- [ ] **Step 3: Add one FX-on golden** + +Extend `internal/render/golden_test.go` with a case that builds a deterministic overlay: `fx.NewWorld(7, fx.Config{Enabled: true})`, `Resize(100, 36, 20, 20)`, a fixed script (hard drop at 0ms, a two-row clear at 100ms, advance in 16ms steps to 200ms), then `Render` with `fixtureScene(ModeFull, 100, 36, OverlayNone)` plus `FX: world.Compose()`. Compare `ansi.Strip` output to `testdata/fx-wide.golden`. + +Add a comment above the case: this golden pins geometry, not beauty — re-record it with `-update` whenever FX amplitudes are tuned, and read the diff before committing it. + +Run: `go test ./internal/render/ -run TestGoldenLayouts -update && go test ./internal/render/ -count=3 -v` +Expected: golden written; three consecutive runs pass. Read `fx-wide.golden` and confirm the board box is intact and the active piece is visible. + +- [ ] **Step 4: Run the §43 coolness acceptance test by hand** + +Run: `go run ./cmd/cosmic-tetris`. Within the first 30 seconds of normal play, confirm you see all six: a moving starfield, an animated board border, piece trails, a hard-drop impact, particles, and mission-control commentary. On the first completed line, confirm the supernova animation, the debris, and the border reaction. On a four-line clear, confirm the reaction from §43. Write down anything that felt weak, fix it, and re-run. + +Then walk the §47 definition-of-done list end to end, including: `--ascii`, `--no-fx`, `--reduced-motion`, `--seed`, live resize down past 40×24 and back, pause, restart, and a full game to game over. Check for visible flicker under normal play; if it flickers, look for a render that changes size frame to frame rather than reaching for optimizations. + +- [ ] **Step 5: Update the README** + +Document what the game is, the CLI surface (§46), the three rendering modes, the key bindings, the architecture in five lines (`game → events → fx → overlay → render`, two RNGs, engine takes `dt`), and how to run the tests including `-update` for goldens. + +- [ ] **Step 6: Final verification** + +Run: +```bash +go build ./... && go vet ./... && go test ./... -count=1 +gofmt -l . +grep -rn "\*game\.Game" internal/fx internal/flavor || echo "fx/flavor never touch a live game" +grep -rn "time.Now" internal/game || echo "engine is clock-free" +``` +Expected: everything green, `gofmt -l` prints nothing, both greps print their reassurance. + +- [ ] **Step 7: Commit** + +```bash +git add internal/fx internal/app internal/render README.md +git commit -m "test: FX gating, RNG isolation, FX golden; document the finished universe" +``` + +--- + +## Done when + +- Every §47 item holds, verified by hand as well as by tests. +- `--no-fx` yields a good, quiet game; the default yields a much funnier one. +- `--reduced-motion` suppresses shake, hyperdrive acceleration, and shockwaves, and nothing else. +- ASCII mode emits no rune ≥ 128 anywhere, including banners, notices, and particles. +- The same seed and input script produce byte-identical game state with FX on and off. +- Screen shake never exceeds one cell; FX never covers the active piece; particles never persist into the next frame's board. +- A four-line clear produces the §43 reaction. That is an actual product requirement. diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/P1-proportion-cosmic-52/2026-09-17-cosmic-tetris-00-overview.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/P1-proportion-cosmic-52/2026-09-17-cosmic-tetris-00-overview.md new file mode 100644 index 000000000..587e8d675 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/P1-proportion-cosmic-52/2026-09-17-cosmic-tetris-00-overview.md @@ -0,0 +1,29 @@ +# Cosmic Tetris — Plan Index + +**Spec:** `design.md` + +The spec's own build order (§42) is the plan boundary. Each plan below ends with +software you can run and enjoy; later plans only add spectacle. + +| # | Plan | Spec sections | Ends with | +|---|------|---------------|-----------| +| 01 | [Engine](2026-09-17-cosmic-tetris-01-engine.md) | §5–13, §34–35, §40, §49.1, §49.2, §49.6 | Headless deterministic game, `go test ./internal/game` green | +| 02 | [Playable terminal](2026-09-17-cosmic-tetris-02-playable-terminal.md) | §4, §8–10, §31–33, §36–37, §39, §41, §46, §49.3–49.5 | A genuinely good terminal Tetris with no FX | +| 03 | [Cosmic foundation](2026-09-17-cosmic-tetris-03-cosmic-foundation.md) | §14–15, §17, §25–27, §45 | Living background, animated border, trails, Mission Control | +| 04 | [Violence](2026-09-17-cosmic-tetris-04-violence.md) | §16, §18–24, §44 | Hard-drop impact, supernova clears, hyperdrive, four-line event | +| 05 | [Polish](2026-09-17-cosmic-tetris-05-polish.md) | §28–30, §32, §43, §45, §47 | Boot sequence, black-hole game over, mode matrix, README | + +Read them in order. Plan 02 assumes plan 01's exported API exists exactly as +written there; each plan's **Interfaces** blocks name what crosses the seam. + +One deliberate deviation from §33, stated once here so it is not mistaken for +drift: the plans add small files *inside* the four packages §33 names — one per +subsystem, e.g. `internal/fx/starfield.go`, `internal/fx/shake.go` — rather than +squeezing every subsystem into the exact filenames §33 lists. No package boundary +moves, no fifth package appears, and no file grows past a few hundred lines. That +is §33's "unless genuinely necessary" clause spent on file granularity, which is +also what §48's "understandable in an afternoon" wants. + +Two rules hold across all five and are repeated in each plan's Global +Constraints because they are the ones that quietly rot: `internal/game` never +reads a clock (§49.2), and `internal/fx` never writes to game state (§14). diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/P1-proportion-cosmic-52/2026-09-17-cosmic-tetris-01-engine.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/P1-proportion-cosmic-52/2026-09-17-cosmic-tetris-01-engine.md new file mode 100644 index 000000000..ac05e7a88 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/P1-proportion-cosmic-52/2026-09-17-cosmic-tetris-01-engine.md @@ -0,0 +1,854 @@ +# Cosmic Tetris — Plan 01: Deterministic Game Engine Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build `internal/game`, a headless, clock-free, seed-reproducible falling-block engine with full unit tests, before any terminal code exists. + +**Architecture:** One package, six small files, no dependencies outside the standard library. The engine is a pure state machine driven by two entry points: `Apply(Input)` for player actions and `Advance(dt)` for time. Both return `[]Event` describing what happened; nothing inside the package reads a clock or renders anything. A single `*rand.Rand` owned by `Game` drives the 7-bag and nothing else. + +**Tech Stack:** Go 1.26, `math/rand/v2` (PCG source), `testing`. No third-party modules in this plan. + +**Spec:** `design.md` (this plan implements §5–13, §34, §35, §40 "Board/Pieces/Bag/Hold/Drop/Score/Game over/Determinism", §49.1, §49.2, §49.6) + +## Global Constraints + +- Language: Go. Module path `cosmic-tetris`, `go 1.26` in `go.mod`. +- Directory layout is exactly §33's tree. Do not add packages beyond it. +- `internal/game` never calls `time.Now()` or any other clock (§49.2). `time.Duration` appears only as a parameter or accumulator type. +- Board is `width: 10`, `height: 22`, `visible rows: 20`, `hidden spawn rows: 2` (§5). +- Game RNG and FX RNG never share (§49.6). This plan creates only the game RNG: `Game.rng`, seeded from `Game.Seed`, used for the 7-bag and nothing else. +- Gravity: `interval = 800ms * 0.86^(level-1)`, clamped at approximately `60ms`; level increases every `10 cleared lines` (§11). +- `lock delay = 500ms`, `max lock resets = 15` (§12). +- Clear values `100/300/500/800 × level`; combo bonus `50 × (combo - 1) × level` (§13, §49.1). +- Soft drop `+1 point / cell`, hard drop `+2 points / cell` (§11). +- Rotation kick offsets, in order: `(0,0) (-1,0) (1,0) (-2,0) (2,0) (0,-1) (-1,-1) (1,-1)` (§7). +- No networking, profiles, achievements, plugin system, or database (§2). + +## Review Focus + +Five things the spec requires to work but never names as a test. Each has a test in the task that owns the code. + +1. **`Advance` with `dt <= 0`** — a frame can arrive with zero or negative elapsed time (clock skew, first frame). Nothing should move and nothing should panic. *(Task 6)* +2. **`Advance` with a huge `dt`** — a stalled process resumes with `dt = 10s` at a 60ms interval. The catch-up loop must terminate, must not push a piece through the floor, and must not skip the lock. *(Task 6)* +3. **Input after game over** — the app keeps delivering frames and keys during the 1.3s game-over animation (§28). `Apply` and `Advance` must be no-ops returning no events. *(Task 4)* +4. **Kicks above the ceiling** — the `(0,-1)` and `(±1,-1)` offsets can put a spawn-height piece at `y = -1`. Occupancy must treat `y < 0` as free without indexing the cell array negatively. *(Task 1 and Task 5)* +5. **Level past the clamp** — at 300 cleared lines, `0.86^30` underflows toward zero. The interval must sit at the 60ms clamp, never zero or negative, and level maths must not overflow. *(Task 7)* + +--- + +### Task 1: Module bootstrap and board geometry + +**Files:** +- Create: `go.mod`, `internal/game/board.go` +- Test: `internal/game/board_test.go` + +**Interfaces:** +- Consumes: nothing. +- Produces: + ```go + const (Width = 10; Height = 22; VisibleRows = 20; HiddenRows = 2) + type Cell uint8 // 0 == empty; otherwise Cell(kind) + 1 + type Board struct{ Cells [Height][Width]Cell } + func (b *Board) At(x, y int) Cell + func (b *Board) Set(x, y int, c Cell) + func (b *Board) Occupied(x, y int) bool // out of side/bottom bounds == true; y < 0 == false + func (b *Board) FullRows() []int // ascending row indices + func (b *Board) ClearRows(rows []int) // removes rows, collapses everything above down + ``` + +Coordinate convention, to be repeated as a doc comment on `Board`: `y = 0` is the +top hidden row, `y = 1` the second hidden row, `y = 2` the first *visible* row, +`y = 21` the floor row. `x = 0` is the left wall. + +- [ ] **Step 1: Write the failing tests** + +`internal/game/board_test.go`: + +```go +func TestOccupiedTreatsWallsAndFloorAsSolid(t *testing.T) +// (-1, 5) true; (Width, 5) true; (5, Height) true; (5, 5) false on an empty board. + +func TestOccupiedTreatsAboveCeilingAsFree(t *testing.T) +// (5, -1) and (5, -2) false, and the call must not panic. Review Focus 4. + +func TestSetAndAtRoundTrip(t *testing.T) +// Set(3, 21, Cell(KindT)+1) then At(3, 21) equals that value; At(3, 20) is 0. + +func TestFullRowsFindsCompleteRowsAscending(t *testing.T) +// Fill rows 21 and 19 completely, leave one gap in row 20 -> []int{19, 21}. + +func TestClearRowsCollapsesAbove(t *testing.T) +// Row 21 full, row 20 holds a single block at x=0. ClearRows([]int{21}) leaves +// row 21 holding that single block at x=0 and row 20 empty. + +func TestClearRowsHandlesMultipleNonAdjacentRows(t *testing.T) +// Rows 21 and 19 full, row 20 has one block at x=9. After clearing both, row 21 +// has exactly one block at x=9 and rows 0..20 are empty. +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/game/ -run 'Board|Occupied|Set|FullRows|ClearRows' -v` +Expected: build failure — package `game` does not exist. + +- [ ] **Step 3: Implement** + +`go.mod`: + +``` +module cosmic-tetris + +go 1.26 +``` + +`internal/game/board.go`: the constants, `Cell`, `Board`, and the six methods +above. `ClearRows` copies surviving rows downward into a fresh `[Height][Width]Cell` +walking from the bottom up; it does not allocate per row. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add go.mod internal/game/board.go internal/game/board_test.go +git commit -m "feat(game): board geometry, occupancy, row clearing" +``` + +--- + +### Task 2: Piece kinds, shapes, and the rotation table + +**Files:** +- Create: `internal/game/piece.go` +- Test: `internal/game/piece_test.go` + +**Interfaces:** +- Consumes: `Width`, `Height`, `Board.Occupied` (Task 1). +- Produces: + ```go + type PieceKind uint8 + const (KindI PieceKind = iota; KindJ; KindL; KindO; KindS; KindT; KindZ) + const KindCount = 7 + func (k PieceKind) String() string // "I","J","L","O","S","T","Z" + type Piece struct{ Kind PieceKind; Rotation int; X, Y int } + func (p Piece) Cells() [4][2]int // absolute (x, y) of the four filled cells + func (p Piece) BoxSize() int // 4 for I, 2 for O, 3 for the rest + func SpawnPiece(k PieceKind) Piece // Rotation 0 at the spawn position + ``` + +Rotation is stored `0..3` and normalised by `Cells()`; `Rotation` is always kept +in range by the mutators in Task 5. + +Shape data lives in one place: a base 4×4/3×3/2×2 grid per kind in string art, and +`init()` derives rotations 1–3 by rotating the box clockwise +(`out[x][n-1-y] = in[y][x]`) into `var rotations [KindCount][4][4][4]bool`. This +is what §6's "four predefined rotations per piece" means here — four rotations +exist as data by the time any caller runs, they are just built from one base each +instead of 28 hand-typed grids. Rotating inside a per-kind box (4 for I, 3 for +J/L/S/T/Z, 2 for O) keeps the centre fixed, so pieces do not drift sideways when +rotated, and `O` is rotation-invariant for free (§6). + +Base art (`#` filled, `.` empty), left column is row 0: + +``` +I (4x4) O (2x2) T (3x3) J (3x3) L (3x3) S (3x3) Z (3x3) +.... ## .#. #.. ..# .## ##. +#### ## ### ### ### ##. .## +.... ... ... ... ... ... +.... +``` + +Spawn: `X = (Width - BoxSize()) / 2` (3 for I, 4 for O, 3 for the rest), `Y = 1`. +That puts every piece's lowest filled row on `y = 2`, the first visible row, so a +new piece is visible immediately while its box still overlaps the hidden rows. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestEveryKindHasFourCellsInEveryRotation(t *testing.T) +// For all 7 kinds x 4 rotations: Cells() returns 4 distinct coordinates. + +func TestORotationIsIdentical(t *testing.T) +// KindO at rotations 0..3 yields the same sorted cell set. + +func TestIRotationAlternatesHorizontalAndVertical(t *testing.T) +// Rotation 0 and 2: all four cells share a y. Rotation 1 and 3: all share an x. + +func TestTRotationsMatchExpectedCells(t *testing.T) +// Piece{KindT, 0, 0, 0} -> {(1,0),(0,1),(1,1),(2,1)} +// rotation 1 -> {(1,0),(1,1),(2,1),(1,2)} +// rotation 2 -> {(0,1),(1,1),(2,1),(1,2)} +// rotation 3 -> {(1,0),(0,1),(1,1),(1,2)} + +func TestRotationsStayInsideTheBox(t *testing.T) +// For all kinds and rotations, every cell offset is within [0, BoxSize()). + +func TestSpawnPositionsAreCentredAndVisible(t *testing.T) +// SpawnPiece(KindT).Cells() == {(4,1),(3,2),(4,2),(5,2)} +// SpawnPiece(KindI).Cells() all have y == 2 and x in 3..6 +// SpawnPiece(KindO).Cells() == {(4,1),(5,1),(4,2),(5,2)} +// Every kind: max y == 2, so exactly one row is visible at spawn. +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/game/ -run 'Rotation|Spawn|Kind' -v` +Expected: FAIL — undefined: `PieceKind`, `Piece`, `SpawnPiece`. + +- [ ] **Step 3: Implement** + +`internal/game/piece.go` per the interface block. Parse the string art once in +`init()`; `Cells()` walks the cached `[4][4]bool` and adds `p.X`, `p.Y`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/piece.go internal/game/piece_test.go +git commit -m "feat(game): tetromino kinds and derived rotation table" +``` + +--- + +### Task 3: Seeded 7-bag + +**Files:** +- Create: `internal/game/bag.go` +- Test: `internal/game/bag_test.go` + +**Interfaces:** +- Consumes: `PieceKind`, `KindCount` (Task 2). +- Produces: + ```go + type Bag struct { rng *rand.Rand; remaining []PieceKind } + func NewBag(rng *rand.Rand) *Bag + func (b *Bag) Next() PieceKind // refills and reshuffles when empty + ``` + +`rand` here is `math/rand/v2`. The generator is created by the caller +(Task 4) as `rand.New(rand.NewPCG(uint64(seed), 0x9E3779B97F4A7C15))` so the +whole engine is reproducible from one `int64`. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestEachBagContainsEverySevenExactlyOnce(t *testing.T) +// Pull 70 pieces; every consecutive group of 7 has all seven kinds once. + +func TestSameSeedProducesSameSequence(t *testing.T) +// Two bags from separately constructed PCG generators with seed 8675309 yield +// identical 40-piece sequences. + +func TestDifferentSeedsDiverge(t *testing.T) +// Seeds 1 and 2 differ somewhere within the first 40 pieces. + +func TestBagShuffles(t *testing.T) +// Across 20 bags from seed 42, at least two bags have different orderings +// (guards against forgetting to shuffle). +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/game/ -run Bag -v` +Expected: FAIL — undefined: `NewBag`. + +- [ ] **Step 3: Implement** + +`internal/game/bag.go`. `Next()` refills `remaining` with all seven kinds and +`b.rng.Shuffle` when it is empty, then pops from the end. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/bag.go internal/game/bag_test.go +git commit -m "feat(game): seeded 7-bag piece generator" +``` + +--- + +### Task 4: Game struct, spawning, events, and game over + +**Files:** +- Create: `internal/game/game.go`, `internal/game/events.go` +- Test: `internal/game/game_test.go` + +**Interfaces:** +- Consumes: everything from Tasks 1–3. +- Produces: + ```go + const NextQueueLen = 5 // §6: render the next five + + type Game struct { + Board Board + Active Piece + Hold *PieceKind + CanHold bool + Next []PieceKind // always NextQueueLen long + Score int + Lines int + Level int + Combo int + Over bool + Seed int64 + bag *Bag + rng *rand.Rand + gravityAcc time.Duration + lockAcc time.Duration + lockResets int + grounded bool + } + func New(seed int64) *Game + func (g *Game) Ghost() Piece // Active dropped to its landing row + func (g *Game) Apply(in Input) []Event + func (g *Game) Advance(dt time.Duration) []Event + + type Input int + const (InputNone Input = iota; InputLeft; InputRight; InputSoftDrop; + InputHardDrop; InputRotateCW; InputRotateCCW; InputHold) + + type EventKind int + const (EvPieceMoved EventKind = iota; EvPieceRotated; EvPieceHardDropped; + EvPieceLocked; EvHoldUsed; EvLinesCleared; EvComboChanged; + EvLevelChanged; EvGameOver) + type Event struct { + Kind EventKind + Piece Piece // the piece involved, at its position after the action + Rows []int // EvLinesCleared: cleared row indices, pre-collapse + Count int // EvLinesCleared: len(Rows). EvPieceHardDropped: cells fallen. + // EvComboChanged: new combo. EvLevelChanged: new level. + } + ``` + +§14 lists the events as separate Go types; one flat struct with a `Kind` is the +same information in a form that `[]Event` can carry without boxing, which §49.2's +`Advance(dt) []Event` signature requires. The FX layer switches on `Kind`. + +This task implements only: construction, the next queue, `spawn()`, `Ghost()`, +and the over-state guards. `Apply` and `Advance` exist but handle nothing beyond +the guards yet; Tasks 5–9 fill them in. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestNewGameStartsWithFullQueueAndActivePiece(t *testing.T) +// len(Next) == 5; Level == 1; Score, Lines, Combo == 0; CanHold true; !Over; +// Active is a spawn-position piece; Seed recorded. + +func TestNextQueueRefillsAsPiecesSpawn(t *testing.T) +// After forcing 10 spawns, len(Next) is still 5 and the pieces consumed match +// the order a bag with the same seed produces. + +func TestGhostSitsOnTheStack(t *testing.T) +// Empty board: every Ghost cell has y in the bottom rows and Ghost cannot move +// down. With row 21 filled, Ghost's lowest cells are in row 20. + +func TestGhostDoesNotMoveTheActivePiece(t *testing.T) +// Active.Y is unchanged after calling Ghost(). + +func TestSpawnIntoOccupiedCellsEndsTheGame(t *testing.T) +// Fill visible rows 2 and 3 completely, lock the active piece, and assert: +// Over is true and an EvGameOver event was returned. + +func TestApplyAndAdvanceAreNoOpsAfterGameOver(t *testing.T) +// Set Over via a blocked spawn, snapshot Score/Lines/Board/Active, then call +// Apply for every Input value and Advance(1s): no events returned, snapshot +// unchanged. Review Focus 3. +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/game/ -run 'NewGame|Queue|Ghost|Spawn|Apply' -v` +Expected: FAIL — undefined: `New`, `Ghost`, `Apply`. + +- [ ] **Step 3: Implement** + +`internal/game/events.go` holds `EventKind`, `Event`, `Input`. `internal/game/game.go` +holds `Game`, `New`, `Ghost`, the `spawn()` helper (pops `Next[0]`, tops the queue +up from the bag, resets `CanHold`, `gravityAcc`, `lockAcc`, `lockResets`, +`grounded`, and sets `Over` plus an `EvGameOver` event if the new piece collides), +and `Apply`/`Advance` bodies that `return nil` when `g.Over`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/game.go internal/game/events.go internal/game/game_test.go +git commit -m "feat(game): game state, spawning, event type, game over" +``` + +--- + +### Task 5: Movement and wall-kick rotation + +**Files:** +- Modify: `internal/game/game.go` +- Create: `internal/game/rules.go` +- Test: `internal/game/movement_test.go` + +**Interfaces:** +- Consumes: Task 4's `Game`, `Apply`, `Event`. +- Produces: + ```go + var KickOffsets = [8][2]int{{0,0},{-1,0},{1,0},{-2,0},{2,0},{0,-1},{-1,-1},{1,-1}} + const (LockDelay = 500 * time.Millisecond; MaxLockResets = 15) + func (b *Board) Fits(p Piece) bool // no cell Occupied + func (g *Game) tryMove(dx, dy int) bool // moves Active if it fits + func (g *Game) tryRotate(dir int) bool // dir +1 CW, -1 CCW; walks KickOffsets + ``` + +`Apply` now handles `InputLeft`, `InputRight`, `InputRotateCW`, `InputRotateCCW`, +emitting `EvPieceMoved` / `EvPieceRotated` only on success. A successful move or +rotation while `grounded` resets `lockAcc` to 0 and increments `lockResets`, up to +`MaxLockResets` (§12) — past that, further successes still move the piece but no +longer reset the timer. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestMoveLeftAndRightShiftTheActivePiece(t *testing.T) +// InputLeft decrements X by 1 and returns one EvPieceMoved. + +func TestMoveIntoWallFailsSilently(t *testing.T) +// Push the piece to x=0 and apply InputLeft: X unchanged, no events. + +func TestMoveIntoLockedCellsFails(t *testing.T) +// Place a wall of cells to the right of the active piece: InputRight is refused. + +func TestRotateAdvancesRotationModuloFour(t *testing.T) +// Four InputRotateCW returns the piece to rotation 0; InputRotateCCW from 0 gives 3. + +func TestRotationKicksOffTheLeftWall(t *testing.T) +// Vertical I at x=-? -> place a piece so the naive rotation overlaps the wall; +// assert the resulting X equals the first KickOffsets entry that fits, and one +// EvPieceRotated is returned. + +func TestRotationKicksTriedInSpecOrder(t *testing.T) +// Construct a board where only the (-2,0) offset fits and assert the piece lands +// exactly two cells left, proving earlier offsets were tried and rejected first. + +func TestRotationFailsWhenNoOffsetFits(t *testing.T) +// Box the piece in on all sides: rotation unchanged, no events. + +func TestRotationKickAboveCeilingDoesNotPanic(t *testing.T) +// Spawn-height piece with the row below full so only a (0,-1)/( -1,-1) offset +// fits: rotation succeeds with Y == 0 or -1, no panic. Review Focus 4. + +func TestGroundedMoveResetsLockTimerUpToTheCap(t *testing.T) +// Ground a piece, Advance(400ms), apply InputLeft, Advance(400ms): still not +// locked. Repeat the reset 16 times and assert the 16th does not prevent locking. +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/game/ -run 'Move|Rotat|Grounded' -v` +Expected: FAIL — undefined: `KickOffsets`, and moves are no-ops. + +- [ ] **Step 3: Implement** + +`internal/game/rules.go` holds `KickOffsets`, `LockDelay`, `MaxLockResets`. +`Board.Fits` goes in `board.go`. `tryMove`/`tryRotate` and the `Apply` cases go in +`game.go`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/rules.go internal/game/board.go internal/game/game.go internal/game/movement_test.go +git commit -m "feat(game): movement and forgiving wall-kick rotation" +``` + +--- + +### Task 6: Gravity, lock delay, and locking + +**Files:** +- Modify: `internal/game/game.go`, `internal/game/rules.go` +- Test: `internal/game/gravity_test.go` + +**Interfaces:** +- Consumes: Tasks 4–5. +- Produces: + ```go + const (BaseInterval = 800 * time.Millisecond; MinInterval = 60 * time.Millisecond) + func DropInterval(level int) time.Duration // max(MinInterval, 800ms * 0.86^(level-1)) + func (g *Game) lock() []Event // commit, clear, score, spawn + ``` + +`Advance(dt)`: return `nil` if `g.Over` or `dt <= 0`. Otherwise add `dt` to +`gravityAcc` and step down while `gravityAcc >= DropInterval(g.Level)`, breaking +out the moment a step is refused, and capping the loop at `Height` iterations so a +pathological `dt` cannot spin. Then, if the piece cannot move down, add `dt` to +`lockAcc` and call `lock()` once `lockAcc >= LockDelay`; if it can move down, +`grounded` is false and `lockAcc` resets to 0. + +`lock()` runs §12's order: commit the piece cells to the board, detect full rows, +clear them, update score (Task 7), emit `EvPieceLocked` then `EvLinesCleared` / +`EvComboChanged` / `EvLevelChanged`, then spawn (which may append `EvGameOver`). +Task 6 wires the sequence with scoring stubbed at zero; Task 7 fills in the maths. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestDropIntervalHalvesRoughlyEveryFiveLevels(t *testing.T) +// level 1 == 800ms; level 2 == 688ms; level 5 is within 1ms of 800*0.86^4. + +func TestDropIntervalClampsAtSixtyMilliseconds(t *testing.T) +// Levels 20, 30, 300 all return exactly MinInterval and never <= 0. Review Focus 5. + +func TestGravityStepsOnceAfterOneInterval(t *testing.T) +// Advance(799ms): Y unchanged. Advance(2ms): Y+1, one EvPieceMoved. + +func TestGravityCarriesRemainderBetweenFrames(t *testing.T) +// Ten Advance(100ms) calls produce exactly one step, not zero and not two. + +func TestAdvanceWithZeroOrNegativeDtDoesNothing(t *testing.T) +// Advance(0) and Advance(-50ms): no events, Y and accumulators unchanged. +// Review Focus 1. + +func TestHugeDtDoesNotPushPieceThroughTheFloor(t *testing.T) +// Level 20 (MinInterval), empty board, Advance(10s): every Active cell has +// y < Height, the piece is resting on the floor or already locked, and the call +// returns without hanging. Review Focus 2. + +func TestPieceLocksAfterLockDelayOnTheFloor(t *testing.T) +// Drop to the floor, Advance(499ms): Active unchanged, board empty. +// Advance(2ms): board holds four cells, EvPieceLocked returned, new Active +// spawned at spawn height. + +func TestLockCommitsExactlyTheActiveCells(t *testing.T) +// After a lock, the set of non-empty board cells equals the pre-lock Cells(), +// each holding Cell(kind)+1. +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/game/ -run 'DropInterval|Gravity|Advance|Lock' -v` +Expected: FAIL — undefined: `DropInterval`. + +- [ ] **Step 3: Implement** + +Add `DropInterval` (`math.Pow(0.86, float64(level-1))`) and `MinInterval`/`BaseInterval` +to `rules.go`; fill in `Advance` and `lock` in `game.go`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/rules.go internal/game/game.go internal/game/gravity_test.go +git commit -m "feat(game): elapsed-time gravity, lock delay, locking" +``` + +--- + +### Task 7: Scoring, combo, and level progression + +**Files:** +- Create: `internal/game/scoring.go` +- Modify: `internal/game/game.go` +- Test: `internal/game/scoring_test.go` + +**Interfaces:** +- Consumes: Task 6's `lock()`. +- Produces: + ```go + func ClearScore(lines, level int) int // 0/100/300/500/800 x level + func ComboBonus(combo, level int) int // 50 * (combo-1) * level, 0 below combo 2 + func LevelForLines(lines int) int // 1 + lines/10 + ``` + +`lock()` now: `Lines += n`; `Score += ClearScore(n, level) + ComboBonus(combo, level)` +using the level *before* the level-up (so a clear is paid at the level it happened +at); `Combo` becomes `Combo+1` when `n > 0` and `0` when `n == 0`, emitting +`EvComboChanged` whenever the value changes; `Level` recomputed with +`LevelForLines`, emitting `EvLevelChanged` when it moves. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestClearScoreMatchesSpecTable(t *testing.T) +// level 1: 100/300/500/800 for 1..4 lines. level 7: 700/2100/3500/5600. 0 lines: 0. + +func TestComboBonusStartsAtComboTwo(t *testing.T) +// ComboBonus(0,5) == 0; ComboBonus(1,5) == 0; ComboBonus(2,5) == 250; +// ComboBonus(4,3) == 450. + +func TestComboIncrementsOnConsecutiveClears(t *testing.T) +// Two clearing locks in a row: Combo == 2 and an EvComboChanged{Count: 2} fired. + +func TestNonClearingPlacementResetsCombo(t *testing.T) +// Combo 3, then a lock that clears nothing: Combo == 0, EvComboChanged{Count: 0}. + +func TestNonClearingPlacementAtComboZeroEmitsNoComboEvent(t *testing.T) +// Combo already 0: no EvComboChanged (guards against event spam every lock). + +func TestLevelRisesEveryTenLines(t *testing.T) +// LevelForLines: 0->1, 9->1, 10->2, 19->2, 20->3, 127->13. + +func TestLevelChangedEventFiresOnceOnCrossing(t *testing.T) +// Engineer the board so a lock takes Lines from 9 to 10: exactly one +// EvLevelChanged{Count: 2}. + +func TestClearIsPaidAtThePreLevelUpLevel(t *testing.T) +// A single-line clear taking lines 9->10 scores 100*1, not 100*2. +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/game/ -run 'Score|Combo|Level' -v` +Expected: FAIL — undefined: `ClearScore`. + +- [ ] **Step 3: Implement** + +`internal/game/scoring.go` with the three pure functions; wire them into `lock()`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/scoring.go internal/game/game.go internal/game/scoring_test.go +git commit -m "feat(game): clear scoring, combo bonus, level progression" +``` + +--- + +### Task 8: Soft drop and hard drop + +**Files:** +- Modify: `internal/game/game.go`, `internal/game/scoring.go` +- Test: `internal/game/drop_test.go` + +**Interfaces:** +- Consumes: Tasks 5–7. +- Produces: + ```go + const (SoftDropPoints = 1; HardDropPoints = 2) + ``` + +`Apply(InputSoftDrop)`: try one step down; on success `Score += SoftDropPoints` +and emit `EvPieceMoved`, and reset `gravityAcc` so soft drop and gravity do not +double-step. On failure, nothing (lock delay owns locking). + +`Apply(InputHardDrop)`: step down until refused, counting cells; `Score += 2 * cells`; +emit `EvPieceHardDropped{Piece: final, Count: cells}`, then lock immediately — +hard drop does not wait out the lock delay, which is what makes §18's impact land +on the same frame as the key. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestSoftDropMovesOneCellAndScoresOne(t *testing.T) +// Y+1, Score+1, one EvPieceMoved. + +func TestSoftDropOnTheFloorScoresNothing(t *testing.T) +// Grounded piece: Y and Score unchanged, no events, piece not yet locked. + +func TestHardDropLandsOnTheStackAndScoresTwoPerCell(t *testing.T) +// From spawn on an empty board: final cells match Ghost()'s cells before the +// drop, Score == 2 * cells fallen, and EvPieceHardDropped.Count equals that count. + +func TestHardDropLocksImmediately(t *testing.T) +// Events include EvPieceHardDropped then EvPieceLocked, a new piece is active, +// and the board holds the dropped cells with no Advance call in between. + +func TestHardDropWithNowhereToFallScoresZeroAndStillLocks(t *testing.T) +// Grounded piece: EvPieceHardDropped.Count == 0, Score unchanged, EvPieceLocked +// present. + +func TestHardDropThatClearsALineReportsBothEvents(t *testing.T) +// Board one cell short of a full row: events contain EvPieceHardDropped, +// EvPieceLocked, and EvLinesCleared with the right Rows. +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/game/ -run Drop -v` +Expected: FAIL — soft/hard drop inputs are no-ops. + +- [ ] **Step 3: Implement** + +Add the two constants and the two `Apply` cases. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/game.go internal/game/scoring.go internal/game/drop_test.go +git commit -m "feat(game): soft drop and hard drop with drop scoring" +``` + +--- + +### Task 9: Hold + +**Files:** +- Modify: `internal/game/game.go` +- Test: `internal/game/hold_test.go` + +**Interfaces:** +- Consumes: Tasks 4–8. +- Produces: `Apply(InputHold)` behaviour; no new exported names. + +Rules (§9): if `!CanHold`, refuse. If `Hold == nil`, store the active kind and +spawn the next piece from the queue. Otherwise swap: the held kind becomes a fresh +spawn-rotation piece at the spawn position, the active kind goes into `Hold`. +Either way `CanHold = false`, timers reset, and one `EvHoldUsed` is emitted +carrying the *incoming* piece. `CanHold` returns to true on the next lock-driven +spawn. A hold that would place the incoming piece into occupied cells ends the +game exactly as a blocked spawn does. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestFirstHoldStoresActiveAndPullsFromQueue(t *testing.T) +// Hold points at the old active kind; the new Active is the old Next[0]; +// len(Next) still 5; one EvHoldUsed. + +func TestSecondHoldBeforeLockIsRefused(t *testing.T) +// Two InputHold in a row: second returns no events and changes nothing. + +func TestHoldSwapsWithStoredPiece(t *testing.T) +// Hold an I, lock a piece, hold again: Active is the I at spawn position and +// rotation 0, Hold now holds the kind that was active. + +func TestHeldPieceReturnsAtSpawnRotation(t *testing.T) +// Rotate the active piece to rotation 2, hold it, lock a piece, hold again: +// Active.Rotation == 0 and Active is at the spawn X/Y for its kind. + +func TestHoldIsAvailableAgainAfterALock(t *testing.T) +// Hold, hard drop, then hold: succeeds. + +func TestHoldDoesNotChangeScoreOrLines(t *testing.T) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/game/ -run Hold -v` +Expected: FAIL — `InputHold` is a no-op. + +- [ ] **Step 3: Implement** + +The `InputHold` case in `Apply`, reusing the `spawn()` helper's collision-and- +game-over path. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/game.go internal/game/hold_test.go +git commit -m "feat(game): hold with once-per-piece restriction" +``` + +--- + +### Task 10: Determinism replay test + +**Files:** +- Create: `internal/game/replay_test.go` +- Test: same file (this task is entirely test code plus whatever it exposes) + +**Interfaces:** +- Consumes: `New`, `Apply`, `Advance`, all state fields. +- Produces: + ```go + func (g *Game) Fingerprint() uint64 // FNV-1a over board cells, then + // Score, Lines, Level, Combo, Active, + // Hold, Next — in that order + ``` + +This is §35's promise made checkable and §49.2's reason for existing: timing is an +input, so a canned `(Input, dt)` stream is a complete description of a session. + +- [ ] **Step 1: Write the failing test** + +```go +type step struct{ in Input; dt time.Duration } + +func canned() []step // ~200 deterministic steps built from a fixed pattern: + // rotate/left/right/soft/hard-drop/hold interleaved with + // dt values of 0, 17ms, 40ms and 250ms. No randomness. + +func TestReplayIsReproducible(t *testing.T) +// Two games from seed 8675309 fed canned(): identical Fingerprint(), Score, +// Lines, Level, Combo, Board, Next. + +func TestReplayFingerprintIsStable(t *testing.T) +// One game from seed 8675309 fed canned(): Fingerprint() equals a constant +// recorded in the test. Fill the constant in from the first run; this pins the +// engine against accidental behaviour changes. + +func TestReplayDiffersBySeed(t *testing.T) +// Seeds 8675309 and 1234 fed the same stream: different Fingerprint(). + +func TestNoClockCallsInGamePackage(t *testing.T) +// go/parser walks every non-test .go file in the package directory and fails if +// any selector expression is time.Now, time.Since, or time.Tick. Review: this is +// the executable form of the Global Constraint from 49.2. +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/game/ -run Replay -v` +Expected: FAIL — undefined: `Fingerprint`. + +- [ ] **Step 3: Implement** + +Add `Fingerprint()` to `game.go` using `hash/fnv`. Run the replay test once to +read the fingerprint, then paste it into `TestReplayFingerprintIsStable`. + +- [ ] **Step 4: Run the whole suite** + +Run: `go test ./internal/game/ -v -count=1` +Expected: PASS. Also run `go vet ./...` and `gofmt -l internal cmd` (expect no output). + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/game.go internal/game/replay_test.go +git commit -m "test(game): canned-input replay pins determinism" +``` + +--- + +## Done when + +`go test ./internal/game/ -count=1` is green and covers every §40 bullet under +Board, Pieces, Bag, Hold, Drop, Score, Game over, and Determinism. The package +imports nothing outside the standard library, contains no clock call, and exposes +`Apply`, `Advance`, `Ghost`, `Fingerprint` plus the state fields that Plan 02's +renderer reads. diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/P1-proportion-cosmic-52/2026-09-17-cosmic-tetris-02-playable-terminal.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/P1-proportion-cosmic-52/2026-09-17-cosmic-tetris-02-playable-terminal.md new file mode 100644 index 000000000..2163a5d87 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/P1-proportion-cosmic-52/2026-09-17-cosmic-tetris-02-playable-terminal.md @@ -0,0 +1,921 @@ +# Cosmic Tetris — Plan 02: Playable Terminal Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Turn Plan 01's headless engine into a genuinely good, resizable, keyboard-immediate terminal Tetris — no cosmic effects yet. + +**Architecture:** Bubble Tea owns the clock and the input; `internal/render` is a pure function from (game snapshot, terminal size, mode) to a string. Everything is composited into one character grid (`render.Grid`) so that Plan 03/04 can drop stars, particles, and a shake offset into the same surface without string surgery. `internal/app` holds the Bubble Tea model and is the only place that touches `time.Now()`. + +**Tech Stack:** Go 1.26, `charm.land/bubbletea/v2 v2.0.9`, `charm.land/lipgloss/v2 v2.0.6`, `charm.land/bubbles/v2 v2.2.1`, `github.com/charmbracelet/x/ansi` (ANSI stripping in tests), `github.com/charmbracelet/colorprofile` (capability detection). + +**Spec:** `design.md` (§4, §8, §9 mechanics side, §10, §31, §32 modes, §33, §34, §36, §37, §39, §41, §46, §49.3, §49.4, §49.5) + +## Global Constraints + +- Bubble Tea is the application/event loop; do not abstract it behind a homegrown framework (§3). +- §33's tree is the package structure, not a filename whitelist: these plans add one small file per subsystem inside `internal/render`, `internal/app`, and `internal/fx`. No new package, no file over a few hundred lines. +- Use Bubbles only for key bindings, help, and the boot spinner (§3). +- Verified v2 API shapes — the model interface is `Init() tea.Cmd`, `Update(tea.Msg) (tea.Model, tea.Cmd)`, `View() tea.View`. Alt screen is a field, not an option: `v := tea.NewView(s); v.AltScreen = true`. Key presses arrive as `tea.KeyPressMsg`. There is no `tea.WithAltScreen` in v2.0.9. +- One logical block occupies `2 terminal columns × 1 terminal row` (§5). Pieces render as filled glyphs with a bright foreground — not a foreground/background pair (§49.4). +- Glyphs: pieces `██`, ASCII `[]`; ghost `░░`, ASCII `··` (§49.4). +- Active piece renders one step brighter than locked cells (§49.4). +- Minimum usable terminal `~40 columns × ~24 rows`; below that show the too-small notice (§31). +- Small-terminal drop order: title border, then mission control, then stats labels. NEXT never stacks above or below the board; it moves beside the board and truncates to 3 pieces. Board and controls are last (§49.3). +- CLI surface is exactly: bare, `--seed 1234`, `--ascii`, `--no-fx`, `--reduced-motion`, `--help` (§49.5). +- Rendering must not mutate game state (§37). `internal/render` takes values and pointers-to-const-use only; it never calls `Apply` or `Advance`. +- Input must not wait for ticks (§36, §44). Key handling calls `game.Apply` inside `Update` immediately. +- Never crash from terminal resizing (§31). +- The §4 mockup is mood, not geometry. The ANSI-stripped golden tests are the binding layout contract (§49.7). + +## Review Focus + +1. **`WindowSizeMsg{0, 0}`** — several terminals and every `WithoutRenderer` test send a zero or 1×1 size before the real one. `View()` must return a non-empty string, and no lipgloss call may receive a negative width. *(Task 6)* +2. **Sub-minimum terminals** — at 34×19 the too-small notice itself does not fit. It must truncate to the available box rather than wrap into garbage or panic. *(Task 6)* +3. **Resize while an overlay is open** — pause, help, and game-over panels must re-centre on the next frame and must not panic when the new size is smaller than the panel. *(Task 8)* +4. **Several keys inside one frame** — a fast player (or held-key auto-repeat) produces multiple `KeyPressMsg` between two `FrameMsg`s. Every one must be applied, and none may disturb the gravity accumulator except soft drop, which resets it by design. *(Task 7)* +5. **`r` from pause and from game over** — restart must produce a fresh game on the same seed and return the app to `StatePlaying` from any state, including mid-overlay. *(Task 7)* + +--- + +### Task 1: Character grid compositing surface + +**Files:** +- Create: `internal/render/grid.go` +- Test: `internal/render/grid_test.go` + +**Interfaces:** +- Consumes: nothing. +- Produces: + ```go + type Cell struct { R rune; Style lipgloss.Style; Styled bool } + type Grid struct { W, H int; cells []Cell } + func NewGrid(w, h int) *Grid + func (g *Grid) Resize(w, h int) // reuses the backing slice when it fits + func (g *Grid) Clear() + func (g *Grid) Set(x, y int, r rune, st lipgloss.Style) // out of bounds: no-op + func (g *Grid) SetPlain(x, y int, r rune) + func (g *Grid) Text(x, y int, s string, st lipgloss.Style) // left-to-right, clipped + func (g *Grid) Blit(x, y int, block string, st lipgloss.Style) // multi-line, clipped + func (g *Grid) String() string // rows joined by \n, trailing blanks trimmed + ``` + +`String()` groups runs of consecutive cells sharing a style and renders each run +with one `st.Render(run)` call, so a 80×24 frame costs tens of style renders, not +two thousand (§38). Unstyled cells render as raw runes. Empty cells are spaces. + +Every write is silently clipped. That single decision is what makes resize-safety +and Review Focus 1/2 cheap for every later task. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestSetAndStringRoundTrip(t *testing.T) +// 3x2 grid, SetPlain a few runes, String() == "ab\n c" style expectation with +// trailing spaces trimmed per line. + +func TestWritesOutsideBoundsAreDropped(t *testing.T) +// Set at (-1,0), (0,-1), (W,0), (0,H): no panic, grid unchanged. + +func TestTextClipsAtRightEdge(t *testing.T) +// 4-wide grid, Text(2, 0, "hello"): row reads " he". + +func TestBlitPlacesMultilineBlock(t *testing.T) +// Blit a 3-line box at (1,1) in a 10x6 grid: lines land at rows 1..3, column 1. + +func TestBlitClipsBelowBottom(t *testing.T) +// A 5-line block blitted at y=4 of a 6-row grid keeps rows 4..5 and drops the rest. + +func TestZeroSizedGridStringIsEmpty(t *testing.T) +// NewGrid(0, 0).String() == "" and Set does not panic. Review Focus 1. + +func TestResizePreservesNothingButDoesNotLeak(t *testing.T) +// Resize(80,24) then Resize(40,12) then String(): 12 lines, each at most 40 wide. + +func TestStringGroupsAdjacentSameStyleRuns(t *testing.T) +// Set five adjacent cells with one style; assert the raw output contains exactly +// one occurrence of that style's SGR prefix on that row. +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/render/ -v` +Expected: build failure — package does not exist. + +- [ ] **Step 3: Implement** + +`go get charm.land/lipgloss/v2@v2.0.6` then write `grid.go` per the interfaces. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add go.mod go.sum internal/render/grid.go internal/render/grid_test.go +git commit -m "feat(render): clipped character grid compositing surface" +``` + +--- + +### Task 2: Palette and render modes + +**Files:** +- Create: `internal/render/palette.go` +- Test: `internal/render/palette_test.go` + +**Interfaces:** +- Consumes: `game.PieceKind` (Plan 01 Task 2). +- Produces: + ```go + type Mode int + const (ModeFull Mode = iota; ModeReduced; ModeASCII) + func ModeForProfile(p colorprofile.Profile, forceASCII bool) Mode + type Palette struct { Mode Mode } + func NewPalette(m Mode) Palette + func (p Palette) Block() string // "██" or "[]" + func (p Palette) Ghost() string // "░░" or "··" + func (p Palette) Locked(k game.PieceKind) lipgloss.Style + func (p Palette) Active(k game.PieceKind) lipgloss.Style // one step brighter + func (p Palette) GhostStyle() lipgloss.Style + func (p Palette) Chrome() lipgloss.Style // borders, box lines + func (p Palette) Label() lipgloss.Style // dim HUD labels + func (p Palette) Value() lipgloss.Style // bright HUD numbers + func (p Palette) Accent() lipgloss.Style // ✦ marks, titles + ``` + +Piece hues follow §26's intent, as truecolor hex in `ModeFull` and the nearest +ANSI-256 index in `ModeReduced`; `ModeASCII` keeps the eight basic ANSI colors: + +``` +I plasma cyan #22e4f0 / 45 J deep electric blue #3a5cf0 / 27 +L solar orange #ff8c22 / 208 O stellar gold #ffd23f / 220 +S alien green #46e06a / 41 T ultraviolet #a95cff / 141 +Z supernova pink #ff3d68 / 197 +``` + +`Active` is the same hue with `Bold(true)` and a lightened hex (mix 35% toward +white); locked cells use the base hue unbolded. That is §49.4's "one step +brighter" without inventing a second palette. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestModeForProfileMapsCapabilities(t *testing.T) +// TrueColor -> ModeFull; ANSI256 -> ModeReduced; ANSI and Ascii and NoTTY -> +// ModeASCII; forceASCII overrides TrueColor to ModeASCII. + +func TestBlockAndGhostGlyphsPerMode(t *testing.T) +// ModeFull and ModeReduced: "██" and "░░". ModeASCII: "[]" and "··". + +func TestASCIIModeEmitsOnlyASCIIRunes(t *testing.T) +// Every rune of Block(), Ghost(), and the box-drawing set returned by Chrome- +// adjacent helpers in ModeASCII is < 128. Repeated end-to-end in Task 8. + +func TestEveryKindHasADistinctColor(t *testing.T) +// The seven Locked styles have seven distinct foreground values in ModeFull. + +func TestActiveIsBrighterThanLocked(t *testing.T) +// For each kind, Active differs from Locked and is bold. +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/render/ -run 'Mode|Glyph|ASCII|Kind|Active' -v` +Expected: FAIL — undefined: `NewPalette`. + +- [ ] **Step 3: Implement** + +`go get github.com/charmbracelet/colorprofile` then write `palette.go`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add go.mod go.sum internal/render/palette.go internal/render/palette_test.go +git commit -m "feat(render): neon space palette and three render modes" +``` + +--- + +### Task 3: Board rendering with ghost and active piece + +**Files:** +- Create: `internal/render/board.go` +- Test: `internal/render/board_test.go` + +**Interfaces:** +- Consumes: `Grid`, `Palette`, `game.Board`, `game.Piece`, `game.Ghost`. +- Produces: + ```go + const (BoardInnerW = game.Width * 2 // 20 columns + BoardInnerH = game.VisibleRows // 20 rows + BoardOuterW = BoardInnerW + 2 + BoardOuterH = BoardInnerH + 2) + type BoardView struct { + Board game.Board + Active game.Piece + Ghost game.Piece + ShowGhost bool + } + // DrawBoard writes the border and interior into g with the outer top-left at + // (ox, oy). border is the four-corner box style Chrome() unless a caller + // (Plan 03) overrides it. + func DrawBoard(g *Grid, ox, oy int, v BoardView, p Palette, border lipgloss.Style) + ``` + +Draw order inside the board region follows §37 steps 3–5: locked cells, then +ghost, then active. The ghost only writes into cells that are empty in the board +*and* not covered by the active piece — that is §10's "must never obscure locked +blocks", enforced by construction rather than by draw order luck. + +Only rows `HiddenRows..Height-1` are drawn; the two spawn rows are never visible. + +- [ ] **Step 1: Write the failing tests** + +Tests strip ANSI with `ansi.Strip` and assert on plain text. + +```go +func TestEmptyBoardIsBorderedAndBlank(t *testing.T) +// A 22x22 grid drawn at (0,0): 22 lines; top line is "╔" + 20 "═" + "╗"; +// interior lines are "║" + 20 spaces + "║". + +func TestLockedCellsRenderAsTwoColumnBlocks(t *testing.T) +// One cell at board (0, 21) -> last interior row starts with "██" after the "║". + +func TestHiddenRowsAreNotDrawn(t *testing.T) +// Cells at y=0 and y=1 produce a completely blank interior. + +func TestActivePieceIsDrawn(t *testing.T) +// A T piece at a known position appears at the expected columns/rows. + +func TestGhostIsDrawnBeneathActive(t *testing.T) +// Active near the top with Ghost on the floor: ghost rows show "░░", active rows +// show "██", and no row shows a ghost glyph where the active piece sits. + +func TestGhostNeverOverwritesLockedCells(t *testing.T) +// Place locked cells overlapping the ghost's cells; those positions still read +// "██", not "░░". (§10) + +func TestASCIIModeUsesBracketsAndDots(t *testing.T) +// Same scene in ModeASCII: "[]" for blocks, "··" for ghost, and every output +// rune is < 128 including the border. + +func TestDrawBoardAtOffsetDoesNotWriteOutsideTheGrid(t *testing.T) +// Draw at (70, 20) in an 80x24 grid: no panic, output has 24 lines. +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/render/ -run Board -v` +Expected: FAIL — undefined: `DrawBoard`. + +- [ ] **Step 3: Implement** + +`board.go`. In `ModeASCII` the border uses `+`, `-`, `|`; in the other modes +`╔ ═ ╗ ║ ╚ ╝`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/board.go internal/render/board_test.go +git commit -m "feat(render): board, ghost, and active piece drawing" +``` + +--- + +### Task 4: HUD panels — hold, next, stats, controls + +**Files:** +- Create: `internal/render/hud.go` +- Test: `internal/render/hud_test.go` + +**Interfaces:** +- Consumes: `Grid`, `Palette`, `game.PieceKind`. +- Produces: + ```go + type Stats struct { Score, Lines, Level, Combo int; Seed int64 } + func DrawMini(g *Grid, ox, oy int, k game.PieceKind, p Palette) // 8x2 piece thumbnail + func DrawHold(g *Grid, ox, oy int, held *game.PieceKind, p Palette, label bool) + func DrawNext(g *Grid, ox, oy int, next []game.PieceKind, count int, p Palette, label bool) + func DrawStats(g *Grid, ox, oy int, s Stats, p Palette, labels bool) + func DrawControls(g *Grid, ox, oy, width int, p Palette) // one line, truncated to width + func DrawTitle(g *Grid, ox, oy, width int, seed int64, p Palette) + ``` + +`DrawMini` renders a kind's rotation-0 cells inside a fixed 8×2 area so the HOLD +and NEXT columns never jitter as pieces change. `labels bool` is §49.3's third +drop: false renders `042` where true renders `LINES` above `042`. + +`DrawControls` renders `←→ move ↑ rotate ↓ descend SPACE YEET C hold ? help` +and truncates from the right at `width`, dropping whole segments rather than +cutting mid-word. + +`DrawTitle` renders `╭─ ✦ COSMIC TETRIS ── LOCAL UNIVERSE 7F3A ─...─╮`, where the +universe id is `fmt.Sprintf("%04X", uint16(seed))` — the seed made decorative, as +§4's mockup implies. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestMiniPieceFitsEightByTwo(t *testing.T) +// Each of the seven kinds drawn into a clean 8x2 grid: no line exceeds 8 columns +// and at most 2 lines are non-blank. + +func TestHoldShowsEmptyStateWhenNothingHeld(t *testing.T) +// held == nil: the thumbnail area is blank, no panic. + +func TestNextRendersRequestedCount(t *testing.T) +// count 5 -> five thumbnails at 3-row spacing; count 3 -> three, and nothing is +// drawn in the rows the other two would have used. + +func TestNextCountLargerThanQueueIsClamped(t *testing.T) +// A 2-element queue with count 5 draws two thumbnails and does not panic. + +func TestStatsWithLabels(t *testing.T) +// Output contains "SCORE", "00129340" zero-padded to 8, "LINES", "042" padded to +// 3, "LEVEL", "07" padded to 2. + +func TestStatsWithoutLabels(t *testing.T) +// Same values, labels false: contains the numbers, contains none of the words. + +func TestControlsTruncateToWidth(t *testing.T) +// width 40: output is at most 40 columns and ends at a segment boundary +// (no partial word). + +func TestTitleFillsExactWidth(t *testing.T) +// width 66: the rendered line is exactly 66 columns and ends with "╮". + +func TestTitleShowsSeedAsUniverseID(t *testing.T) +// seed 0x7F3A -> output contains "7F3A". +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/render/ -run 'Mini|Hold|Next|Stats|Controls|Title' -v` +Expected: FAIL — undefined: `DrawHold`. + +- [ ] **Step 3: Implement** + +`hud.go` per the interfaces. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/hud.go internal/render/hud_test.go +git commit -m "feat(render): hold, next, stats, controls, and title chrome" +``` + +--- + +### Task 5: Overlay panels — pause, help, game over + +**Files:** +- Create: `internal/render/overlay.go` +- Test: `internal/render/overlay_test.go` + +**Interfaces:** +- Consumes: `Grid`, `Palette`, `Stats`, `bubbles/v2/key`, `bubbles/v2/help`. +- Produces: + ```go + func DrawCentredPanel(g *Grid, title string, lines []string, p Palette) + func DrawPause(g *Grid, p Palette) + func DrawHelp(g *Grid, p Palette, h help.Model, keys help.KeyMap) + func DrawGameOver(g *Grid, s Stats, p Palette) + ``` + +`DrawCentredPanel` builds the box with lipgloss (`Border(lipgloss.RoundedBorder())`, +`Padding(1, 2)`), then blits it at the grid's centre. If the box is wider or taller +than the grid, the blit clips (Task 1) — that is Review Focus 3's guarantee, and +`DrawCentredPanel` additionally clamps the panel's content width to `g.W - 2` so a +narrow terminal gets a narrow panel instead of a clipped one. + +Copy is fixed by the spec: pause is `TEMPORAL SUSPENSION` / `SPACE IS PAUSED` / +`p resume` (§30); game over is `UNIVERSE EXPIRED`, the three stat lines, +`r REBOOT UNIVERSE`, `q ACCEPT COSMIC DEATH`, subtitle `CAUSE: EXCESSIVE GEOMETRY` +(§28); help is `FLIGHT MANUAL` with §39's exact ten rows, rendered from the key map +so the bindings and the manual cannot drift apart. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestPausePanelIsCentredAndContainsSpecCopy(t *testing.T) +// 80x24 grid: contains "TEMPORAL SUSPENSION", "SPACE IS PAUSED", "p resume"; +// the panel's left edge is within 1 column of centre. + +func TestGameOverPanelShowsFormattedStats(t *testing.T) +// Stats{Score: 483200, Lines: 127, Level: 13} -> contains "UNIVERSE EXPIRED", +// "483,200", "127", "13", "REBOOT UNIVERSE", "ACCEPT COSMIC DEATH". + +func TestHelpPanelListsEveryBinding(t *testing.T) +// Contains "FLIGHT MANUAL" and the descriptions "move spacecraft", +// "accelerate doom", "rotate geometry", "YEET", "quantum storage", +// "suspend spacetime", "reboot universe", "abandon mission". + +func TestPanelInTinyGridDoesNotPanicAndStaysInside(t *testing.T) +// 20x6 and 1x1 grids: no panic; every output line is at most g.W columns and +// there are at most g.H lines. Review Focus 3. + +func TestPanelDoesNotWriteOutsideGridOnOddSizes(t *testing.T) +// 41x25 grid: line count and widths respected. +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/render/ -run 'Pause|GameOver|Help|Panel' -v` +Expected: FAIL — undefined: `DrawPause`. + +- [ ] **Step 3: Implement** + +`go get charm.land/bubbles/v2@v2.2.1` then write `overlay.go`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add go.mod go.sum internal/render/overlay.go internal/render/overlay_test.go +git commit -m "feat(render): pause, help, and game-over panels" +``` + +--- + +### Task 6: Responsive layout and the too-small notice + +**Files:** +- Create: `internal/render/layout.go` +- Test: `internal/render/layout_test.go` + +**Interfaces:** +- Consumes: the `Board*` constants and the draw functions from Tasks 3–5. +- Produces: + ```go + const (MinCols = 40; MinRows = 24) + type Tier int + const (TierTooSmall Tier = iota; TierSmall; TierMedium; TierWide) + type Layout struct { + Tier Tier + W, H int + BoardX, BoardY int // outer top-left of the board box + HoldX, HoldY int // -1 when hidden + NextX, NextY int + NextCount int // 5 wide/medium, 3 small + StatsX, StatsY int + ShowTitle bool + ShowMission bool + MissionY int + ShowLabels bool + ControlsY int + ControlsW int + } + func Compute(w, h int) Layout + func DrawTooSmall(g *Grid, w, h int, p Palette) + ``` + +Tiers, from §31 and §49.3: + +``` +TierWide w >= 64 and h >= 26 HOLD | BOARD | NEXT, stats left column, title, mission +TierMedium w >= 48 and h >= 24 BOARD | compact HUD right (next 5 + stats), mission if h >= 25 +TierSmall w >= 40 and h >= 24 BOARD | NEXT(3) + bare stat values beside it +TierTooSmall otherwise +``` + +The board box is 22×22, so at `h == 24` only two rows remain. Drop order as height +runs out, applied in this order (§49.3): `ShowTitle` off first, then `ShowMission`, +then `ShowLabels`. `ControlsY` and the board are never dropped. `Compute` centres +the whole assembly horizontally and clamps every coordinate to `>= 0`. + +The notice text is §31's, with real numbers: + +``` +THIS UNIVERSE IS TOO SMALL + +resize terminal to continue + +current: 34 × 19 +needed: approximately 40 × 24 +``` + +- [ ] **Step 1: Write the failing tests** + +```go +func TestTierBoundaries(t *testing.T) +// Table: (39,24)->TooSmall (40,24)->Small (47,24)->Small (48,24)->Medium +// (63,26)->Medium (64,26)->Wide (64,25)->Medium (100,40)->Wide. + +func TestWideLayoutPlacesHoldLeftAndNextRight(t *testing.T) +// 100x40: HoldX < BoardX, NextX > BoardX + BoardOuterW - 1, NextCount == 5, +// ShowTitle and ShowMission true, ShowLabels true. + +func TestSmallLayoutTruncatesNextToThree(t *testing.T) +// 40x24: NextCount == 3, ShowTitle false, ShowMission false, ShowLabels false, +// HoldX == -1, and NextY is within the board's vertical span (never above or +// below the board). (§49.3) + +func TestDropOrderAsHeightShrinks(t *testing.T) +// 80x28 -> title+mission+labels; 80x26 -> no title; 80x25 -> no title, no +// mission; 80x24 -> also no labels. + +func TestEverythingFitsInsideTheTerminal(t *testing.T) +// For every (w,h) in 40..120 x 24..50: BoardX+BoardOuterW <= w, +// BoardY+BoardOuterH <= h, ControlsY < h, all coordinates >= 0. + +func TestBoardIsHorizontallyCentredWithinTwoColumns(t *testing.T) +// For each tier, the drawn assembly's left and right margins differ by at most 2. + +func TestComputeAtZeroAndOneReturnsTooSmall(t *testing.T) +// (0,0) and (1,1): TierTooSmall, no panic, coordinates non-negative. +// Review Focus 1. + +func TestTooSmallNoticeFitsTinyTerminals(t *testing.T) +// DrawTooSmall into 34x19, 20x5, and 1x1 grids: no panic, every line <= g.W, +// line count <= g.H, and at 34x19 the output contains "34 × 19" and "40 × 24". +// Review Focus 2. +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/render/ -run 'Tier|Layout|Drop|Centred|TooSmall|Compute' -v` +Expected: FAIL — undefined: `Compute`. + +- [ ] **Step 3: Implement** + +`layout.go` per the interfaces. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/layout.go internal/render/layout_test.go +git commit -m "feat(render): responsive layout tiers and too-small notice" +``` + +--- + +### Task 7: Bubble Tea model, keys, and the frame clock + +**Files:** +- Create: `internal/app/model.go`, `internal/app/update.go`, `internal/app/keys.go`, `internal/app/messages.go` +- Test: `internal/app/update_test.go`, `internal/app/keys_test.go` + +**Interfaces:** +- Consumes: `game.*` (Plan 01), `render.Compute`, `render.Mode`. +- Produces: + ```go + type AppState int + const (StatePlaying AppState = iota; StatePaused; StateHelp; StateOver) + + type Options struct { + Seed int64 + ASCII bool + NoFX bool + ReducedMotion bool + } + + type FrameMsg struct{ Now time.Time } + func frameCmd() tea.Cmd // tea.Tick(16ms, ...) -> FrameMsg + + type Model struct { + Game *game.Game + Opts Options + Width int + Height int + State AppState + LastFrame time.Time + Keys KeyMap + Help help.Model + Palette render.Palette + Grid *render.Grid + } + func New(opts Options) *Model + func (m *Model) Init() tea.Cmd + func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) + func (m *Model) View() tea.View + func (m *Model) inputFor(k tea.KeyPressMsg) game.Input + + type KeyMap struct { + Left, Right, SoftDrop, RotateCW, RotateCCW, HardDrop, + Hold, Pause, Restart, Help, Quit key.Binding + } + func DefaultKeyMap() KeyMap + func (k KeyMap) ShortHelp() []key.Binding + func (k KeyMap) FullHelp() [][]key.Binding + ``` + +Bindings (§8, WASD aliases included): + +``` +Left left, h, a Right right, l, d +SoftDrop down, j, s RotateCW up, k, x, w +RotateCCW z HardDrop space +Hold c Pause p +Restart r Help ? +Quit q, esc, ctrl+c +``` + +Held-key repeat (§8) comes from the terminal's own auto-repeat: each repeat is +another `KeyPressMsg` and each is applied immediately, so there is no DAS timer to +write and no path by which input waits for a tick (§36, §44). + +Clock: exactly one `tea.Tick(16 * time.Millisecond)` loop producing `FrameMsg` +(≈60 Hz, §36). On each `FrameMsg`: `dt := msg.Now.Sub(m.LastFrame)`, clamp to +`[0, 250ms]`, store `LastFrame`, and in `StatePlaying` call `m.Game.Advance(dt)`. +There is no separate `GravityMsg` — §36 offers one but also asks for a single +animation clock with accumulated elapsed time, and the engine's `Advance(dt)` +already is the gravity clock. §36's `GameEventMsg` is likewise unnecessary: +`Apply` and `Advance` return their events synchronously, so Plan 03 hands them to +the FX world inside the same `Update` call. Posting them back through the message +queue would only delay every effect by one frame. The returned events go nowhere +until Plan 03. + +State transitions: `p` toggles Playing↔Paused (ignored in Over); `?` toggles +Help↔Playing and also resumes from Paused into Help and back; `r` rebuilds the +game from `Opts.Seed` and sets Playing from *any* state; `q`/esc/ctrl+c returns +`tea.Quit`. In Paused, Help, and Over the model still handles `FrameMsg` (so Plan +03's stars keep drifting) but does not call `Advance`. When `m.Game.Over` becomes +true, `State` becomes `StateOver`. + +`tea.ColorProfileMsg` sets `m.Palette` via `render.ModeForProfile(msg.Profile, m.Opts.ASCII)`. + +- [ ] **Step 1: Write the failing tests** + +Tests drive `Update` directly with synthetic messages — no terminal needed. + +```go +func TestKeyMapCoversSpecBindings(t *testing.T) +// Table of every key string in §8 plus the WASD aliases -> expected game.Input. + +func TestUnboundKeyProducesNoInput(t *testing.T) +// "Q", "1", "f" -> game.InputNone and no state change. + +func TestFrameAdvancesGameByElapsedTime(t *testing.T) +// Two FrameMsgs 800ms apart: the active piece dropped exactly one row. + +func TestFrameClampsAbsurdDelta(t *testing.T) +// A FrameMsg 30s after the last: dt is clamped to 250ms, so the piece drops at +// most a few rows and nothing panics. + +func TestFirstFrameDoesNotAdvance(t *testing.T) +// LastFrame is zero until the first FrameMsg; that frame advances nothing. + +func TestMultipleKeysInOneFrameAllApply(t *testing.T) +// Three InputLeft key messages with no FrameMsg between them move the piece +// three columns; the gravity accumulator is untouched (piece Y unchanged). +// Review Focus 4. + +func TestPauseStopsGravityButKeepsHandlingFrames(t *testing.T) +// Pause, then FrameMsgs spanning 5s: piece Y unchanged, Update still returns a +// frame command. + +func TestKeysAreIgnoredWhilePaused(t *testing.T) +// Paused + InputLeft key: piece X unchanged. "p" resumes. + +func TestHelpTogglesFromPlayingAndPaused(t *testing.T) +func TestRestartFromPlayingPausedAndOver(t *testing.T) +// From each state, "r" yields State == StatePlaying, Score == 0, Lines == 0, +// and the same Seed. Review Focus 5. + +func TestGameOverEntersOverState(t *testing.T) +// Fill the board so the next lock blocks the spawn: State becomes StateOver and +// further FrameMsgs change nothing. + +func TestQuitKeysReturnQuitCommand(t *testing.T) +// "q", "esc", "ctrl+c" each return a non-nil command whose message is tea.QuitMsg. + +func TestWindowSizeIsStored(t *testing.T) +// WindowSizeMsg{100, 40} -> Width/Height set and the grid resized. + +func TestZeroWindowSizeDoesNotPanic(t *testing.T) +// WindowSizeMsg{0, 0} then View(): non-panicking, returns a tea.View. +// Review Focus 1. +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/app/ -v` +Expected: build failure — package does not exist. + +- [ ] **Step 3: Implement** + +`go get charm.land/bubbletea/v2@v2.0.9`, then the four files. `View()` is a thin +wrapper that calls Task 8's `render.Frame` and sets `AltScreen = true`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/app/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add go.mod go.sum internal/app/ internal/render/ +git commit -m "feat(app): bubble tea model, key map, single frame clock" +``` + +--- + +### Task 8: Frame assembly and golden snapshot tests + +**Files:** +- Create: `internal/render/frame.go`, `internal/render/golden_test.go`, `internal/render/testdata/*.golden` +- Modify: `internal/app/model.go` (`View` calls `render.Frame`) + +**Interfaces:** +- Consumes: everything in `internal/render` plus `app.AppState`. +- Produces: + ```go + type Frame struct { + W, H int + State int // mirrors app.AppState; render does not import app + Board BoardView + Hold *game.PieceKind + Next []game.PieceKind + Stats Stats + Mission string + Palette Palette + HelpModel help.Model + HelpKeys help.KeyMap + } + func Render(g *Grid, f Frame) string + ``` + +`Render` walks §37's pipeline in order, skipping steps 2, 6, 9, and 10 (Plans +03–04 fill those in), and returns `g.String()`. Overlays draw last so pause, help, +and game over sit on top of a still-rendered board (§28: "do not instantly replace +the board"). + +`render` must not import `internal/app` — `State` is a plain int with constants +mirrored in `frame.go` and a compile-time assertion in `app` that the two agree. + +The golden helper is 15 lines in the test file: strip ANSI, compare to +`testdata/.golden`, rewrite the file when `-update` is passed. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestGolden(t *testing.T) +// Subtests, each rendering a deterministic scene (seed 8675309, a fixed board +// built by hard-dropping a canned input list, Mission "NOMINALISH"): +// wide 100x40 ModeFull StatePlaying +// medium 56x26 ModeFull StatePlaying +// small 40x24 ModeFull StatePlaying +// ascii 80x30 ModeASCII StatePlaying +// pause 80x30 ModeFull StatePaused +// help 80x30 ModeFull StateHelp +// gameover 80x30 ModeFull StateOver +// toosmall 34x19 ModeFull StatePlaying +// Each compares ANSI-stripped output to testdata/.golden. + +func TestGoldenScenesHaveCorrectGeometry(t *testing.T) +// For every scene: line count <= H, every line width <= W (measured with +// lipgloss.Width on the stripped string), and in the playing scenes the board's +// 22 box lines are present and unbroken — i.e. exactly 20 interior rows each +// starting and ending with the border glyph. This is §41's "nothing overlaps / +// board dimensions stay correct / HUD doesn't corrupt board". + +func TestASCIISceneIsPureASCII(t *testing.T) +// Every rune of the ascii scene's stripped output is < 128. + +func TestResizeSweepNeverPanicsOrOverflows(t *testing.T) +// For w in 1..140 step 1 and h in 1..50 step 1 (playing state), Render returns +// without panic, line count <= h, and every line width <= w. §31's "never crash +// from terminal resizing", exhaustively. + +func TestRenderDoesNotMutateGameState(t *testing.T) +// Fingerprint the game (Plan 01 Task 10), render every scene twice, fingerprint +// again: unchanged. §37. +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/render/ -run Golden -v` +Expected: FAIL — undefined: `Render`; no testdata. + +- [ ] **Step 3: Implement** + +Write `frame.go`, then generate the goldens with +`go test ./internal/render/ -run Golden -update` and **read every generated file +before committing it**. A golden file is only correct if it looks like the game; +eyeball the board box, the HUD columns, and the panel borders. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./... -count=1` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/frame.go internal/render/golden_test.go internal/render/testdata internal/app/model.go +git commit -m "feat(render): frame assembly with golden layout snapshots" +``` + +--- + +### Task 9: CLI entry point + +**Files:** +- Create: `cmd/cosmic-tetris/main.go`, `internal/app/options.go` +- Test: `internal/app/options_test.go` + +**Interfaces:** +- Consumes: `app.New`, `app.Options`. +- Produces: + ```go + func ParseFlags(args []string) (Options, string, error) + // returns (opts, usage-if-help-requested, error). Unknown flag -> error. + ``` + +`main.go`: parse, print usage and exit 0 on `--help`, print the error and usage to +stderr and exit 2 on a bad flag, otherwise seed from `time.Now().UnixNano()` when +`--seed` is absent, detect the color profile with +`colorprofile.Detect(os.Stdout, os.Environ())`, build the model, and run +`tea.NewProgram(m, tea.WithFPS(60))`. + +`--seed` is the only flag with a value. `--no-fx` and `--reduced-motion` are stored +in `Options` and consumed by Plans 03–04; wiring them now means the CLI surface is +final and the later plans add behaviour, not flags. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestBareInvocationUsesDefaults(t *testing.T) +// No flags: ASCII/NoFX/ReducedMotion all false, Seed == 0 (main fills it in). + +func TestEachFlagParses(t *testing.T) +// "--seed 1234" -> Seed 1234; "--ascii"; "--no-fx"; "--reduced-motion". + +func TestFlagsCombine(t *testing.T) +// "--ascii --no-fx --seed 7" sets all three. + +func TestHelpReturnsUsageAndNoError(t *testing.T) +// "--help": usage string mentions exactly the five documented flags and nothing +// else. Guards §49.5's "nothing else is necessary". + +func TestUnknownFlagIsAnError(t *testing.T) +// "--turbo" and "-x" return an error. + +func TestNonNumericSeedIsAnError(t *testing.T) +// "--seed banana" returns an error. + +func TestNegativeSeedIsAccepted(t *testing.T) +// "--seed -1" -> Seed == -1 (int64, and the PCG construction must not care). +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/app/ -run 'Flag|Help|Seed|Bare' -v` +Expected: FAIL — undefined: `ParseFlags`. + +- [ ] **Step 3: Implement** + +`options.go` with a `flag.FlagSet` (`ContinueOnError`, output to an +`io.Discard`-backed buffer so tests stay quiet) and `main.go` per above. + +- [ ] **Step 4: Run tests, then play it** + +Run: `go test ./... -count=1` — expect PASS. +Run: `go build ./... && ./cosmic-tetris --seed 8675309` +Play for a minute. Check by hand: keys respond instantly, gravity accelerates as +levels rise, hold works, ghost tracks the piece, `p`/`?`/`r`/`q` behave, and +resizing the terminal (including down past 40×24 and back) never breaks the frame. + +- [ ] **Step 5: Commit** + +```bash +git add cmd/cosmic-tetris/main.go internal/app/options.go internal/app/options_test.go +git commit -m "feat(cli): cosmic-tetris entry point with the final flag surface" +``` + +--- + +## Done when + +`cosmic-tetris` is a complete, resizable, keyboard-immediate Tetris with hold, +ghost, a five-piece queue, pause, restart, help, ASCII mode, golden-tested layout +at four sizes plus three overlays, and no effects at all. §42 Phase 2's bar — +"at this point it should already be a genuinely good game" — is the acceptance +criterion, and it is judged by playing it, not only by the suite. diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/P1-proportion-cosmic-52/2026-09-17-cosmic-tetris-03-cosmic-foundation.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/P1-proportion-cosmic-52/2026-09-17-cosmic-tetris-03-cosmic-foundation.md new file mode 100644 index 000000000..e69e95b48 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/P1-proportion-cosmic-52/2026-09-17-cosmic-tetris-03-cosmic-foundation.md @@ -0,0 +1,673 @@ +# Cosmic Tetris — Plan 03: Cosmic Foundation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the terminal feel alive when nobody is touching it — starfield, breathing board border, ion trails, and Mission Control commentary — with an FX layer that can observe the game and can never touch it. + +**Architecture:** `internal/fx` is a self-contained simulation: it receives `[]game.Event` plus a read-only snapshot, integrates its own state on `Advance(dt)`, and exposes that state as plain exported data. `internal/render` reads that data and draws it. Nothing in `fx` imports `render`, and nothing in `fx` holds a mutable reference to `*game.Game`. `internal/flavor` is a pure message-pool lookup. + +**Tech Stack:** Go 1.26, `math/rand/v2` (a second, independent generator), `charm.land/lipgloss/v2`. + +**Spec:** `design.md` (§14, §15, §17, §25, §26 colour behaviour, §27, §37, §44, §45) + +## Global Constraints + +- The FX system may observe game events. It may **never modify GameState** (§14). +- Game RNG and FX RNG never share; crossing them makes piece order depend on particle counts (§49.6). +- FX may never obscure the active piece, delay gameplay, make controls lag, or permanently alter the rendered board (§44). +- Board readability is sacred: never make the background so busy that the board becomes harder to read (§15). +- No goroutine per particle, no goroutine per frame, no filesystem operations during gameplay, no synchronous logging every frame (§38). +- Two coordinate spaces, never mixed: **screen space** in terminal cells `(0,0)`–`(W-1,H-1)` for the starfield, and **board-local space** in board cells `(0,0)`–`(9,21)` for anything anchored to the playfield. `render` converts board-local to screen using the layout's `BoardX+1, BoardY+1-HiddenRows` origin. +- §33's tree is the package structure, not a filename whitelist: one small file per subsystem inside the existing packages, no new package, no file over a few hundred lines. +- `internal/fx` simulates; `internal/render` draws. `fx` must not import `render` and `render` must not call any `fx` mutator. +- Star glyph set: `. · ˚ ✦ ✧ *` (§15). Trail ramp: `██ ▓▓ ▒▒ ░░` (§17). +- `--no-fx` yields a game with no starfield, no trails, and a static border, and it must still be a good game (§32). + +## Review Focus + +1. **`fx.Advance` with `dt <= 0` or a huge `dt`** — the app clamps, but FX is also called from tests and from paused frames. Stars must not jump, positions must never become NaN or Inf, and nothing may divide by `dt`. *(Task 2)* +2. **Resize to a smaller viewport with live FX** — stars and trails already outside the new bounds must be culled or clipped, never written out of range and never left frozen at the old edge. *(Task 2)* +3. **`--no-fx` completeness** — with FX off, the HUD, board, ghost, and Mission Control line must still render a complete frame; the Mission line becomes static text rather than disappearing and leaving a hole in the layout. *(Task 6)* +4. **ASCII mode purity with FX on** — stars, trails, and the border all pull glyphs from the palette, so a single hard-coded `✦` anywhere breaks `--ascii` on a terminal that cannot show it. Assert the whole frame is ASCII. *(Task 6)* +5. **Event bursts in one frame** — a hard drop that clears four lines and levels up delivers six events at once. Mission Control must not swap messages faster than its dwell time, and the highest-priority message must win rather than the last one in the slice (§27 "give them time to breathe"). *(Task 5)* + +--- + +### Task 1: FX world skeleton, options, and the one-way seam + +**Files:** +- Create: `internal/fx/events.go`, `internal/fx/world.go` +- Test: `internal/fx/world_test.go` + +**Interfaces:** +- Consumes: `game.Event`, `game.EventKind`, `game.Piece` (Plan 01). +- Produces: + ```go + type Options struct { Disabled bool; ReducedMotion bool; ASCII bool } + + // Snapshot is everything FX is allowed to know about the game. It is copied by + // value once per frame; FX holds no pointer into game state. + type Snapshot struct { + Level, Combo, Score, Lines int + Active game.Piece + IsHighScore bool + Playing bool // false while paused, in help, or after game over + } + + type World struct { + Opts Options + W, H int // screen space + rng *rand.Rand // FX RNG: never the game's + elapsed time.Duration + // per-subsystem state added by later tasks + } + func New(seed int64, w, h int, opts Options) *World + func (w *World) Resize(width, height int) + func (w *World) Handle(events []game.Event, s Snapshot) + func (w *World) Advance(dt time.Duration) + func (w *World) Elapsed() time.Duration + ``` + +`New` seeds with `rand.NewPCG(uint64(seed)^0xC05M1C, 0x2545F4914F6CDD1D)` — derived +from the same CLI seed so a session is reproducible end to end, but a *different* +stream, so FX consumption can never shift piece order. + +`Handle` and `Advance` both return nothing: FX is a sink. `Advance` ignores +`dt <= 0` and clamps `dt` to 100ms before integrating, so a stalled process +produces one modest step rather than a teleport. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestNewWorldIsUsableAtAnySize(t *testing.T) +// New(1, 0, 0, Options{}) and New(1, 200, 60, Options{}): no panic; Resize to +// (0,0) and back to (80,24) also fine. + +func TestAdvanceIgnoresNonPositiveDt(t *testing.T) +// Elapsed() unchanged after Advance(0) and Advance(-1s). Review Focus 1. + +func TestAdvanceClampsHugeDt(t *testing.T) +// Advance(30s) increases Elapsed() by at most 100ms. + +func TestFXNeverTouchesGameState(t *testing.T) +// Build a game, fingerprint it (game.Fingerprint), run 600 frames of +// Handle(events, snapshot)+Advance(16ms) with events produced by real game calls +// captured beforehand, fingerprint again: identical. §14. + +func TestFXRNGIsIndependentOfGameRNG(t *testing.T) +// Two games on seed 42: one played with an FX world attached that consumes +// randomness every frame, one with no FX world. Same input stream. Identical +// game Fingerprint(). §49.6. + +func TestDisabledWorldDoesNothing(t *testing.T) +// Options{Disabled: true}: after 600 frames plus a full event burst, every +// exported FX slice is empty. (Extended by each later task.) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ -v` +Expected: build failure — package does not exist. + +- [ ] **Step 3: Implement** + +`events.go` holds `Options` and `Snapshot`; `world.go` holds `World` and the five +methods. `Handle` currently switches on event kind and does nothing per case — +later tasks fill the cases in. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/fx/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/events.go internal/fx/world.go internal/fx/world_test.go +git commit -m "feat(fx): world skeleton with an observe-only game seam" +``` + +--- + +### Task 2: Starfield + +**Files:** +- Create: `internal/fx/starfield.go` +- Modify: `internal/fx/world.go` +- Test: `internal/fx/starfield_test.go` + +**Interfaces:** +- Consumes: Task 1's `World`. +- Produces: + ```go + type StarLayer int + const (LayerFar StarLayer = iota; LayerMid; LayerNear) + type Star struct { X, Y float64; Layer StarLayer; Glyph rune; Bright float64 } + func (w *World) Stars() []Star // read-only view for the renderer + func (w *World) SpeedScale() float64 // 1.0 normally; raised by level and, later, hyperdrive + ``` + +Three layers (§15), densities as a fraction of screen cells so the field reads the +same at any size — far `1/40`, mid `1/70`, near `1/140` of `W*H`, recomputed on +`Resize`: + +``` +Far 0.8 cells/sec glyphs: '.' Bright 0.25 +Mid 2.0 cells/sec glyphs: '·' '˚' Bright 0.55 +Near 4.5 cells/sec glyphs: '✦' '✧' '*' Bright 1.0 +``` + +Stars drift downward (§15). A star past the bottom wraps to `Y = -1` at a fresh +random `X`. `SpeedScale` returns `1 + 0.04*(level-1)`, capped at `2.0` — §15's +"star velocity subtly increases", bounded so level 20 is not a blizzard. + +`ASCII` mode substitutes `.` `:` `+` `*` for the Unicode glyphs, chosen when the +star is created so the swap costs nothing per frame. + +While `Snapshot.Playing` is false the field keeps drifting at `0.25×` speed — +§30's "background stars may continue drifting very slowly". + +- [ ] **Step 1: Write the failing tests** + +```go +func TestStarCountScalesWithViewport(t *testing.T) +// 80x24 has more stars than 40x24; all three layers are represented; count is +// within 20% of the density formula. + +func TestStarsDriftDownward(t *testing.T) +// After Advance(500ms), every star's Y is greater than before (or it wrapped), +// and no star's X changed. + +func TestNearStarsMoveFasterThanFarStars(t *testing.T) +// Mean Y delta over 1s: Near > Mid > Far by at least 1.5x each step. + +func TestStarsWrapAtTheBottom(t *testing.T) +// Advance 10s in 16ms steps: every star stays within -1 <= Y < H and +// 0 <= X < W. Also asserts no NaN/Inf. Review Focus 1. + +func TestSpeedScaleRisesWithLevelAndIsCapped(t *testing.T) +// level 1 -> 1.0; level 6 -> 1.2; level 40 -> 2.0 exactly. + +func TestPausedFieldDriftsSlowly(t *testing.T) +// Playing false: total Y movement over 1s is roughly a quarter of the playing +// case, and non-zero. + +func TestResizeSmallerCullsOutOfBoundsStars(t *testing.T) +// From 120x50 to 40x24: every star is inside the new bounds. Review Focus 2. + +func TestResizeLargerAddsStars(t *testing.T) +// From 40x24 to 120x50: star count grows to the new density. + +func TestASCIIStarsAreASCII(t *testing.T) +// Options{ASCII: true}: every star glyph is < 128. + +func TestDisabledWorldHasNoStars(t *testing.T) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ -run Star -v` +Expected: FAIL — undefined: `Stars`. + +- [ ] **Step 3: Implement** + +`starfield.go`; call its step from `World.Advance` and its reseed from +`World.Resize`. Reuse the `[]Star` backing array across resizes. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/fx/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/starfield.go internal/fx/world.go internal/fx/starfield_test.go +git commit -m "feat(fx): three-layer drifting starfield" +``` + +--- + +### Task 3: Animated board border + +**Files:** +- Create: `internal/fx/border.go` +- Modify: `internal/fx/world.go` +- Test: `internal/fx/border_test.go` + +**Interfaces:** +- Produces: + ```go + type BorderState struct { + Phase float64 // 0..1, advances slowly and wraps + Energy float64 // 0..1, spikes on events then decays + Flash float64 // 0..1, hard-drop border flash, decays fast + } + func (w *World) Border() BorderState + func (w *World) borderBump(energy float64) // internal, called from Handle + ``` + and in `render`: + ```go + func (p Palette) BorderStyleAt(b fx.BorderState, t float64) lipgloss.Style + // t is 0..1 position around the border perimeter + ``` + +`Phase` advances at `0.06/sec` normally — a full colour cycle takes about 17 +seconds, which is §25's "the shift should be subtle". `Energy` adds +`Phase` speed (up to `6×`) so major events send the gradient racing around the +border (§25). The palette maps `Phase + t*Energy` onto §25's ramp: deep violet → +electric cyan → magenta → stellar blue → hot white. `Flash` lifts the whole border +toward hot white for 120ms. + +Energy bumps, from `Handle`: locked `0.10`, single/double/triple clear +`0.35/0.5/0.65`, four-line `1.0`, level change `0.6`, combo change `0.15×combo`. +Decay is `Energy -= 1.2*dt` clamped at 0. `Flash` is set to 1 on +`EvPieceHardDropped` and decays at `8.0/sec` (§18.4). + +In `ModeReduced` the ramp is five ANSI-256 indices with no interpolation; in +`ModeASCII` the border is a single static colour and `Phase` is ignored. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestPhaseAdvancesSlowlyAndWraps(t *testing.T) +// After 1s, Phase moved by ~0.06; after 60s of 16ms steps, Phase stays in [0,1). + +func TestEnergyDecaysToZero(t *testing.T) +// borderBump(1.0) then Advance in 16ms steps: Energy reaches 0 within 1s and +// never goes negative. + +func TestClearEventsRaiseEnergyByLineCount(t *testing.T) +// Energy after a 4-line clear > after 1-line clear > baseline. + +func TestHardDropSetsAndDecaysFlash(t *testing.T) +// Flash == 1 immediately after the event; < 0.1 after 300ms; exactly 0 eventually. + +func TestBorderStyleVariesWithPhase(t *testing.T) +// ModeFull: BorderStyleAt at four phases yields at least three distinct colours. + +func TestBorderStyleIsStaticInASCIIMode(t *testing.T) +// ModeASCII: the style is identical at every phase and t. + +func TestDisabledWorldHasStaticBorder(t *testing.T) +// Options{Disabled: true}: Phase stays 0, Energy stays 0 after an event burst. +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ -run Border -v && go test ./internal/render/ -run Border -v` +Expected: FAIL — undefined: `Border`, `BorderStyleAt`. + +- [ ] **Step 3: Implement** + +`internal/fx/border.go` and the `BorderStyleAt` method in +`internal/render/palette.go`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./... -v` +Expected: PASS (golden tests are regenerated in Task 6, so a golden failure here is +expected only if you already wired the border into `Render` — do that in Task 6.) + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/border.go internal/fx/border_test.go internal/render/palette.go internal/render/palette_test.go +git commit -m "feat(fx): board border as an energy-state indicator" +``` + +--- + +### Task 4: Piece trails + +**Files:** +- Create: `internal/fx/trail.go` +- Modify: `internal/fx/world.go` +- Test: `internal/fx/trail_test.go` + +**Interfaces:** +- Produces: + ```go + type Trail struct { + X, Y int // board-local cell + Kind game.PieceKind + Life float64 // seconds remaining + Max float64 + Streak bool // horizontal quantum-storage smear (§9) + } + func (w *World) Trails() []Trail + func TrailGlyphIndex(life, max float64) int // 0..3 -> ██ ▓▓ ▒▒ ░░ + func (w *World) HoldFlash() float64 // 0..1, incoming piece flash + ``` + +On `EvPieceMoved` and `EvPieceRotated`, push a trail cell for each of the piece's +four cells at its *previous* position with `Max = 0.13s` (§17's 100–160ms). +`EvPieceHardDropped` pushes trails for every cell the piece crossed — +`Count` rows' worth — with `Max = 0.22s`, which is §17's "stronger vertical trail" +and the first half of §18.1. + +**Quantum storage (§9).** `EvHoldUsed` gets its own ~120ms effect built from the +same primitive: for each cell of the *outgoing* piece, push three `Streak` trails +running sideways from that cell toward the HOLD side of the board with +`Max = 0.12s`, so the piece reads as compressed, streaked sideways, and gone. At +the same time `HoldFlash` is set to 1 and decays at `8.0/sec`; the renderer uses it +to brighten the incoming piece for those same 120ms. Gameplay does not wait for any +of this — the engine already swapped the pieces before the event was emitted (§9). + +`World` keeps the previous active-piece cells from the last `Handle` call so it can +place the trail where the piece *was*. Trails are stored in a reused slice, +compacted in place on `Advance`, and capped at 240 entries — oldest dropped first. +Trails are board-local; the renderer skips any trail cell that a locked, ghost, or +active cell already occupies, which is §44's "never obscure the active piece" +enforced at draw time. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestMoveLeavesTrailAtThePreviousPosition(t *testing.T) +// Handle a move event where the piece went from x=4 to x=3: trails exist at the +// x=4 cells, not the x=3 cells. + +func TestTrailsExpireWithinSpecWindow(t *testing.T) +// After 100ms some trails remain; after 200ms Trails() is empty. + +func TestHardDropTrailCoversTheFallenColumn(t *testing.T) +// EvPieceHardDropped{Count: 12}: trail cells span 12 rows above the final +// position for each occupied column. + +func TestTrailGlyphRampFollowsLife(t *testing.T) +// TrailGlyphIndex(max, max) == 0; at 2/3 -> 1; at 1/3 -> 2; near 0 -> 3. + +func TestHoldStreaksSidewaysAndFlashes(t *testing.T) +// EvHoldUsed: Streak trails exist, they extend horizontally from the outgoing +// piece's cells (same Y, several X), HoldFlash() == 1, and everything is gone by +// 150ms. (§9) + +func TestHoldEffectDoesNotDelayAnything(t *testing.T) +// game.Fingerprint is unchanged by the hold effect, and Handle returns without +// sleeping. (§9 "gameplay does not wait for the animation") + +func TestTrailsAreCapped(t *testing.T) +// 500 move events in one frame: len(Trails()) <= 240 and the survivors are the +// newest. + +func TestTrailsDoNotOverdrawTheActivePiece(t *testing.T) +// Render a frame where a trail cell coincides with an active-piece cell: the +// stripped output shows the block glyph, not the trail glyph. (in render tests) + +func TestDisabledWorldHasNoTrails(t *testing.T) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ -run Trail -v` +Expected: FAIL — undefined: `Trails`. + +- [ ] **Step 3: Implement** + +`trail.go` plus the `Handle` cases. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/fx/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/trail.go internal/fx/world.go internal/fx/trail_test.go +git commit -m "feat(fx): short-lived ion trails behind moving pieces" +``` + +--- + +### Task 5: Flavor pool and the Mission Control channel + +**Files:** +- Create: `internal/flavor/messages.go`, `internal/fx/mission.go` +- Test: `internal/flavor/messages_test.go`, `internal/fx/mission_test.go` + +**Interfaces:** +- Produces: + ```go + // internal/flavor + type Trigger int + const (Idle Trigger = iota; Lock; Clear1; Clear2; Clear3; Clear4; Combo; + LevelUp; Hold; HoldO; HardDrop; KineticRod; GameOver; Rare) + func Pick(rng *rand.Rand, t Trigger) string + func Pool(t Trigger) []string // exported for the tests that assert copy + func ComboLine(combo int) string // "COMBO 5 // UNAUTHORIZED ORBITAL MANEUVER" + + // internal/fx + type Mission struct { Text string; Age time.Duration } + func (w *World) Mission() Mission + ``` + +Pools carry the spec's copy verbatim, at least four lines each, including §27's +nine examples, §21's combo lines for 5/6/7, and §45's specials: `Rare` holds +`DID YOU KNOW YOU'RE IN A TERMINAL?`, `NUMBER BECAME BIGGER`, and `CAPTAIN?`; +`KineticRod` holds `KINETIC ROD DEPLOYED` (a vertical I hard drop); `HoldO` holds +`CUBE ADJACENT OBJECT SECURED`. + +Channel rules, which are §27's "do not rotate messages constantly" made concrete: + +``` +MinDwell 2.5s a message cannot be replaced before this +IdleAfter 9s with no message change, pick an Idle line +RarePer 1/60 chance an Idle pick comes from Rare instead +Priority GameOver > Clear4 > LevelUp > Combo > Clear3..1 > HardDrop/Hold > Lock > Idle +``` + +When a frame delivers several events, `Handle` selects the single +highest-priority trigger and offers it to the channel; the channel accepts only if +`Age >= MinDwell` or the new trigger outranks the current one by two or more levels +(so a four-line clear can still interrupt a fresh `Lock` line). This is Review +Focus 5. + +`KineticRod` is chosen over `HardDrop` when the dropped piece is an `I` in a +vertical rotation; `HoldO` over `Hold` when the held kind is `O` (§45). + +- [ ] **Step 1: Write the failing tests** + +```go +// internal/flavor +func TestEveryTriggerHasAPool(t *testing.T) +// Pool(t) is non-empty for every Trigger value, and every line is <= 52 columns +// so it fits the narrowest layout that shows Mission Control. + +func TestPickIsDeterministicPerSeed(t *testing.T) +// Same seed, same trigger, same sequence of 20 picks. + +func TestSpecCopyIsPresent(t *testing.T) +// Asserts the literal presence of "GRAVITY REMAINS MOSTLY LEGAL", +// "ORBITAL OSHA HAS ENTERED THE CHAT", "MOON NOTIFIED", +// "KINETIC ROD DEPLOYED", "CUBE ADJACENT OBJECT SECURED", +// "DID YOU KNOW YOU'RE IN A TERMINAL?". + +func TestComboLineIncludesTheNumber(t *testing.T) +// ComboLine(5) contains "COMBO 5"; ComboLine(6) and (7) match §21's copy; +// ComboLine(12) still returns a non-empty line. + +func TestAllFlavorIsASCIIOrSafeUnicode(t *testing.T) +// Every line's runes are either < 128 or in the small approved set (✦ ☄ ·). + +// internal/fx +func TestMissionStartsWithAnIdleLine(t *testing.T) +// Mission().Text is non-empty immediately after New. + +func TestMissionRespectsMinimumDwell(t *testing.T) +// A Lock event, then another Lock event 200ms later: text unchanged. + +func TestHighPriorityEventInterruptsAFreshLine(t *testing.T) +// A Lock line 200ms old, then a 4-line clear: text changes to a Clear4 line. +// Review Focus 5. + +func TestBurstOfEventsPicksTheHighestPriority(t *testing.T) +// One Handle call with [Locked, LinesCleared(4), LevelChanged, ComboChanged]: +// the resulting text is from the Clear4 pool, not the last event's pool. + +func TestIdleLineAppearsAfterQuiet(t *testing.T) +// No events for 10s: text changed at least once to an Idle line. + +func TestDisabledWorldStillReportsAStaticMissionLine(t *testing.T) +// Options{Disabled: true}: Mission().Text is non-empty and never changes. +// Review Focus 3. +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/flavor/ ./internal/fx/ -run 'Flavor|Pool|Pick|Combo|Mission|Spec' -v` +Expected: FAIL — undefined: `flavor.Pool`, `World.Mission`. + +- [ ] **Step 3: Implement** + +`internal/flavor/messages.go` (pools as package-level slices) and +`internal/fx/mission.go` (channel state, priority table, dwell logic). + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./... -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/flavor internal/fx/mission.go internal/fx/mission_test.go +git commit -m "feat(flavor): mission control channel with dwell and priority" +``` + +--- + +### Task 6: Wire FX into the app and the render pipeline + +**Files:** +- Create: `internal/render/fxdraw.go` +- Modify: `internal/app/model.go`, `internal/app/update.go`, `internal/render/frame.go`, `internal/render/golden_test.go`, `internal/render/testdata/*.golden` +- Test: `internal/render/fxdraw_test.go`, `internal/app/fx_test.go` + +**Interfaces:** +- Consumes: `fx.World`, `fx.Star`, `fx.Trail`, `fx.BorderState`, `fx.Mission`. +- Produces: + ```go + func DrawStars(g *Grid, stars []fx.Star, p Palette, boardRect Rect) + func DrawTrails(g *Grid, trails []fx.Trail, p Palette, ox, oy int, occupied func(x, y int) bool) + func DrawMission(g *Grid, x, y, width int, m fx.Mission, p Palette) + type Rect struct{ X, Y, W, H int } + // new palette methods + func (p Palette) StarStyle(layer fx.StarLayer, bright float64) lipgloss.Style + func (p Palette) TrailStyle(k game.PieceKind, step int) lipgloss.Style + func (p Palette) FlashStyle(k game.PieceKind, flash float64) lipgloss.Style // hold flash + ``` + and `Frame` gains `FX *fx.World` (nil-safe: a nil `FX` renders Plan 02's frame + exactly). + +Wiring in `app`: + +- `Model` gains `FX *fx.World`, built in `New` from `Opts.Seed` with + `fx.Options{Disabled: Opts.NoFX, ReducedMotion: Opts.ReducedMotion, ASCII: Opts.ASCII}`. +- Key handling: the events returned by `game.Apply` are collected and passed to + `FX.Handle` in the same `Update` call, so a keypress's FX starts on the frame the + key arrived, not the next one. +- `FrameMsg`: `evs := Game.Advance(dt)`, then `FX.Handle(evs, snapshot)`, then + `FX.Advance(dt)` — always, including while paused (with `Snapshot.Playing` false). +- `WindowSizeMsg`: `FX.Resize(w, h)`. + +Drawing slots into §37's pipeline: `DrawStars` is step 2, `DrawTrails` step 6, +border style feeds step 7, `DrawMission` step 11. `FX.HoldFlash()` feeds +`FlashStyle` when drawing the active piece (step 5) and the HOLD thumbnail (step 8), +so §9's incoming piece "briefly flashes into existence". + +`DrawStars` skips any cell inside `boardRect`'s interior that is non-empty — stars +show through the empty parts of the playfield but never over a block, which keeps +§15's readability promise without a second buffer. Star brightness maps to +`Palette.StarStyle(layer, bright)`. + +- [ ] **Step 1: Write the failing tests** + +```go +// internal/render +func TestStarsDrawOutsideAndThroughTheBoard(t *testing.T) +// Stars at known positions: one outside the board appears; one over an empty +// board cell appears; one over a locked block does not. + +func TestStarsNeverOverwriteTheActivePiece(t *testing.T) +// A star placed exactly on an active-piece cell: output shows the block glyph. + +func TestTrailsDrawInBoardLocalSpace(t *testing.T) +// Trail{X:0, Y:21} lands on the board's bottom-left interior cell. + +func TestMissionLineIsPrefixedAndTruncated(t *testing.T) +// Output starts with "☄ MISSION CONTROL: " and the whole line is <= width; +// ASCII mode uses "* MISSION CONTROL: ". + +func TestHoldStreakAndFlashAreDrawn(t *testing.T) +// A frame right after a hold: streak glyphs appear beside the outgoing piece's +// former cells, and the HOLD thumbnail is rendered with the brightened style. + +func TestNilFXRendersPlanTwoFrame(t *testing.T) +// Frame with FX nil equals the Plan 02 golden for the same scene. + +func TestGoldenWithFX(t *testing.T) +// The eight scenes from Plan 02 Task 8, now with an FX world seeded 8675309 and +// advanced by a fixed 30-frame 16ms sequence before rendering. Regenerated +// goldens; same geometry assertions as before. + +func TestNoFXSceneHasNoStarsOrTrailsButKeepsMissionLine(t *testing.T) +// Options{Disabled: true} scene: no star glyph anywhere, no trail glyph, the +// mission line present, the HUD complete, board box intact. Review Focus 3. + +func TestFXSceneInASCIIModeIsPureASCII(t *testing.T) +// The ascii golden scene with FX fully active: every rune < 128. Review Focus 4. + +// internal/app +func TestKeyEventsReachFXOnTheSameFrame(t *testing.T) +// A hard-drop key with no intervening FrameMsg: FX.Trails() is non-empty and the +// border Flash is 1. + +func TestFXAdvancesWhilePaused(t *testing.T) +// Paused, 60 FrameMsgs: stars moved, game state Fingerprint unchanged. + +func TestResizeReachesFX(t *testing.T) +// WindowSizeMsg{40,24} after {120,50}: every star is within the new bounds. +// Review Focus 2. + +func TestNoFXOptionProducesAnEmptyWorld(t *testing.T) +// Opts.NoFX: after a minute of frames and every kind of event, no stars, no +// trails, static border. +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/render/ ./internal/app/ -v` +Expected: FAIL — undefined: `DrawStars`; `Frame` has no `FX` field. + +- [ ] **Step 3: Implement** + +`fxdraw.go`, the `Frame.FX` field and pipeline calls, the app wiring, then +regenerate goldens with `-update` and **read each one** before committing. + +- [ ] **Step 4: Run tests, then look at it** + +Run: `go test ./... -count=1` — expect PASS. +Run: `./cosmic-tetris --seed 8675309` and watch it do nothing for ten seconds. +Stars should drift, the border should breathe, moving a piece should smear, and +Mission Control should say something and then shut up for a while. Then +`./cosmic-tetris --no-fx` and confirm it is still a clean, complete game, and +`./cosmic-tetris --ascii` and confirm nothing renders as a replacement glyph. + +- [ ] **Step 5: Commit** + +```bash +git add internal/render internal/app +git commit -m "feat: composite starfield, trails, border, and mission control" +``` + +--- + +## Done when + +§43's first-30-seconds list is half satisfied: moving starfield, animated board +border, piece trails, and Mission Control commentary all appear during ordinary +play. `go test ./... -count=1` is green, the FX package holds its own RNG, and the +determinism tests prove FX cannot shift piece order. Hard-drop impact, particles, +and supernovas are Plan 04. diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/P1-proportion-cosmic-52/2026-09-17-cosmic-tetris-04-violence.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/P1-proportion-cosmic-52/2026-09-17-cosmic-tetris-04-violence.md new file mode 100644 index 000000000..05f77c400 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/P1-proportion-cosmic-52/2026-09-17-cosmic-tetris-04-violence.md @@ -0,0 +1,640 @@ +# Cosmic Tetris — Plan 04: Violence Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make hard drops feel like dropping a refrigerator from orbit and make a four-line clear produce an involuntary reaction, using particles, screen shake, supernova clears, shockwaves, hyperdrive, and banners — none of which may touch gameplay. + +**Architecture:** One tiny float physics simulation in `internal/fx/particle.go` plus four short-lived state machines (shake, clear animation, hyperdrive, banner queue) hanging off the same `World` built in Plan 03. `Advance(dt)` integrates them all; `internal/render` draws whatever it finds. `Options.ReducedMotion` is read in exactly three places and nowhere else. + +**Tech Stack:** Go 1.26, `math`, `math/rand/v2` (the FX generator from Plan 03), `charm.land/lipgloss/v2`. + +**Spec:** `design.md` (§16, §18, §19, §20, §21, §22, §23, §24, §44, §49.5) + +## Global Constraints + +- Effects never modify game state and never delay gameplay for animation (§14, §44). +- §33's tree is the package structure, not a filename whitelist: one small file per subsystem inside the existing packages, no new package, no file over a few hundred lines. +- Screen shake never exceeds roughly one cell (§44) and lasts ~80ms (§18.3). +- Never obscure the active piece; never let particles permanently alter the rendered board (§44). +- Particle integration per step: `position += velocity × dt; velocity += acceleration × dt; velocity *= drag; life -= dt`. Particles die at `life <= 0` or outside the viewport. No collision detection (§23). +- A few hundred particles must be trivial; reuse slices; no goroutine per particle or per frame (§38). +- `--reduced-motion` suppresses screen shake, hyperdrive acceleration, and shockwaves while leaving colour, trails, and particles alone (§49.5). +- Shockwaves last ~300ms and are used sparingly (§24). +- Banners must not block gameplay input (§20). +- Board readability remains sacred as combos escalate (§21). + +## Review Focus + +1. **Sustained four-line clears** — a good player produces one every few seconds. The particle pool must be capped with oldest-first eviction so a long session cannot grow it without bound or degrade the frame. *(Task 1)* +2. **Particles at the viewport edge** — a float position of `-0.4` truncates to `0` and `W-0.1` truncates to `W-1`, but `-0.6` must not wrap to the opposite side or index out of range. Emission near a wall is the common case, not the rare one. *(Task 1)* +3. **`--reduced-motion` actually reduces** — assert the shake offset is always `(0,0)`, the hyperdrive multiplier is always `1.0`, and no shockwave ever exists, while particles and trails still appear. This is an accessibility promise, not a nicety. *(Task 6)* +4. **Simultaneous events** — one hard drop can clear four lines, change the combo, and level up on the same frame. Banners must queue rather than overdraw, and the combined effect must not cover the active piece. *(Task 5)* +5. **Clear overlay versus the new piece** — the 220ms clear animation draws in rows the board has already collapsed, and a new piece can enter those rows during it. The overlay must yield to the active piece and to locked cells, every frame, without exception. *(Task 3)* + +--- + +### Task 1: Particle simulation + +**Files:** +- Create: `internal/fx/particle.go` +- Modify: `internal/fx/world.go` +- Test: `internal/fx/particle_test.go` + +**Interfaces:** +- Produces: + ```go + const MaxParticles = 600 + + type Particle struct { + X, Y float64 // board-local cells (fractional) + VX, VY float64 // cells per second + Life float64 // seconds + MaxLife float64 + Glyph rune + Brightness float64 // 0..1 + Hue ParticleHue + } + type ParticleHue int + const (HueDebris ParticleHue = iota; HueSpark; HueMeteor; HueStellar) + + func (w *World) Particles() []Particle + func (w *World) emit(p Particle) + func (w *World) emitBurst(x, y float64, n int, spread, speed float64, hue ParticleHue) + func (w *World) emitRadial(x, y float64, n int, speed float64, hue ParticleHue) + ``` + +Forces, applied in `Advance` (§23): gravity `+14 cells/s²` on `VY`, drag `0.90^(dt*60)` +on both components, plus whatever radial impulse the emitter gave. Life ticks down; +dead particles and particles outside `-1 <= X <= Width` / `-1 <= Y <= Height` are +compacted out of the slice in place. + +Glyphs come from a per-hue set: debris `· * ✦ +` (§18.2), sparks `· ˚ +`, +meteors `* ✦ ·`, stellar `✧ ✦ *`. In ASCII mode every set collapses to `. * + :`. + +The pool is capped at `MaxParticles`; `emit` on a full pool overwrites the oldest +entry rather than growing, so worst-case memory and worst-case frame cost are both +fixed (Review Focus 1). + +- [ ] **Step 1: Write the failing tests** + +```go +func TestParticleIntegratesPositionFromVelocity(t *testing.T) +// One particle at (5,5) with VX=2, VY=0: after Advance(500ms), X is ~6 (drag +// makes it slightly less), Y has increased by gravity. + +func TestDragSlowsParticles(t *testing.T) +// |VX| after 1s is less than half the initial value. + +func TestParticlesDieAtZeroLife(t *testing.T) +// MaxLife 200ms: Particles() is empty after Advance totalling 250ms. + +func TestParticlesOutsideTheViewportAreCulled(t *testing.T) +// Emit with VY = -200: within a few frames the particle is gone, and no +// coordinate ever produced a panic. Review Focus 2. + +func TestEmitBurstProducesRequestedCount(t *testing.T) +// emitBurst(5, 10, 24, ...) -> 24 particles, all within 1 cell of (5,10), +// with a spread of directions (not all identical VX). + +func TestEmitRadialSpreadsEvenly(t *testing.T) +// 16 particles: their velocity angles cover all four quadrants. + +func TestPoolIsCappedWithOldestEviction(t *testing.T) +// Emit 2000 particles in one frame: len(Particles()) == MaxParticles, and the +// survivors are the last 600 emitted (check a marker in Brightness). +// Review Focus 1. + +func TestParticleSimulationIsDeterministic(t *testing.T) +// Two worlds, same FX seed, same event sequence, same dt sequence: identical +// particle slices after 100 frames. + +func TestASCIIParticlesAreASCII(t *testing.T) + +func TestDisabledWorldEmitsNothing(t *testing.T) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ -run Particle -v` +Expected: FAIL — undefined: `Particles`. + +- [ ] **Step 3: Implement** + +`particle.go` plus the integration call in `World.Advance`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/fx/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/particle.go internal/fx/world.go internal/fx/particle_test.go +git commit -m "feat(fx): terminal-space particle simulation with a fixed pool" +``` + +--- + +### Task 2: Hard-drop impact and screen shake + +**Files:** +- Create: `internal/fx/shake.go` +- Modify: `internal/fx/world.go` +- Test: `internal/fx/shake_test.go`, `internal/fx/impact_test.go` + +**Interfaces:** +- Produces: + ```go + var ShakePattern = [5][2]int{{0, 1}, {-1, 0}, {1, 0}, {0, -1}, {0, 0}} + const ShakeStep = 16 * time.Millisecond // 5 steps ~ 80ms (§18.3) + func (w *World) ShakeOffset() (dx, dy int) // (0,0) when idle or reduced-motion + func (w *World) shake(strength int) // 1 normal, 2 = replay the pattern twice + ``` + +`EvPieceHardDropped` now fires all four of §18's parts: the trail (Plan 03 Task 4), +`emitBurst` of `8 + 3*columns` debris particles along the contact row with upward- +and-outward velocity, `shake(1)`, and the border flash (Plan 03 Task 3). A +four-line clear calls `shake(2)` — §20's "larger screen shake", still one cell of +displacement, just for longer. + +The offset is a lookup into `ShakePattern` by elapsed shake time, so it is +deterministic and provably bounded to one cell — no random jitter, which §44 +requires. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestShakeWalksTheSpecPatternThenStops(t *testing.T) +// shake(1); sample ShakeOffset() every 16ms: the five pattern entries in order, +// then (0,0) forever. + +func TestShakeNeverExceedsOneCell(t *testing.T) +// Over the whole pattern at strength 1 and 2: |dx| <= 1 and |dy| <= 1 always. + +func TestShakeStrengthTwoLastsLonger(t *testing.T) +// Strength 2 is still non-zero after 80ms and zero by 200ms. + +func TestReducedMotionDisablesShake(t *testing.T) +// Options{ReducedMotion: true}: ShakeOffset() is (0,0) at every sample after +// shake(2). Review Focus 3. + +func TestHardDropEmitsDebrisAndShakes(t *testing.T) +// Handle(EvPieceHardDropped): particles exist near the piece's final row, +// ShakeOffset() is non-zero, Border().Flash == 1, trails exist. + +func TestHardDropWithZeroFallStillImpacts(t *testing.T) +// Count == 0 (piece already grounded): debris and shake still happen, no panic. + +func TestDisabledWorldDoesNotShake(t *testing.T) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ -run 'Shake|Impact|HardDrop' -v` +Expected: FAIL — undefined: `ShakeOffset`. + +- [ ] **Step 3: Implement** + +`shake.go` and the expanded `EvPieceHardDropped` case in `Handle`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/fx/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/shake.go internal/fx/world.go internal/fx/shake_test.go internal/fx/impact_test.go +git commit -m "feat(fx): hard-drop debris and bounded deterministic screen shake" +``` + +--- + +### Task 3: Line-clear supernova + +**Files:** +- Create: `internal/fx/clearanim.go` +- Modify: `internal/fx/world.go` +- Test: `internal/fx/clearanim_test.go` + +**Interfaces:** +- Produces: + ```go + const ClearAnimTotal = 220 * time.Millisecond + type ClearPhase int + const (PhaseNone ClearPhase = iota; PhaseCriticalMass; PhaseSupernova; PhaseCollapse) + type ClearAnim struct { + Rows []int // board-local rows, as cleared + Age time.Duration + Count int // lines cleared, 1..4 + } + func (w *World) ClearAnims() []ClearAnim + func (a ClearAnim) Phase() ClearPhase + func (a ClearAnim) Progress() float64 // 0..1 within the whole animation + // Glyph for one cell of an animating row, given phase and distance from centre. + func ClearGlyph(a ClearAnim, col int, ascii bool) rune + ``` + +Phases (§19), by age: `0–70ms` critical mass, `70–150ms` supernova, `150–220ms` +collapse. `ClearGlyph` implements §19's three pictures — critical mass dims the row +from the edges inward (`▓` outside, `█` in the middle), supernova drives a bright +front outward from the centre (`✦` at the front, `██` inside, `░░` behind), and +collapse returns a sparse debris glyph or a space. + +At the start of `PhaseCollapse` the row's cells become particles: `emitBurst` per +column with `VX = (col - 4.5) * 3.5` so debris inherits horizontal velocity from +its position relative to centre (§19), and `VY` slightly upward. + +The board has already collapsed underneath — the engine cleared the rows the +instant the piece locked, because §44 forbids delaying gameplay for animation. The +overlay therefore paints §19's spectacle over the rows' original positions while +real cells sit beneath it, and it must yield to any locked or active cell, which is +Review Focus 5 and is enforced in Task 6's draw function rather than here. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestClearEventStartsAnAnimation(t *testing.T) +// Handle(EvLinesCleared{Rows: []int{20, 21}, Count: 2}): one ClearAnim with those +// rows and Age 0. + +func TestPhasesFollowSpecTiming(t *testing.T) +// Age 0 and 69ms -> PhaseCriticalMass; 71ms and 149ms -> PhaseSupernova; +// 151ms and 219ms -> PhaseCollapse; 221ms -> the anim is gone. + +func TestSupernovaFrontMovesOutward(t *testing.T) +// Sample ClearGlyph across all 10 columns early and late in PhaseSupernova: the +// bright glyph's column indices are nearer the centre early and nearer the edges +// late. + +func TestCollapseEmitsDebrisWithOutwardVelocity(t *testing.T) +// After entering PhaseCollapse: particles exist; those left of centre have +// negative VX and those right of centre positive. (§19) + +func TestDebrisIsEmittedOncePerAnimation(t *testing.T) +// Particle count does not grow on every frame of PhaseCollapse. + +func TestConcurrentAnimationsAreIndependent(t *testing.T) +// Two clears 100ms apart: two ClearAnims with different Ages, each expiring on +// its own schedule. + +func TestASCIIClearGlyphsAreASCII(t *testing.T) + +func TestDisabledWorldHasNoClearAnim(t *testing.T) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ -run Clear -v` +Expected: FAIL — undefined: `ClearAnims`. + +- [ ] **Step 3: Implement** + +`clearanim.go` plus the `EvLinesCleared` case in `Handle`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/fx/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/clearanim.go internal/fx/world.go internal/fx/clearanim_test.go +git commit -m "feat(fx): three-phase supernova line-clear animation" +``` + +--- + +### Task 4: Hyperdrive and shockwaves + +**Files:** +- Create: `internal/fx/hyperdrive.go`, `internal/fx/shockwave.go` +- Modify: `internal/fx/world.go`, `internal/fx/starfield.go` +- Test: `internal/fx/hyperdrive_test.go`, `internal/fx/shockwave_test.go` + +**Interfaces:** +- Produces: + ```go + const HyperdriveTotal = 1100 * time.Millisecond + func (w *World) hyperdrive() // triggers the sequence + func (w *World) HyperMultiplier() float64 // 1.0 idle; 0.0 during the pause + func (w *World) HyperStretch() float64 // 0..1, renderer draws '|' streaks + + const ShockwaveLife = 300 * time.Millisecond + type Shockwave struct { X, Y float64; Radius, MaxRadius float64; Life, MaxLife float64 } + func (w *World) Shockwaves() []Shockwave + func (w *World) shockwave(x, y, maxRadius float64) + func ShockGlyph(progress float64, ascii bool) rune // · ○ ◌ ◯ (§24) + ``` + +Hyperdrive timeline, exactly §16's keyframes, linearly interpolated between them: + +``` + 0ms multiplier 0.0 stretch 0.0 (stars pause) + 50ms multiplier 0.0 stretch 1.0 (stars stretch) + 100ms multiplier 6.0 stretch 1.0 (violent acceleration) + 500ms multiplier 9.0 stretch 1.0 (peak) + 800ms multiplier 3.0 stretch 0.4 (decay) +1100ms multiplier 1.0 stretch 0.0 (normal) +``` + +`Starfield` multiplies its per-layer speed by `SpeedScale() * HyperMultiplier()`. +Triggers (§16): a four-line clear, a combo reaching 5 or more, and a new high score +(`Snapshot.IsHighScore` rising within the session). Under `ReducedMotion`, +`hyperdrive()` is a no-op and `HyperMultiplier` stays `1.0`. + +Shockwaves are used only for four-line clears and combo ≥ 5 — §24's "use sparingly" +— radiate from the board centre, and are suppressed entirely under +`ReducedMotion`. The ring is drawn as glyphs on an approximate ellipse with an +x-radius twice the y-radius, because a terminal cell is about twice as tall as it +is wide. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestHyperdriveFollowsSpecKeyframes(t *testing.T) +// Sample HyperMultiplier at 0, 50, 100, 500, 800, 1100ms: matches the table +// within 0.1, and returns to exactly 1.0 after 1100ms. + +func TestStarsPauseThenSprint(t *testing.T) +// Star Y movement in the first 50ms is ~0; movement in the 400ms after 100ms is +// several times a normal 400ms. + +func TestFourLineClearTriggersHyperdrive(t *testing.T) +func TestComboFiveTriggersHyperdrive(t *testing.T) +func TestNewHighScoreTriggersHyperdriveOnce(t *testing.T) +// IsHighScore rising triggers once, not on every subsequent frame. + +func TestReducedMotionKeepsMultiplierAtOne(t *testing.T) +// Review Focus 3. + +func TestShockwaveExpandsAndExpires(t *testing.T) +// Radius grows monotonically; Shockwaves() is empty after 350ms. + +func TestShockwaveGlyphProgression(t *testing.T) +// ShockGlyph at 0, 0.35, 0.7, 1.0 returns the four §24 glyphs in order. + +func TestReducedMotionSuppressesShockwaves(t *testing.T) +// A four-line clear produces none. Review Focus 3. + +func TestShockwavesAreRare(t *testing.T) +// Twenty single-line clears produce zero shockwaves. +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ -run 'Hyper|Shock|Star' -v` +Expected: FAIL — undefined: `HyperMultiplier`. + +- [ ] **Step 3: Implement** + +Both files plus the starfield multiplier hook and the `Handle` triggers. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/fx/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/hyperdrive.go internal/fx/shockwave.go internal/fx/starfield.go internal/fx/world.go internal/fx/hyperdrive_test.go internal/fx/shockwave_test.go +git commit -m "feat(fx): hyperdrive sequence and sparing radial shockwaves" +``` + +--- + +### Task 5: Banners, level-up notice, and combo escalation + +**Files:** +- Create: `internal/fx/banner.go` +- Modify: `internal/fx/world.go`, `internal/flavor/messages.go` +- Test: `internal/fx/banner_test.go` + +**Interfaces:** +- Produces: + ```go + type BannerStyle int + const (BannerGiant BannerStyle = iota; BannerNotice) + type Banner struct { + Title, Subtitle string + Style BannerStyle + Age, Life time.Duration + } + func (w *World) Banners() []Banner // at most 2; index 0 draws on top + func (w *World) HudPulse() float64 // 0..1, non-zero from combo 4 (§21) + func (w *World) Intensity() float64 // 0..1 overall chaos, drives densities + ``` + and in `flavor`: `func FourLineBanner(rng *rand.Rand) string`, + `func LevelSubtitle(rng *rand.Rand) string`. + +Four-line clear (§20): a `BannerGiant` with `Life = 700ms`, title from §20's four +options (`✦ EVENT HORIZON ✦`, `QUADRUPLE COSMIC INCIDENT`, +`FOUR ROWS HAVE LEFT THE CHAT`, `SPACE-TIME HAS FILED A COMPLAINT`), fired together +with hyperdrive, `shake(2)`, a shockwave, an `emitRadial` eruption, `HudPulse`, and +a temporary star-density increase via `Intensity()`. + +Level up (§22): a `BannerNotice` with `Life = 1400ms`, title +`GRAVITY ANOMALY DETECTED` / `LEVEL 08`, subtitle from §22's three options. It +slides in over the first 150ms and fades over the last 300ms, and it never pauses +anything. + +Combo escalation (§21): combo 2 → sparks (`emitBurst`, `HueSpark`), 3 → meteors, +4 → `HudPulse` begins, 5+ → hyperdrive, a shockwave, and a `BannerNotice` carrying +`flavor.ComboLine(combo)`. `Intensity()` is `min(1, 0.15*combo + 0.4*clearAnims + flash)` +and multiplies star density and burst sizes — the single knob §21's "effects +intensify" hangs on. + +The queue holds at most two banners; a new giant banner evicts a notice, a new +notice never evicts a giant. That is Review Focus 4: simultaneous events produce a +stack with a defined order rather than two boxes fighting for the same rows. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestFourLineClearRaisesEverything(t *testing.T) +// One EvLinesCleared{Count: 4}: a BannerGiant exists with one of §20's four +// titles, HyperMultiplier != 1, ShakeOffset != (0,0), a shockwave exists, +// particles > 40, Intensity() > 0.5. + +func TestGiantBannerLivesSevenHundredMilliseconds(t *testing.T) +// Present at 690ms, gone at 710ms. (§20) + +func TestLevelUpBannerShowsTheNewLevel(t *testing.T) +// EvLevelChanged{Count: 8}: title contains "LEVEL 08", subtitle is one of §22's +// three, Style == BannerNotice. + +func TestComboThresholds(t *testing.T) +// combo 2: sparks only, no HudPulse. combo 3: meteor particles. combo 4: +// HudPulse > 0. combo 5: hyperdrive active and a banner with "COMBO 5". + +func TestSimultaneousEventsQueueRatherThanOverdraw(t *testing.T) +// One Handle with [HardDropped, Locked, LinesCleared(4), ComboChanged(3), +// LevelChanged(9)]: len(Banners()) == 2, Banners()[0].Style == BannerGiant. +// Review Focus 4. + +func TestNoticeNeverEvictsAGiant(t *testing.T) +// Giant banner live, then a level-up: the giant is still index 0. + +func TestIntensityDecaysBackToZero(t *testing.T) +// After a four-line clear and 3s of frames: Intensity() == 0. + +func TestBannersDoNotBlockAnything(t *testing.T) +// While banners are live, game Fingerprint is unchanged by FX calls, and +// Handle/Advance return in bounded time (no sleeps). (§20) + +func TestDisabledWorldHasNoBanners(t *testing.T) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ -run 'Banner|Combo|Intensity|Level' -v` +Expected: FAIL — undefined: `Banners`. + +- [ ] **Step 3: Implement** + +`banner.go`, the flavor additions, and the `Handle` cases for `EvLinesCleared`, +`EvComboChanged`, and `EvLevelChanged`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./... -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/banner.go internal/fx/world.go internal/flavor/messages.go internal/fx/banner_test.go +git commit -m "feat(fx): banners, level-up notice, and combo escalation" +``` + +--- + +### Task 6: Draw the violence + +**Files:** +- Modify: `internal/render/fxdraw.go`, `internal/render/frame.go`, `internal/render/palette.go`, `internal/render/golden_test.go`, `internal/render/testdata/*.golden` +- Test: `internal/render/violence_test.go` + +**Interfaces:** +- Produces: + ```go + func DrawParticles(g *Grid, ps []fx.Particle, p Palette, ox, oy int, occupied func(x, y int) bool) + func DrawClearAnims(g *Grid, as []fx.ClearAnim, p Palette, ox, oy int, occupied func(x, y int) bool) + func DrawShockwaves(g *Grid, ws []fx.Shockwave, p Palette, ox, oy int) + func DrawBanners(g *Grid, bs []fx.Banner, p Palette) + func DrawHyperStreaks(g *Grid, stretch float64, stars []fx.Star, p Palette, board Rect) + func (p Palette) ParticleStyle(h fx.ParticleHue, bright float64) lipgloss.Style + func (p Palette) PulseStyle(base lipgloss.Style, pulse float64) lipgloss.Style + ``` + `Render` applies `FX.ShakeOffset()` to the board's origin only — the HUD, title, + mission line, and banners do not move, so a shake reads as the board jolting + inside a stable frame rather than the whole terminal tearing (§18.3, §44). + +`occupied(x, y)` reports board-local cells filled by a locked block, the active +piece, or the ghost. Every board-local FX draw consults it and skips those cells. +One helper, four callers, and §44's "never obscure the active piece" holds for +particles, trails, stars, and the clear overlay at once — including Review Focus 5, +where a new piece has already entered the animating rows. + +`DrawBanners` centres a giant banner on the board with a bright bordered box and +places a notice two rows below the board's top edge, both clipped by the grid. +`HudPulse` feeds `Palette.PulseStyle(base, pulse)`, which brightens and bolds HUD +values on the beat (§21 combo 4). + +- [ ] **Step 1: Write the failing tests** + +```go +func TestParticlesDrawInBoardLocalSpaceAndClip(t *testing.T) +// A particle at (0.2, 21.4) lands on the board's bottom-left interior cell; one +// at (-3, 30) draws nothing and does not panic. Review Focus 2. + +func TestParticlesNeverCoverBlocksOrTheActivePiece(t *testing.T) +// Particles placed on a locked cell, a ghost cell, and an active cell: all three +// positions still show their block/ghost glyph. + +func TestClearOverlayYieldsToTheActivePiece(t *testing.T) +// A ClearAnim on rows 20-21 with the active piece already occupying a cell in +// row 21: that cell shows the piece glyph. Review Focus 5. + +func TestShakeMovesOnlyTheBoard(t *testing.T) +// Render with a forced shake offset of (0,1): the board's border line moves one +// row; the title, mission line, and stats stay on the same rows. + +func TestShakeNeverPushesTheBoardOutOfTheFrame(t *testing.T) +// At 40x24 with every pattern offset: line count <= 24, widths <= 40, board box +// still complete. + +func TestGiantBannerIsCentredAndReadable(t *testing.T) +// Output contains the banner title inside a bordered box near the board centre, +// and the box is at most the board's width. + +func TestBannerDoesNotCoverTheActivePiece(t *testing.T) +// With the active piece at spawn height, the banner rows do not overlap the +// piece's rows; if geometry forces overlap, the piece wins. Review Focus 4. + +func TestGoldenViolenceScenes(t *testing.T) +// New golden scenes, each with a deterministic FX world advanced to a fixed age: +// impact 80x30 mid-shake, debris live +// supernova 80x30 PhaseSupernova on two rows +// fourline 80x30 giant banner + hyperdrive + shockwave +// levelup 80x30 notice banner +// reduced 80x30 same events as fourline with ReducedMotion +// ascii-fx 80x30 fourline in ModeASCII +// Geometry assertions as in Plan 02 Task 8 for every scene. + +func TestReducedMotionSceneHasNoShakeAndNoRings(t *testing.T) +// The reduced scene: board box starts on the same row as a no-FX render, no +// shockwave glyph anywhere, and particles are still present. Review Focus 3. + +func TestASCIIFXSceneIsPureASCII(t *testing.T) +// Every rune of the ascii-fx scene < 128. + +func TestRenderStillDoesNotMutateGameState(t *testing.T) +// Fingerprint before and after rendering all scenes twice. +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/render/ -v` +Expected: FAIL — undefined: `DrawParticles`. + +- [ ] **Step 3: Implement** + +The five draw functions, the `occupied` helper, `PulseStyle`, the shake offset +application, and the pipeline order (§37: particles and clear overlay at step 6, +shockwaves and hyper streaks at step 9, banners at step 10). Regenerate goldens +with `-update` and read each new file. + +- [ ] **Step 4: Run tests, then feel it** + +Run: `go test ./... -count=1` — expect PASS. +Run: `./cosmic-tetris --seed 8675309`, then hard drop something. §18's bar is +"dropping a refrigerator from orbit"; if it does not read that way, tune the debris +count and border flash, not the shake amplitude — one cell is the ceiling. +Then clear four lines at once. §43's bar is an involuntary reaction, and §20's is a +major astronomical event. Then run `./cosmic-tetris --reduced-motion` and confirm +the same four-line clear is still exciting and completely still. + +- [ ] **Step 5: Commit** + +```bash +git add internal/render +git commit -m "feat(render): draw particles, supernovas, shockwaves, and banners" +``` + +--- + +## Done when + +A hard drop produces a trail, debris, one cell of shake, and a border flash on the +frame the key was pressed. A line clear produces §19's three phases and debris that +flies outward from centre. A four-line clear produces hyperdrive, a bigger shake, a +racing border gradient, an eruption, a HUD flash, denser stars, and a giant banner +for 700ms — simultaneously, without blocking input. `--reduced-motion` removes the +shake, the hyperdrive acceleration, and the shockwaves and nothing else. Every +board-local effect yields to the active piece, and `go test ./... -count=1` is +green. diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/P1-proportion-cosmic-52/2026-09-17-cosmic-tetris-05-polish.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/P1-proportion-cosmic-52/2026-09-17-cosmic-tetris-05-polish.md new file mode 100644 index 000000000..78ac6331a --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/P1-proportion-cosmic-52/2026-09-17-cosmic-tetris-05-polish.md @@ -0,0 +1,498 @@ +# Cosmic Tetris — Plan 05: Absurd Polish Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add the boot sequence, the black-hole game over, size-aware effect reduction, and the small rare details — then verify the whole §47 definition of done and ship the README. + +**Architecture:** Two new app states (`StateBoot`, and `StateOver` gaining a timeline) plus one FX subsystem (`collapse`) and one layout-driven intensity clamp. Nothing structural changes: the boot screen and the collapse are both `render` functions reading `fx`/`app` state, exactly like every other effect. + +**Tech Stack:** Go 1.26, `charm.land/bubbles/v2/spinner`, the existing packages. + +**Spec:** `design.md` (§28, §29, §30, §31 effect reduction, §32, §39, §43, §45, §47, §48) + +## Global Constraints + +- Boot lasts approximately one second, any key skips it, no menu is required (§29). +- Game over is theatrical and does not instantly replace the board; the timeline is `0–300ms` freeze with `SIGNAL LOST`, `300–900ms` blocks falling inward, `900–1300ms` collapse into a black hole, then the final panel (§28). +- Pause freezes gameplay and gameplay particles; background stars may keep drifting very slowly (§30). +- Effects automatically reduce outside the board at small sizes (§31). +- The optional tiny details of §45 stay occasional. +- The boring mode (`--no-fx`, `--ascii`) must still be a good game (§32). +- Terminal output must not visibly flicker under normal conditions; animations never block input (§47). +- The codebase should be small enough to understand in an afternoon (§48). This plan adds no new packages. + +## Review Focus + +1. **Keypress during boot** — the first thing an impatient player does. Any key must jump straight to a playable first frame, with the game's clock starting then rather than 1s in the past. *(Task 1)* +2. **Game over while banners and particles are live** — dying on a four-line clear is common. The collapse must run, the final panel must appear, and the live banner must not sit on top of the panel. *(Task 2)* +3. **Resize during the collapse or the boot** — both are timed sequences that own the whole screen. A resize mid-sequence must reposition without panicking and without restarting the timeline. *(Tasks 1, 2)* +4. **`r` mid-collapse** — restart during the animation must produce a clean playing state with the FX world reset, not a new game wearing the old collapse. *(Task 2)* +5. **Quit at any moment** — `q`, `esc`, and `ctrl+c` from boot, playing, paused, help, mid-collapse, and the final panel must all exit with the terminal restored and no stray output after the prompt returns. *(Task 5)* + +--- + +### Task 1: Boot sequence + +**Files:** +- Create: `internal/render/boot.go` +- Modify: `internal/app/model.go`, `internal/app/update.go`, `internal/render/frame.go` +- Test: `internal/render/boot_test.go`, `internal/app/boot_test.go` + +**Interfaces:** +- Produces: + ```go + // app + const StateBoot AppState = 4 // appended so existing constants keep their values + const BootDuration = 1100 * time.Millisecond + // model gains: BootAge time.Duration, Spinner spinner.Model + + // render + type BootView struct { Age time.Duration; Spinner string; Palette Palette } + func DrawBoot(g *Grid, v BootView) + func BootChecklist(age time.Duration) []string // progressive reveal + // Frame gains: Boot BootView, used only when State == StateBoot + ``` + +`New` starts in `StateBoot`. `FrameMsg` accumulates `BootAge` and advances the +spinner; at `BootDuration` the state becomes `StatePlaying`. **Any** `KeyPressMsg` +except the quit keys skips immediately (§29). On the transition, `LastFrame` is set +to the message's `Now` so the game does not receive the boot's elapsed time as +gravity — that is Review Focus 1's real failure mode. + +The screen is §29's, revealed on a schedule: + +``` + 0ms ✦ and "C O S M I C" +150ms "T E T R I S" +300ms "INITIALIZING LOCAL UNIVERSE..." +450ms "gravity ........ OK" +600ms "spacetime ...... OK" +750ms "tetrominoes .... QUESTIONABLE" +950ms "UNIVERSE ONLINE" +``` + +The spinner (Bubbles, §3) sits beside the `INITIALIZING` line. The starfield draws +behind all of it — `FX.Advance` already runs in every state, so boot gets stars for +free. + +- [ ] **Step 1: Write the failing tests** + +```go +// render +func TestBootRevealsLinesOnSchedule(t *testing.T) +// Age 0: contains "C O S M I C", not "T E T R I S". Age 400ms: contains +// "INITIALIZING LOCAL UNIVERSE...". Age 800ms: contains "QUESTIONABLE". +// Age 1000ms: contains "UNIVERSE ONLINE". + +func TestBootFitsSmallTerminals(t *testing.T) +// 40x24 and 34x19 grids: no panic, all lines within bounds. + +func TestBootIsCentred(t *testing.T) + +// app +func TestAppStartsInBootState(t *testing.T) + +func TestBootEndsAfterItsDuration(t *testing.T) +// FrameMsgs totalling 1.2s: State == StatePlaying. + +func TestAnyKeySkipsBoot(t *testing.T) +// "x", "space", "a": State becomes StatePlaying immediately. Review Focus 1. + +func TestQuitDuringBootStillQuits(t *testing.T) +// "q" returns the quit command rather than skipping to play. + +func TestSkippingBootDoesNotBankGravity(t *testing.T) +// Boot for 900ms, press a key, then one FrameMsg 16ms later: the active piece has +// not moved (no 900ms of gravity applied). Review Focus 1. + +func TestBootIgnoresGameplayKeys(t *testing.T) +// The skip keypress must not also move the piece: X unchanged after skipping +// with "left". + +func TestResizeDuringBootIsFine(t *testing.T) +// WindowSizeMsg mid-boot: no panic, BootAge unchanged. Review Focus 3. +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/render/ ./internal/app/ -run Boot -v` +Expected: FAIL — undefined: `DrawBoot`, `StateBoot`. + +- [ ] **Step 3: Implement** + +`boot.go`, the state, and the `Update` cases. `go get charm.land/bubbles/v2/spinner` +is already satisfied by the bubbles dependency. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./... -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/boot.go internal/render/boot_test.go internal/app internal/render/frame.go +git commit -m "feat: one second of excessive drama before the game starts" +``` + +--- + +### Task 2: Game-over collapse and the black hole + +**Files:** +- Create: `internal/fx/collapse.go`, `internal/render/collapse.go` +- Modify: `internal/fx/world.go`, `internal/app/update.go`, `internal/render/frame.go` +- Test: `internal/fx/collapse_test.go`, `internal/render/collapse_test.go`, `internal/app/gameover_test.go` + +**Interfaces:** +- Produces: + ```go + // fx + const (CollapseFreeze = 300 * time.Millisecond + CollapseFall = 900 * time.Millisecond + CollapseHole = 1300 * time.Millisecond) + type CollapseStage int + const (CollapseNone CollapseStage = iota; CollapseSignalLost; CollapseInfall; CollapseBlackHole; CollapseDone) + type FallingBlock struct { X, Y float64; Kind game.PieceKind } + func (w *World) StartCollapse(b game.Board) + func (w *World) Collapse() (CollapseStage, []FallingBlock, float64) // stage, blocks, hole intensity + func (w *World) ResetCollapse() + + // render + func DrawSignalLost(g *Grid, board Rect, p Palette) + func DrawCollapse(g *Grid, board Rect, blocks []FallingBlock, hole float64, p Palette) + ``` + +`StartCollapse` snapshots the board into `FallingBlock`s at their current cell +positions — a copy, so nothing here reads game state again (§14). Stages follow +§28's timeline. During `CollapseInfall` each block accelerates toward the board +centre with a velocity proportional to its distance, so the outside arrives last. +During `CollapseBlackHole` the remaining blocks are inside a two-cell radius and +`hole` ramps 0→1, driving §28's picture: + +``` + · + ˚ + \ | / + --- ● --- + / | \ + * +``` + +drawn centred on the board, with the accretion arms brightening as `hole` rises. +`CollapseDone` is when the final panel (Plan 02 Task 5's `DrawGameOver`) appears. +Banners are cleared when the collapse starts, which is Review Focus 2. + +App wiring: on `EvGameOver`, `State = StateOver`, `FX.StartCollapse(Game.Board)`, +and the board stops being drawn from game state — `DrawCollapse` owns those rows +until `CollapseDone`. `r` calls `FX.ResetCollapse()` before rebuilding the game +(Review Focus 4). + +- [ ] **Step 1: Write the failing tests** + +```go +// fx +func TestCollapseStagesFollowSpecTiming(t *testing.T) +// 0 and 299ms -> CollapseSignalLost; 301 and 899ms -> CollapseInfall; +// 901 and 1299ms -> CollapseBlackHole; 1301ms -> CollapseDone. + +func TestBlocksFallInwardTowardTheCentre(t *testing.T) +// Snapshot a board with blocks at both edges; after 400ms of infall every block +// is closer to the centre than it started, and none has overshot past it. + +func TestOuterBlocksTravelFurther(t *testing.T) +// A block at x=0 moves more total distance than one at x=4 over the same window. + +func TestHoleIntensityRampsToOne(t *testing.T) +// hole is 0 during infall, rises during CollapseBlackHole, and is 1.0 at 1300ms. + +func TestCollapseSnapshotsRatherThanReferences(t *testing.T) +// Mutate the game board after StartCollapse: the falling blocks are unaffected. + +func TestStartCollapseClearsBanners(t *testing.T) +// A live giant banner is gone after StartCollapse. Review Focus 2. + +func TestResetCollapseReturnsToNone(t *testing.T) + +// render +func TestSignalLostIsDrawnOverTheBoard(t *testing.T) +// Output contains "SIGNAL LOST" within the board's rows. + +func TestBlackHoleGlyphsAppear(t *testing.T) +// At hole 1.0: output contains "●" and the arm characters, centred on the board. + +func TestCollapseFitsSmallTerminalsAndClips(t *testing.T) +// 40x24: no panic, lines within bounds. + +// app +func TestGameOverStartsTheCollapse(t *testing.T) +// Force a blocked spawn: State == StateOver and the collapse stage is +// CollapseSignalLost. + +func TestFinalPanelAppearsAfterTheCollapse(t *testing.T) +// 1.4s of frames after death: the frame contains "UNIVERSE EXPIRED" and the +// score; before 1.3s it does not. (§28) + +func TestGameOverDuringAFourLineClearStillCollapses(t *testing.T) +// Engineer a four-line clear that also blocks the next spawn: the collapse runs +// and the final panel is not covered by the banner. Review Focus 2. + +func TestRestartMidCollapseIsClean(t *testing.T) +// "r" at 600ms into the collapse: State StatePlaying, Score 0, collapse stage +// CollapseNone, no falling blocks. Review Focus 4. + +func TestResizeMidCollapseDoesNotPanicOrRestartTheTimeline(t *testing.T) +// Review Focus 3. + +func TestKeysOtherThanRAndQAreIgnoredWhileOver(t *testing.T) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ ./internal/render/ ./internal/app/ -run 'Collapse|GameOver|SignalLost|BlackHole' -v` +Expected: FAIL — undefined: `StartCollapse`. + +- [ ] **Step 3: Implement** + +Both files plus the wiring and the `Render` branch that hands the board rows to +`DrawCollapse` while a collapse is live. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./... -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/collapse.go internal/render/collapse.go internal/fx/world.go internal/app internal/render/frame.go +git commit -m "feat: collapse the universe into a black hole on game over" +``` + +--- + +### Task 3: Size-aware effect reduction and the rare details + +**Files:** +- Modify: `internal/fx/world.go`, `internal/fx/starfield.go`, `internal/fx/mission.go`, `internal/render/frame.go` +- Create: `internal/fx/shootingstar.go` +- Test: `internal/fx/reduction_test.go`, `internal/fx/shootingstar_test.go` + +**Interfaces:** +- Produces: + ```go + type Budget struct { Stars, Particles float64; Banners, Shockwaves bool } + func (w *World) SetBudget(b Budget) + func BudgetForTier(tier int) Budget // tier mirrors render.Tier + type ShootingStar struct { X, Y, VX, VY float64; Life, MaxLife float64; Tail int } + func (w *World) ShootingStars() []ShootingStar + ``` + +§31's "effects automatically reduce outside the board" becomes one multiplier set, +applied where densities and burst sizes are chosen: + +``` +TierWide Stars 1.0 Particles 1.0 Banners yes Shockwaves yes +TierMedium Stars 0.7 Particles 0.8 Banners yes Shockwaves yes +TierSmall Stars 0.35 Particles 0.5 Banners no Shockwaves no +``` + +At `TierSmall` there is no room for a banner without covering the board, and §44 +makes the board win. `app` calls `SetBudget(fx.BudgetForTier(int(layout.Tier)))` +whenever the size changes. + +Shooting stars (§45): during quiet play, roughly one every 25 seconds, a bright +particle crosses the background diagonally with a three-cell tail. It is drawn +outside the board only, using the same `occupied` guard as everything else. + +Idle details (§45), all routed through the existing Mission Control channel so they +inherit its dwell rules: `CAPTAIN?` after 20 seconds with no player input; +`NUMBER BECAME BIGGER` when the score crosses a power-of-ten boundary; +`DID YOU KNOW YOU'RE IN A TERMINAL?` at a 1-in-60 chance on an idle pick. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestBudgetForTierScalesDown(t *testing.T) +// Wide > Medium > Small for both multipliers; Small disables banners and +// shockwaves. + +func TestSmallBudgetReducesStarCount(t *testing.T) +// Same viewport, Budget{Stars: 0.35}: about a third of the stars. + +func TestSmallBudgetSuppressesBanners(t *testing.T) +// A four-line clear at the small budget produces no banner but still produces +// particles and a border reaction. + +func TestBudgetChangeDoesNotStutter(t *testing.T) +// Switching budgets mid-flight keeps every existing star inside bounds and does +// not reset positions. + +func TestShootingStarsAreRare(t *testing.T) +// Over 60 simulated seconds of quiet play: between 1 and 5 shooting stars. + +func TestShootingStarCrossesAndExpires(t *testing.T) +// Position moves diagonally; it is gone within its MaxLife. + +func TestShootingStarsPauseWithGameplay(t *testing.T) +// Playing false: no new shooting stars spawn. + +func TestIdleCaptainLineAfterTwentySeconds(t *testing.T) +// No input events for 20s: Mission text becomes "MISSION CONTROL: CAPTAIN?"'s +// pool line at least once. + +func TestScoreRolloverLine(t *testing.T) +// Snapshot score crossing 10000: "NUMBER BECAME BIGGER" appears (subject to dwell). +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ -run 'Budget|Shooting|Idle|Rollover' -v` +Expected: FAIL — undefined: `SetBudget`. + +- [ ] **Step 3: Implement** + +The budget plumbing, `shootingstar.go`, the mission triggers, and the `app` call +site. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./... -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx internal/render/frame.go internal/app +git commit -m "feat(fx): size-aware effect budget, shooting stars, rare flavor" +``` + +--- + +### Task 4: README and licence + +**Files:** +- Create: `README.md`, `LICENSE` +- Test: none (documentation) + +`README.md` covers, in this order: one-paragraph pitch in the spec's voice; a +screenshot-style ASCII sample of the wide layout taken from the actual golden file +(`internal/render/testdata/wide.golden`, not §4's mockup, which does not align — +§49.7); install and run; the six CLI forms from §49.5 with one line each, including +what `--reduced-motion` suppresses; the control table from §8; a short +"how it fits together" section naming the four packages and the one-way +`game → fx → render` flow; and how to run the tests, including `-update` for the +goldens. + +`LICENSE`: MIT, with the copyright line filled in. + +- [ ] **Step 1: Write the README** + +Pull the layout sample from the golden file so the README cannot drift from what +the program actually prints. + +- [ ] **Step 2: Verify the commands in it** + +Run every command the README lists, in order, from a clean clone-like state: +`go build ./...`, `go test ./...`, `./cosmic-tetris --help`, and each flag. + +- [ ] **Step 3: Commit** + +```bash +git add README.md LICENSE +git commit -m "docs: README with real layout sample, controls, and flags" +``` + +--- + +### Task 5: Definition-of-done verification pass + +**Files:** +- Create: `internal/app/acceptance_test.go` +- Test: same file + +**Interfaces:** +- Consumes: everything. + +This task turns §47's checklist into a single test file plus a scripted manual pass, +so "done" is a thing that can fail rather than a thing that gets asserted in a +commit message. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestModeMatrix(t *testing.T) +// For each of the 8 combinations of {ascii, no-fx, reduced-motion}: build a model, +// feed 1200 frames of 16ms with a canned input stream that clears lines, levels +// up, holds, pauses, resumes, opens help, closes it, dies, and restarts. Assert: +// no panic, every frame's line count <= H and width <= W, the game reaches +// Level >= 2, and the frame is non-empty in every state. + +func TestQuitFromEveryState(t *testing.T) +// boot, playing, paused, help, mid-collapse, final panel: "q", "esc", "ctrl+c" +// each return a quit command. Review Focus 5. + +func TestFrameIsStableWhenNothingHappens(t *testing.T) +// --no-fx, paused: two consecutive renders are byte-identical, which is the +// testable half of §47's "does not visibly flicker". + +func TestNoFrameExceedsATimeBudget(t *testing.T) +// 600 frames at TierWide with a four-line clear and 600 live particles: mean +// Update+View time under 4ms on the test machine. §38's "the terminal is the +// bottleneck" should hold; a regression here means an accidental O(n^2). + +func TestEffectsNeverModifyGameState(t *testing.T) +// The whole matrix run, with game.Fingerprint compared against a second run of +// the same input stream with --no-fx: identical. §47. + +func TestDeterministicSessionAcrossTheWholeApp(t *testing.T) +// Two models on the same seed fed an identical (key, frame-dt) stream end with +// identical game Fingerprint, Score, Lines, Level. +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/app/ -run 'Matrix|Quit|Stable|Budget|Effects|Deterministic' -v` +Expected: FAIL until the file compiles and any real defects it finds are fixed. + +- [ ] **Step 3: Fix whatever it finds** + +Expect real bugs here — this is the first test that exercises the modes in +combination. Fix them in the owning package, not in the acceptance test. + +- [ ] **Step 4: Walk §47 and §43 by hand** + +Run `./cosmic-tetris` and confirm each line, ticking them off: + +§47: playable start to game over · controls immediate · resizing works · hold · +ghost · next queue · deterministic piece generation · game and FX RNG isolated · +line clearing correct · gravity increases · pause · restart · ASCII fallback · +no-FX mode · unit tests · snapshot tests · no visible flicker · animations never +block input · effects never modify state · four-line clears gloriously excessive · +game over collapses into a black hole · fun with effects off · much funnier with +them on. + +§43, within the first 30 seconds of normal play: moving starfield, animated border, +piece trails, hard-drop impact, particles, Mission Control commentary. Within the +first completed line: supernova, debris, border reaction. On a four-line clear: the +reaction §43 actually specifies. + +Anything that fails is a bug in the owning plan's task; fix it there. + +- [ ] **Step 5: Commit** + +```bash +git add internal/app/acceptance_test.go +git commit -m "test: definition-of-done acceptance matrix across every mode" +``` + +--- + +## Done when + +Every line of §47 is checked, the acceptance matrix is green, `go test ./... -count=1` +passes, `go vet ./...` is clean, `gofmt -l .` prints nothing, and the game +delivers §43's reaction on a four-line clear. §48's standard is the last gate: +this should read as a tiny terminal arcade game whose universe is losing its shit, +not as Tetris implemented with Bubble Tea, and the whole codebase should still be +readable in an afternoon. diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/P1-proportion-ledgerlite-81.plan.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/P1-proportion-ledgerlite-81.plan.md new file mode 100644 index 000000000..c209be845 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/P1-proportion-ledgerlite-81.plan.md @@ -0,0 +1,756 @@ +# ledgerlite Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build `ledgerlite`, a stdlib-only Python CLI that reads a transactions CSV, categorizes each row from a rules file, and prints per-category totals plus the closing balance. + +**Architecture:** Five small pure modules (`model`, `parse`, `rules`, `balance`, `report`) with no I/O, plus `cli.py` which owns all file reading, error messages, and exit codes. Money is `decimal.Decimal` end to end; the CLI is the only place that touches the filesystem or stderr, so every other module is testable by passing strings and lists. + +**Tech Stack:** Python 3.11+, standard library only (`argparse`, `csv`, `dataclasses`, `datetime`, `decimal`, `re`, `unittest`). + +**Spec:** `design.md` + +## Global Constraints + +- Python 3.11+. Standard library only — no third-party dependencies. +- Amounts are `decimal.Decimal`, never `float`. No arithmetic on money goes through `float` at any point. +- Package lives in `ledgerlite/`; tests live at the repo root as `test_.py` and run with `python3 -m unittest`. +- Exit codes: `0` success, `1` a file could not be read, `2` malformed input content. +- Error messages go to stderr, exactly: + - `ledgerlite: cannot read : ` + - `ledgerlite: :: ` +- On any error, nothing is written to stdout. +- Amounts are formatted with exactly two fractional digits, a leading `-` only for negative values, and no thousands separators. +- The literal category name for transactions matching no rule is `uncategorized`, and it is always printed last. + +## Review Focus + +These are input classes the spec implies but does not spell out. Each has a test in the task that owns the code. + +1. An amount of `-0.00`, or a category whose amounts cancel out, must print as `0.00` — never `-0.00`. (Task 4) +2. A CSV with a missing or misspelled header row must be reported as malformed at line 1, not silently swallow its first data row as a header. (Task 1) +3. A transactions path that is a directory, or a file that is not valid UTF-8, must produce the `cannot read` message and exit 1 — not a traceback. (Task 5) +4. `--opening` given junk (`abc`) or three fractional digits (`1.005`) must be rejected with a usage error, not coerced or silently truncated. (Task 5) +5. Fields padded with spaces (`2026-03-04, -7.50, coffee`) are ordinary CSV in the wild; date and amount must be stripped before parsing rather than rejected as malformed. (Task 1) + +--- + +### Task 1: Transaction model and CSV parsing + +**Files:** +- Create: `ledgerlite/__init__.py` (empty) +- Create: `ledgerlite/model.py` +- Create: `ledgerlite/parse.py` +- Test: `test_parse.py` + +**Interfaces:** +- Consumes: nothing. +- Produces: + - `model.Transaction` — frozen dataclass with fields `date: datetime.date`, `amount: decimal.Decimal`, `description: str`. + - `parse.ParseError(Exception)` — constructed `ParseError(lineno: int, message: str)`; attributes `.lineno` and `.message`; `str(e) == e.message`. + - `parse.parse_amount(text: str) -> Decimal` — raises `ValueError` whose message is the `` text. + - `parse.parse_transactions(text: str) -> list[Transaction]` — rows in input order; raises `ParseError`. + +Message wording is fixed here because later tasks and tests assert on it: + +| Condition | message | +|---|---| +| header row not `date,amount,description` | `expected header 'date,amount,description'` | +| row has N != 3 fields | `expected 3 columns, got N` | +| date unparseable | `invalid date ''` | +| amount unparseable | `invalid amount ''` | +| amount has >2 fractional digits | `amount '' has more than two fractional digits` | + +- [ ] **Step 1: Write the failing tests** + +`test_parse.py`: + +```python +import unittest +from datetime import date +from decimal import Decimal + +from ledgerlite.model import Transaction +from ledgerlite.parse import ParseError, parse_amount, parse_transactions + +HEADER = "date,amount,description\n" + + +class ParseAmountTest(unittest.TestCase): + def test_accepts_two_one_and_zero_decimal_places(self): + self.assertEqual(parse_amount("-12.50"), Decimal("-12.50")) + self.assertEqual(parse_amount("1.5"), Decimal("1.5")) + self.assertEqual(parse_amount("1200"), Decimal("1200")) + + def test_rejects_three_decimal_places(self): + with self.assertRaises(ValueError) as cm: + parse_amount("1.005") + self.assertEqual(str(cm.exception), "amount '1.005' has more than two fractional digits") + + def test_rejects_non_numbers(self): + for raw in ["abc", "", "1.2.3", "NaN", "Infinity", "1e5", "--1"]: + with self.subTest(raw=raw), self.assertRaises(ValueError) as cm: + parse_amount(raw) + self.assertEqual(str(cm.exception), f"invalid amount '{raw}'") + + def test_returns_decimal_not_float(self): + self.assertIsInstance(parse_amount("0.10"), Decimal) + + +class ParseTransactionsTest(unittest.TestCase): + def test_parses_rows_in_input_order(self): + text = HEADER + "2026-03-05,-7.50,Coffee Shop\n2026-03-04,2500.00,Salary\n" + self.assertEqual( + parse_transactions(text), + [ + Transaction(date(2026, 3, 5), Decimal("-7.50"), "Coffee Shop"), + Transaction(date(2026, 3, 4), Decimal("2500.00"), "Salary"), + ], + ) + + def test_empty_text_and_header_only_yield_no_transactions(self): + self.assertEqual(parse_transactions(""), []) + self.assertEqual(parse_transactions(HEADER), []) + + def test_quoted_description_may_contain_comma(self): + text = HEADER + '2026-03-04,-1.00,"Coffee, large"\n' + self.assertEqual(parse_transactions(text)[0].description, "Coffee, large") + + def test_strips_whitespace_around_date_and_amount(self): + text = HEADER + "2026-03-04, -7.50 ,coffee\n" + txn = parse_transactions(text)[0] + self.assertEqual((txn.date, txn.amount), (date(2026, 3, 4), Decimal("-7.50"))) + + def test_missing_header_is_rejected_at_line_1(self): + with self.assertRaises(ParseError) as cm: + parse_transactions("2026-03-04,-7.50,coffee\n") + self.assertEqual((cm.exception.lineno, cm.exception.message), + (1, "expected header 'date,amount,description'")) + + def test_header_is_case_and_space_insensitive(self): + parse_transactions("Date, Amount, Description\n") # must not raise + + def test_wrong_column_count_reports_line_number(self): + with self.assertRaises(ParseError) as cm: + parse_transactions(HEADER + "2026-03-04,-7.50\n") + self.assertEqual((cm.exception.lineno, cm.exception.message), (2, "expected 3 columns, got 2")) + + def test_bad_date_reports_line_number(self): + with self.assertRaises(ParseError) as cm: + parse_transactions(HEADER + "2026-03-04,-1.00,ok\n2026-13-01,-1.00,bad\n") + self.assertEqual((cm.exception.lineno, cm.exception.message), (3, "invalid date '2026-13-01'")) + + def test_bad_amount_reports_line_number(self): + with self.assertRaises(ParseError) as cm: + parse_transactions(HEADER + "2026-03-04,abc,bad\n") + self.assertEqual((cm.exception.lineno, cm.exception.message), (2, "invalid amount 'abc'")) + + def test_amount_with_three_decimals_is_malformed(self): + with self.assertRaises(ParseError) as cm: + parse_transactions(HEADER + "2026-03-04,1.005,bad\n") + self.assertEqual(cm.exception.message, "amount '1.005' has more than two fractional digits") + + def test_first_bad_row_wins(self): + with self.assertRaises(ParseError) as cm: + parse_transactions(HEADER + "2026-03-04,abc,bad\n2026-99-99,1.00,also bad\n") + self.assertEqual(cm.exception.lineno, 2) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_parse -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite'` + +- [ ] **Step 3: Write `ledgerlite/__init__.py` and `ledgerlite/model.py`** + +`__init__.py` is empty. `model.py`: + +```python +from dataclasses import dataclass +from datetime import date +from decimal import Decimal + + +@dataclass(frozen=True) +class Transaction: + date: date + amount: Decimal + description: str +``` + +- [ ] **Step 4: Write `ledgerlite/parse.py`** + +```python +AMOUNT_RE = re.compile(r"-?\d+(\.\d+)?") # used with fullmatch: no exponents, no NaN/Infinity +HEADER = ("date", "amount", "description") + + +class ParseError(Exception): + def __init__(self, lineno: int, message: str) -> None: ... # sets .lineno, .message; super().__init__(message) + + +def parse_amount(text: str) -> Decimal: + """Decimal from an amount field. ValueError with the report-ready message on bad input.""" +``` + +Implementation notes for `parse_amount`: strip `text`; if it does not fully match `AMOUNT_RE`, raise `ValueError(f"invalid amount '{text}'")`; build `Decimal(stripped)`; if `-value.as_tuple().exponent > 2`, raise `ValueError(f"amount '{text}' has more than two fractional digits")`; return the `Decimal`. Note the `''` in both messages is the *stripped* text, matching the tests. + +```python +def parse_transactions(text: str) -> list[Transaction]: + """Parse the whole CSV. Raises ParseError on the first malformed row.""" +``` + +Implementation notes: +- `reader = csv.reader(io.StringIO(text, newline=""))`; iterate with `for row in reader`, using `reader.line_num` as the line number so quoted embedded newlines still count correctly. +- If `text.strip() == ""`, return `[]` before touching the reader (an empty file is zero transactions, not a missing header). +- First row is the header: raise `ParseError(1, "expected header 'date,amount,description'")` unless `tuple(f.strip().lower() for f in row) == HEADER`. +- For each later row: `len(row) != 3` → `ParseError(reader.line_num, f"expected 3 columns, got {len(row)}")`; then `datetime.date.fromisoformat(row[0].strip())` wrapped so `ValueError` becomes `ParseError(reader.line_num, f"invalid date '{row[0].strip()}'")`; then `parse_amount(row[1])` wrapped so `ValueError` becomes `ParseError(reader.line_num, str(exc))`. Description is `row[2]` unchanged. +- Return the transactions in input order; do not sort here. + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `python3 -m unittest test_parse -v` +Expected: PASS (all tests) + +- [ ] **Step 6: Commit** + +```bash +git add ledgerlite/__init__.py ledgerlite/model.py ledgerlite/parse.py test_parse.py +git commit -m "feat: Transaction model and transactions CSV parsing" +``` + +--- + +### Task 2: Rules file parsing and categorization + +**Files:** +- Create: `ledgerlite/rules.py` +- Test: `test_rules.py` + +**Interfaces:** +- Consumes: `parse.ParseError(lineno, message)`. +- Produces: + - `rules.Rule` — type alias `tuple[str, str]` of `(substring, category)`. + - `rules.parse_rules(text: str) -> list[Rule]` — rules in file order; raises `ParseError`. + - `rules.categorize(description: str, rules: list[Rule]) -> str | None` — first case-insensitive substring match wins; `None` if nothing matches. + +Fixed messages: `rule has no '='`, `rule has empty substring`, `rule has empty category`. + +- [ ] **Step 1: Write the failing tests** + +`test_rules.py`: + +```python +import unittest + +from ledgerlite.parse import ParseError +from ledgerlite.rules import categorize, parse_rules + + +class ParseRulesTest(unittest.TestCase): + def test_parses_rules_in_file_order(self): + self.assertEqual(parse_rules("coffee=food\nrent=housing\n"), + [("coffee", "food"), ("rent", "housing")]) + + def test_skips_blank_lines(self): + self.assertEqual(parse_rules("\ncoffee=food\n\n \n"), [("coffee", "food")]) + + def test_empty_text_yields_no_rules(self): + self.assertEqual(parse_rules(""), []) + + def test_splits_on_first_equals_only(self): + self.assertEqual(parse_rules("a=b=c\n"), [("a", "b=c")]) + + def test_strips_surrounding_whitespace(self): + self.assertEqual(parse_rules(" coffee = food \n"), [("coffee", "food")]) + + def test_line_without_equals_is_rejected(self): + with self.assertRaises(ParseError) as cm: + parse_rules("coffee=food\noops\n") + self.assertEqual((cm.exception.lineno, cm.exception.message), (2, "rule has no '='")) + + def test_empty_substring_is_rejected(self): + with self.assertRaises(ParseError) as cm: + parse_rules("=food\n") + self.assertEqual((cm.exception.lineno, cm.exception.message), (1, "rule has empty substring")) + + def test_empty_category_is_rejected(self): + with self.assertRaises(ParseError) as cm: + parse_rules("coffee=\n") + self.assertEqual((cm.exception.lineno, cm.exception.message), (1, "rule has empty category")) + + +class CategorizeTest(unittest.TestCase): + RULES = [("coffee", "food"), ("rent", "housing"), ("co", "other")] + + def test_matches_substring_case_insensitively(self): + self.assertEqual(categorize("MORNING COFFEE", self.RULES), "food") + self.assertEqual(categorize("Coffee Shop", self.RULES), "food") + + def test_first_matching_rule_wins(self): + self.assertEqual(categorize("coffee", self.RULES), "food") + self.assertEqual(categorize("Costco", self.RULES), "other") + + def test_returns_none_when_nothing_matches(self): + self.assertIsNone(categorize("Salary", self.RULES)) + + def test_no_rules_means_no_category(self): + self.assertIsNone(categorize("coffee", [])) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_rules -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.rules'` + +- [ ] **Step 3: Write `ledgerlite/rules.py`** + +```python +Rule = tuple[str, str] + + +def parse_rules(text: str) -> list[Rule]: + """One `=` per line. Raises ParseError on a bad line.""" + + +def categorize(description: str, rules: list[Rule]) -> str | None: + """Category of the first rule whose substring appears in description, else None.""" +``` + +Implementation notes: +- `parse_rules`: `enumerate(text.splitlines(), start=1)`; skip lines that are empty after `.strip()`; `if "=" not in line` → `ParseError(lineno, "rule has no '='")`; `substring, _, category = line.partition("=")`, then strip both; empty substring or category → the matching `ParseError`. Append `(substring, category)`. +- `categorize`: lowercase the description once, then `for substring, category in rules: if substring.lower() in lowered: return category`; fall through to `None`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_rules -v` +Expected: PASS (all tests) + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/rules.py test_rules.py +git commit -m "feat: rules file parsing and categorization" +``` + +--- + +### Task 3: Date ordering and closing balance + +**Files:** +- Create: `ledgerlite/balance.py` +- Test: `test_balance.py` + +**Interfaces:** +- Consumes: `model.Transaction`. +- Produces: + - `balance.in_date_order(transactions: list[Transaction]) -> list[Transaction]` — new list, sorted by date, ties keeping input order. + - `balance.running_balance(opening: Decimal, transactions: list[Transaction]) -> list[Decimal]` — balance after each transaction, in date order. + - `balance.closing_balance(opening: Decimal, transactions: list[Transaction]) -> Decimal` — last running balance, or `opening` when there are none. + +- [ ] **Step 1: Write the failing tests** + +`test_balance.py`: + +```python +import unittest +from datetime import date +from decimal import Decimal + +from ledgerlite.balance import closing_balance, in_date_order, running_balance +from ledgerlite.model import Transaction + + +def txn(day, amount, description="x"): + return Transaction(date(2026, 3, day), Decimal(amount), description) + + +class InDateOrderTest(unittest.TestCase): + def test_sorts_by_date(self): + self.assertEqual(in_date_order([txn(5, "1"), txn(4, "2")]), [txn(4, "2"), txn(5, "1")]) + + def test_ties_keep_input_order(self): + first, second = txn(4, "1", "first"), txn(4, "2", "second") + self.assertEqual(in_date_order([first, second]), [first, second]) + self.assertEqual(in_date_order([second, first]), [second, first]) + + def test_does_not_mutate_input(self): + given = [txn(5, "1"), txn(4, "2")] + in_date_order(given) + self.assertEqual(given, [txn(5, "1"), txn(4, "2")]) + + +class BalanceTest(unittest.TestCase): + def test_running_balance_follows_date_order(self): + self.assertEqual( + running_balance(Decimal("100"), [txn(5, "-7.50"), txn(4, "2500.00")]), + [Decimal("2600.00"), Decimal("2592.50")], + ) + + def test_closing_balance_is_the_last_running_balance(self): + self.assertEqual(closing_balance(Decimal("100"), [txn(4, "-7.50"), txn(4, "-900.00"), + txn(5, "2500.00")]), Decimal("1692.50")) + + def test_closing_balance_of_no_transactions_is_the_opening_amount(self): + self.assertEqual(closing_balance(Decimal("100"), []), Decimal("100")) + self.assertEqual(running_balance(Decimal("100"), []), []) + + def test_money_stays_decimal(self): + self.assertIsInstance(closing_balance(Decimal("0"), [txn(4, "0.10"), txn(4, "0.20")]), Decimal) + self.assertEqual(closing_balance(Decimal("0"), [txn(4, "0.10"), txn(4, "0.20")]), Decimal("0.30")) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_balance -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.balance'` + +- [ ] **Step 3: Write `ledgerlite/balance.py`** + +```python +def in_date_order(transactions: list[Transaction]) -> list[Transaction]: + """Copy sorted by date; Python's sort is stable, so ties keep input order.""" + return sorted(transactions, key=lambda t: t.date) + + +def running_balance(opening: Decimal, transactions: list[Transaction]) -> list[Decimal]: + """Balance after each transaction, walking them in date order.""" + + +def closing_balance(opening: Decimal, transactions: list[Transaction]) -> Decimal: + """Balance after the last transaction, or opening if there are none.""" +``` + +Implementation notes: `running_balance` accumulates `balance += t.amount` over `in_date_order(transactions)`, appending after each add. `closing_balance` returns the last element of `running_balance(...)` or `opening` for an empty list. Never convert to `float`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_balance -v` +Expected: PASS (all tests) + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/balance.py test_balance.py +git commit -m "feat: date ordering and running/closing balance" +``` + +--- + +### Task 4: Category totals and report formatting + +**Files:** +- Create: `ledgerlite/report.py` +- Test: `test_report.py` + +**Interfaces:** +- Consumes: `model.Transaction`, `rules.Rule`, `rules.categorize`, `balance.closing_balance`. +- Produces: + - `report.UNCATEGORIZED = "uncategorized"` + - `report.format_amount(amount: Decimal) -> str` — exactly two fractional digits; `-` only for values below zero. + - `report.category_totals(transactions: list[Transaction], rules: list[Rule]) -> list[tuple[str, Decimal]]` — categories alphabetically (case-insensitive), `uncategorized` last. + - `report.format_report(transactions, rules, opening: Decimal) -> str` — the whole report, ending in a single newline. + +- [ ] **Step 1: Write the failing tests** + +`test_report.py`: + +```python +import unittest +from datetime import date +from decimal import Decimal + +from ledgerlite.model import Transaction +from ledgerlite.report import category_totals, format_amount, format_report + +RULES = [("coffee", "food"), ("rent", "housing")] + + +def txn(day, amount, description): + return Transaction(date(2026, 3, day), Decimal(amount), description) + + +class FormatAmountTest(unittest.TestCase): + def test_two_fractional_digits(self): + self.assertEqual(format_amount(Decimal("-12.5")), "-12.50") + self.assertEqual(format_amount(Decimal("1200")), "1200.00") + self.assertEqual(format_amount(Decimal("0")), "0.00") + + def test_no_thousands_separators(self): + self.assertEqual(format_amount(Decimal("1234567.8")), "1234567.80") + + def test_negative_zero_prints_as_zero(self): + self.assertEqual(format_amount(Decimal("-0.00")), "0.00") + self.assertEqual(format_amount(Decimal("-0.50") + Decimal("0.50")), "0.00") + + +class CategoryTotalsTest(unittest.TestCase): + def test_sums_per_category_alphabetically_with_uncategorized_last(self): + transactions = [txn(4, "-900.00", "Rent"), txn(4, "2500.00", "Salary"), + txn(5, "-7.50", "Coffee"), txn(6, "-2.50", "coffee again")] + self.assertEqual( + category_totals(transactions, RULES), + [("food", Decimal("-10.00")), ("housing", Decimal("-900.00")), + ("uncategorized", Decimal("2500.00"))], + ) + + def test_uncategorized_absent_when_every_row_matches(self): + self.assertEqual(category_totals([txn(4, "-7.50", "Coffee")], RULES), + [("food", Decimal("-7.50"))]) + + def test_without_rules_everything_is_uncategorized(self): + self.assertEqual(category_totals([txn(4, "-7.50", "Coffee")], []), + [("uncategorized", Decimal("-7.50"))]) + + def test_no_transactions_yields_no_categories(self): + self.assertEqual(category_totals([], RULES), []) + + def test_alphabetical_order_ignores_case(self): + rules = [("a", "Zebra"), ("b", "apple")] + totals = category_totals([txn(4, "1.00", "a"), txn(4, "1.00", "b")], rules) + self.assertEqual([name for name, _ in totals], ["apple", "Zebra"]) + + +class FormatReportTest(unittest.TestCase): + def test_matches_the_spec_example(self): + transactions = [txn(4, "-900.00", "Rent"), txn(5, "-7.50", "Coffee"), + txn(6, "2500.00", "Salary")] + self.assertEqual( + format_report(transactions, RULES, Decimal("100")), + "food: -7.50\nhousing: -900.00\nuncategorized: 2500.00\n\nclosing balance: 1692.50\n", + ) + + def test_no_transactions_prints_only_the_closing_balance(self): + self.assertEqual(format_report([], RULES, Decimal("0")), "\nclosing balance: 0.00\n") +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_report -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.report'` + +- [ ] **Step 3: Write `ledgerlite/report.py`** + +```python +UNCATEGORIZED = "uncategorized" +TWO_PLACES = Decimal("0.01") + + +def format_amount(amount: Decimal) -> str: + """Two fractional digits, leading '-' only for values below zero.""" + + +def category_totals(transactions, rules) -> list[tuple[str, Decimal]]: + """(category, total) pairs, alphabetical case-insensitively, UNCATEGORIZED last.""" + + +def format_report(transactions, rules, opening: Decimal) -> str: + """Category lines, a blank line, then the closing balance. Ends with one newline.""" +``` + +Implementation notes: +- `format_amount`: `quantized = amount.quantize(TWO_PLACES)`; if `quantized == 0`, use `abs(quantized)` so `-0.00` becomes `0.00`; return `f"{quantized:f}"` (plain `f` format — never `,`, never `float`). +- `category_totals`: accumulate into a `dict[str, Decimal]` keyed by `categorize(t.description, rules) or UNCATEGORIZED`, seeding new keys with `Decimal("0")`; then sort the non-`UNCATEGORIZED` keys with `key=lambda name: (name.casefold(), name)` and append `UNCATEGORIZED` at the end if present. A rules file that literally names a category `uncategorized` therefore merges with the unmatched bucket and lands last — that is fine. +- `format_report`: `[f"{name}: {format_amount(total)}" for name, total in category_totals(...)]`, then `""`, then `f"closing balance: {format_amount(closing_balance(opening, transactions))}"`; `"\n".join(lines) + "\n"`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_report -v` +Expected: PASS (all tests) + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/report.py test_report.py +git commit -m "feat: per-category totals and report formatting" +``` + +--- + +### Task 5: CLI entry point + +**Files:** +- Create: `ledgerlite/cli.py` +- Create: `ledgerlite/__main__.py` +- Test: `test_cli.py` + +**Interfaces:** +- Consumes: `parse.ParseError`, `parse.parse_amount`, `parse.parse_transactions`, `rules.parse_rules`, `report.format_report`. +- Produces: `cli.main(argv: list[str] | None = None) -> int`. + +`__main__.py` is not in the spec's file list but is needed to run the package as `python3 -m ledgerlite report ...`; it is three lines and adds no logic. + +Behavior: +- `ledgerlite report TRANSACTIONS [--rules RULES] [--opening AMOUNT]`. +- `--opening` defaults to `0` and is validated with `parse.parse_amount`; a bad value is an argparse usage error (stderr, exit 2). +- Unreadable transactions *or* rules file → `ledgerlite: cannot read : ` on stderr, return 1. `` is `exc.strerror` for `OSError`, otherwise `str(exc)`. +- `ParseError` from either file → `ledgerlite: :: ` on stderr, return 2. +- Success → write the report to stdout, return 0. Nothing reaches stdout on either error path. + +- [ ] **Step 1: Write the failing tests** + +`test_cli.py`: + +```python +import io +import unittest +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path +from tempfile import TemporaryDirectory + +from ledgerlite.cli import main + +CSV = ("date,amount,description\n" + "2026-03-06,2500.00,Salary\n" + "2026-03-04,-900.00,Monthly Rent\n" + "2026-03-05,-7.50,Coffee Shop\n") +RULES = "coffee=food\nrent=housing\n" + + +class CliTest(unittest.TestCase): + def setUp(self): + self.tmp = TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.dir = Path(self.tmp.name) + + def write(self, name, text): + path = self.dir / name + path.write_text(text, encoding="utf-8") + return str(path) + + def run_cli(self, argv): + out, err = io.StringIO(), io.StringIO() + with redirect_stdout(out), redirect_stderr(err): + code = main(argv) + return code, out.getvalue(), err.getvalue() + + def test_reports_the_spec_example(self): + code, out, err = self.run_cli(["report", self.write("t.csv", CSV), + "--rules", self.write("r.txt", RULES), "--opening", "100"]) + self.assertEqual(code, 0) + self.assertEqual(err, "") + self.assertEqual(out, "food: -7.50\nhousing: -900.00\nuncategorized: 2500.00\n" + "\nclosing balance: 1692.50\n") + + def test_defaults_no_rules_and_zero_opening(self): + code, out, _ = self.run_cli(["report", self.write("t.csv", CSV)]) + self.assertEqual(code, 0) + self.assertEqual(out, "uncategorized: 1592.50\n\nclosing balance: 1592.50\n") + + def test_missing_transactions_file(self): + path = str(self.dir / "nope.csv") + code, out, err = self.run_cli(["report", path]) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertEqual(err, f"ledgerlite: cannot read {path}: No such file or directory\n") + + def test_transactions_path_is_a_directory(self): + code, out, err = self.run_cli(["report", str(self.dir)]) + self.assertEqual((code, out), (1, "")) + self.assertTrue(err.startswith(f"ledgerlite: cannot read {self.dir}: ")) + + def test_transactions_file_is_not_utf8(self): + path = self.dir / "bin.csv" + path.write_bytes(b"date,amount,description\n2026-03-04,-1.00,\xff\xfe\n") + code, out, err = self.run_cli(["report", str(path)]) + self.assertEqual((code, out), (1, "")) + self.assertTrue(err.startswith(f"ledgerlite: cannot read {path}: ")) + + def test_missing_rules_file(self): + rules_path = str(self.dir / "nope.txt") + code, out, err = self.run_cli(["report", self.write("t.csv", CSV), "--rules", rules_path]) + self.assertEqual((code, out), (1, "")) + self.assertEqual(err, f"ledgerlite: cannot read {rules_path}: No such file or directory\n") + + def test_malformed_row_reports_path_and_line_and_exits_2(self): + path = self.write("t.csv", "date,amount,description\n2026-03-04,1.005,oops\n") + code, out, err = self.run_cli(["report", path]) + self.assertEqual((code, out), (2, "")) + self.assertEqual(err, f"ledgerlite: {path}:2: " + "amount '1.005' has more than two fractional digits\n") + + def test_malformed_rules_line_reports_rules_path_and_exits_2(self): + rules_path = self.write("r.txt", "coffee=food\noops\n") + code, out, err = self.run_cli(["report", self.write("t.csv", CSV), "--rules", rules_path]) + self.assertEqual((code, out), (2, "")) + self.assertEqual(err, f"ledgerlite: {rules_path}:2: rule has no '='\n") + + def test_bad_opening_is_a_usage_error(self): + for value in ["abc", "1.005"]: + with self.subTest(value=value), self.assertRaises(SystemExit) as cm: + self.run_cli(["report", self.write("t.csv", CSV), "--opening", value]) + self.assertEqual(cm.exception.code, 2) + + def test_negative_opening_is_allowed(self): + code, out, _ = self.run_cli(["report", self.write("t.csv", "date,amount,description\n"), + "--opening", "-5"]) + self.assertEqual((code, out), (0, "\nclosing balance: -5.00\n")) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_cli -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.cli'` + +- [ ] **Step 3: Write `ledgerlite/cli.py`** + +```python +def _opening_amount(text: str) -> Decimal: + """argparse type for --opening; ArgumentTypeError becomes a usage error.""" + + +def _read(path: str) -> str: + """Read a UTF-8 text file, letting OSError/UnicodeDecodeError escape to main.""" + + +def main(argv: list[str] | None = None) -> int: + """Parse args, print the report or an error message. Returns the exit code.""" +``` + +Implementation notes: +- `_opening_amount`: call `parse_amount(text)`, converting `ValueError` into `argparse.ArgumentTypeError(str(exc))`. +- Parser: `argparse.ArgumentParser(prog="ledgerlite")` with `add_subparsers(dest="command", required=True)`; subparser `report` with positional `transactions`, `--rules` (default `None`), `--opening` (`type=_opening_amount`, `default=Decimal("0")`). +- `_read`: `Path(path).read_text(encoding="utf-8")`. +- In `main`, one `try` per file, in this order — rules only if `--rules` was given: + - `except OSError as exc:` print `f"ledgerlite: cannot read {path}: {exc.strerror}"` to `sys.stderr`; `return 1`. + - `except UnicodeDecodeError as exc:` same message shape with `str(exc)` as the reason; `return 1`. + - `except ParseError as exc:` print `f"ledgerlite: {path}:{exc.lineno}: {exc.message}"`; `return 2`. +- Parse both files *before* writing anything to stdout, then `sys.stdout.write(format_report(transactions, rules, args.opening))` and `return 0`. +- Write errors with `print(..., file=sys.stderr)` so the tests' `redirect_stderr` captures them. + +- [ ] **Step 4: Write `ledgerlite/__main__.py`** + +```python +import sys + +from ledgerlite.cli import main + +sys.exit(main()) +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `python3 -m unittest test_cli -v` +Expected: PASS (all tests) + +- [ ] **Step 6: Run the whole suite** + +Run: `python3 -m unittest -v` +Expected: PASS — every test from Tasks 1–5. + +- [ ] **Step 7: Check the CLI end to end by hand** + +```bash +printf 'date,amount,description\n2026-03-06,2500.00,Salary\n2026-03-04,-900.00,Monthly Rent\n2026-03-05,-7.50,Coffee Shop\n' > /tmp/t.csv +printf 'coffee=food\nrent=housing\n' > /tmp/r.txt +python3 -m ledgerlite report /tmp/t.csv --rules /tmp/r.txt --opening 100 +``` + +Expected: the four report lines from `design.md`, ending `closing balance: 1692.50`, and `echo $?` prints `0`. + +- [ ] **Step 8: Commit** + +```bash +git add ledgerlite/cli.py ledgerlite/__main__.py test_cli.py +git commit -m "feat: ledgerlite report CLI" +``` diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/P1-proportion-ledgerlite-82.plan.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/P1-proportion-ledgerlite-82.plan.md new file mode 100644 index 000000000..7c4ef93ca --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/P1-proportion-ledgerlite-82.plan.md @@ -0,0 +1,853 @@ +# ledgerlite Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A command-line tool that reads a CSV of bank transactions, categorizes each row with a rules file, and prints per-category totals plus the closing balance. + +**Architecture:** Five small pure modules (`model`, `parse`, `rules`, `balance`, `report`) with no I/O, plus `cli.py` which does all file reading, all error printing, and all exit codes. Parsing raises `ParseError(line, message)`; the CLI is the only place that turns exceptions into stderr text and status codes. Money is `decimal.Decimal` end to end — no float ever touches an amount. + +**Tech Stack:** Python 3.11+, standard library only (`csv`, `decimal`, `datetime`, `argparse`, `dataclasses`, `re`, `io`), tests with `unittest`. + +**Spec:** `design.md` (repo root) + +## Global Constraints + +- Python 3.11+. Standard library only — no third-party dependencies. +- All amounts are `decimal.Decimal`. Never `float`, at any point, including in tests. +- Package layout is exactly as in `design.md` § Package layout: `ledgerlite/{__init__,model,parse,rules,balance,report,cli}.py`. +- Tests live at the repo root as `test_.py` and run with `python3 -m unittest`. +- Error text is byte-exact: `ledgerlite: cannot read : ` and `ledgerlite: :: `, both on stderr. +- Exit codes: 0 success, 1 unreadable file, 2 malformed input. +- Amounts print with exactly two fractional digits, leading `-` for negatives, no thousands separators. +- Commit after every task (each task's final step). + +## Review Focus + +Input classes the spec implies but does not spell out. Each has a test in the task named. + +1. `nan`, `Infinity`, `1e3`, `0x10` — `Decimal()` accepts all of these, so a naive `Decimal(raw)` silently admits non-numbers and infinities that poison every total. They must be malformed (Task 1). +2. A category total of exactly zero reached from negatives (`-5.00` + `5.00`) — `Decimal` keeps the sign and prints `-0.00`, which the spec's `0.00` example forbids (Task 4). +3. An empty or header-only transactions file — a legitimate month with no activity must print the blank line and `closing balance: `, not crash and not print a bogus `uncategorized: 0.00` (Tasks 1, 4, 5). +4. A `--rules` path that does not exist or cannot be read — the spec defines the read error only for TRANSACTIONS, but a typo'd rules path must produce the same `cannot read` line and exit 1, not a traceback (Task 5). +5. A rules file with blank lines, or a rule whose category is literally `uncategorized`, or a category containing `=` — junk lines must not become rules named `""`, and an explicit `uncategorized` category must merge with the no-match bucket and stay last (Tasks 2, 4). + +## File Structure + +| File | Responsibility | +|------|----------------| +| `ledgerlite/__init__.py` | Empty package marker. | +| `ledgerlite/model.py` | `Transaction` frozen dataclass. No logic. | +| `ledgerlite/parse.py` | `ParseError`, `parse_amount`, `parse_transactions` (text → transactions). | +| `ledgerlite/rules.py` | `parse_rules` (text → rules), `categorize`. Imports `ParseError` from `parse`. | +| `ledgerlite/balance.py` | `order_transactions`, `closing_balance`. | +| `ledgerlite/report.py` | `UNCATEGORIZED`, `format_amount`, `category_totals`, `format_report`. | +| `ledgerlite/cli.py` | `main(argv)` — argparse, file reads, stderr text, exit codes. | +| `ledgerlite/__main__.py` | 2-line shim so `python3 -m ledgerlite` works. | +| `test_parse.py`, `test_rules.py`, `test_balance.py`, `test_report.py`, `test_cli.py` | One per module, repo root. | + +**Assumptions** (spec is silent; recorded here so the executor does not re-decide them): +- `__main__.py` is added beyond the spec's layout because the spec gives a `ledgerlite report ...` invocation but no packaging metadata; `python3 -m ledgerlite report ...` is the runnable form. +- Date format is strict `YYYY-MM-DD`. `date.fromisoformat` also accepts `20260304` and week dates; the spec says ISO 8601 `2026-03-04`, so a regex gates it and `2026-3-4` is malformed. +- Empty file and header-only file both mean "no transactions", not "missing header". +- In a rules file, blank lines and lines whose first non-blank character is `#` are ignored; any other line without `=` is an error. + +--- + +### Task 1: Transaction model and CSV parsing + +**Files:** +- Create: `ledgerlite/__init__.py` (empty), `ledgerlite/model.py`, `ledgerlite/parse.py` +- Test: `test_parse.py` + +**Interfaces:** +- Consumes: nothing. +- Produces: + - `model.Transaction` — frozen dataclass, fields `date: datetime.date`, `amount: Decimal`, `description: str`. + - `parse.ParseError(Exception)` with attributes `line: int`, `message: str`. + - `parse.parse_amount(raw: str) -> Decimal` — raises `ValueError` on anything that is not a plain decimal with ≤2 fractional digits. + - `parse.parse_transactions(text: str) -> list[Transaction]` — raises `ParseError`; input order preserved. + +- [ ] **Step 1: Write the failing tests** + +`test_parse.py`: + +```python +import unittest +from datetime import date +from decimal import Decimal +from ledgerlite.parse import ParseError, parse_amount, parse_transactions + +HEADER = "date,amount,description\n" + +class ParseAmountTest(unittest.TestCase): + def test_accepts_one_or_two_fractional_digits(self): + self.assertEqual(parse_amount("1.5"), Decimal("1.5")) + self.assertEqual(parse_amount("1.50"), Decimal("1.50")) + self.assertEqual(parse_amount("-12"), Decimal("-12")) + self.assertEqual(parse_amount("+3.25"), Decimal("3.25")) + self.assertEqual(parse_amount(" 7.00 "), Decimal("7.00")) + + def test_returns_decimal_never_float(self): + self.assertIsInstance(parse_amount("0.10"), Decimal) + + def test_rejects_more_than_two_fractional_digits(self): + with self.assertRaises(ValueError): + parse_amount("1.005") + + def test_rejects_decimal_specials_and_exponents(self): + # Decimal() accepts all of these; parse_amount must not. + for raw in ("nan", "NaN", "Infinity", "-inf", "1e3", "0x10", "1_000", "1,000.00", "", "abc"): + with self.subTest(raw=raw), self.assertRaises(ValueError): + parse_amount(raw) + +class ParseTransactionsTest(unittest.TestCase): + def test_parses_rows_preserving_input_order(self): + txns = parse_transactions(HEADER + "2026-03-05,-7.50,Coffee\n2026-03-04,2500.00,Salary\n") + self.assertEqual([t.date for t in txns], [date(2026, 3, 5), date(2026, 3, 4)]) + self.assertEqual(txns[0].amount, Decimal("-7.50")) + self.assertEqual(txns[1].description, "Salary") + + def test_empty_file_has_no_transactions(self): + self.assertEqual(parse_transactions(""), []) + + def test_header_only_file_has_no_transactions(self): + self.assertEqual(parse_transactions(HEADER), []) + + def test_rejects_bad_header(self): + with self.assertRaises(ParseError) as ctx: + parse_transactions("when,how much,what\n") + self.assertEqual(ctx.exception.line, 1) + + def test_rejects_wrong_column_count(self): + for row in ("2026-03-04,-7.50\n", "2026-03-04,-7.50,Coffee,extra\n"): + with self.subTest(row=row), self.assertRaises(ParseError) as ctx: + parse_transactions(HEADER + row) + self.assertEqual(ctx.exception.line, 2) + + def test_rejects_unparseable_date(self): + for raw in ("2026-13-40", "2026-02-30", "2026-3-4", "04/03/2026", "20260304", ""): + with self.subTest(raw=raw), self.assertRaises(ParseError) as ctx: + parse_transactions(HEADER + f"{raw},-7.50,Coffee\n") + self.assertEqual(ctx.exception.line, 2) + + def test_rejects_malformed_amount_with_row_line_number(self): + text = HEADER + "2026-03-04,-7.50,Coffee\n2026-03-05,1.005,Rent\n" + with self.assertRaises(ParseError) as ctx: + parse_transactions(text) + self.assertEqual(ctx.exception.line, 3) + + def test_line_number_accounts_for_newline_inside_quoted_field(self): + text = HEADER + '2026-03-04,-7.50,"Coffee\nshop"\n2026-03-05,nan,Rent\n' + with self.assertRaises(ParseError) as ctx: + parse_transactions(text) + self.assertEqual(ctx.exception.line, 4) + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the tests and confirm they fail** + +Run: `python3 -m unittest test_parse -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite'`. + +- [ ] **Step 3: Write the model** + +`ledgerlite/__init__.py`: empty file. + +`ledgerlite/model.py`: + +```python +"""The one data type that moves between modules.""" +from dataclasses import dataclass +from datetime import date +from decimal import Decimal + + +@dataclass(frozen=True) +class Transaction: + date: date + amount: Decimal + description: str +``` + +- [ ] **Step 4: Write the parser** + +`ledgerlite/parse.py`: + +```python +"""Transactions CSV -> list[Transaction].""" +import csv +import io +import re +from datetime import date +from decimal import Decimal + +from ledgerlite.model import Transaction + +COLUMNS = ["date", "amount", "description"] +_AMOUNT_RE = re.compile(r"^[+-]?(\d+(\.\d{1,2})?|\.\d{1,2})$") +_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") + + +class ParseError(Exception): + """A malformed line. `line` is 1-based within the source file.""" + + def __init__(self, line: int, message: str) -> None: + super().__init__(f"{line}: {message}") + self.line = line + self.message = message + + +def parse_amount(raw: str) -> Decimal: + """A plain decimal with at most two fractional digits. No nan/inf/exponents.""" + text = raw.strip() + if not _AMOUNT_RE.match(text): + raise ValueError(f"{raw!r} is not an amount with at most two decimal places") + return Decimal(text) + + +def _parse_date(raw: str) -> date: + text = raw.strip() + if not _DATE_RE.match(text): + raise ValueError(f"{raw!r} is not an ISO 8601 date (YYYY-MM-DD)") + try: + return date.fromisoformat(text) + except ValueError: + raise ValueError(f"{raw!r} is not a real date") from None + + +def parse_transactions(text: str) -> list[Transaction]: + reader = csv.reader(io.StringIO(text, newline="")) + rows = iter(reader) + header = next(rows, None) + if header is None: + return [] + if [field.strip().lower() for field in header] != COLUMNS: + raise ParseError(reader.line_num, f"expected header {','.join(COLUMNS)}") + + transactions = [] + for row in rows: + line = reader.line_num + if not row: + continue + if len(row) != len(COLUMNS): + raise ParseError(line, f"expected {len(COLUMNS)} columns, got {len(row)}") + raw_date, raw_amount, description = row + try: + when = _parse_date(raw_date) + amount = parse_amount(raw_amount) + except ValueError as err: + raise ParseError(line, str(err)) from None + transactions.append(Transaction(date=when, amount=amount, description=description)) + return transactions +``` + +- [ ] **Step 5: Run the tests and confirm they pass** + +Run: `python3 -m unittest test_parse -v` +Expected: PASS, all tests. + +- [ ] **Step 6: Commit** + +```bash +git add ledgerlite/__init__.py ledgerlite/model.py ledgerlite/parse.py test_parse.py +git commit -m "feat: transaction model and strict CSV parsing" +``` + +--- + +### Task 2: Rules parsing and categorization + +**Files:** +- Create: `ledgerlite/rules.py` +- Test: `test_rules.py` + +**Interfaces:** +- Consumes: `parse.ParseError(line, message)`. +- Produces: + - `rules.Rule = tuple[str, str]` — `(lowercased substring, category)`. + - `rules.parse_rules(text: str) -> list[Rule]` — file order preserved; raises `ParseError`. + - `rules.categorize(description: str, rules: list[Rule]) -> str | None` — first match wins, case-insensitive; `None` when nothing matches. + +- [ ] **Step 1: Write the failing tests** + +`test_rules.py`: + +```python +import unittest +from ledgerlite.parse import ParseError +from ledgerlite.rules import categorize, parse_rules + +class ParseRulesTest(unittest.TestCase): + def test_parses_rules_in_file_order_lowercasing_the_substring(self): + self.assertEqual(parse_rules("Coffee=food\nRENT=housing\n"), + [("coffee", "food"), ("rent", "housing")]) + + def test_strips_surrounding_whitespace(self): + self.assertEqual(parse_rules(" coffee = food \n"), [("coffee", "food")]) + + def test_ignores_blank_and_comment_lines(self): + self.assertEqual(parse_rules("\n# a note\n \ncoffee=food\n"), [("coffee", "food")]) + + def test_category_may_contain_equals(self): + self.assertEqual(parse_rules("a=b=c\n"), [("a", "b=c")]) + + def test_line_without_equals_is_an_error_with_its_line_number(self): + with self.assertRaises(ParseError) as ctx: + parse_rules("coffee=food\nrent housing\n") + self.assertEqual(ctx.exception.line, 2) + + def test_empty_substring_or_category_is_an_error(self): + for text in ("=food\n", "coffee=\n"): + with self.subTest(text=text), self.assertRaises(ParseError) as ctx: + parse_rules(text) + self.assertEqual(ctx.exception.line, 1) + +class CategorizeTest(unittest.TestCase): + RULES = [("coffee", "food"), ("rent", "housing")] + + def test_matches_substring_case_insensitively(self): + self.assertEqual(categorize("MORNING COFFEE", self.RULES), "food") + self.assertEqual(categorize("Rent for March", self.RULES), "housing") + + def test_first_matching_rule_wins(self): + rules = [("coffee", "food"), ("coffee shop", "treats")] + self.assertEqual(categorize("Coffee Shop", rules), "food") + + def test_no_match_returns_none(self): + self.assertIsNone(categorize("Salary", self.RULES)) + + def test_no_rules_returns_none(self): + self.assertIsNone(categorize("Coffee", [])) + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the tests and confirm they fail** + +Run: `python3 -m unittest test_rules -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.rules'`. + +- [ ] **Step 3: Write the implementation** + +`ledgerlite/rules.py`: + +```python +"""Rules text -> rules; description -> category.""" +from ledgerlite.parse import ParseError + +Rule = tuple[str, str] + + +def parse_rules(text: str) -> list[Rule]: + rules: list[Rule] = [] + for line_number, raw_line in enumerate(text.splitlines(), start=1): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + substring, sep, category = line.partition("=") + if not sep: + raise ParseError(line_number, f"expected =, got {raw_line.strip()!r}") + substring, category = substring.strip(), category.strip() + if not substring or not category: + raise ParseError(line_number, "rule needs a non-empty substring and category") + rules.append((substring.lower(), category)) + return rules + + +def categorize(description: str, rules: list[Rule]) -> str | None: + haystack = description.lower() + for substring, category in rules: + if substring in haystack: + return category + return None +``` + +- [ ] **Step 4: Run the tests and confirm they pass** + +Run: `python3 -m unittest test_rules -v` +Expected: PASS, all tests. + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/rules.py test_rules.py +git commit -m "feat: rules parsing and first-match categorization" +``` + +--- + +### Task 3: Date ordering and closing balance + +**Files:** +- Create: `ledgerlite/balance.py` +- Test: `test_balance.py` + +**Interfaces:** +- Consumes: `model.Transaction`. +- Produces: + - `balance.order_transactions(transactions: list[Transaction]) -> list[Transaction]` — sorted by date, stable so same-date rows keep input order. Returns a new list. + - `balance.closing_balance(transactions: list[Transaction], opening: Decimal) -> Decimal` — opening plus every amount; `opening` when the list is empty. + +Note: a sum is order-independent, so ordering does not change the closing figure. The spec still specifies the ordering, so it is a real function with its own tests, and the CLI orders before reporting. + +- [ ] **Step 1: Write the failing tests** + +`test_balance.py`: + +```python +import unittest +from datetime import date +from decimal import Decimal +from ledgerlite.balance import closing_balance, order_transactions +from ledgerlite.model import Transaction + +def txn(day, amount, description="x"): + return Transaction(date=date(2026, 3, day), amount=Decimal(amount), description=description) + +class OrderTransactionsTest(unittest.TestCase): + def test_orders_by_date(self): + ordered = order_transactions([txn(5, "1.00"), txn(1, "2.00"), txn(3, "3.00")]) + self.assertEqual([t.date.day for t in ordered], [1, 3, 5]) + + def test_ties_keep_input_order(self): + ordered = order_transactions([txn(4, "1.00", "b"), txn(4, "1.00", "a"), txn(2, "1.00", "c")]) + self.assertEqual([t.description for t in ordered], ["c", "b", "a"]) + + def test_does_not_mutate_input(self): + given = [txn(5, "1.00"), txn(1, "2.00")] + order_transactions(given) + self.assertEqual([t.date.day for t in given], [5, 1]) + +class ClosingBalanceTest(unittest.TestCase): + def test_adds_every_amount_to_opening(self): + txns = [txn(4, "-7.50"), txn(5, "-900.00"), txn(6, "2500.00")] + self.assertEqual(closing_balance(txns, Decimal("100")), Decimal("1692.50")) + + def test_no_transactions_gives_opening(self): + self.assertEqual(closing_balance([], Decimal("100.00")), Decimal("100.00")) + + def test_arithmetic_is_exact_decimal_not_float(self): + total = closing_balance([txn(1, "0.10"), txn(2, "0.20")], Decimal("0")) + self.assertEqual(total, Decimal("0.30")) + self.assertIsInstance(total, Decimal) + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the tests and confirm they fail** + +Run: `python3 -m unittest test_balance -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.balance'`. + +- [ ] **Step 3: Write the implementation** + +`ledgerlite/balance.py`: + +```python +"""Date ordering and the running balance's final value.""" +from decimal import Decimal + +from ledgerlite.model import Transaction + + +def order_transactions(transactions: list[Transaction]) -> list[Transaction]: + """By date; `sorted` is stable, so same-date rows keep input order.""" + return sorted(transactions, key=lambda t: t.date) + + +def closing_balance(transactions: list[Transaction], opening: Decimal) -> Decimal: + """The running balance after the last transaction.""" + balance = opening + for transaction in order_transactions(transactions): + balance += transaction.amount + return balance +``` + +- [ ] **Step 4: Run the tests and confirm they pass** + +Run: `python3 -m unittest test_balance -v` +Expected: PASS, all tests. + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/balance.py test_balance.py +git commit -m "feat: date ordering and closing balance" +``` + +--- + +### Task 4: Per-category totals and report formatting + +**Files:** +- Create: `ledgerlite/report.py` +- Test: `test_report.py` + +**Interfaces:** +- Consumes: `model.Transaction`, `rules.Rule`, `rules.categorize`, `balance.closing_balance`. +- Produces: + - `report.UNCATEGORIZED = "uncategorized"`. + - `report.format_amount(value: Decimal) -> str` — two fractional digits, `-` for negatives, never `-0.00`, no thousands separators. + - `report.category_totals(transactions, rules) -> list[tuple[str, Decimal]]` — alphabetical, `uncategorized` last; categories with no transactions do not appear. + - `report.format_report(transactions, rules, opening: Decimal) -> str` — the whole report, one trailing newline. + +- [ ] **Step 1: Write the failing tests** + +`test_report.py`: + +```python +import unittest +from datetime import date +from decimal import Decimal +from ledgerlite.model import Transaction +from ledgerlite.report import UNCATEGORIZED, category_totals, format_amount, format_report + +def txn(day, amount, description): + return Transaction(date=date(2026, 3, day), amount=Decimal(amount), description=description) + +RULES = [("coffee", "food"), ("rent", "housing")] +EXAMPLE = [txn(5, "-7.50", "Morning coffee"), txn(1, "-900.00", "Rent"), txn(3, "2500.00", "Salary")] + +class FormatAmountTest(unittest.TestCase): + def test_two_fractional_digits(self): + self.assertEqual(format_amount(Decimal("1200")), "1200.00") + self.assertEqual(format_amount(Decimal("1.5")), "1.50") + self.assertEqual(format_amount(Decimal("-12.5")), "-12.50") + self.assertEqual(format_amount(Decimal("0")), "0.00") + + def test_no_thousands_separators(self): + self.assertEqual(format_amount(Decimal("-1234567.89")), "-1234567.89") + + def test_zero_total_reached_from_negatives_has_no_minus_sign(self): + self.assertEqual(format_amount(Decimal("-5.00") + Decimal("5.00")), "0.00") + self.assertEqual(format_amount(Decimal("-0.00")), "0.00") + +class CategoryTotalsTest(unittest.TestCase): + def test_alphabetical_with_uncategorized_last(self): + self.assertEqual(category_totals(EXAMPLE, RULES), + [("food", Decimal("-7.50")), + ("housing", Decimal("-900.00")), + (UNCATEGORIZED, Decimal("2500.00"))]) + + def test_sums_repeated_categories(self): + txns = [txn(1, "-2.50", "Coffee"), txn(2, "-5.00", "coffee beans")] + self.assertEqual(category_totals(txns, RULES), [("food", Decimal("-7.50"))]) + + def test_no_transactions_gives_no_lines(self): + self.assertEqual(category_totals([], RULES), []) + + def test_uncategorized_absent_when_everything_matches(self): + self.assertEqual([name for name, _ in category_totals([txn(1, "-1.00", "Coffee")], RULES)], ["food"]) + + def test_all_uncategorized_without_rules(self): + self.assertEqual(category_totals([txn(1, "-1.00", "Coffee")], []), + [(UNCATEGORIZED, Decimal("-1.00"))]) + + def test_rule_category_named_uncategorized_merges_and_stays_last(self): + rules = [("coffee", "uncategorized"), ("rent", "housing")] + txns = [txn(1, "-2.00", "Coffee"), txn(2, "-900.00", "Rent"), txn(3, "5.00", "Salary")] + self.assertEqual(category_totals(txns, rules), + [("housing", Decimal("-900.00")), (UNCATEGORIZED, Decimal("3.00"))]) + +class FormatReportTest(unittest.TestCase): + def test_matches_the_design_example(self): + self.assertEqual( + format_report(EXAMPLE, RULES, Decimal("100")), + "food: -7.50\nhousing: -900.00\nuncategorized: 2500.00\n\nclosing balance: 1692.50\n", + ) + + def test_no_transactions_prints_only_the_closing_balance(self): + self.assertEqual(format_report([], RULES, Decimal("100")), "\nclosing balance: 100.00\n") + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the tests and confirm they fail** + +Run: `python3 -m unittest test_report -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.report'`. + +- [ ] **Step 3: Write the implementation** + +`ledgerlite/report.py`: + +```python +"""Per-category totals and the printed report.""" +from decimal import Decimal + +from ledgerlite.balance import closing_balance +from ledgerlite.model import Transaction +from ledgerlite.rules import Rule, categorize + +UNCATEGORIZED = "uncategorized" +_CENTS = Decimal("0.01") + + +def format_amount(value: Decimal) -> str: + """Exactly two fractional digits; `-0.00` is normalized to `0.00`.""" + quantized = value.quantize(_CENTS) + if quantized == 0: + quantized = abs(quantized) + return f"{quantized:f}" + + +def category_totals(transactions: list[Transaction], rules: list[Rule]) -> list[tuple[str, Decimal]]: + """Alphabetical by category, with `uncategorized` always last.""" + totals: dict[str, Decimal] = {} + for transaction in transactions: + name = categorize(transaction.description, rules) or UNCATEGORIZED + totals[name] = totals.get(name, Decimal("0")) + transaction.amount + return sorted(totals.items(), key=lambda item: (item[0] == UNCATEGORIZED, item[0])) + + +def format_report(transactions: list[Transaction], rules: list[Rule], opening: Decimal) -> str: + lines = [f"{name}: {format_amount(total)}" for name, total in category_totals(transactions, rules)] + lines.append("") + lines.append(f"closing balance: {format_amount(closing_balance(transactions, opening))}") + return "\n".join(lines) + "\n" +``` + +- [ ] **Step 4: Run the tests and confirm they pass** + +Run: `python3 -m unittest test_report -v` +Expected: PASS, all tests. + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/report.py test_report.py +git commit -m "feat: per-category totals and report formatting" +``` + +--- + +### Task 5: CLI — argument parsing, file reading, exit codes + +**Files:** +- Create: `ledgerlite/cli.py`, `ledgerlite/__main__.py` +- Test: `test_cli.py` + +**Interfaces:** +- Consumes: `parse.ParseError`, `parse.parse_amount`, `parse.parse_transactions`, `rules.parse_rules`, `report.format_report`. +- Produces: `cli.main(argv: list[str] | None = None) -> int`. Writes the report to stdout, errors to stderr, returns the exit code (never calls `sys.exit` itself except via argparse's own usage errors). + +- [ ] **Step 1: Write the failing tests** + +`test_cli.py`: + +```python +import io +import os +import tempfile +import unittest +from contextlib import redirect_stderr, redirect_stdout +from ledgerlite.cli import main + +TRANSACTIONS = ( + "date,amount,description\n" + "2026-03-05,-7.50,Morning coffee\n" + "2026-03-01,-900.00,Rent\n" + "2026-03-03,2500.00,Salary\n" +) +RULES = "coffee=food\nrent=housing\n" + +class CliTestCase(unittest.TestCase): + def setUp(self): + self.dir = tempfile.TemporaryDirectory() + self.addCleanup(self.dir.cleanup) + + def write(self, name, text): + path = os.path.join(self.dir.name, name) + with open(path, "w", encoding="utf-8") as handle: + handle.write(text) + return path + + def run_cli(self, *argv): + out, err = io.StringIO(), io.StringIO() + with redirect_stdout(out), redirect_stderr(err): + code = main(list(argv)) + return code, out.getvalue(), err.getvalue() + +class SuccessTest(CliTestCase): + def test_prints_report_and_returns_zero(self): + txns = self.write("t.csv", TRANSACTIONS) + rules = self.write("r.txt", RULES) + code, out, err = self.run_cli("report", txns, "--rules", rules, "--opening", "100") + self.assertEqual(code, 0) + self.assertEqual(out, "food: -7.50\nhousing: -900.00\nuncategorized: 2500.00\n\n" + "closing balance: 1692.50\n") + self.assertEqual(err, "") + + def test_opening_defaults_to_zero(self): + txns = self.write("t.csv", TRANSACTIONS) + code, out, _ = self.run_cli("report", txns, "--rules", self.write("r.txt", RULES)) + self.assertEqual(code, 0) + self.assertIn("closing balance: 1592.50\n", out) + + def test_without_rules_everything_is_uncategorized(self): + txns = self.write("t.csv", TRANSACTIONS) + code, out, _ = self.run_cli("report", txns) + self.assertEqual(code, 0) + self.assertEqual(out, "uncategorized: 1592.50\n\nclosing balance: 1592.50\n") + + def test_header_only_file_reports_the_opening_balance(self): + txns = self.write("t.csv", "date,amount,description\n") + code, out, err = self.run_cli("report", txns, "--opening", "100") + self.assertEqual((code, err), (0, "")) + self.assertEqual(out, "\nclosing balance: 100.00\n") + +class FailureTest(CliTestCase): + def test_unreadable_transactions_file_returns_1(self): + missing = os.path.join(self.dir.name, "nope.csv") + code, out, err = self.run_cli("report", missing) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertEqual(err, f"ledgerlite: cannot read {missing}: No such file or directory\n") + + def test_unreadable_rules_file_returns_1(self): + txns = self.write("t.csv", TRANSACTIONS) + missing = os.path.join(self.dir.name, "nope.txt") + code, out, err = self.run_cli("report", txns, "--rules", missing) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertEqual(err, f"ledgerlite: cannot read {missing}: No such file or directory\n") + + def test_malformed_row_returns_2_and_prints_nothing_to_stdout(self): + txns = self.write("t.csv", "date,amount,description\n2026-03-04,1.005,Coffee\n") + code, out, err = self.run_cli("report", txns) + self.assertEqual(code, 2) + self.assertEqual(out, "") + self.assertTrue(err.startswith(f"ledgerlite: {txns}:2: "), err) + + def test_whole_file_is_rejected_even_when_only_the_last_row_is_bad(self): + txns = self.write("t.csv", TRANSACTIONS + "2026-03-06,nan,Mystery\n") + code, out, err = self.run_cli("report", txns) + self.assertEqual((code, out), (2, "")) + self.assertIn(f"{txns}:5:", err) + + def test_malformed_rules_file_returns_2_with_the_rules_path(self): + txns = self.write("t.csv", TRANSACTIONS) + rules = self.write("r.txt", "coffee=food\nrent housing\n") + code, out, err = self.run_cli("report", txns, "--rules", rules) + self.assertEqual((code, out), (2, "")) + self.assertTrue(err.startswith(f"ledgerlite: {rules}:2: "), err) + + def test_invalid_opening_amount_is_rejected(self): + txns = self.write("t.csv", TRANSACTIONS) + with self.assertRaises(SystemExit) as ctx, redirect_stderr(io.StringIO()): + main(["report", txns, "--opening", "1.005"]) + self.assertEqual(ctx.exception.code, 2) + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the tests and confirm they fail** + +Run: `python3 -m unittest test_cli -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.cli'`. + +- [ ] **Step 3: Write the implementation** + +`ledgerlite/cli.py`: + +```python +"""Command-line entry point: the only module that does I/O or picks exit codes.""" +import argparse +import sys +from decimal import Decimal + +from ledgerlite.parse import ParseError, parse_amount, parse_transactions +from ledgerlite.report import format_report +from ledgerlite.rules import parse_rules + +EXIT_OK = 0 +EXIT_UNREADABLE = 1 +EXIT_MALFORMED = 2 + + +def _opening(raw: str) -> Decimal: + try: + return parse_amount(raw) + except ValueError as err: + raise argparse.ArgumentTypeError(str(err)) from None + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="ledgerlite") + subcommands = parser.add_subparsers(dest="command", required=True) + report = subcommands.add_parser("report", help="print a per-category summary") + report.add_argument("transactions", help="transactions CSV") + report.add_argument("--rules", help="rules file (= per line)") + report.add_argument("--opening", type=_opening, default=Decimal("0"), help="opening balance") + return parser + + +def _read(path: str) -> str: + with open(path, encoding="utf-8") as handle: + return handle.read() + + +def main(argv: list[str] | None = None) -> int: + args = _build_parser().parse_args(argv) + + sources = [(args.transactions, parse_transactions)] + if args.rules is not None: + sources.append((args.rules, parse_rules)) + + parsed = [] + for path, parse_func in sources: + try: + text = _read(path) + except OSError as err: + print(f"ledgerlite: cannot read {path}: {err.strerror or err}", file=sys.stderr) + return EXIT_UNREADABLE + try: + parsed.append(parse_func(text)) + except ParseError as err: + print(f"ledgerlite: {path}:{err.line}: {err.message}", file=sys.stderr) + return EXIT_MALFORMED + + transactions = parsed[0] + rules = parsed[1] if len(parsed) > 1 else [] + print(format_report(transactions, rules, args.opening), end="") + return EXIT_OK +``` + +`ledgerlite/__main__.py`: + +```python +import sys + +from ledgerlite.cli import main + +sys.exit(main()) +``` + +- [ ] **Step 4: Run the tests and confirm they pass** + +Run: `python3 -m unittest test_cli -v` +Expected: PASS, all tests. + +- [ ] **Step 5: Run the whole suite and the tool by hand** + +```bash +python3 -m unittest -v +printf 'date,amount,description\n2026-03-05,-7.50,Morning coffee\n2026-03-01,-900.00,Rent\n2026-03-03,2500.00,Salary\n' > /tmp/t.csv +printf 'coffee=food\nrent=housing\n' > /tmp/r.txt +python3 -m ledgerlite report /tmp/t.csv --rules /tmp/r.txt --opening 100 +``` + +Expected: every test passes, and the command prints exactly the report from `design.md` § Report. + +- [ ] **Step 6: Commit** + +```bash +git add ledgerlite/cli.py ledgerlite/__main__.py test_cli.py +git commit -m "feat: ledgerlite report CLI with exit codes and error messages" +``` diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/P1-proportion-ledgerlite-83.plan.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/P1-proportion-ledgerlite-83.plan.md new file mode 100644 index 000000000..bc02e053c --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/P1-proportion-ledgerlite-83.plan.md @@ -0,0 +1,934 @@ +# ledgerlite Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build `ledgerlite`, a stdlib-only CLI that reads a transactions CSV, categorizes each row with a rules file, and prints per-category totals plus the closing balance. + +**Architecture:** Five small pure modules (`model`, `parse`, `rules`, `balance`, `report`) plus a thin `cli` that owns all I/O. Parsing functions take *text*, not paths, so the CLI is the only place that touches the filesystem and the only place that formats error messages and picks exit codes. Money is `decimal.Decimal` end to end; a float never appears. + +**Tech Stack:** Python 3.11+ standard library only (`csv`, `decimal`, `datetime`, `dataclasses`, `re`, `argparse`), tests with `unittest`. + +**Spec:** `design.md` + +## Global Constraints + +- Python 3.11+. Standard library only — no third-party dependencies, no `pyproject.toml` needed. +- Amounts are `decimal.Decimal` everywhere. Never `float`, not even transiently. +- Package layout is exactly as `design.md` specifies: `ledgerlite/{__init__,model,parse,rules,balance,report,cli}.py`. +- Tests live at the repo root as `test_.py` and run with `python3 -m unittest`. +- Error messages go to stderr and are prefixed `ledgerlite: `. The report goes to stdout. +- Exit codes: 0 success, 1 unreadable file, 2 malformed input. +- Amounts print with exactly two fractional digits, a leading `-` only for genuinely negative values, and no thousands separators. + +## Review Focus + +Five things the spec implies but does not spell out. Each has a test in the task that owns the code. + +1. `--rules` pointing at a file that cannot be read must produce the same `cannot read` message and exit 1, not a traceback. The spec only names TRANSACTIONS, but a typo'd rules path is just as likely. (Task 5) +2. Amount strings that `Decimal()` happily accepts but the spec does not describe — `1e2`, `NaN`, `Infinity`, `1.`, `--5` — must be malformed (exit 2), not silently accepted as money. (Task 1) +3. A blank line in the CSV (including a file that ends with a stray newline) must be skipped, not reported as a wrong column count. (Task 1) +4. A file with no transactions (header only) must print `closing balance: ` alone — no category lines and no leading blank line. (Task 4) +5. A total of `Decimal("-0.00")` must print `0.00`, not `-0.00`; the leading `-` is for negatives only. (Task 4) + +--- + +### Task 1: Transaction model and CSV parsing + +**Files:** +- Create: `ledgerlite/__init__.py` (empty) +- Create: `ledgerlite/model.py` +- Create: `ledgerlite/parse.py` +- Test: `test_parse.py` + +**Interfaces:** +- Consumes: nothing. +- Produces: + - `ledgerlite.model.Transaction` — frozen dataclass, fields `date: datetime.date`, `amount: decimal.Decimal`, `description: str` (in that order). + - `ledgerlite.parse.ParseError(Exception)` with attributes `line: int` and `message: str`. + - `ledgerlite.parse.parse_amount(value: str) -> decimal.Decimal` — raises `ValueError(message)` where `message` is the human-readable reason. Reused by Task 5 for `--opening`. + - `ledgerlite.parse.parse_transactions(text: str) -> list[Transaction]` — file order preserved, raises `ParseError` on the first bad row. + +Exact error message strings (tests and Task 5 depend on them): +- `expected header date,amount,description` +- `expected 3 columns, got {n}` +- `invalid date: {value!r}` +- `invalid amount: {value!r}` +- `amount has more than two fractional digits: {value!r}` + +- [ ] **Step 1: Write the failing tests** + +Create `test_parse.py`. Note `HEADER` — every fixture needs it, and line numbers count the header as line 1, so the first data row is line 2. + +```python +import unittest +from datetime import date +from decimal import Decimal + +from ledgerlite.parse import ParseError, parse_amount, parse_transactions + +HEADER = "date,amount,description\n" + + +class ParseTransactionsTest(unittest.TestCase): + def test_parses_rows_in_file_order(self): + txns = parse_transactions( + HEADER + "2026-03-05,-7.50,Coffee Bar\n2026-03-04,2500.00,Salary\n" + ) + self.assertEqual( + [(t.date, t.amount, t.description) for t in txns], + [ + (date(2026, 3, 5), Decimal("-7.50"), "Coffee Bar"), + (date(2026, 3, 4), Decimal("2500.00"), "Salary"), + ], + ) + + def test_amount_is_decimal_not_float(self): + [txn] = parse_transactions(HEADER + "2026-03-04,0.10,dime\n") + self.assertIsInstance(txn.amount, Decimal) + self.assertEqual(txn.amount, Decimal("0.10")) + + def test_header_only_file_has_no_transactions(self): + self.assertEqual(parse_transactions(HEADER), []) + + def test_blank_lines_are_skipped(self): + # Review Focus 3: a stray or trailing blank line is not a bad row. + txns = parse_transactions(HEADER + "\n2026-03-04,1.00,a\n\n") + self.assertEqual(len(txns), 1) + + def test_description_may_contain_commas_when_quoted(self): + [txn] = parse_transactions(HEADER + '2026-03-04,-1.00,"Cafe, Downtown"\n') + self.assertEqual(txn.description, "Cafe, Downtown") + + def test_accepts_one_or_two_fractional_digits(self): + txns = parse_transactions( + HEADER + "2026-03-04,1.5,a\n2026-03-04,1.50,b\n2026-03-04,2,c\n" + ) + self.assertEqual([t.amount for t in txns], + [Decimal("1.5"), Decimal("1.50"), Decimal("2")]) + + def assertRejects(self, text, line, message): + with self.assertRaises(ParseError) as caught: + parse_transactions(text) + self.assertEqual((caught.exception.line, caught.exception.message), + (line, message)) + + def test_rejects_missing_header(self): + self.assertRejects( + "2026-03-04,-7.50,coffee\n", 1, + "expected header date,amount,description", + ) + + def test_rejects_empty_file(self): + self.assertRejects("", 1, "expected header date,amount,description") + + def test_accepts_header_with_padding_and_odd_case(self): + self.assertEqual(parse_transactions("Date, Amount , description\n"), []) + + def test_rejects_wrong_column_count(self): + self.assertRejects(HEADER + "2026-03-04,-7.50\n", 2, + "expected 3 columns, got 2") + self.assertRejects(HEADER + "2026-03-04,-7.50,coffee,extra\n", 2, + "expected 3 columns, got 4") + + def test_rejects_unparseable_dates(self): + for value in ["2026-02-30", "04/03/2026", "2026-3-4", "20260304", ""]: + with self.subTest(value=value): + self.assertRejects(HEADER + f"{value},1.00,a\n", 2, + f"invalid date: {value!r}") + + def test_rejects_non_decimal_amounts(self): + # Review Focus 2: Decimal() accepts several of these; the spec does not. + for value in ["abc", "1e2", "NaN", "Infinity", "1.", "--5", "", "1,000.00"]: + with self.subTest(value=value): + self.assertRejects(HEADER + f'2026-03-04,"{value}",a\n', 2, + f"invalid amount: {value!r}") + + def test_rejects_more_than_two_fractional_digits(self): + self.assertRejects(HEADER + "2026-03-04,1.005,a\n", 2, + "amount has more than two fractional digits: '1.005'") + + def test_reports_the_line_of_the_first_bad_row(self): + text = HEADER + "2026-03-04,1.00,a\n2026-03-05,nope,b\n" + self.assertRejects(text, 3, "invalid amount: 'nope'") + + +class ParseAmountTest(unittest.TestCase): + def test_parses_signed_amounts(self): + self.assertEqual(parse_amount("-12.50"), Decimal("-12.50")) + self.assertEqual(parse_amount("+3"), Decimal("3")) + self.assertEqual(parse_amount(" 4.25 "), Decimal("4.25")) + + def test_raises_value_error_with_reason(self): + with self.assertRaises(ValueError) as caught: + parse_amount("1e2") + self.assertEqual(str(caught.exception), "invalid amount: '1e2'") +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_parse -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite'`. + +- [ ] **Step 3: Write the implementation** + +Create empty `ledgerlite/__init__.py`. + +`ledgerlite/model.py`: + +```python +"""The one data type the rest of the package passes around.""" +from dataclasses import dataclass +from datetime import date +from decimal import Decimal + + +@dataclass(frozen=True) +class Transaction: + date: date + amount: Decimal + description: str +``` + +`ledgerlite/parse.py`. Two regexes do the strictness the stdlib won't: `date.fromisoformat` accepts `20260304`, and `Decimal` accepts `NaN`/`1e2`, so validate the shape first and only then convert. + +```python +"""Transactions CSV -> list[Transaction].""" +import csv +import io +import re +from datetime import date +from decimal import Decimal + +from ledgerlite.model import Transaction + +HEADER = ("date", "amount", "description") +_DATE = re.compile(r"\d{4}-\d{2}-\d{2}\Z") +_AMOUNT = re.compile(r"[+-]?\d+(?:\.(?P\d+))?\Z") + + +class ParseError(Exception): + def __init__(self, line: int, message: str) -> None: + super().__init__(f"{line}: {message}") + self.line = line + self.message = message + + +def parse_amount(value: str) -> Decimal: + """Parse a money amount. Raises ValueError with a display-ready reason.""" + text = value.strip() + match = _AMOUNT.match(text) + if match is None: + raise ValueError(f"invalid amount: {value!r}") + frac = match.group("frac") + if frac is not None and len(frac) > 2: + raise ValueError(f"amount has more than two fractional digits: {value!r}") + return Decimal(text) + + +def _parse_date(value: str) -> date: + text = value.strip() + if _DATE.match(text) is None: + raise ValueError(f"invalid date: {value!r}") + try: + return date.fromisoformat(text) + except ValueError: + raise ValueError(f"invalid date: {value!r}") from None + + +def parse_transactions(text: str) -> list[Transaction]: + """Parse the whole file, or raise ParseError on the first bad row.""" + reader = csv.reader(io.StringIO(text)) + transactions: list[Transaction] = [] + header_seen = False + for row in reader: + if not row or all(field.strip() == "" for field in row): + continue # blank line: not a row at all + if not header_seen: + header_seen = True + if tuple(f.strip().lower() for f in row) != HEADER: + raise ParseError(reader.line_num, + "expected header date,amount,description") + continue + if len(row) != 3: + raise ParseError(reader.line_num, + f"expected 3 columns, got {len(row)}") + raw_date, raw_amount, description = row + try: + when = _parse_date(raw_date) + amount = parse_amount(raw_amount) + except ValueError as error: + raise ParseError(reader.line_num, str(error)) from None + transactions.append(Transaction(when, amount, description.strip())) + if not header_seen: + raise ParseError(1, "expected header date,amount,description") + return transactions +``` + +Note the blank-line skip runs before the header check, so a leading blank line does not become "the header". + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_parse -v` +Expected: PASS (all tests). + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/__init__.py ledgerlite/model.py ledgerlite/parse.py test_parse.py +git commit -m "feat: parse transactions CSV into Transaction records" +``` + +--- + +### Task 2: Rules file and categorization + +**Files:** +- Create: `ledgerlite/rules.py` +- Test: `test_rules.py` + +**Interfaces:** +- Consumes: nothing from Task 1. +- Produces: + - `ledgerlite.rules.parse_rules(text: str) -> list[tuple[str, str]]` — `(substring, category)` pairs in file order; the substring is lowercased for matching, the category is kept as written (stripped). + - `ledgerlite.rules.categorize(description: str, rules: list[tuple[str, str]]) -> str | None` — first match wins, `None` when nothing matches. + +Skipped rule lines (the spec is silent; these choices keep a typo from silently recategorizing everything): blank/whitespace-only lines, lines with no `=`, lines with an empty substring (an empty substring matches every description), and lines with an empty category. + +- [ ] **Step 1: Write the failing tests** + +```python +import unittest + +from ledgerlite.rules import categorize, parse_rules + + +class ParseRulesTest(unittest.TestCase): + def test_parses_one_rule_per_line_in_order(self): + self.assertEqual(parse_rules("coffee=food\nrent=housing\n"), + [("coffee", "food"), ("rent", "housing")]) + + def test_lowercases_substring_and_strips_padding(self): + self.assertEqual(parse_rules(" COFFEE Bar = food \n"), + [("coffee bar", "food")]) + + def test_splits_on_the_first_equals_only(self): + self.assertEqual(parse_rules("a=b=c\n"), [("a", "b=c")]) + + def test_skips_blank_and_unusable_lines(self): + text = "\n \nnoequals\n=food\ncoffee=\ncoffee=food\n" + self.assertEqual(parse_rules(text), [("coffee", "food")]) + + def test_empty_text_gives_no_rules(self): + self.assertEqual(parse_rules(""), []) + + +class CategorizeTest(unittest.TestCase): + RULES = [("coffee", "food"), ("rent", "housing")] + + def test_matches_substring_case_insensitively(self): + self.assertEqual(categorize("Monthly COFFEE order", self.RULES), "food") + + def test_first_matching_rule_wins(self): + rules = [("bar", "drink"), ("coffee bar", "food")] + self.assertEqual(categorize("Coffee Bar", rules), "drink") + + def test_returns_none_when_nothing_matches(self): + self.assertIsNone(categorize("Salary", self.RULES)) + + def test_returns_none_with_no_rules(self): + self.assertIsNone(categorize("Coffee", [])) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_rules -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.rules'`. + +- [ ] **Step 3: Write the implementation** + +`ledgerlite/rules.py`: + +```python +"""Rules text -> (substring, category) pairs, and description matching.""" + + +def parse_rules(text: str) -> list[tuple[str, str]]: + rules: list[tuple[str, str]] = [] + for line in text.splitlines(): + substring, sep, category = line.partition("=") + if not sep: + continue + substring, category = substring.strip().lower(), category.strip() + if substring and category: + rules.append((substring, category)) + return rules + + +def categorize(description: str, rules: list[tuple[str, str]]) -> str | None: + haystack = description.lower() + for substring, category in rules: + if substring in haystack: + return category + return None +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_rules -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/rules.py test_rules.py +git commit -m "feat: parse rules file and categorize descriptions" +``` + +--- + +### Task 3: Date ordering and closing balance + +**Files:** +- Create: `ledgerlite/balance.py` +- Test: `test_balance.py` + +**Interfaces:** +- Consumes: `ledgerlite.model.Transaction` (Task 1). +- Produces: + - `ledgerlite.balance.order_by_date(transactions: Iterable[Transaction]) -> list[Transaction]` — sorted by date, ties keeping input order. + - `ledgerlite.balance.closing_balance(opening: Decimal, transactions: Iterable[Transaction]) -> Decimal` — the running balance after the last transaction in date order; `opening` when there are none. + +- [ ] **Step 1: Write the failing tests** + +```python +import unittest +from datetime import date +from decimal import Decimal + +from ledgerlite.balance import closing_balance, order_by_date +from ledgerlite.model import Transaction + + +def txn(day, amount, description="x"): + return Transaction(date(2026, 3, day), Decimal(amount), description) + + +class OrderByDateTest(unittest.TestCase): + def test_sorts_by_date(self): + rows = [txn(5, "1"), txn(3, "2"), txn(4, "3")] + self.assertEqual([t.date.day for t in order_by_date(rows)], [3, 4, 5]) + + def test_ties_keep_input_order(self): + rows = [txn(4, "1", "second-in-file"), txn(3, "2"), txn(4, "3", "third-in-file")] + ordered = order_by_date(rows) + self.assertEqual([t.description for t in ordered], + ["x", "second-in-file", "third-in-file"]) + + def test_does_not_mutate_input(self): + rows = [txn(5, "1"), txn(3, "2")] + order_by_date(rows) + self.assertEqual([t.date.day for t in rows], [5, 3]) + + +class ClosingBalanceTest(unittest.TestCase): + def test_adds_every_amount_to_the_opening(self): + rows = [txn(4, "-7.50"), txn(3, "2500.00"), txn(5, "-900.00")] + self.assertEqual(closing_balance(Decimal("100"), rows), Decimal("1692.50")) + + def test_no_transactions_gives_the_opening(self): + self.assertEqual(closing_balance(Decimal("100.00"), []), Decimal("100.00")) + + def test_arithmetic_is_exact_decimal(self): + rows = [txn(3, "0.10"), txn(4, "0.20")] + self.assertEqual(closing_balance(Decimal("0"), rows), Decimal("0.30")) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_balance -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.balance'`. + +- [ ] **Step 3: Write the implementation** + +`ledgerlite/balance.py`: + +```python +"""Date-ordered running balance.""" +from collections.abc import Iterable +from decimal import Decimal + +from ledgerlite.model import Transaction + + +def order_by_date(transactions: Iterable[Transaction]) -> list[Transaction]: + """Sort by date. sorted() is stable, so same-date rows keep input order.""" + return sorted(transactions, key=lambda t: t.date) + + +def closing_balance(opening: Decimal, transactions: Iterable[Transaction]) -> Decimal: + balance = opening + for transaction in order_by_date(transactions): + balance += transaction.amount + return balance +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_balance -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/balance.py test_balance.py +git commit -m "feat: order transactions by date and compute closing balance" +``` + +--- + +### Task 4: Per-category totals and report formatting + +**Files:** +- Create: `ledgerlite/report.py` +- Test: `test_report.py` + +**Interfaces:** +- Consumes: `Transaction` (Task 1), `categorize` (Task 2), `closing_balance` (Task 3). +- Produces: + - `ledgerlite.report.UNCATEGORIZED = "uncategorized"` + - `ledgerlite.report.format_amount(amount: Decimal) -> str` + - `ledgerlite.report.category_totals(transactions, rules) -> list[tuple[str, Decimal]]` — display order: categories alphabetically, then `uncategorized` if any transaction is uncategorized. + - `ledgerlite.report.format_report(transactions, rules, opening: Decimal) -> str` — the whole report, **no** trailing newline (the CLI's `print` adds it). + +- [ ] **Step 1: Write the failing tests** + +```python +import unittest +from datetime import date +from decimal import Decimal + +from ledgerlite.model import Transaction +from ledgerlite.report import category_totals, format_amount, format_report + + +def txn(day, amount, description): + return Transaction(date(2026, 3, day), Decimal(amount), description) + + +RULES = [("coffee", "food"), ("rent", "housing")] + + +class FormatAmountTest(unittest.TestCase): + def test_two_fractional_digits_and_sign(self): + self.assertEqual(format_amount(Decimal("-12.5")), "-12.50") + self.assertEqual(format_amount(Decimal("0")), "0.00") + self.assertEqual(format_amount(Decimal("1200")), "1200.00") + + def test_negative_zero_prints_unsigned(self): + # Review Focus 5. + self.assertEqual(format_amount(Decimal("-0.00")), "0.00") + + def test_no_thousands_separators(self): + self.assertEqual(format_amount(Decimal("1234567.5")), "1234567.50") + + +class CategoryTotalsTest(unittest.TestCase): + def test_sums_each_category_alphabetically(self): + rows = [txn(3, "-900.00", "Rent"), txn(4, "-7.50", "Coffee Bar"), + txn(5, "-2.00", "coffee beans")] + self.assertEqual(category_totals(rows, RULES), + [("food", Decimal("-9.50")), ("housing", Decimal("-900.00"))]) + + def test_uncategorized_is_last_even_when_not_alphabetically_last(self): + rules = [("coffee", "food"), ("zoo", "zebras")] + rows = [txn(3, "1.00", "Zoo"), txn(4, "2.00", "Coffee"), txn(5, "3.00", "Salary")] + self.assertEqual([name for name, _ in category_totals(rows, rules)], + ["food", "zebras", "uncategorized"]) + + def test_uncategorized_omitted_when_everything_matches(self): + self.assertEqual(category_totals([txn(3, "1.00", "Coffee")], RULES), + [("food", Decimal("1.00"))]) + + def test_no_transactions_gives_no_categories(self): + self.assertEqual(category_totals([], RULES), []) + + +class FormatReportTest(unittest.TestCase): + def test_matches_the_design_example(self): + rows = [txn(4, "-7.50", "Coffee Bar"), txn(3, "-900.00", "Rent"), + txn(5, "2500.00", "Salary")] + self.assertEqual( + format_report(rows, RULES, Decimal("100")), + "food: -7.50\n" + "housing: -900.00\n" + "uncategorized: 2500.00\n" + "\n" + "closing balance: 1692.50", + ) + + def test_no_transactions_prints_only_the_closing_balance(self): + # Review Focus 4: no category lines means no leading blank line either. + self.assertEqual(format_report([], RULES, Decimal("100")), + "closing balance: 100.00") + + def test_without_rules_everything_is_uncategorized(self): + self.assertEqual( + format_report([txn(3, "-5.00", "Coffee")], [], Decimal("0")), + "uncategorized: -5.00\n\nclosing balance: -5.00", + ) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_report -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.report'`. + +- [ ] **Step 3: Write the implementation** + +`ledgerlite/report.py`: + +```python +"""Per-category totals and report text.""" +from collections.abc import Iterable +from decimal import Decimal + +from ledgerlite.balance import closing_balance +from ledgerlite.model import Transaction +from ledgerlite.rules import categorize + +UNCATEGORIZED = "uncategorized" + + +def format_amount(amount: Decimal) -> str: + """Exactly two fractional digits; a leading '-' only for real negatives.""" + if amount == 0: + amount = Decimal(0) # collapse -0.00 + return f"{amount:.2f}" + + +def category_totals( + transactions: Iterable[Transaction], rules: list[tuple[str, str]] +) -> list[tuple[str, Decimal]]: + totals: dict[str, Decimal] = {} + for transaction in transactions: + name = categorize(transaction.description, rules) or UNCATEGORIZED + totals[name] = totals.get(name, Decimal(0)) + transaction.amount + ordered = sorted(name for name in totals if name != UNCATEGORIZED) + if UNCATEGORIZED in totals: + ordered.append(UNCATEGORIZED) + return [(name, totals[name]) for name in ordered] + + +def format_report( + transactions: Iterable[Transaction], + rules: list[tuple[str, str]], + opening: Decimal, +) -> str: + transactions = list(transactions) + lines = [ + f"{name}: {format_amount(total)}" + for name, total in category_totals(transactions, rules) + ] + if lines: + lines.append("") + closing = closing_balance(opening, transactions) + lines.append(f"closing balance: {format_amount(closing)}") + return "\n".join(lines) +``` + +`format_amount` reassigns rather than special-casing the return so that `Decimal("-0.00")` and `Decimal("0")` take the same path. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_report -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/report.py test_report.py +git commit -m "feat: format per-category totals and closing balance report" +``` + +--- + +### Task 5: CLI entry point + +**Files:** +- Create: `ledgerlite/cli.py` +- Test: `test_cli.py` + +**Interfaces:** +- Consumes: `parse_amount`, `parse_transactions`, `ParseError` (Task 1); `parse_rules` (Task 2); `format_report` (Task 4). +- Produces: `ledgerlite.cli.main(argv: list[str] | None = None) -> int`. + +Behavior: `ledgerlite report TRANSACTIONS [--rules RULES] [--opening AMOUNT]`. `--opening` defaults to `0` and is validated with `parse_amount`, so a bad value is an argparse usage error (exit 2). Both file reads are guarded: `OSError` → `ledgerlite: cannot read : ` on stderr, exit 1, where `` is `e.strerror` when present. `ParseError` → `ledgerlite: :: `, exit 2. The report is only printed after both files parse cleanly, so a malformed file leaves stdout empty. `argparse` raises `SystemExit` for usage errors; `main` catches it and returns the code so callers always get an int. + +- [ ] **Step 1: Write the failing tests** + +```python +import contextlib +import io +import tempfile +import unittest +from pathlib import Path + +from ledgerlite.cli import main + +TRANSACTIONS = ( + "date,amount,description\n" + "2026-03-04,-7.50,Coffee Bar\n" + "2026-03-03,-900.00,Rent\n" + "2026-03-05,2500.00,Salary\n" +) +RULES = "coffee=food\nrent=housing\n" + + +class CliTest(unittest.TestCase): + def setUp(self): + self.dir = Path(tempfile.mkdtemp()) + + def write(self, name, text): + path = self.dir / name + path.write_text(text) + return str(path) + + def run_cli(self, argv): + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = main(argv) + return code, out.getvalue(), err.getvalue() + + def test_prints_the_report_and_returns_zero(self): + argv = ["report", self.write("t.csv", TRANSACTIONS), + "--rules", self.write("r.txt", RULES), "--opening", "100"] + code, out, err = self.run_cli(argv) + self.assertEqual(code, 0) + self.assertEqual(err, "") + self.assertEqual(out, "food: -7.50\nhousing: -900.00\n" + "uncategorized: 2500.00\n\nclosing balance: 1692.50\n") + + def test_defaults_are_zero_opening_and_no_rules(self): + code, out, _ = self.run_cli(["report", self.write("t.csv", TRANSACTIONS)]) + self.assertEqual(code, 0) + self.assertEqual(out, "uncategorized: 1592.50\n\nclosing balance: 1592.50\n") + + def test_unreadable_transactions_file(self): + missing = str(self.dir / "nope.csv") + code, out, err = self.run_cli(["report", missing]) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertEqual(err, f"ledgerlite: cannot read {missing}: " + "No such file or directory\n") + + def test_unreadable_rules_file(self): + # Review Focus 1. + missing = str(self.dir / "nope.txt") + code, out, err = self.run_cli( + ["report", self.write("t.csv", TRANSACTIONS), "--rules", missing]) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertEqual(err, f"ledgerlite: cannot read {missing}: " + "No such file or directory\n") + + def test_malformed_row_rejects_the_whole_file(self): + path = self.write("bad.csv", "date,amount,description\n" + "2026-03-04,-7.50,Coffee\n" + "2026-03-05,1.005,Rent\n") + code, out, err = self.run_cli(["report", path]) + self.assertEqual(code, 2) + self.assertEqual(out, "") + self.assertEqual(err, f"ledgerlite: {path}:3: amount has more than " + "two fractional digits: '1.005'\n") + + def test_bad_opening_amount_is_a_usage_error(self): + code, out, err = self.run_cli( + ["report", self.write("t.csv", TRANSACTIONS), "--opening", "1.005"]) + self.assertEqual(code, 2) + self.assertEqual(out, "") + self.assertIn("usage:", err) + + def test_negative_opening_is_accepted(self): + code, out, _ = self.run_cli( + ["report", self.write("t.csv", "date,amount,description\n"), + "--opening", "-50.25"]) + self.assertEqual((code, out), (0, "closing balance: -50.25\n")) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_cli -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.cli'`. + +- [ ] **Step 3: Write the implementation** + +`ledgerlite/cli.py`: + +```python +"""Command line entry point: the only module that touches the filesystem.""" +import argparse +import sys +from decimal import Decimal +from pathlib import Path + +from ledgerlite.parse import ParseError, parse_amount, parse_transactions +from ledgerlite.report import format_report +from ledgerlite.rules import parse_rules + + +def _opening(value: str) -> Decimal: + try: + return parse_amount(value) + except ValueError as error: + raise argparse.ArgumentTypeError(str(error)) from None + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="ledgerlite") + subcommands = parser.add_subparsers(dest="command", required=True) + report = subcommands.add_parser("report", help="print a categorized summary") + report.add_argument("transactions", metavar="TRANSACTIONS") + report.add_argument("--rules", metavar="RULES") + report.add_argument("--opening", metavar="AMOUNT", type=_opening, + default=Decimal(0)) + return parser + + +def _fail(message: str, code: int) -> int: + print(f"ledgerlite: {message}", file=sys.stderr) + return code + + +def _read(path: str) -> str: + return Path(path).read_text() + + +def main(argv: list[str] | None = None) -> int: + try: + args = _build_parser().parse_args(sys.argv[1:] if argv is None else argv) + except SystemExit as exit_request: # argparse already printed usage + return int(exit_request.code or 0) + + try: + transactions_text = _read(args.transactions) + rules_text = _read(args.rules) if args.rules else "" + except OSError as error: + path = error.filename or args.transactions + return _fail(f"cannot read {path}: {error.strerror or error}", 1) + + try: + transactions = parse_transactions(transactions_text) + except ParseError as error: + return _fail(f"{args.transactions}:{error.line}: {error.message}", 2) + + print(format_report(transactions, parse_rules(rules_text), args.opening)) + return 0 + + +if __name__ == "__main__": # python3 -m ledgerlite.cli report ... + sys.exit(main()) +``` + +Note `_build_parser` returns the *top-level* parser (the one `parse_args` is called on), not the `report` subparser. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_cli -v` +Expected: PASS. + +- [ ] **Step 5: Run the whole suite** + +Run: `python3 -m unittest -v` +Expected: PASS — every test from Tasks 1-5. + +- [ ] **Step 6: Commit** + +```bash +git add ledgerlite/cli.py test_cli.py +git commit -m "feat: add report CLI with exit codes for unreadable and malformed input" +``` + +--- + +### Task 6: End-to-end smoke check and README note + +**Files:** +- Create: `README.md` +- Test: `test_cli.py` (add one subprocess test) + +**Interfaces:** +- Consumes: `ledgerlite.cli.main` (Task 5). +- Produces: nothing other modules use. + +The design gives no packaging file, so the invocation is `python3 -m ledgerlite.cli report ...` from the repo root. This task pins that down so nobody has to guess, and proves the exit codes survive a real process boundary. + +- [ ] **Step 1: Write the failing test** + +Append to `test_cli.py`: + +```python +import subprocess +import sys + + +class SubprocessTest(unittest.TestCase): + def test_module_invocation_prints_report_and_exits_zero(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "t.csv" + path.write_text("date,amount,description\n2026-03-04,-7.50,Coffee\n") + done = subprocess.run( + [sys.executable, "-m", "ledgerlite.cli", "report", str(path)], + capture_output=True, text=True, cwd=Path(__file__).parent) + self.assertEqual(done.returncode, 0) + self.assertEqual(done.stdout, + "uncategorized: -7.50\n\nclosing balance: -7.50\n") + + def test_module_invocation_exits_two_on_malformed_input(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "t.csv" + path.write_text("date,amount,description\nnope,-7.50,Coffee\n") + done = subprocess.run( + [sys.executable, "-m", "ledgerlite.cli", "report", str(path)], + capture_output=True, text=True, cwd=Path(__file__).parent) + self.assertEqual(done.returncode, 2) + self.assertEqual(done.stdout, "") + self.assertIn("invalid date: 'nope'", done.stderr) +``` + +- [ ] **Step 2: Run the tests** + +Run: `python3 -m unittest test_cli.SubprocessTest -v` +Expected: PASS — this exercises the `__main__` guard added in Task 5. If it fails with a usage error or empty stdout, the guard is wrong: it must be `sys.exit(main())` with no argument, so `main` reads `sys.argv` itself. + +- [ ] **Step 3: Write the README** + +`README.md`: + +```markdown +# ledgerlite + +Categorize a bank-transactions CSV and print per-category totals with the +closing balance. Python 3.11+, standard library only. + + python3 -m ledgerlite.cli report transactions.csv --rules rules.txt --opening 100 + +- `transactions.csv` has the header `date,amount,description`. +- `rules.txt` holds one `=` rule per line; matching is + case-insensitive on the description and the first match wins. +- Exit codes: `0` success, `1` a file could not be read, `2` the transactions + file is malformed (nothing is printed to stdout in that case). + +Run the tests with `python3 -m unittest`. +``` + +- [ ] **Step 4: Run the whole suite one more time** + +Run: `python3 -m unittest -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add README.md test_cli.py +git commit -m "docs: document invocation and add end-to-end CLI checks" +``` diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/Q1-reader-cosmic-21/2026-09-17-cosmic-tetris-1-engine.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/Q1-reader-cosmic-21/2026-09-17-cosmic-tetris-1-engine.md new file mode 100644 index 000000000..ed120a5fd --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/Q1-reader-cosmic-21/2026-09-17-cosmic-tetris-1-engine.md @@ -0,0 +1,3570 @@ +# Cosmic Tetris — Plan 1: Deterministic Game Engine Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the headless, deterministic falling-block engine in `internal/game` — pieces, board, 7-bag, movement, wall-kick rotation, gravity, lock delay, line clearing, hold, scoring, and game over — with comprehensive unit tests and a seeded replay test, and no terminal code at all. + +**Architecture:** One package, `internal/game`, with no dependencies outside the Go standard library. The package never reads a clock: the caller passes elapsed time in via `Advance(dt)` (design §49.2), and player actions come in via `Apply(Input)`. Both return a `[]Event` slice describing what happened, which later plans feed to the FX system. The package owns exactly one `*rand.Rand`, used only by the 7-bag (design §49.6). + +**Tech Stack:** Go 1.26, standard library only (`math`, `math/rand/v2`, `time`, `strings`, `fmt`, `testing`). No Charm libraries in this plan. + +**Spec:** `design.md` (this repo). Sections implemented here: §5 board representation, §6 pieces, §7 rotation, §11 gravity, §12 locking, §13 scoring, §34 core state, §35 determinism, §40 tests, §42 Phase 1, §49.1, §49.2, §49.6. + +## Global Constraints + +- Go module path: `cosmic-tetris`. Imports are `cosmic-tetris/internal/game`. +- Go version line in `go.mod`: `go 1.26`. +- Nothing under `internal/game` may call `time.Now()`, read the filesystem, print, or import any non-stdlib package. `time` is imported only for the `time.Duration` type and duration constants. +- `internal/game` must not import `internal/fx`, `internal/render`, or `internal/app`. The dependency arrow only ever points at `game`. +- Board geometry is fixed: width 10, height 22, visible rows 20, hidden spawn rows 2 (§5). +- Coordinates: `x` grows right from 0, `y` grows **down** from 0. Rows 0 and 1 are the hidden spawn rows; rows 2–21 are visible. Row 21 is the bottom. +- Gravity: `800ms * 0.86^(level-1)`, clamped to a 60ms floor (§11). +- Lock delay 500ms, max 15 lock resets (§12). +- Combo bonus is `50 × (combo - 1) × level`; the first clearing placement sets combo to 1 and earns no bonus (§49.1). +- Every exported identifier gets a doc comment. This engine is the part a reader must understand in an afternoon (§48). +- Run `gofmt -l .` before every commit; it must print nothing. + +## Review Focus + +These are input classes the spec implies but does not call out in §40's test list. Each has a test assigned to the task that owns the code. + +1. **A single enormous `dt`** (laptop sleep, SIGSTOP, a debugger pause) reaches `Advance` as one 5-second step. The gravity loop must terminate, the piece must land rather than tunnel through the stack, and the lock sequence must still run. — Task 9. +2. **A wall kick that pushes blocks above the ceiling** (`(0,-1)` offset at spawn) produces block coordinates with `y < 0`. `Occupied` must treat above-the-ceiling as free space and `Lock` must discard those blocks rather than index a negative row. — Tasks 3 and 6. +3. **Hard drop on a piece already resting on the stack** (drop distance 0) must award 0 drop points, lock exactly once, and emit exactly one `EventPieceLocked`. — Task 10. +4. **Hold used repeatedly across many placements** must keep the next queue at exactly 5 kinds and must never hand out a piece the bag has not produced. — Task 11. +5. **A line clear that includes a hidden row** (0 or 1) must collapse correctly and leave no stale cells at the top of the board. — Task 4. + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `go.mod` | Module declaration, Go version. No requires yet. | +| `internal/game/piece.go` | `PieceKind`, the rotation offset table, `Piece`, `Blocks`, spawn construction. | +| `internal/game/board.go` | `Cell`, `Board`, bounds/occupancy/collision, locking, row completion, row clearing, drop distance, ghost. | +| `internal/game/bag.go` | The 7-bag generator. Holds no RNG of its own. | +| `internal/game/rules.go` | Rotation with the §7 wall-kick ladder. | +| `internal/game/scoring.go` | Line values, combo bonus, level progression, gravity interval, timing constants. | +| `internal/game/event.go` | `EventType`, `Event`, `String()`. | +| `internal/game/game.go` | `Game`, `Input`, `New`, `Apply`, `Advance`, `Snapshot`, and the private spawn/lock/move helpers. | +| `internal/game/*_test.go` | One test file per source file above. | +| `internal/game/testdata/replay-8675309.golden` | Recorded final state of the canned replay. | + +--- + +### Task 1: Module bootstrap and piece geometry + +**Files:** +- Create: `go.mod` +- Create: `internal/game/piece.go` +- Create: `.gitignore` +- Test: `internal/game/piece_test.go` + +**Interfaces:** +- Consumes: nothing. +- Produces: `PieceKind` (`KindI, KindJ, KindL, KindO, KindS, KindT, KindZ`), `KindCount = 7`, `func (PieceKind) Letter() byte`, `var Offsets [KindCount][4][4][2]int` indexed `[kind][rotation][block][0=x,1=y]`, `type Piece struct { Kind PieceKind; Rotation int; X, Y int }`, `func (Piece) Blocks() [4][2]int`, `func Spawn(k PieceKind) Piece`, `const SpawnX = 3`, `const SpawnY = 0`. + +- [ ] **Step 1: Create the module and ignore file** + +```bash +cd /private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/inline-eval-br/reps/wpplans-21/repo +go mod init cosmic-tetris +printf '/cosmic-tetris\n' > .gitignore +``` + +Confirm `go.mod` reads: + +``` +module cosmic-tetris + +go 1.26 +``` + +- [ ] **Step 2: Write the failing test** + +Create `internal/game/piece_test.go`: + +```go +package game + +import "testing" + +func TestLetter(t *testing.T) { + want := "IJLOSTZ" + for k := PieceKind(0); k < KindCount; k++ { + if got := k.Letter(); got != want[k] { + t.Errorf("kind %d letter = %q, want %q", k, got, want[k]) + } + } +} + +// Every rotation of every kind is four distinct cells inside a 4x4 box. +func TestOffsetsWellFormed(t *testing.T) { + for k := PieceKind(0); k < KindCount; k++ { + for r := 0; r < 4; r++ { + seen := map[[2]int]bool{} + for _, off := range Offsets[k][r] { + if off[0] < 0 || off[0] > 3 || off[1] < 0 || off[1] > 3 { + t.Errorf("%c rot %d: offset %v outside 4x4 box", k.Letter(), r, off) + } + if seen[off] { + t.Errorf("%c rot %d: duplicate offset %v", k.Letter(), r, off) + } + seen[off] = true + } + if len(seen) != 4 { + t.Errorf("%c rot %d: %d distinct cells, want 4", k.Letter(), r, len(seen)) + } + } + } +} + +// O is visually identical through rotation (design section 6). +func TestOSquareIdenticalThroughRotation(t *testing.T) { + for r := 1; r < 4; r++ { + if Offsets[KindO][r] != Offsets[KindO][0] { + t.Errorf("O rot %d = %v, want same as rot 0 %v", r, Offsets[KindO][r], Offsets[KindO][0]) + } + } +} + +func TestSpecificShapes(t *testing.T) { + tests := []struct { + kind PieceKind + rot int + want [4][2]int + }{ + {KindI, 0, [4][2]int{{0, 1}, {1, 1}, {2, 1}, {3, 1}}}, + {KindI, 1, [4][2]int{{2, 0}, {2, 1}, {2, 2}, {2, 3}}}, + {KindT, 0, [4][2]int{{1, 0}, {0, 1}, {1, 1}, {2, 1}}}, + {KindT, 2, [4][2]int{{0, 1}, {1, 1}, {2, 1}, {1, 2}}}, + {KindS, 0, [4][2]int{{1, 0}, {2, 0}, {0, 1}, {1, 1}}}, + {KindZ, 0, [4][2]int{{0, 0}, {1, 0}, {1, 1}, {2, 1}}}, + {KindJ, 0, [4][2]int{{0, 0}, {0, 1}, {1, 1}, {2, 1}}}, + {KindL, 0, [4][2]int{{2, 0}, {0, 1}, {1, 1}, {2, 1}}}, + } + for _, tc := range tests { + if got := Offsets[tc.kind][tc.rot]; got != tc.want { + t.Errorf("%c rot %d = %v, want %v", tc.kind.Letter(), tc.rot, got, tc.want) + } + } +} + +func TestBlocksTranslatesByPosition(t *testing.T) { + p := Piece{Kind: KindT, Rotation: 0, X: 4, Y: 7} + want := [4][2]int{{5, 7}, {4, 8}, {5, 8}, {6, 8}} + if got := p.Blocks(); got != want { + t.Errorf("Blocks() = %v, want %v", got, want) + } +} + +// Rotation is masked, so callers may pass any integer. +func TestBlocksNormalizesRotation(t *testing.T) { + a := Piece{Kind: KindL, Rotation: 1, X: 0, Y: 0} + b := Piece{Kind: KindL, Rotation: 5, X: 0, Y: 0} + if a.Blocks() != b.Blocks() { + t.Errorf("rotation 5 = %v, want same as rotation 1 %v", b.Blocks(), a.Blocks()) + } +} + +func TestSpawnPosition(t *testing.T) { + p := Spawn(KindI) + if p.Kind != KindI || p.Rotation != 0 || p.X != SpawnX || p.Y != SpawnY { + t.Errorf("Spawn(I) = %+v, want {I 0 %d %d}", p, SpawnX, SpawnY) + } + // Spawned pieces start inside the hidden rows. + for _, b := range p.Blocks() { + if b[1] >= HiddenRows { + t.Errorf("spawned I block %v is already visible, want y < %d", b, HiddenRows) + } + } +} +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `go test ./internal/game/` +Expected: FAIL — `undefined: PieceKind`, `undefined: Offsets`, `undefined: HiddenRows`. + +- [ ] **Step 4: Write the implementation** + +Create `internal/game/piece.go`: + +```go +// Package game implements the Cosmic Tetris rules engine. +// +// The package is deliberately headless and deterministic: it never reads a +// clock, never touches the filesystem, and never renders anything. Elapsed +// time arrives through Game.Advance and player actions through Game.Apply, +// both of which report what happened as a slice of Event values. +package game + +// PieceKind identifies one of the seven tetromino families. +type PieceKind uint8 + +// The seven tetromino families. +const ( + KindI PieceKind = iota + KindJ + KindL + KindO + KindS + KindT + KindZ +) + +// KindCount is the number of tetromino families. +const KindCount = 7 + +// Letter returns the conventional single-letter name of the kind. +func (k PieceKind) Letter() byte { return "IJLOSTZ"[k] } + +// Spawn coordinates. A newly spawned piece sits in the hidden rows above the +// visible board, horizontally centred for both 3-wide and 4-wide shapes. +const ( + SpawnX = 3 + SpawnY = 0 +) + +// Offsets holds the block layout of every kind at every rotation as four +// (x, y) offsets inside a 4x4 box. Indexed [kind][rotation][block][0=x, 1=y]. +// y grows downward, so offset {1, 0} is above offset {1, 1}. +var Offsets = [KindCount][4][4][2]int{ + KindI: { + {{0, 1}, {1, 1}, {2, 1}, {3, 1}}, + {{2, 0}, {2, 1}, {2, 2}, {2, 3}}, + {{0, 2}, {1, 2}, {2, 2}, {3, 2}}, + {{1, 0}, {1, 1}, {1, 2}, {1, 3}}, + }, + KindJ: { + {{0, 0}, {0, 1}, {1, 1}, {2, 1}}, + {{1, 0}, {2, 0}, {1, 1}, {1, 2}}, + {{0, 1}, {1, 1}, {2, 1}, {2, 2}}, + {{1, 0}, {1, 1}, {0, 2}, {1, 2}}, + }, + KindL: { + {{2, 0}, {0, 1}, {1, 1}, {2, 1}}, + {{1, 0}, {1, 1}, {1, 2}, {2, 2}}, + {{0, 1}, {1, 1}, {2, 1}, {0, 2}}, + {{0, 0}, {1, 0}, {1, 1}, {1, 2}}, + }, + KindO: { + {{1, 0}, {2, 0}, {1, 1}, {2, 1}}, + {{1, 0}, {2, 0}, {1, 1}, {2, 1}}, + {{1, 0}, {2, 0}, {1, 1}, {2, 1}}, + {{1, 0}, {2, 0}, {1, 1}, {2, 1}}, + }, + KindS: { + {{1, 0}, {2, 0}, {0, 1}, {1, 1}}, + {{1, 0}, {1, 1}, {2, 1}, {2, 2}}, + {{1, 1}, {2, 1}, {0, 2}, {1, 2}}, + {{0, 0}, {0, 1}, {1, 1}, {1, 2}}, + }, + KindT: { + {{1, 0}, {0, 1}, {1, 1}, {2, 1}}, + {{1, 0}, {1, 1}, {2, 1}, {1, 2}}, + {{0, 1}, {1, 1}, {2, 1}, {1, 2}}, + {{1, 0}, {0, 1}, {1, 1}, {1, 2}}, + }, + KindZ: { + {{0, 0}, {1, 0}, {1, 1}, {2, 1}}, + {{2, 0}, {1, 1}, {2, 1}, {1, 2}}, + {{0, 1}, {1, 1}, {1, 2}, {2, 2}}, + {{1, 0}, {0, 1}, {1, 1}, {0, 2}}, + }, +} + +// Piece is a tetromino at a position and rotation on the board. +type Piece struct { + Kind PieceKind + Rotation int + X int + Y int +} + +// Blocks returns the four absolute board coordinates the piece occupies. +// Rotation is normalized, so any integer rotation is valid. +func (p Piece) Blocks() [4][2]int { + offs := Offsets[p.Kind][p.Rotation&3] + var out [4][2]int + for i, off := range offs { + out[i] = [2]int{p.X + off[0], p.Y + off[1]} + } + return out +} + +// Spawn returns a piece of the given kind at the spawn position and rotation. +func Spawn(k PieceKind) Piece { + return Piece{Kind: k, Rotation: 0, X: SpawnX, Y: SpawnY} +} +``` + +`HiddenRows` is defined in Task 2; the piece test will not compile until then. To keep this task independently green, add the board geometry constants now, at the top of a new `internal/game/board.go`: + +```go +package game + +// Board geometry (design section 5). y grows downward: rows 0 and 1 are the +// hidden spawn rows, rows 2 through 21 are the visible playfield. +const ( + Width = 10 + Height = 22 + VisibleHeight = 20 + HiddenRows = Height - VisibleHeight +) +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v -run 'TestLetter|TestOffsets|TestOSquare|TestSpecificShapes|TestBlocks|TestSpawnPosition'` +Expected: PASS, 7 tests. + +- [ ] **Step 6: Commit** + +```bash +gofmt -l . +git add go.mod .gitignore internal/game/piece.go internal/game/board.go internal/game/piece_test.go +git commit -m "feat(game): tetromino kinds, rotation table, and board geometry" +``` + +--- + +### Task 2: Board cells, bounds, and collision + +**Files:** +- Modify: `internal/game/board.go` +- Test: `internal/game/board_test.go` + +**Interfaces:** +- Consumes: `Piece`, `Piece.Blocks`, `PieceKind` from Task 1. +- Produces: `type Cell uint8`, `const EmptyCell Cell = 0`, `func FilledCell(k PieceKind) Cell`, `func (Cell) Filled() bool`, `func (Cell) Kind() PieceKind`, `type Board struct { Cells [Height][Width]Cell }`, `func (*Board) At(x, y int) Cell`, `func (*Board) Set(x, y int, c Cell)`, `func (*Board) Occupied(x, y int) bool`, `func (*Board) Collides(p Piece) bool`, `func (*Board) Lock(p Piece)`. + +- [ ] **Step 1: Write the failing test** + +Create `internal/game/board_test.go`: + +```go +package game + +import "testing" + +// fill marks a run of cells in one row as occupied by the given kind. +func fill(b *Board, y int, xs ...int) { + for _, x := range xs { + b.Set(x, y, FilledCell(KindT)) + } +} + +// fillRow marks an entire row as occupied except for the listed gaps. +func fillRow(b *Board, y int, gaps ...int) { + skip := map[int]bool{} + for _, g := range gaps { + skip[g] = true + } + for x := 0; x < Width; x++ { + if !skip[x] { + b.Set(x, y, FilledCell(KindI)) + } + } +} + +func TestCellRoundTrip(t *testing.T) { + if EmptyCell.Filled() { + t.Error("EmptyCell.Filled() = true, want false") + } + for k := PieceKind(0); k < KindCount; k++ { + c := FilledCell(k) + if !c.Filled() { + t.Errorf("FilledCell(%c).Filled() = false, want true", k.Letter()) + } + if got := c.Kind(); got != k { + t.Errorf("FilledCell(%c).Kind() = %c, want %c", k.Letter(), got.Letter(), k.Letter()) + } + } +} + +func TestOccupiedWallsAndFloor(t *testing.T) { + var b Board + tests := []struct { + name string + x, y int + want bool + }{ + {"left wall", -1, 5, true}, + {"right wall", Width, 5, true}, + {"far right", Width + 3, 5, true}, + {"floor", 0, Height, true}, + {"below floor", 0, Height + 4, true}, + {"empty interior", 5, 5, false}, + {"above ceiling is free", 5, -1, false}, + {"far above ceiling is free", 5, -9, false}, + {"above ceiling but out of column", -1, -1, true}, + } + for _, tc := range tests { + if got := b.Occupied(tc.x, tc.y); got != tc.want { + t.Errorf("%s: Occupied(%d,%d) = %v, want %v", tc.name, tc.x, tc.y, got, tc.want) + } + } +} + +func TestOccupiedLockedCell(t *testing.T) { + var b Board + b.Set(4, 9, FilledCell(KindZ)) + if !b.Occupied(4, 9) { + t.Error("locked cell reported free") + } + if b.Occupied(4, 8) { + t.Error("cell above locked cell reported occupied") + } +} + +func TestAtOutOfBoundsIsEmpty(t *testing.T) { + var b Board + for _, c := range [][2]int{{-1, 0}, {Width, 0}, {0, -1}, {0, Height}} { + if got := b.At(c[0], c[1]); got != EmptyCell { + t.Errorf("At(%d,%d) = %v, want EmptyCell", c[0], c[1], got) + } + } +} + +func TestSetOutOfBoundsIsIgnored(t *testing.T) { + var b Board + b.Set(-1, 0, FilledCell(KindI)) + b.Set(0, -1, FilledCell(KindI)) + b.Set(Width, 0, FilledCell(KindI)) + b.Set(0, Height, FilledCell(KindI)) + for y := 0; y < Height; y++ { + for x := 0; x < Width; x++ { + if b.Cells[y][x].Filled() { + t.Fatalf("out-of-bounds Set wrote to (%d,%d)", x, y) + } + } + } +} + +func TestCollidesWalls(t *testing.T) { + var b Board + // T rot 0 spans offsets x 0..2. + if b.Collides(Piece{Kind: KindT, X: 0, Y: 5}) { + t.Error("T at x=0 should fit") + } + if !b.Collides(Piece{Kind: KindT, X: -1, Y: 5}) { + t.Error("T at x=-1 should collide with the left wall") + } + if b.Collides(Piece{Kind: KindT, X: Width - 3, Y: 5}) { + t.Errorf("T at x=%d should fit", Width-3) + } + if !b.Collides(Piece{Kind: KindT, X: Width - 2, Y: 5}) { + t.Errorf("T at x=%d should collide with the right wall", Width-2) + } +} + +func TestCollidesFloor(t *testing.T) { + var b Board + // T rot 0 occupies relative rows 0 and 1, so y = Height-2 is the last fit. + if b.Collides(Piece{Kind: KindT, X: 4, Y: Height - 2}) { + t.Error("T resting on the floor should not collide") + } + if !b.Collides(Piece{Kind: KindT, X: 4, Y: Height - 1}) { + t.Error("T past the floor should collide") + } +} + +func TestCollidesLockedCells(t *testing.T) { + var b Board + fill(&b, 10, 4) + // T rot 0 at (3,9) covers (4,9),(3,10),(4,10),(5,10) -> hits (4,10). + if !b.Collides(Piece{Kind: KindT, X: 3, Y: 9}) { + t.Error("piece overlapping a locked cell should collide") + } + if b.Collides(Piece{Kind: KindT, X: 3, Y: 8}) { + t.Error("piece one row above a locked cell should not collide") + } +} + +// A wall kick may lift a piece above the ceiling; that must not collide. +func TestCollidesAboveCeiling(t *testing.T) { + var b Board + if b.Collides(Piece{Kind: KindI, X: 3, Y: -1}) { + t.Error("piece partly above the ceiling should not collide") + } +} + +func TestLockWritesKind(t *testing.T) { + var b Board + b.Lock(Piece{Kind: KindS, Rotation: 0, X: 4, Y: 10}) + for _, blk := range (Piece{Kind: KindS, Rotation: 0, X: 4, Y: 10}).Blocks() { + c := b.At(blk[0], blk[1]) + if !c.Filled() || c.Kind() != KindS { + t.Errorf("cell %v = %v, want filled S", blk, c) + } + } +} + +// Lock must silently drop blocks that sit above the ceiling. +func TestLockClipsAboveCeiling(t *testing.T) { + var b Board + p := Piece{Kind: KindJ, Rotation: 1, X: 3, Y: -1} + b.Lock(p) + filled := 0 + for y := 0; y < Height; y++ { + for x := 0; x < Width; x++ { + if b.Cells[y][x].Filled() { + filled++ + } + } + } + if filled != 3 { + t.Errorf("locked %d cells, want 3 (one block was above the ceiling)", filled) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/game/ -run 'TestCell|TestOccupied|TestAt|TestSet|TestCollides|TestLock'` +Expected: FAIL — `undefined: Cell`, `undefined: Board`. + +- [ ] **Step 3: Write the implementation** + +Append to `internal/game/board.go`: + +```go +// Cell is one board square. The zero value is empty; a filled cell stores its +// tetromino kind so the renderer can colour locked blocks by origin. +type Cell uint8 + +// EmptyCell is an unoccupied board square. +const EmptyCell Cell = 0 + +// FilledCell returns the cell value for a square occupied by kind k. +func FilledCell(k PieceKind) Cell { return Cell(k) + 1 } + +// Filled reports whether the cell is occupied. +func (c Cell) Filled() bool { return c != EmptyCell } + +// Kind returns the tetromino kind that filled the cell. Only meaningful when +// Filled reports true. +func (c Cell) Kind() PieceKind { return PieceKind(c - 1) } + +// Board is the logical playfield. Row 0 is the top hidden row. +type Board struct { + Cells [Height][Width]Cell +} + +// InBounds reports whether (x, y) addresses a real board square. +func (b *Board) InBounds(x, y int) bool { + return x >= 0 && x < Width && y >= 0 && y < Height +} + +// At returns the cell at (x, y), or EmptyCell if out of bounds. +func (b *Board) At(x, y int) Cell { + if !b.InBounds(x, y) { + return EmptyCell + } + return b.Cells[y][x] +} + +// Set writes a cell. Out-of-bounds writes are ignored. +func (b *Board) Set(x, y int, c Cell) { + if !b.InBounds(x, y) { + return + } + b.Cells[y][x] = c +} + +// Occupied reports whether (x, y) blocks a piece. The side walls and the floor +// are occupied. The space above the ceiling (y < 0) is free, so a wall kick +// that nudges a piece upward can succeed. +func (b *Board) Occupied(x, y int) bool { + if x < 0 || x >= Width || y >= Height { + return true + } + if y < 0 { + return false + } + return b.Cells[y][x].Filled() +} + +// Collides reports whether any of the piece's blocks is obstructed. +func (b *Board) Collides(p Piece) bool { + for _, blk := range p.Blocks() { + if b.Occupied(blk[0], blk[1]) { + return true + } + } + return false +} + +// Lock commits the piece's blocks to the board. Blocks above the ceiling are +// discarded. +func (b *Board) Lock(p Piece) { + c := FilledCell(p.Kind) + for _, blk := range p.Blocks() { + b.Set(blk[0], blk[1], c) + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/game/` +Expected: PASS (Task 1 tests still green). + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . +git add internal/game/board.go internal/game/board_test.go +git commit -m "feat(game): board cells, bounds, and collision" +``` + +--- + +### Task 3: Row completion, clearing, and collapse + +**Files:** +- Modify: `internal/game/board.go` +- Modify: `internal/game/board_test.go` + +**Interfaces:** +- Consumes: `Board`, `Cell`, `FilledCell`, the `fill`/`fillRow` test helpers from Task 2. +- Produces: `func (*Board) CompleteRows() []int` (ascending row indices, nil when none), `func (*Board) ClearRows(rows []int)`, `func (*Board) DropDistance(p Piece) int`, `func (*Board) Ghost(p Piece) Piece`, `func (*Board) TopRow() int`. + +- [ ] **Step 1: Write the failing test** + +Append to `internal/game/board_test.go`: + +```go +import "slices" // add to the existing import block alongside "testing" + +func TestCompleteRowsNone(t *testing.T) { + var b Board + fillRow(&b, 21, 3) + if got := b.CompleteRows(); len(got) != 0 { + t.Errorf("CompleteRows() = %v, want empty", got) + } +} + +func TestCompleteRowsAscending(t *testing.T) { + var b Board + fillRow(&b, 21) + fillRow(&b, 19) + fillRow(&b, 20, 7) + got := b.CompleteRows() + want := []int{19, 21} + if !slices.Equal(got, want) { + t.Errorf("CompleteRows() = %v, want %v", got, want) + } +} + +func TestClearRowsCollapsesStack(t *testing.T) { + var b Board + fillRow(&b, 21) // complete, will clear + b.Set(0, 20, FilledCell(KindS)) // lone survivor above it + b.ClearRows([]int{21}) + if b.At(0, 21) != FilledCell(KindS) { + t.Errorf("survivor should have fallen to row 21, got %v", b.At(0, 21)) + } + if b.At(0, 20).Filled() { + t.Error("row 20 should be empty after collapse") + } + for x := 1; x < Width; x++ { + if b.At(x, 21).Filled() { + t.Errorf("row 21 col %d should be empty after clear", x) + } + } +} + +func TestClearRowsMultipleNonAdjacent(t *testing.T) { + var b Board + fillRow(&b, 21) + b.Set(2, 20, FilledCell(KindT)) + fillRow(&b, 19) + b.Set(5, 18, FilledCell(KindZ)) + b.ClearRows([]int{19, 21}) + // Two rows vanished: the T falls one row (only row 21 was below it), + // the Z falls two rows. + if b.At(2, 21) != FilledCell(KindT) { + t.Errorf("T at (2,21) = %v, want filled T", b.At(2, 21)) + } + if b.At(5, 20) != FilledCell(KindZ) { + t.Errorf("Z at (5,20) = %v, want filled Z", b.At(5, 20)) + } + count := 0 + for y := 0; y < Height; y++ { + for x := 0; x < Width; x++ { + if b.Cells[y][x].Filled() { + count++ + } + } + } + if count != 2 { + t.Errorf("%d cells remain, want 2", count) + } +} + +func TestClearRowsFourLines(t *testing.T) { + var b Board + for y := 18; y <= 21; y++ { + fillRow(&b, y) + } + rows := b.CompleteRows() + if !slices.Equal(rows, []int{18, 19, 20, 21}) { + t.Fatalf("CompleteRows() = %v, want [18 19 20 21]", rows) + } + b.ClearRows(rows) + for y := 0; y < Height; y++ { + for x := 0; x < Width; x++ { + if b.Cells[y][x].Filled() { + t.Fatalf("board not empty after four-line clear: (%d,%d)", x, y) + } + } + } +} + +// Review Focus 5: a clear that includes a hidden row must collapse cleanly and +// leave no stale cells at the top. +func TestClearRowsIncludingHiddenRow(t *testing.T) { + var b Board + fillRow(&b, 0) // hidden row, complete + fillRow(&b, 1, 4) + b.ClearRows([]int{0}) + if b.At(4, 1).Filled() { + t.Error("gap column should still be empty after collapse") + } + for x := 0; x < Width; x++ { + if x == 4 { + continue + } + if !b.At(x, 1).Filled() { + t.Errorf("row 1 col %d should still be filled", x) + } + } + for x := 0; x < Width; x++ { + if b.At(x, 0).Filled() { + t.Errorf("row 0 col %d should be empty after collapse", x) + } + } +} + +func TestClearRowsEmptyInputIsNoop(t *testing.T) { + var b Board + fillRow(&b, 21, 3) + before := b.Cells + b.ClearRows(nil) + if b.Cells != before { + t.Error("ClearRows(nil) modified the board") + } +} + +func TestDropDistanceEmptyBoard(t *testing.T) { + var b Board + // T rot 0 at Y=0 occupies rows 0..1; it can fall until row 1 sits at 21. + p := Piece{Kind: KindT, X: 4, Y: 0} + if got, want := b.DropDistance(p), Height-2; got != want { + t.Errorf("DropDistance = %d, want %d", got, want) + } +} + +func TestDropDistanceOntoStack(t *testing.T) { + var b Board + fillRow(&b, 21) + p := Piece{Kind: KindO, X: 4, Y: 0} // O occupies relative rows 0..1 + if got, want := b.DropDistance(p), Height-3; got != want { + t.Errorf("DropDistance = %d, want %d", got, want) + } +} + +func TestDropDistanceZeroWhenResting(t *testing.T) { + var b Board + fillRow(&b, 21) + p := Piece{Kind: KindO, X: 4, Y: Height - 3} + if got := b.DropDistance(p); got != 0 { + t.Errorf("DropDistance = %d, want 0", got) + } +} + +func TestGhostIsPieceAtLanding(t *testing.T) { + var b Board + fillRow(&b, 21) + p := Piece{Kind: KindL, Rotation: 2, X: 2, Y: 3} + g := b.Ghost(p) + if g.Kind != p.Kind || g.Rotation != p.Rotation || g.X != p.X { + t.Errorf("Ghost changed shape or column: %+v vs %+v", g, p) + } + if want := p.Y + b.DropDistance(p); g.Y != want { + t.Errorf("Ghost Y = %d, want %d", g.Y, want) + } + if b.Collides(g) { + t.Error("ghost position collides") + } + if !b.Collides(Piece{Kind: g.Kind, Rotation: g.Rotation, X: g.X, Y: g.Y + 1}) { + t.Error("ghost is not resting: one row lower does not collide") + } +} + +func TestTopRow(t *testing.T) { + var b Board + if got := b.TopRow(); got != Height { + t.Errorf("empty board TopRow = %d, want %d", got, Height) + } + b.Set(7, 14, FilledCell(KindI)) + b.Set(2, 19, FilledCell(KindI)) + if got := b.TopRow(); got != 14 { + t.Errorf("TopRow = %d, want 14", got) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/game/ -run 'TestCompleteRows|TestClearRows|TestDropDistance|TestGhost|TestTopRow'` +Expected: FAIL — `b.CompleteRows undefined`. + +- [ ] **Step 3: Write the implementation** + +Append to `internal/game/board.go`: + +```go +// CompleteRows returns the indices of every fully occupied row, ascending. +// It returns nil when no row is complete. +func (b *Board) CompleteRows() []int { + var rows []int + for y := 0; y < Height; y++ { + full := true + for x := 0; x < Width; x++ { + if !b.Cells[y][x].Filled() { + full = false + break + } + } + if full { + rows = append(rows, y) + } + } + return rows +} + +// ClearRows removes the given rows and collapses everything above them +// downward. Row indices may arrive in any order; out-of-range indices are +// ignored. +func (b *Board) ClearRows(rows []int) { + if len(rows) == 0 { + return + } + var drop [Height]bool + for _, r := range rows { + if r >= 0 && r < Height { + drop[r] = true + } + } + var out [Height][Width]Cell + dst := Height - 1 + for src := Height - 1; src >= 0; src-- { + if drop[src] { + continue + } + out[dst] = b.Cells[src] + dst-- + } + b.Cells = out +} + +// DropDistance returns how many rows the piece can descend before colliding. +// It is 0 when the piece is already resting. +func (b *Board) DropDistance(p Piece) int { + d := 0 + for { + next := p + next.Y = p.Y + d + 1 + if b.Collides(next) { + return d + } + d++ + } +} + +// Ghost returns the piece translated to its landing position (design 10). +func (b *Board) Ghost(p Piece) Piece { + p.Y += b.DropDistance(p) + return p +} + +// TopRow returns the index of the highest occupied row, or Height when the +// board is empty. It is how the HUD and the game-over collapse find the stack. +func (b *Board) TopRow() int { + for y := 0; y < Height; y++ { + for x := 0; x < Width; x++ { + if b.Cells[y][x].Filled() { + return y + } + } + } + return Height +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS, all tests. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . +git add internal/game/board.go internal/game/board_test.go +git commit -m "feat(game): row completion, clearing, collapse, and ghost landing" +``` + +--- + +### Task 4: The 7-bag + +**Files:** +- Create: `internal/game/bag.go` +- Test: `internal/game/bag_test.go` + +**Interfaces:** +- Consumes: `PieceKind`, `KindCount`. +- Produces: `type Bag struct { ... }`, `func (*Bag) Next(rng *rand.Rand) PieceKind`, `func (*Bag) Remaining() int`, `func NewRNG(seed int64) *rand.Rand`. + +Design §49.6 puts RNG ownership in `Game`, so `Bag` takes the generator as an argument and holds none of its own. + +- [ ] **Step 1: Write the failing test** + +Create `internal/game/bag_test.go`: + +```go +package game + +import ( + "testing" +) + +func TestEveryBagHoldsAllSevenKindsOnce(t *testing.T) { + rng := NewRNG(42) + var bag Bag + for round := 0; round < 20; round++ { + var seen [KindCount]int + for i := 0; i < KindCount; i++ { + seen[bag.Next(rng)]++ + } + for k := PieceKind(0); k < KindCount; k++ { + if seen[k] != 1 { + t.Fatalf("round %d: kind %c appeared %d times, want 1", round, k.Letter(), seen[k]) + } + } + } +} + +func TestBagSeededGenerationIsReproducible(t *testing.T) { + draw := func(seed int64, n int) []PieceKind { + rng := NewRNG(seed) + var bag Bag + out := make([]PieceKind, n) + for i := range out { + out[i] = bag.Next(rng) + } + return out + } + a := draw(8675309, 40) + b := draw(8675309, 40) + for i := range a { + if a[i] != b[i] { + t.Fatalf("draw %d differs: %c vs %c", i, a[i].Letter(), b[i].Letter()) + } + } + c := draw(1234, 40) + same := true + for i := range a { + if a[i] != c[i] { + same = false + break + } + } + if same { + t.Error("different seeds produced identical piece order") + } +} + +func TestBagShuffles(t *testing.T) { + // Across many bags at least one must not be in ascending kind order. + rng := NewRNG(7) + var bag Bag + shuffledSomewhere := false + for round := 0; round < 10; round++ { + ascending := true + for i := 0; i < KindCount; i++ { + if bag.Next(rng) != PieceKind(i) { + ascending = false + } + } + if !ascending { + shuffledSomewhere = true + } + } + if !shuffledSomewhere { + t.Error("bag never shuffled across 10 refills") + } +} + +func TestBagRemaining(t *testing.T) { + rng := NewRNG(3) + var bag Bag + if got := bag.Remaining(); got != 0 { + t.Errorf("fresh Bag Remaining = %d, want 0", got) + } + bag.Next(rng) + if got := bag.Remaining(); got != KindCount-1 { + t.Errorf("after one draw Remaining = %d, want %d", got, KindCount-1) + } + for i := 0; i < KindCount-1; i++ { + bag.Next(rng) + } + if got := bag.Remaining(); got != 0 { + t.Errorf("after a full bag Remaining = %d, want 0", got) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/game/ -run TestBag` +Expected: FAIL — `undefined: NewRNG`, `undefined: Bag`. + +- [ ] **Step 3: Write the implementation** + +Create `internal/game/bag.go`: + +```go +package game + +import "math/rand/v2" + +// NewRNG returns the deterministic generator used for a given seed. The same +// seed always produces the same sequence, which is what makes design section 35 +// testable. Game owns one of these for the piece bag; fx.World owns a separate, +// unrelated generator so particle randomness can never shift piece order. +func NewRNG(seed int64) *rand.Rand { + return rand.New(rand.NewPCG(uint64(seed), uint64(seed)^0x9E3779B97F4A7C15)) +} + +// Bag is a seven-bag piece generator (design section 6): fill a bag with one +// of every kind, shuffle it, hand pieces out until it is empty, refill. +// +// Bag does not own a generator. Callers pass Game's generator in, which keeps +// RNG ownership in one place (design section 49.6). +type Bag struct { + queue []PieceKind +} + +// Remaining reports how many pieces are left in the current bag. +func (b *Bag) Remaining() int { return len(b.queue) } + +// Next returns the next kind, refilling and reshuffling when the bag empties. +func (b *Bag) Next(rng *rand.Rand) PieceKind { + if len(b.queue) == 0 { + b.refill(rng) + } + k := b.queue[0] + b.queue = b.queue[1:] + return k +} + +func (b *Bag) refill(rng *rand.Rand) { + b.queue = b.queue[:0] + for k := PieceKind(0); k < KindCount; k++ { + b.queue = append(b.queue, k) + } + rng.Shuffle(len(b.queue), func(i, j int) { + b.queue[i], b.queue[j] = b.queue[j], b.queue[i] + }) +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v -run TestBag` +Expected: PASS, 4 tests. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . +git add internal/game/bag.go internal/game/bag_test.go +git commit -m "feat(game): seeded seven-bag piece generator" +``` + +--- + +### Task 5: Rotation with wall kicks + +**Files:** +- Create: `internal/game/rules.go` +- Test: `internal/game/rules_test.go` + +**Interfaces:** +- Consumes: `Board`, `Piece`, `Offsets`. +- Produces: `var KickOffsets [8][2]int`, `func (*Board) TryRotate(p Piece, dir int) (Piece, bool)`. + +`dir` is `+1` for clockwise and `-1` for counter-clockwise. Design §7 fixes the kick ladder and says "accept the first valid position". + +- [ ] **Step 1: Write the failing test** + +Create `internal/game/rules_test.go`: + +```go +package game + +import "testing" + +func TestKickOffsetsMatchSpecOrder(t *testing.T) { + want := [8][2]int{ + {0, 0}, {-1, 0}, {1, 0}, {-2, 0}, {2, 0}, {0, -1}, {-1, -1}, {1, -1}, + } + if KickOffsets != want { + t.Errorf("KickOffsets = %v, want %v", KickOffsets, want) + } +} + +func TestRotateClockwiseInOpenSpace(t *testing.T) { + var b Board + p := Piece{Kind: KindT, Rotation: 0, X: 4, Y: 10} + got, ok := b.TryRotate(p, 1) + if !ok { + t.Fatal("rotation in open space failed") + } + if got.Rotation != 1 || got.X != 4 || got.Y != 10 { + t.Errorf("rotated to %+v, want rotation 1 at (4,10)", got) + } +} + +func TestRotateCounterClockwiseWraps(t *testing.T) { + var b Board + p := Piece{Kind: KindT, Rotation: 0, X: 4, Y: 10} + got, ok := b.TryRotate(p, -1) + if !ok { + t.Fatal("counter-clockwise rotation failed") + } + if got.Rotation != 3 { + t.Errorf("rotation = %d, want 3", got.Rotation) + } +} + +func TestEveryRotationSucceedsInOpenSpace(t *testing.T) { + var b Board + for k := PieceKind(0); k < KindCount; k++ { + for r := 0; r < 4; r++ { + p := Piece{Kind: k, Rotation: r, X: 3, Y: 10} + for _, dir := range []int{1, -1} { + got, ok := b.TryRotate(p, dir) + if !ok { + t.Errorf("%c rot %d dir %d failed in open space", k.Letter(), r, dir) + continue + } + want := (r + dir + 4) % 4 + if got.Rotation != want { + t.Errorf("%c rot %d dir %d -> rotation %d, want %d", k.Letter(), r, dir, got.Rotation, want) + } + } + } + } +} + +func TestRotateKicksOffLeftWall(t *testing.T) { + var b Board + // I rot 1 is a vertical bar in relative column 2. At X=-2 it sits in + // board column 0. Rotating to rot 2 needs columns -2..1, so the piece must + // be kicked right. + p := Piece{Kind: KindI, Rotation: 1, X: -2, Y: 8} + if b.Collides(p) { + t.Fatal("test setup: starting position collides") + } + got, ok := b.TryRotate(p, 1) + if !ok { + t.Fatal("rotation against the left wall failed, want a kick") + } + if got.X <= p.X { + t.Errorf("kicked to X=%d, want a rightward kick from X=%d", got.X, p.X) + } + if b.Collides(got) { + t.Errorf("kicked position %+v collides", got) + } +} + +func TestRotateKicksOffRightWall(t *testing.T) { + var b Board + p := Piece{Kind: KindI, Rotation: 1, X: Width - 3, Y: 8} + if b.Collides(p) { + t.Fatal("test setup: starting position collides") + } + got, ok := b.TryRotate(p, 1) + if !ok { + t.Fatal("rotation against the right wall failed, want a kick") + } + if b.Collides(got) { + t.Errorf("kicked position %+v collides", got) + } +} + +func TestRotatePrefersEarlierKickOffsets(t *testing.T) { + var b Board + // Wall off column 3 so the in-place rotation fails but the (-1,0) kick + // works; the result must be the (-1,0) candidate, not a later one. + for y := 0; y < Height; y++ { + b.Set(3, y, FilledCell(KindI)) + } + p := Piece{Kind: KindT, Rotation: 1, X: 3, Y: 10} // occupies column 4 and 5 + if b.Collides(p) { + t.Fatal("test setup: starting position collides") + } + got, ok := b.TryRotate(p, 1) + if !ok { + t.Fatal("rotation failed, want the (-1,0) or later kick to succeed") + } + if got.X != p.X { + // Rotation 2 of T needs columns 3..5, blocked, so it must shift. + if got.X != p.X+1 { + t.Errorf("kicked to X=%d, want %d (first viable candidate)", got.X, p.X+1) + } + } + if b.Collides(got) { + t.Errorf("kicked position %+v collides", got) + } +} + +func TestRotateFailsWhenFullyBoxedIn(t *testing.T) { + var b Board + // Fill everything except a 2x2 pocket at columns 4..5, rows 20..21, and + // put an O in it. O rotation never changes shape, so use a T instead: + // fill the whole board, then carve exactly the cells the T occupies. + for y := 0; y < Height; y++ { + for x := 0; x < Width; x++ { + b.Set(x, y, FilledCell(KindI)) + } + } + p := Piece{Kind: KindT, Rotation: 0, X: 4, Y: 18} + for _, blk := range p.Blocks() { + b.Set(blk[0], blk[1], EmptyCell) + } + if b.Collides(p) { + t.Fatal("test setup: carved position collides") + } + if got, ok := b.TryRotate(p, 1); ok { + t.Errorf("boxed-in rotation succeeded as %+v, want failure", got) + } + if got, ok := b.TryRotate(p, -1); ok { + t.Errorf("boxed-in counter-rotation succeeded as %+v, want failure", got) + } +} + +// Review Focus 2: the (0,-1) kick can lift a piece above the ceiling. +func TestRotateMayKickAboveCeiling(t *testing.T) { + var b Board + // Fill rows 1 and below so the only way out is upward. + for y := 1; y < Height; y++ { + for x := 0; x < Width; x++ { + b.Set(x, y, FilledCell(KindI)) + } + } + p := Piece{Kind: KindI, Rotation: 0, X: 3, Y: -1} // horizontal bar in row 0 + for _, blk := range p.Blocks() { + b.Set(blk[0], blk[1], EmptyCell) + } + if b.Collides(p) { + t.Fatal("test setup: starting position collides") + } + // Whatever happens, it must not panic and must not return a colliding piece. + if got, ok := b.TryRotate(p, 1); ok && b.Collides(got) { + t.Errorf("TryRotate returned colliding piece %+v", got) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/game/ -run 'TestKick|TestRotate|TestEveryRotation'` +Expected: FAIL — `undefined: KickOffsets`, `b.TryRotate undefined`. + +- [ ] **Step 3: Write the implementation** + +Create `internal/game/rules.go`: + +```go +package game + +// KickOffsets is the wall-kick ladder from design section 7, tried in order. +// The first candidate that does not collide wins; if none fit, rotation fails. +// This is deliberately not a full SRS table: forgiving terminal rotation +// without turning rotation into a subsystem. +var KickOffsets = [8][2]int{ + {0, 0}, + {-1, 0}, + {1, 0}, + {-2, 0}, + {2, 0}, + {0, -1}, + {-1, -1}, + {1, -1}, +} + +// TryRotate rotates the piece by dir (+1 clockwise, -1 counter-clockwise), +// testing the kick ladder in order. It returns the accepted piece and true, or +// the original piece and false when every candidate collides. +func (b *Board) TryRotate(p Piece, dir int) (Piece, bool) { + rotated := p + rotated.Rotation = (p.Rotation + dir + 4) % 4 + for _, k := range KickOffsets { + cand := rotated + cand.X += k[0] + cand.Y += k[1] + if !b.Collides(cand) { + return cand, true + } + } + return p, false +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v -run 'TestKick|TestRotate|TestEveryRotation'` +Expected: PASS, 9 tests. + +If `TestRotatePrefersEarlierKickOffsets` fails because the piece happens to fit in place, adjust the walled-off column in the test until the in-place candidate genuinely collides — do not relax the assertion that the *first* viable candidate is chosen. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . +git add internal/game/rules.go internal/game/rules_test.go +git commit -m "feat(game): rotation with the wall-kick ladder" +``` + +--- + +### Task 6: Scoring, level progression, and gravity timing + +**Files:** +- Create: `internal/game/scoring.go` +- Test: `internal/game/scoring_test.go` + +**Interfaces:** +- Consumes: nothing. +- Produces: `func LineScore(lines, level int) int`, `func ComboBonus(combo, level int) int`, `func LevelFor(lines int) int`, `func GravityInterval(level int) time.Duration`, and constants `LockDelay = 500ms`, `MaxLockResets = 15`, `BaseGravity = 800ms`, `MinGravity = 60ms`, `GravityDecay = 0.86`, `LinesPerLevel = 10`, `SoftDropPoints = 1`, `HardDropPoints = 2`. + +- [ ] **Step 1: Write the failing test** + +Create `internal/game/scoring_test.go`: + +```go +package game + +import ( + "testing" + "time" +) + +func TestLineScoreBaseValues(t *testing.T) { + tests := []struct { + lines, level, want int + }{ + {0, 1, 0}, + {1, 1, 100}, + {2, 1, 300}, + {3, 1, 500}, + {4, 1, 800}, + {1, 7, 700}, + {4, 13, 10400}, + } + for _, tc := range tests { + if got := LineScore(tc.lines, tc.level); got != tc.want { + t.Errorf("LineScore(%d, %d) = %d, want %d", tc.lines, tc.level, got, tc.want) + } + } +} + +func TestLineScoreClampsOutOfRange(t *testing.T) { + if got := LineScore(9, 3); got != LineScore(4, 3) { + t.Errorf("LineScore(9,3) = %d, want the four-line value %d", got, LineScore(4, 3)) + } + if got := LineScore(-1, 3); got != 0 { + t.Errorf("LineScore(-1,3) = %d, want 0", got) + } +} + +// Design section 49.1: bonus = 50 * (combo - 1) * level, so a lone clear pays +// nothing and the bonus first appears at combo 2. +func TestComboBonus(t *testing.T) { + tests := []struct { + combo, level, want int + }{ + {0, 5, 0}, + {1, 5, 0}, + {2, 1, 50}, + {2, 5, 250}, + {3, 2, 200}, + {7, 3, 900}, + } + for _, tc := range tests { + if got := ComboBonus(tc.combo, tc.level); got != tc.want { + t.Errorf("ComboBonus(%d, %d) = %d, want %d", tc.combo, tc.level, got, tc.want) + } + } +} + +func TestLevelFor(t *testing.T) { + tests := []struct{ lines, want int }{ + {0, 1}, {1, 1}, {9, 1}, {10, 2}, {19, 2}, {20, 3}, {127, 13}, + } + for _, tc := range tests { + if got := LevelFor(tc.lines); got != tc.want { + t.Errorf("LevelFor(%d) = %d, want %d", tc.lines, got, tc.want) + } + } +} + +func TestGravityIntervalKnownValues(t *testing.T) { + tests := []struct { + level int + want time.Duration + }{ + {1, 800 * time.Millisecond}, + {2, 688 * time.Millisecond}, + {19, MinGravity}, + {50, MinGravity}, + {0, 800 * time.Millisecond}, // clamped up to level 1 + {-3, 800 * time.Millisecond}, // clamped up to level 1 + } + for _, tc := range tests { + got := GravityInterval(tc.level) + if got != tc.want { + t.Errorf("GravityInterval(%d) = %v, want %v", tc.level, got, tc.want) + } + } +} + +func TestGravityIntervalDecaysMonotonically(t *testing.T) { + prev := GravityInterval(1) + for level := 2; level <= 40; level++ { + got := GravityInterval(level) + if got > prev { + t.Errorf("GravityInterval(%d) = %v, want <= %v", level, got, prev) + } + if got < MinGravity { + t.Errorf("GravityInterval(%d) = %v, below the %v floor", level, got, MinGravity) + } + prev = got + } +} + +func TestTimingConstants(t *testing.T) { + if LockDelay != 500*time.Millisecond { + t.Errorf("LockDelay = %v, want 500ms", LockDelay) + } + if MaxLockResets != 15 { + t.Errorf("MaxLockResets = %d, want 15", MaxLockResets) + } + if SoftDropPoints != 1 || HardDropPoints != 2 { + t.Errorf("drop points = %d/%d, want 1/2", SoftDropPoints, HardDropPoints) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/game/ -run 'TestLineScore|TestCombo|TestLevelFor|TestGravity|TestTiming'` +Expected: FAIL — `undefined: LineScore`. + +- [ ] **Step 3: Write the implementation** + +Create `internal/game/scoring.go`: + +```go +package game + +import ( + "math" + "time" +) + +// Gravity and lock timing (design sections 11 and 12). +const ( + // BaseGravity is the level-1 drop interval. + BaseGravity = 800 * time.Millisecond + // MinGravity is the floor the drop interval is clamped to. + MinGravity = 60 * time.Millisecond + // GravityDecay multiplies the interval once per level. + GravityDecay = 0.86 + // LinesPerLevel is how many cleared lines advance the level. + LinesPerLevel = 10 + // LockDelay is how long a grounded piece waits before locking. + LockDelay = 500 * time.Millisecond + // MaxLockResets caps how many times movement may reset the lock timer, + // which is what stops a player from stalling forever. + MaxLockResets = 15 +) + +// Drop scoring (design section 11). +const ( + SoftDropPoints = 1 + HardDropPoints = 2 +) + +// lineValues indexes base clear values by number of lines cleared. +var lineValues = [5]int{0, 100, 300, 500, 800} + +// LineScore returns the base points for clearing n lines at the given level. +// n above four is treated as four; n below zero scores nothing. +func LineScore(lines, level int) int { + if lines <= 0 { + return 0 + } + if lines >= len(lineValues) { + lines = len(lineValues) - 1 + } + return lineValues[lines] * level +} + +// ComboBonus returns the combo bonus for a clearing placement, using the +// indexing pinned in design section 49.1: the first clearing placement is +// combo 1 and earns nothing, so the bonus starts at combo 2. +func ComboBonus(combo, level int) int { + if combo < 2 { + return 0 + } + return 50 * (combo - 1) * level +} + +// LevelFor returns the level for a total cleared-line count, starting at 1. +func LevelFor(lines int) int { + if lines < 0 { + lines = 0 + } + return lines/LinesPerLevel + 1 +} + +// GravityInterval returns how long the active piece waits before descending one +// row at the given level: BaseGravity * GravityDecay^(level-1), clamped to +// MinGravity. +func GravityInterval(level int) time.Duration { + if level < 1 { + level = 1 + } + ms := float64(BaseGravity/time.Millisecond) * math.Pow(GravityDecay, float64(level-1)) + if ms < float64(MinGravity/time.Millisecond) { + return MinGravity + } + return time.Duration(ms * float64(time.Millisecond)) +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v -run 'TestLineScore|TestCombo|TestLevelFor|TestGravity|TestTiming'` +Expected: PASS, 7 tests. `GravityInterval(2)` is exactly `688ms` and `GravityInterval(19)` clamps to `60ms`; if either assertion fails, the formula or the clamp is wrong, not the test. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . +git add internal/game/scoring.go internal/game/scoring_test.go +git commit -m "feat(game): scoring, level progression, and gravity timing" +``` + +--- + +### Task 7: Events + +**Files:** +- Create: `internal/game/event.go` +- Test: `internal/game/event_test.go` + +**Interfaces:** +- Consumes: `Piece`. +- Produces: `type EventType uint8` with `EventPieceSpawned, EventPieceMoved, EventPieceRotated, EventPieceHardDropped, EventPieceLocked, EventHoldUsed, EventLinesCleared, EventComboChanged, EventLevelChanged, EventGameOver`, `func (EventType) String() string`, `type Event struct { Type EventType; Piece Piece; Rows []int; Count int; Points int }`. + +Later plans pattern-match on `Event.Type` and read `Count`/`Rows`/`Piece`, so the meaning of each field per type is part of the contract and is documented in the source. + +- [ ] **Step 1: Write the failing test** + +Create `internal/game/event_test.go`: + +```go +package game + +import "testing" + +func TestEventTypeStrings(t *testing.T) { + tests := []struct { + t EventType + want string + }{ + {EventPieceSpawned, "PieceSpawned"}, + {EventPieceMoved, "PieceMoved"}, + {EventPieceRotated, "PieceRotated"}, + {EventPieceHardDropped, "PieceHardDropped"}, + {EventPieceLocked, "PieceLocked"}, + {EventHoldUsed, "HoldUsed"}, + {EventLinesCleared, "LinesCleared"}, + {EventComboChanged, "ComboChanged"}, + {EventLevelChanged, "LevelChanged"}, + {EventGameOver, "GameOver"}, + } + for _, tc := range tests { + if got := tc.t.String(); got != tc.want { + t.Errorf("EventType(%d).String() = %q, want %q", tc.t, got, tc.want) + } + } + if got := EventType(200).String(); got != "EventType(200)" { + t.Errorf("unknown EventType String = %q, want %q", got, "EventType(200)") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/game/ -run TestEventType` +Expected: FAIL — `undefined: EventPieceSpawned`. + +- [ ] **Step 3: Write the implementation** + +Create `internal/game/event.go`: + +```go +package game + +import "fmt" + +// EventType names something that happened during Apply or Advance. The FX +// system observes these; it may never write back into the game (design 14). +type EventType uint8 + +// Event types (design section 14). +const ( + EventPieceSpawned EventType = iota + EventPieceMoved + EventPieceRotated + EventPieceHardDropped + EventPieceLocked + EventHoldUsed + EventLinesCleared + EventComboChanged + EventLevelChanged + EventGameOver +) + +var eventTypeNames = [...]string{ + EventPieceSpawned: "PieceSpawned", + EventPieceMoved: "PieceMoved", + EventPieceRotated: "PieceRotated", + EventPieceHardDropped: "PieceHardDropped", + EventPieceLocked: "PieceLocked", + EventHoldUsed: "HoldUsed", + EventLinesCleared: "LinesCleared", + EventComboChanged: "ComboChanged", + EventLevelChanged: "LevelChanged", + EventGameOver: "GameOver", +} + +// String returns the event type name. +func (t EventType) String() string { + if int(t) < len(eventTypeNames) && eventTypeNames[t] != "" { + return eventTypeNames[t] + } + return fmt.Sprintf("EventType(%d)", uint8(t)) +} + +// Event describes one thing the engine did. Which fields carry meaning depends +// on Type: +// +// PieceSpawned Piece = the new piece at its spawn position +// PieceMoved Piece = the piece after moving +// PieceRotated Piece = the piece after rotating (position may have kicked) +// PieceHardDropped Piece = the piece at its landing position +// Count = rows fallen, Points = drop points awarded +// PieceLocked Piece = the piece as committed to the board +// HoldUsed Piece = the piece that is now active after the swap +// LinesCleared Rows = cleared row indices, ascending +// Count = len(Rows), Points = line score plus combo bonus +// ComboChanged Count = the new combo value (0 when it reset) +// LevelChanged Count = the new level +// GameOver Piece = the piece that could not spawn +type Event struct { + Type EventType + Piece Piece + Rows []int + Count int + Points int +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v -run TestEventType` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . +git add internal/game/event.go internal/game/event_test.go +git commit -m "feat(game): event types describing engine state changes" +``` + +--- + +### Task 8: Game construction, next queue, and horizontal movement + +**Files:** +- Create: `internal/game/game.go` +- Test: `internal/game/game_test.go` + +**Interfaces:** +- Consumes: everything from Tasks 1–7. +- Produces: + - `type State uint8` with `StatePlaying`, `StateOver`; `func (State) String() string` + - `type Input uint8` with `InputNone, InputLeft, InputRight, InputSoftDrop, InputRotateCW, InputRotateCCW, InputHardDrop, InputHold` + - `const NextQueueLen = 5` + - `type Game struct { Board Board; Active Piece; Hold *PieceKind; CanHold bool; Next []PieceKind; Bag Bag; Score, Lines, Level, Combo int; State State; GravityAccumulator, LockAccumulator time.Duration; LockResets int; Seed int64; rng *rand.Rand }` + - `func New(seed int64) *Game` + - `func (*Game) Apply(in Input) []Event` + - `func (*Game) Ghost() Piece` + - `func (*Game) Grounded() bool` + +Tasks 9–12 extend `Apply` and add `Advance`. This task implements only `InputLeft`, `InputRight`, `InputRotateCW`, `InputRotateCCW`; the remaining inputs return nil for now and are filled in by later tasks. + +- [ ] **Step 1: Write the failing test** + +Create `internal/game/game_test.go`: + +```go +package game + +import ( + "testing" + "time" +) + +// findEvent returns the first event of the given type and whether it was found. +func findEvent(evs []Event, typ EventType) (Event, bool) { + for _, e := range evs { + if e.Type == typ { + return e, true + } + } + return Event{}, false +} + +// countEvents returns how many events of the given type are present. +func countEvents(evs []Event, typ EventType) int { + n := 0 + for _, e := range evs { + if e.Type == typ { + n++ + } + } + return n +} + +func TestNewGameInitialState(t *testing.T) { + g := New(8675309) + if g.Seed != 8675309 { + t.Errorf("Seed = %d, want 8675309", g.Seed) + } + if g.State != StatePlaying { + t.Errorf("State = %v, want StatePlaying", g.State) + } + if g.Score != 0 || g.Lines != 0 || g.Combo != 0 { + t.Errorf("Score/Lines/Combo = %d/%d/%d, want 0/0/0", g.Score, g.Lines, g.Combo) + } + if g.Level != 1 { + t.Errorf("Level = %d, want 1", g.Level) + } + if !g.CanHold { + t.Error("CanHold = false, want true on a fresh game") + } + if g.Hold != nil { + t.Errorf("Hold = %v, want nil", g.Hold) + } + if len(g.Next) != NextQueueLen { + t.Errorf("len(Next) = %d, want %d", len(g.Next), NextQueueLen) + } + if g.Active.X != SpawnX || g.Active.Y != SpawnY || g.Active.Rotation != 0 { + t.Errorf("Active = %+v, want spawn position", g.Active) + } + for y := 0; y < Height; y++ { + for x := 0; x < Width; x++ { + if g.Board.Cells[y][x].Filled() { + t.Fatalf("fresh board has a filled cell at (%d,%d)", x, y) + } + } + } + if g.GravityAccumulator != 0 || g.LockAccumulator != 0 || g.LockResets != 0 { + t.Error("fresh game has non-zero accumulators") + } +} + +func TestNewGameIsReproducible(t *testing.T) { + a, b := New(4242), New(4242) + if a.Active != b.Active { + t.Errorf("active pieces differ: %+v vs %+v", a.Active, b.Active) + } + for i := range a.Next { + if a.Next[i] != b.Next[i] { + t.Errorf("next[%d] differs: %c vs %c", i, a.Next[i].Letter(), b.Next[i].Letter()) + } + } +} + +func TestNewGameDifferentSeedsDiffer(t *testing.T) { + a, b := New(1), New(2) + same := a.Active.Kind == b.Active.Kind + for i := range a.Next { + if a.Next[i] != b.Next[i] { + same = false + } + } + if same { + t.Error("seeds 1 and 2 produced the same opening sequence") + } +} + +func TestApplyLeftAndRight(t *testing.T) { + g := New(1) + g.Active = Piece{Kind: KindT, Rotation: 0, X: 4, Y: 10} + + evs := g.Apply(InputLeft) + if g.Active.X != 3 { + t.Errorf("after left X = %d, want 3", g.Active.X) + } + e, ok := findEvent(evs, EventPieceMoved) + if !ok { + t.Fatal("left move emitted no PieceMoved event") + } + if e.Piece.X != 3 { + t.Errorf("event piece X = %d, want 3", e.Piece.X) + } + + g.Apply(InputRight) + g.Apply(InputRight) + if g.Active.X != 5 { + t.Errorf("after two rights X = %d, want 5", g.Active.X) + } +} + +func TestApplyBlockedMoveEmitsNoEvent(t *testing.T) { + g := New(1) + g.Active = Piece{Kind: KindT, Rotation: 0, X: 0, Y: 10} + evs := g.Apply(InputLeft) + if g.Active.X != 0 { + t.Errorf("blocked move changed X to %d, want 0", g.Active.X) + } + if len(evs) != 0 { + t.Errorf("blocked move emitted %v, want no events", evs) + } +} + +func TestApplyRotationEmitsEvent(t *testing.T) { + g := New(1) + g.Active = Piece{Kind: KindT, Rotation: 0, X: 4, Y: 10} + evs := g.Apply(InputRotateCW) + if g.Active.Rotation != 1 { + t.Errorf("rotation = %d, want 1", g.Active.Rotation) + } + e, ok := findEvent(evs, EventPieceRotated) + if !ok { + t.Fatal("rotation emitted no PieceRotated event") + } + if e.Piece.Rotation != 1 { + t.Errorf("event rotation = %d, want 1", e.Piece.Rotation) + } + + evs = g.Apply(InputRotateCCW) + if g.Active.Rotation != 0 { + t.Errorf("rotation = %d, want 0", g.Active.Rotation) + } + if _, ok := findEvent(evs, EventPieceRotated); !ok { + t.Fatal("counter-rotation emitted no PieceRotated event") + } +} + +func TestApplyFailedRotationEmitsNoEvent(t *testing.T) { + g := New(1) + for y := 0; y < Height; y++ { + for x := 0; x < Width; x++ { + g.Board.Set(x, y, FilledCell(KindI)) + } + } + p := Piece{Kind: KindT, Rotation: 0, X: 4, Y: 18} + for _, blk := range p.Blocks() { + g.Board.Set(blk[0], blk[1], EmptyCell) + } + g.Active = p + evs := g.Apply(InputRotateCW) + if g.Active != p { + t.Errorf("failed rotation changed the piece to %+v", g.Active) + } + if len(evs) != 0 { + t.Errorf("failed rotation emitted %v, want no events", evs) + } +} + +func TestApplyNoneIsNoop(t *testing.T) { + g := New(1) + before := *g + evs := g.Apply(InputNone) + if len(evs) != 0 { + t.Errorf("InputNone emitted %v, want no events", evs) + } + if g.Active != before.Active || g.Score != before.Score { + t.Error("InputNone changed the game") + } +} + +func TestApplyIgnoredWhenGameOver(t *testing.T) { + g := New(1) + g.State = StateOver + g.Active = Piece{Kind: KindT, Rotation: 0, X: 4, Y: 10} + for _, in := range []Input{InputLeft, InputRight, InputRotateCW, InputRotateCCW} { + evs := g.Apply(in) + if len(evs) != 0 { + t.Errorf("input %d after game over emitted %v", in, evs) + } + } + if g.Active.X != 4 || g.Active.Rotation != 0 { + t.Errorf("input after game over moved the piece to %+v", g.Active) + } +} + +func TestGroundedAndGhost(t *testing.T) { + g := New(1) + g.Active = Piece{Kind: KindO, Rotation: 0, X: 4, Y: 5} + if g.Grounded() { + t.Error("Grounded = true in mid-air") + } + ghost := g.Ghost() + if ghost.Y != Height-2 { + t.Errorf("ghost Y = %d, want %d", ghost.Y, Height-2) + } + g.Active.Y = Height - 2 + if !g.Grounded() { + t.Error("Grounded = false while resting on the floor") + } +} + +func TestStateString(t *testing.T) { + if got := StatePlaying.String(); got != "Playing" { + t.Errorf("StatePlaying = %q, want %q", got, "Playing") + } + if got := StateOver.String(); got != "Over" { + t.Errorf("StateOver = %q, want %q", got, "Over") + } +} + +// Successful movement while grounded resets the lock timer (design 12). +func TestMoveWhileGroundedResetsLockTimer(t *testing.T) { + g := New(1) + g.Active = Piece{Kind: KindO, Rotation: 0, X: 4, Y: Height - 2} + g.LockAccumulator = 300 * time.Millisecond + g.Apply(InputLeft) + if g.LockAccumulator != 0 { + t.Errorf("LockAccumulator = %v, want 0 after a grounded move", g.LockAccumulator) + } + if g.LockResets != 1 { + t.Errorf("LockResets = %d, want 1", g.LockResets) + } +} + +func TestLockResetsCapped(t *testing.T) { + g := New(1) + g.Active = Piece{Kind: KindO, Rotation: 0, X: 4, Y: Height - 2} + for i := 0; i < MaxLockResets+10; i++ { + if i%2 == 0 { + g.Apply(InputLeft) + } else { + g.Apply(InputRight) + } + g.LockAccumulator = 400 * time.Millisecond + } + if g.LockResets != MaxLockResets { + t.Errorf("LockResets = %d, want capped at %d", g.LockResets, MaxLockResets) + } + if g.LockAccumulator != 400*time.Millisecond { + t.Errorf("LockAccumulator = %v, want the timer to stop resetting past the cap", g.LockAccumulator) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/game/ -run 'TestNewGame|TestApply|TestGrounded|TestState|TestMoveWhile|TestLockResets'` +Expected: FAIL — `undefined: New`, `undefined: Input`. + +- [ ] **Step 3: Write the implementation** + +Create `internal/game/game.go`: + +```go +package game + +import ( + "math/rand/v2" + "time" +) + +// NextQueueLen is how many upcoming pieces the game keeps visible (design 6). +const NextQueueLen = 5 + +// State is the coarse engine state. +type State uint8 + +// Engine states. +const ( + StatePlaying State = iota + StateOver +) + +// String returns the state name. +func (s State) String() string { + if s == StateOver { + return "Over" + } + return "Playing" +} + +// Input is one player action. Pause, restart, help and quit are application +// concerns, not engine concerns, so they are not Inputs. +type Input uint8 + +// Player actions. +const ( + InputNone Input = iota + InputLeft + InputRight + InputSoftDrop + InputRotateCW + InputRotateCCW + InputHardDrop + InputHold +) + +// Game is the whole logical game. It never reads a clock: elapsed time comes in +// through Advance (design 49.2). +type Game struct { + Board Board + Active Piece + Hold *PieceKind + CanHold bool + + Next []PieceKind + Bag Bag + + Score int + Lines int + Level int + Combo int + + State State + + GravityAccumulator time.Duration + LockAccumulator time.Duration + LockResets int + + // Seed is recorded for display and restart. The generator below is what + // actually carries RNG state (design 49.6). + Seed int64 + rng *rand.Rand +} + +// New returns a game ready to play, with the first piece already active and the +// next queue filled. The same seed always produces the same piece order. +func New(seed int64) *Game { + g := &Game{ + Level: 1, + CanHold: true, + State: StatePlaying, + Seed: seed, + rng: NewRNG(seed), + Next: make([]PieceKind, 0, NextQueueLen+1), + } + g.fillQueue() + g.Active = Spawn(g.takeNext()) + return g +} + +// fillQueue tops the next queue up to NextQueueLen kinds. +func (g *Game) fillQueue() { + for len(g.Next) < NextQueueLen { + g.Next = append(g.Next, g.Bag.Next(g.rng)) + } +} + +// takeNext pops the head of the next queue and refills it. +func (g *Game) takeNext() PieceKind { + k := g.Next[0] + g.Next = append(g.Next[:0], g.Next[1:]...) + g.fillQueue() + return k +} + +// Ghost returns the active piece at its landing position (design 10). +func (g *Game) Ghost() Piece { return g.Board.Ghost(g.Active) } + +// Grounded reports whether the active piece is resting on the stack or floor. +func (g *Game) Grounded() bool { + next := g.Active + next.Y++ + return g.Board.Collides(next) +} + +// touchLock resets the lock timer after a successful move or rotation while +// grounded, up to MaxLockResets times (design 12). +func (g *Game) touchLock() { + if !g.Grounded() { + return + } + if g.LockResets >= MaxLockResets { + return + } + g.LockAccumulator = 0 + g.LockResets++ +} + +// tryMove shifts the active piece if the destination is free. +func (g *Game) tryMove(dx, dy int) bool { + cand := g.Active + cand.X += dx + cand.Y += dy + if g.Board.Collides(cand) { + return false + } + g.Active = cand + return true +} + +// Apply performs one player action and reports what happened. +func (g *Game) Apply(in Input) []Event { + if g.State != StatePlaying { + return nil + } + switch in { + case InputLeft: + return g.move(-1) + case InputRight: + return g.move(1) + case InputRotateCW: + return g.rotate(1) + case InputRotateCCW: + return g.rotate(-1) + } + return nil +} + +func (g *Game) move(dx int) []Event { + if !g.tryMove(dx, 0) { + return nil + } + g.touchLock() + return []Event{{Type: EventPieceMoved, Piece: g.Active}} +} + +func (g *Game) rotate(dir int) []Event { + rotated, ok := g.Board.TryRotate(g.Active, dir) + if !ok { + return nil + } + g.Active = rotated + g.touchLock() + return []Event{{Type: EventPieceRotated, Piece: g.Active}} +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v -run 'TestNewGame|TestApply|TestGrounded|TestState|TestMoveWhile|TestLockResets'` +Expected: PASS, 12 tests. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . +git add internal/game/game.go internal/game/game_test.go +git commit -m "feat(game): game construction, next queue, movement, and rotation input" +``` + +--- + +### Task 9: Gravity, soft drop, and lock delay via Advance + +**Files:** +- Modify: `internal/game/game.go` +- Modify: `internal/game/game_test.go` + +**Interfaces:** +- Consumes: `Game`, `Input`, `GravityInterval`, `LockDelay`, `Board.Lock`. +- Produces: `func (g *Game) Advance(dt time.Duration) []Event`, extended `Apply` handling `InputSoftDrop`, private `func (g *Game) lockPiece() []Event` and `func (g *Game) spawnNext() []Event`. + +`lockPiece` at this stage commits the piece and spawns the next one; line clearing and combo accounting arrive in Task 10 and game over in Task 12. + +- [ ] **Step 1: Write the failing test** + +Append to `internal/game/game_test.go`: + +```go +func TestAdvanceDropsOnGravityInterval(t *testing.T) { + g := New(1) + g.Active = Piece{Kind: KindO, Rotation: 0, X: 4, Y: 3} + iv := GravityInterval(g.Level) + + if evs := g.Advance(iv - time.Millisecond); len(evs) != 0 { + t.Errorf("just under the interval emitted %v, want nothing", evs) + } + if g.Active.Y != 3 { + t.Errorf("Y = %d, want 3 before the interval elapses", g.Active.Y) + } + evs := g.Advance(2 * time.Millisecond) + if g.Active.Y != 4 { + t.Errorf("Y = %d, want 4 after the interval elapses", g.Active.Y) + } + if _, ok := findEvent(evs, EventPieceMoved); !ok { + t.Error("gravity drop emitted no PieceMoved event") + } +} + +func TestAdvanceUsesLevelInterval(t *testing.T) { + g := New(1) + g.Level = 10 + g.Active = Piece{Kind: KindO, Rotation: 0, X: 4, Y: 3} + g.Advance(GravityInterval(10)) + if g.Active.Y != 4 { + t.Errorf("Y = %d, want 4 after one level-10 interval", g.Active.Y) + } +} + +// Review Focus 1: one enormous dt (sleep, SIGSTOP, debugger pause) must land +// the piece and run the lock sequence rather than tunnel or hang. +func TestAdvanceHugeDtLandsAndLocks(t *testing.T) { + g := New(1) + g.Active = Piece{Kind: KindO, Rotation: 0, X: 4, Y: 0} + evs := g.Advance(5 * time.Second) + if countEvents(evs, EventPieceLocked) != 1 { + t.Fatalf("5s step produced %d lock events, want exactly 1: %v", countEvents(evs, EventPieceLocked), evs) + } + // The O landed on the floor; those two rows must hold four cells. + filled := 0 + for y := 0; y < Height; y++ { + for x := 0; x < Width; x++ { + if g.Board.Cells[y][x].Filled() { + filled++ + } + } + } + if filled != 4 { + t.Errorf("%d cells locked, want 4 (the piece must not tunnel)", filled) + } + if !g.Board.At(4, Height-1).Filled() || !g.Board.At(5, Height-1).Filled() { + t.Error("piece did not come to rest on the floor") + } +} + +func TestAdvanceLocksAfterLockDelay(t *testing.T) { + g := New(1) + g.Active = Piece{Kind: KindO, Rotation: 0, X: 4, Y: Height - 2} + if evs := g.Advance(LockDelay - time.Millisecond); countEvents(evs, EventPieceLocked) != 0 { + t.Error("locked before the lock delay elapsed") + } + if g.Board.At(4, Height-1).Filled() { + t.Error("board written before the lock delay elapsed") + } + evs := g.Advance(2 * time.Millisecond) + if countEvents(evs, EventPieceLocked) != 1 { + t.Fatalf("expected exactly one lock event, got %v", evs) + } + if !g.Board.At(4, Height-1).Filled() { + t.Error("piece not committed to the board after locking") + } +} + +func TestAdvanceLockSpawnsNextPieceAndResetsCounters(t *testing.T) { + g := New(1) + wantKind := g.Next[0] + g.Active = Piece{Kind: KindO, Rotation: 0, X: 4, Y: Height - 2} + evs := g.Advance(LockDelay) + if g.Active.Kind != wantKind { + t.Errorf("new active kind = %c, want %c from the queue", g.Active.Kind.Letter(), wantKind.Letter()) + } + if g.Active.X != SpawnX || g.Active.Y != SpawnY || g.Active.Rotation != 0 { + t.Errorf("new piece = %+v, want spawn position", g.Active) + } + if len(g.Next) != NextQueueLen { + t.Errorf("len(Next) = %d, want %d", len(g.Next), NextQueueLen) + } + if g.LockAccumulator != 0 || g.LockResets != 0 || g.GravityAccumulator != 0 { + t.Errorf("counters not reset after lock: lock=%v resets=%d grav=%v", + g.LockAccumulator, g.LockResets, g.GravityAccumulator) + } + if !g.CanHold { + t.Error("CanHold = false, want true after a lock (design 9)") + } + if _, ok := findEvent(evs, EventPieceSpawned); !ok { + t.Error("lock emitted no PieceSpawned event") + } +} + +func TestAdvanceUngroundedResetsLockAccumulator(t *testing.T) { + g := New(1) + // A shelf with a gap the O can slide into. + fillRow(&g.Board, Height-1, 0, 1) + g.Active = Piece{Kind: KindO, Rotation: 0, X: 4, Y: Height - 3} + g.Advance(200 * time.Millisecond) + if g.LockAccumulator == 0 { + t.Fatal("test setup: piece is not grounded") + } + g.Active.X = 0 // slide over the gap + g.Advance(time.Millisecond) + if g.LockAccumulator != 0 { + t.Errorf("LockAccumulator = %v, want 0 once the piece is airborne again", g.LockAccumulator) + } +} + +func TestAdvanceIgnoredWhenGameOver(t *testing.T) { + g := New(1) + g.State = StateOver + before := g.Active + if evs := g.Advance(10 * time.Second); len(evs) != 0 { + t.Errorf("Advance after game over emitted %v", evs) + } + if g.Active != before { + t.Error("Advance after game over moved the piece") + } +} + +func TestAdvanceZeroDtIsNoop(t *testing.T) { + g := New(1) + before := g.Active + if evs := g.Advance(0); len(evs) != 0 { + t.Errorf("Advance(0) emitted %v", evs) + } + if g.Active != before { + t.Error("Advance(0) moved the piece") + } +} + +func TestSoftDropScoresAndDescends(t *testing.T) { + g := New(1) + g.Active = Piece{Kind: KindO, Rotation: 0, X: 4, Y: 5} + g.GravityAccumulator = 100 * time.Millisecond + evs := g.Apply(InputSoftDrop) + if g.Active.Y != 6 { + t.Errorf("Y = %d, want 6", g.Active.Y) + } + if g.Score != SoftDropPoints { + t.Errorf("Score = %d, want %d", g.Score, SoftDropPoints) + } + if g.GravityAccumulator != 0 { + t.Errorf("GravityAccumulator = %v, want 0 after a soft drop", g.GravityAccumulator) + } + e, ok := findEvent(evs, EventPieceMoved) + if !ok { + t.Fatal("soft drop emitted no PieceMoved event") + } + if e.Points != SoftDropPoints { + t.Errorf("event Points = %d, want %d", e.Points, SoftDropPoints) + } +} + +func TestSoftDropAtRestScoresNothing(t *testing.T) { + g := New(1) + g.Active = Piece{Kind: KindO, Rotation: 0, X: 4, Y: Height - 2} + evs := g.Apply(InputSoftDrop) + if g.Score != 0 { + t.Errorf("Score = %d, want 0 when the soft drop cannot move", g.Score) + } + if len(evs) != 0 { + t.Errorf("blocked soft drop emitted %v", evs) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/game/ -run 'TestAdvance|TestSoftDrop'` +Expected: FAIL — `g.Advance undefined`. + +- [ ] **Step 3: Write the implementation** + +Add `InputSoftDrop` to the `Apply` switch in `internal/game/game.go`: + +```go + case InputSoftDrop: + return g.softDrop() +``` + +and append to `internal/game/game.go`: + +```go +// Advance moves the game forward by dt. It is the only way time enters the +// engine (design 49.2): nothing here reads a clock. +func (g *Game) Advance(dt time.Duration) []Event { + if g.State != StatePlaying || dt <= 0 { + return nil + } + + var evs []Event + + g.GravityAccumulator += dt + iv := GravityInterval(g.Level) + for g.GravityAccumulator >= iv { + g.GravityAccumulator -= iv + if !g.tryMove(0, 1) { + // Landed. Drop the leftover credit so a huge dt cannot bank + // gravity steps against the next piece. + g.GravityAccumulator = 0 + break + } + evs = append(evs, Event{Type: EventPieceMoved, Piece: g.Active}) + } + + if !g.Grounded() { + g.LockAccumulator = 0 + return evs + } + + g.LockAccumulator += dt + if g.LockAccumulator >= LockDelay { + evs = append(evs, g.lockPiece()...) + } + return evs +} + +// softDrop descends one row for a point (design 11). +func (g *Game) softDrop() []Event { + if !g.tryMove(0, 1) { + return nil + } + g.Score += SoftDropPoints + g.GravityAccumulator = 0 + g.touchLock() + return []Event{{Type: EventPieceMoved, Piece: g.Active, Points: SoftDropPoints}} +} + +// lockPiece commits the active piece and spawns the next one, following the +// order in design section 12. Line clearing is layered in by the resolve step. +func (g *Game) lockPiece() []Event { + locked := g.Active + g.Board.Lock(locked) + evs := []Event{{Type: EventPieceLocked, Piece: locked}} + evs = append(evs, g.resolveClears()...) + evs = append(evs, g.spawnNext()...) + return evs +} + +// resolveClears detects and clears completed rows and updates the score. Task +// 10 fills this in; until then locking simply commits the piece. +func (g *Game) resolveClears() []Event { return nil } + +// spawnNext takes the head of the next queue as the new active piece and resets +// the per-piece counters. +func (g *Game) spawnNext() []Event { + g.Active = Spawn(g.takeNext()) + g.GravityAccumulator = 0 + g.LockAccumulator = 0 + g.LockResets = 0 + g.CanHold = true + return []Event{{Type: EventPieceSpawned, Piece: g.Active}} +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v -run 'TestAdvance|TestSoftDrop'` +Expected: PASS, 10 tests. + +- [ ] **Step 5: Run the whole package** + +Run: `go test ./internal/game/` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +gofmt -l . +git add internal/game/game.go internal/game/game_test.go +git commit -m "feat(game): gravity, soft drop, and lock delay driven by Advance(dt)" +``` + +--- + +### Task 10: Line clearing, combo, level progression, and hard drop + +**Files:** +- Modify: `internal/game/game.go` +- Modify: `internal/game/game_test.go` + +**Interfaces:** +- Consumes: `Board.CompleteRows`, `Board.ClearRows`, `Board.DropDistance`, `LineScore`, `ComboBonus`, `LevelFor`. +- Produces: real `func (g *Game) resolveClears() []Event`, `Apply` handling `InputHardDrop`, private `func (g *Game) hardDrop() []Event`. + +- [ ] **Step 1: Write the failing test** + +Append to `internal/game/game_test.go`: + +```go +// setupClear puts the game one placement away from clearing n rows with an O. +// The O lands in columns 4 and 5, so those columns are the gaps. +func setupClear(g *Game, rows int) { + for i := 0; i < rows; i++ { + fillRow(&g.Board, Height-1-i, 4, 5) + } + g.Active = Piece{Kind: KindO, Rotation: 0, X: 4, Y: Height - 1 - rows} +} + +func TestLockClearsSingleRowAndScores(t *testing.T) { + g := New(1) + setupClear(g, 1) + // The O fills (4,21) and (5,21) plus (4,20),(5,20); only row 21 completes. + g.Active.Y = Height - 2 + evs := g.Advance(LockDelay) + + e, ok := findEvent(evs, EventLinesCleared) + if !ok { + t.Fatalf("no LinesCleared event: %v", evs) + } + if e.Count != 1 { + t.Errorf("cleared Count = %d, want 1", e.Count) + } + if len(e.Rows) != 1 || e.Rows[0] != Height-1 { + t.Errorf("cleared Rows = %v, want [%d]", e.Rows, Height-1) + } + if g.Lines != 1 { + t.Errorf("Lines = %d, want 1", g.Lines) + } + if g.Combo != 1 { + t.Errorf("Combo = %d, want 1 on the first clearing placement", g.Combo) + } + // Level 1 single, no combo bonus at combo 1 (design 49.1). + if want := LineScore(1, 1); e.Points != want { + t.Errorf("event Points = %d, want %d", e.Points, want) + } + if g.Score != LineScore(1, 1) { + t.Errorf("Score = %d, want %d", g.Score, LineScore(1, 1)) + } +} + +func TestLockClearsFourRows(t *testing.T) { + g := New(1) + setupClear(g, 4) + // Stand an I on end so it fills four rows in one column pair? Use two + // stacked O placements instead: fill rows 18..21 leaving only column 4, + // then drop a vertical I into it. + g.Board = Board{} + for i := 0; i < 4; i++ { + fillRow(&g.Board, Height-1-i, 4) + } + g.Active = Piece{Kind: KindI, Rotation: 1, X: 2, Y: Height - 4} // column 4, rows 18..21 + if g.Board.Collides(g.Active) { + t.Fatalf("test setup: I at %+v collides", g.Active) + } + evs := g.Advance(LockDelay) + e, ok := findEvent(evs, EventLinesCleared) + if !ok { + t.Fatalf("no LinesCleared event: %v", evs) + } + if e.Count != 4 { + t.Fatalf("cleared %d rows, want 4", e.Count) + } + if g.Lines != 4 { + t.Errorf("Lines = %d, want 4", g.Lines) + } + if g.Score != LineScore(4, 1) { + t.Errorf("Score = %d, want %d", g.Score, LineScore(4, 1)) + } + for y := 0; y < Height; y++ { + for x := 0; x < Width; x++ { + if g.Board.Cells[y][x].Filled() { + t.Fatalf("board not empty after a four-line clear: (%d,%d)", x, y) + } + } + } +} + +func TestComboIncrementsAndResets(t *testing.T) { + g := New(1) + // First clearing placement. + fillRow(&g.Board, Height-1, 4, 5) + g.Active = Piece{Kind: KindO, Rotation: 0, X: 4, Y: Height - 2} + g.Advance(LockDelay) + if g.Combo != 1 { + t.Fatalf("Combo = %d, want 1", g.Combo) + } + + // Second clearing placement in a row: combo 2, bonus appears. + scoreBefore := g.Score + fillRow(&g.Board, Height-1, 4, 5) + g.Active = Piece{Kind: KindO, Rotation: 0, X: 4, Y: Height - 2} + evs := g.Advance(LockDelay) + if g.Combo != 2 { + t.Fatalf("Combo = %d, want 2", g.Combo) + } + e, _ := findEvent(evs, EventLinesCleared) + wantPoints := LineScore(1, g.Level) + ComboBonus(2, g.Level) + if e.Points != wantPoints { + t.Errorf("event Points = %d, want %d (line score plus combo bonus)", e.Points, wantPoints) + } + if g.Score-scoreBefore != wantPoints { + t.Errorf("score delta = %d, want %d", g.Score-scoreBefore, wantPoints) + } + if ce, ok := findEvent(evs, EventComboChanged); !ok || ce.Count != 2 { + t.Errorf("ComboChanged = %+v, want Count 2", ce) + } + + // A placement that clears nothing resets the combo. + g.Active = Piece{Kind: KindO, Rotation: 0, X: 0, Y: Height - 2} + evs = g.Advance(LockDelay) + if g.Combo != 0 { + t.Errorf("Combo = %d, want 0 after a placement that cleared nothing", g.Combo) + } + if ce, ok := findEvent(evs, EventComboChanged); !ok || ce.Count != 0 { + t.Errorf("ComboChanged = %+v, want Count 0", ce) + } +} + +func TestComboChangedNotEmittedWhenUnchanged(t *testing.T) { + g := New(1) + // Two consecutive non-clearing placements: combo stays 0, so only the + // first may report a change, and it should not report one either. + g.Active = Piece{Kind: KindO, Rotation: 0, X: 0, Y: Height - 2} + evs := g.Advance(LockDelay) + if _, ok := findEvent(evs, EventComboChanged); ok { + t.Errorf("ComboChanged emitted while the combo stayed 0: %v", evs) + } +} + +func TestLevelUpAtTenLines(t *testing.T) { + g := New(1) + g.Lines = 9 + fillRow(&g.Board, Height-1, 4, 5) + g.Active = Piece{Kind: KindO, Rotation: 0, X: 4, Y: Height - 2} + evs := g.Advance(LockDelay) + if g.Lines != 10 { + t.Fatalf("Lines = %d, want 10", g.Lines) + } + if g.Level != 2 { + t.Errorf("Level = %d, want 2", g.Level) + } + e, ok := findEvent(evs, EventLevelChanged) + if !ok { + t.Fatalf("no LevelChanged event: %v", evs) + } + if e.Count != 2 { + t.Errorf("LevelChanged Count = %d, want 2", e.Count) + } +} + +func TestNoLevelChangeEventWithoutLevelUp(t *testing.T) { + g := New(1) + fillRow(&g.Board, Height-1, 4, 5) + g.Active = Piece{Kind: KindO, Rotation: 0, X: 4, Y: Height - 2} + evs := g.Advance(LockDelay) + if _, ok := findEvent(evs, EventLevelChanged); ok { + t.Errorf("LevelChanged emitted for the first cleared line: %v", evs) + } +} + +func TestHardDropScoresAndLocks(t *testing.T) { + g := New(1) + g.Active = Piece{Kind: KindO, Rotation: 0, X: 4, Y: 0} + dist := g.Board.DropDistance(g.Active) + evs := g.Apply(InputHardDrop) + + e, ok := findEvent(evs, EventPieceHardDropped) + if !ok { + t.Fatalf("no PieceHardDropped event: %v", evs) + } + if e.Count != dist { + t.Errorf("hard drop Count = %d, want %d", e.Count, dist) + } + if want := dist * HardDropPoints; e.Points != want { + t.Errorf("hard drop Points = %d, want %d", e.Points, want) + } + if e.Piece.Y != Height-2 { + t.Errorf("hard drop landed at Y = %d, want %d", e.Piece.Y, Height-2) + } + if countEvents(evs, EventPieceLocked) != 1 { + t.Errorf("hard drop produced %d lock events, want 1", countEvents(evs, EventPieceLocked)) + } + if !g.Board.At(4, Height-1).Filled() { + t.Error("hard-dropped piece not committed to the board") + } + if g.Score < dist*HardDropPoints { + t.Errorf("Score = %d, want at least %d", g.Score, dist*HardDropPoints) + } +} + +func TestHardDropEventOrder(t *testing.T) { + g := New(1) + g.Active = Piece{Kind: KindO, Rotation: 0, X: 4, Y: 0} + evs := g.Apply(InputHardDrop) + if len(evs) < 2 { + t.Fatalf("hard drop emitted %d events, want at least 2", len(evs)) + } + if evs[0].Type != EventPieceHardDropped { + t.Errorf("first event = %v, want PieceHardDropped", evs[0].Type) + } + if evs[1].Type != EventPieceLocked { + t.Errorf("second event = %v, want PieceLocked", evs[1].Type) + } +} + +// Review Focus 3: hard drop with nowhere to fall. +func TestHardDropAtRestStillLocksOnce(t *testing.T) { + g := New(1) + g.Active = Piece{Kind: KindO, Rotation: 0, X: 4, Y: Height - 2} + evs := g.Apply(InputHardDrop) + e, ok := findEvent(evs, EventPieceHardDropped) + if !ok { + t.Fatalf("no PieceHardDropped event: %v", evs) + } + if e.Count != 0 || e.Points != 0 { + t.Errorf("resting hard drop Count/Points = %d/%d, want 0/0", e.Count, e.Points) + } + if n := countEvents(evs, EventPieceLocked); n != 1 { + t.Errorf("%d lock events, want exactly 1", n) + } + filled := 0 + for y := 0; y < Height; y++ { + for x := 0; x < Width; x++ { + if g.Board.Cells[y][x].Filled() { + filled++ + } + } + } + if filled != 4 { + t.Errorf("%d cells filled, want 4 (locked exactly once)", filled) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/game/ -run 'TestLockClears|TestCombo|TestLevelUp|TestNoLevelChange|TestHardDrop'` +Expected: FAIL — no `LinesCleared` event, hard drop does nothing. + +- [ ] **Step 3: Write the implementation** + +Add `InputHardDrop` to the `Apply` switch: + +```go + case InputHardDrop: + return g.hardDrop() +``` + +Replace the stub `resolveClears` in `internal/game/game.go` and add `hardDrop`: + +```go +// resolveClears detects completed rows, clears them, and updates lines, level, +// combo and score. It follows the order in design section 12: detect, clear, +// score, report. +func (g *Game) resolveClears() []Event { + rows := g.Board.CompleteRows() + var evs []Event + + oldCombo := g.Combo + if len(rows) == 0 { + g.Combo = 0 + if oldCombo != g.Combo { + evs = append(evs, Event{Type: EventComboChanged, Count: g.Combo}) + } + return evs + } + + g.Board.ClearRows(rows) + g.Combo++ + g.Lines += len(rows) + + points := LineScore(len(rows), g.Level) + ComboBonus(g.Combo, g.Level) + g.Score += points + + evs = append(evs, Event{ + Type: EventLinesCleared, + Rows: rows, + Count: len(rows), + Points: points, + }) + if oldCombo != g.Combo { + evs = append(evs, Event{Type: EventComboChanged, Count: g.Combo}) + } + if lvl := LevelFor(g.Lines); lvl != g.Level { + g.Level = lvl + evs = append(evs, Event{Type: EventLevelChanged, Count: g.Level}) + } + return evs +} + +// hardDrop slams the active piece to its landing position and locks it +// immediately, with no lock delay (design 11 and 18). +func (g *Game) hardDrop() []Event { + dist := g.Board.DropDistance(g.Active) + g.Active.Y += dist + points := dist * HardDropPoints + g.Score += points + evs := []Event{{ + Type: EventPieceHardDropped, + Piece: g.Active, + Count: dist, + Points: points, + }} + return append(evs, g.lockPiece()...) +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v -run 'TestLockClears|TestCombo|TestLevelUp|TestNoLevelChange|TestHardDrop'` +Expected: PASS, 9 tests. + +If `TestLockClearsFourRows` reports a collision in setup, print `g.Active.Blocks()` and adjust the vertical I's `X` so its single occupied column is 4 — `Offsets[KindI][1]` puts blocks in relative column 2, so `X = 2`. + +- [ ] **Step 5: Run the whole package** + +Run: `go test ./internal/game/` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +gofmt -l . +git add internal/game/game.go internal/game/game_test.go +git commit -m "feat(game): line clearing, combo, level progression, and hard drop" +``` + +--- + +### Task 11: Hold + +**Files:** +- Modify: `internal/game/game.go` +- Modify: `internal/game/game_test.go` + +**Interfaces:** +- Consumes: `Game`, `Spawn`, `takeNext`. +- Produces: `Apply` handling `InputHold`, private `func (g *Game) holdPiece() []Event`. + +Design §9: `c` swaps active with hold; an empty hold stores the active piece and spawns the next; hold works only once per piece; a piece coming out of hold returns to spawn rotation. + +- [ ] **Step 1: Write the failing test** + +Append to `internal/game/game_test.go`: + +```go +func TestHoldFirstUseStoresAndSpawnsNext(t *testing.T) { + g := New(1) + activeKind := g.Active.Kind + nextKind := g.Next[0] + + evs := g.Apply(InputHold) + if g.Hold == nil { + t.Fatal("Hold is nil after holding") + } + if *g.Hold != activeKind { + t.Errorf("Hold = %c, want %c", g.Hold.Letter(), activeKind.Letter()) + } + if g.Active.Kind != nextKind { + t.Errorf("Active = %c, want %c from the queue", g.Active.Kind.Letter(), nextKind.Letter()) + } + if g.Active.X != SpawnX || g.Active.Y != SpawnY || g.Active.Rotation != 0 { + t.Errorf("Active = %+v, want spawn position", g.Active) + } + if g.CanHold { + t.Error("CanHold = true, want false immediately after a hold") + } + if len(g.Next) != NextQueueLen { + t.Errorf("len(Next) = %d, want %d", len(g.Next), NextQueueLen) + } + e, ok := findEvent(evs, EventHoldUsed) + if !ok { + t.Fatalf("no HoldUsed event: %v", evs) + } + if e.Piece.Kind != nextKind { + t.Errorf("HoldUsed piece = %c, want the new active piece %c", e.Piece.Kind.Letter(), nextKind.Letter()) + } +} + +func TestHoldSwapsWithStoredPiece(t *testing.T) { + g := New(1) + first := g.Active.Kind + g.Apply(InputHold) + second := g.Active.Kind + // Lock the piece so hold is allowed again. + g.Active = Piece{Kind: second, Rotation: 0, X: 0, Y: Height - 4} + g.Apply(InputHardDrop) + third := g.Active.Kind + + g.Apply(InputHold) + if g.Active.Kind != first { + t.Errorf("Active = %c, want the stored piece %c", g.Active.Kind.Letter(), first.Letter()) + } + if *g.Hold != third { + t.Errorf("Hold = %c, want %c", g.Hold.Letter(), third.Letter()) + } +} + +func TestHoldReturnsPieceAtSpawnRotation(t *testing.T) { + g := New(1) + g.Active = Piece{Kind: KindL, Rotation: 3, X: 7, Y: 12} + g.Apply(InputHold) // stores L + g.CanHold = true // pretend a lock happened + g.Active = Piece{Kind: KindT, Rotation: 2, X: 1, Y: 5} + g.Apply(InputHold) // swaps L back in + if g.Active.Kind != KindL { + t.Fatalf("Active = %c, want L", g.Active.Kind.Letter()) + } + if g.Active.Rotation != 0 || g.Active.X != SpawnX || g.Active.Y != SpawnY { + t.Errorf("Active = %+v, want L at spawn rotation and position", g.Active) + } +} + +func TestSecondHoldBlockedBeforeLock(t *testing.T) { + g := New(1) + g.Apply(InputHold) + held := *g.Hold + active := g.Active.Kind + + evs := g.Apply(InputHold) + if len(evs) != 0 { + t.Errorf("second hold emitted %v, want nothing", evs) + } + if *g.Hold != held || g.Active.Kind != active { + t.Errorf("second hold changed state: hold %c active %c", g.Hold.Letter(), g.Active.Kind.Letter()) + } +} + +func TestHoldRestoredAfterLock(t *testing.T) { + g := New(1) + g.Apply(InputHold) + if g.CanHold { + t.Fatal("CanHold = true right after holding") + } + g.Active = Piece{Kind: g.Active.Kind, Rotation: 0, X: 0, Y: Height - 4} + g.Apply(InputHardDrop) + if !g.CanHold { + t.Error("CanHold = false after the piece locked, want true") + } +} + +func TestHoldIgnoredWhenGameOver(t *testing.T) { + g := New(1) + g.State = StateOver + if evs := g.Apply(InputHold); len(evs) != 0 { + t.Errorf("hold after game over emitted %v", evs) + } + if g.Hold != nil { + t.Error("hold after game over stored a piece") + } +} + +// Review Focus 4: repeated holds across many placements must keep the queue +// exactly NextQueueLen long and hand out only bag-produced pieces. +func TestRepeatedHoldsKeepQueueLength(t *testing.T) { + g := New(99) + for i := 0; i < 60; i++ { + g.Apply(InputHold) + if len(g.Next) != NextQueueLen { + t.Fatalf("iteration %d: len(Next) = %d, want %d", i, len(g.Next), NextQueueLen) + } + for j, k := range g.Next { + if k >= KindCount { + t.Fatalf("iteration %d: Next[%d] = %d, not a valid kind", i, j, k) + } + } + // Slam the piece somewhere harmless so hold unlocks again. + g.Active.Y = 0 + g.Apply(InputHardDrop) + if g.State != StatePlaying { + // The stack filled up; that is fine, the queue invariant held. + return + } + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/game/ -run 'TestHold|TestSecondHold|TestRepeatedHolds'` +Expected: FAIL — `Hold is nil after holding`. + +- [ ] **Step 3: Write the implementation** + +Add `InputHold` to the `Apply` switch: + +```go + case InputHold: + return g.holdPiece() +``` + +Append to `internal/game/game.go`: + +```go +// holdPiece swaps the active piece with the held one, or stores it and spawns +// the next when the hold is empty. Hold works once per piece (design 9). The +// incoming piece always returns at spawn rotation and position. +func (g *Game) holdPiece() []Event { + if !g.CanHold { + return nil + } + outgoing := g.Active.Kind + if g.Hold == nil { + g.Active = Spawn(g.takeNext()) + } else { + g.Active = Spawn(*g.Hold) + } + g.Hold = &outgoing + g.CanHold = false + g.GravityAccumulator = 0 + g.LockAccumulator = 0 + g.LockResets = 0 + return []Event{{Type: EventHoldUsed, Piece: g.Active}} +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v -run 'TestHold|TestSecondHold|TestRepeatedHolds'` +Expected: PASS, 7 tests. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . +git add internal/game/game.go internal/game/game_test.go +git commit -m "feat(game): hold with once-per-piece restriction" +``` + +--- + +### Task 12: Game over on blocked spawn + +**Files:** +- Modify: `internal/game/game.go` +- Modify: `internal/game/game_test.go` + +**Interfaces:** +- Consumes: `spawnNext`, `Board.Collides`, `State`. +- Produces: `spawnNext` now emits `EventGameOver` and sets `StateOver` when the spawned piece collides. + +- [ ] **Step 1: Write the failing test** + +Append to `internal/game/game_test.go`: + +```go +func TestGameOverOnBlockedSpawn(t *testing.T) { + g := New(1) + // Fill the spawn area so the next piece cannot appear. + for y := 0; y < 4; y++ { + for x := 0; x < Width; x++ { + g.Board.Set(x, y, FilledCell(KindI)) + } + } + // Place the active piece low so it can lock without hitting the ceiling fill. + g.Active = Piece{Kind: KindO, Rotation: 0, X: 4, Y: Height - 2} + evs := g.Advance(LockDelay) + + if g.State != StateOver { + t.Errorf("State = %v, want StateOver", g.State) + } + e, ok := findEvent(evs, EventGameOver) + if !ok { + t.Fatalf("no GameOver event: %v", evs) + } + if e.Piece.X != SpawnX || e.Piece.Y != SpawnY { + t.Errorf("GameOver piece = %+v, want the blocked spawn position", e.Piece) + } + if _, ok := findEvent(evs, EventPieceSpawned); ok { + t.Error("PieceSpawned emitted alongside GameOver, want only GameOver") + } +} + +func TestGameOverStopsTheEngine(t *testing.T) { + g := New(1) + for y := 0; y < 4; y++ { + for x := 0; x < Width; x++ { + g.Board.Set(x, y, FilledCell(KindI)) + } + } + g.Active = Piece{Kind: KindO, Rotation: 0, X: 4, Y: Height - 2} + g.Advance(LockDelay) + board := g.Board.Cells + score := g.Score + + if evs := g.Advance(5 * time.Second); len(evs) != 0 { + t.Errorf("Advance after game over emitted %v", evs) + } + for _, in := range []Input{InputLeft, InputRight, InputSoftDrop, InputHardDrop, InputHold, InputRotateCW} { + if evs := g.Apply(in); len(evs) != 0 { + t.Errorf("input %d after game over emitted %v", in, evs) + } + } + if g.Board.Cells != board || g.Score != score { + t.Error("game state changed after game over") + } +} + +func TestGameOverKeepsFinalStats(t *testing.T) { + g := New(1) + g.Score = 483200 + g.Lines = 127 + g.Level = 13 + for y := 0; y < 4; y++ { + for x := 0; x < Width; x++ { + g.Board.Set(x, y, FilledCell(KindI)) + } + } + g.Active = Piece{Kind: KindO, Rotation: 0, X: 4, Y: Height - 2} + g.Advance(LockDelay) + if g.Score < 483200 || g.Lines != 127 || g.Level != 13 { + t.Errorf("stats after game over = %d/%d/%d, want them preserved", g.Score, g.Lines, g.Level) + } +} + +func TestGameOverAfterHardDropIntoCeiling(t *testing.T) { + g := New(1) + for y := 0; y < Height; y++ { + for x := 0; x < Width; x++ { + if x != 0 { + g.Board.Set(x, y, FilledCell(KindI)) + } + } + } + g.Active = Piece{Kind: KindI, Rotation: 1, X: -2, Y: 0} // vertical I in column 0 + if g.Board.Collides(g.Active) { + t.Fatalf("test setup: I at %+v collides", g.Active) + } + g.Apply(InputHardDrop) + if g.State != StateOver { + t.Errorf("State = %v, want StateOver once the well is full", g.State) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/game/ -run TestGameOver` +Expected: FAIL — `State = Playing, want StateOver`. + +- [ ] **Step 3: Write the implementation** + +Replace `spawnNext` in `internal/game/game.go`: + +```go +// spawnNext takes the head of the next queue as the new active piece and resets +// the per-piece counters. A spawn that collides ends the game (design 12). +func (g *Game) spawnNext() []Event { + g.Active = Spawn(g.takeNext()) + g.GravityAccumulator = 0 + g.LockAccumulator = 0 + g.LockResets = 0 + g.CanHold = true + if g.Board.Collides(g.Active) { + g.State = StateOver + return []Event{{Type: EventGameOver, Piece: g.Active}} + } + return []Event{{Type: EventPieceSpawned, Piece: g.Active}} +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v -run TestGameOver` +Expected: PASS, 4 tests. + +If `TestGameOverAfterHardDropIntoCeiling` fails on the setup collision check, adjust the vertical I's `X` so its blocks land in column 0: `Offsets[KindI][1]` uses relative column 2, so `X = -2`. + +- [ ] **Step 5: Run the whole package** + +Run: `go test ./internal/game/` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +gofmt -l . +git add internal/game/game.go internal/game/game_test.go +git commit -m "feat(game): game over when a spawn is blocked" +``` + +--- + +### Task 13: Snapshot and the seeded replay test + +**Files:** +- Modify: `internal/game/game.go` +- Create: `internal/game/replay_test.go` +- Create: `internal/game/testdata/replay-8675309.golden` + +**Interfaces:** +- Consumes: the whole engine. +- Produces: `func (g *Game) Snapshot() string` — a stable, human-readable dump of the full logical state, used by the replay golden test and useful when debugging. + +This is design §35's promise made testable, and §40's "replay a canned input stream and assert final game state". + +- [ ] **Step 1: Write the failing test** + +Create `internal/game/replay_test.go`: + +```go +package game + +import ( + "flag" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +var update = flag.Bool("update", false, "rewrite golden files") + +// replayStep is one (input, elapsed time) pair. Timing is an input, which is +// what makes design section 35 checkable at all (design 49.2). +type replayStep struct { + In Input + Dt time.Duration +} + +// cannedReplay is a fixed script: a mix of moves, rotations, holds, soft drops +// and hard drops interleaved with irregular frame times. +func cannedReplay() []replayStep { + inputs := []Input{ + InputLeft, InputLeft, InputRotateCW, InputHardDrop, + InputRight, InputRotateCCW, InputSoftDrop, InputHardDrop, + InputHold, InputLeft, InputHardDrop, + InputRotateCW, InputRotateCW, InputRight, InputRight, InputHardDrop, + InputHold, InputSoftDrop, InputSoftDrop, InputHardDrop, + InputLeft, InputLeft, InputLeft, InputHardDrop, + InputRight, InputRotateCW, InputHardDrop, + InputNone, InputNone, InputNone, + } + dts := []time.Duration{ + 16 * time.Millisecond, + 17 * time.Millisecond, + 33 * time.Millisecond, + 16 * time.Millisecond, + 250 * time.Millisecond, + } + var steps []replayStep + for round := 0; round < 12; round++ { + for i, in := range inputs { + steps = append(steps, replayStep{In: in, Dt: dts[(round+i)%len(dts)]}) + } + } + return steps +} + +func runReplay(seed int64) *Game { + g := New(seed) + for _, s := range cannedReplay() { + g.Apply(s.In) + g.Advance(s.Dt) + } + return g +} + +func TestSnapshotIsStableAndReadable(t *testing.T) { + g := New(1) + a, b := g.Snapshot(), g.Snapshot() + if a != b { + t.Error("Snapshot is not stable across calls") + } + for _, want := range []string{"seed:", "state:", "score:", "lines:", "level:", "combo:", "active:", "next:", "hold:", "board:"} { + if !strings.Contains(a, want) { + t.Errorf("Snapshot missing %q:\n%s", want, a) + } + } + // The board dump must be Height lines of Width characters. + idx := strings.Index(a, "board:\n") + if idx < 0 { + t.Fatalf("Snapshot has no board section:\n%s", a) + } + rows := strings.Split(strings.TrimRight(a[idx+len("board:\n"):], "\n"), "\n") + if len(rows) != Height { + t.Fatalf("board dump has %d rows, want %d", len(rows), Height) + } + for i, r := range rows { + if len(r) != Width { + t.Errorf("board row %d is %d chars, want %d: %q", i, len(r), Width, r) + } + } +} + +func TestSnapshotReflectsState(t *testing.T) { + g := New(1) + before := g.Snapshot() + g.Score = 1234 + if g.Snapshot() == before { + t.Error("Snapshot did not change after the score changed") + } +} + +// Same seed, same inputs, same timings, same final state (design 35). +func TestReplayIsDeterministic(t *testing.T) { + a := runReplay(8675309).Snapshot() + b := runReplay(8675309).Snapshot() + if a != b { + t.Errorf("two identical replays diverged:\n--- first ---\n%s\n--- second ---\n%s", a, b) + } +} + +func TestReplayDiffersBySeed(t *testing.T) { + if runReplay(8675309).Snapshot() == runReplay(11111).Snapshot() { + t.Error("different seeds produced identical final state") + } +} + +// Timing is an input: changing the dt stream must change the outcome. +func TestReplayDependsOnTiming(t *testing.T) { + slow := New(8675309) + for _, s := range cannedReplay() { + slow.Apply(s.In) + slow.Advance(s.Dt * 3) + } + if slow.Snapshot() == runReplay(8675309).Snapshot() { + t.Error("tripling every dt did not change the final state") + } +} + +func TestReplayMatchesGolden(t *testing.T) { + got := runReplay(8675309).Snapshot() + path := filepath.Join("testdata", "replay-8675309.golden") + if *update { + if err := os.MkdirAll("testdata", 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(got), 0o644); err != nil { + t.Fatal(err) + } + t.Logf("wrote %s", path) + return + } + want, err := os.ReadFile(path) + if err != nil { + t.Fatalf("%v (run: go test ./internal/game -update)", err) + } + if got != string(want) { + t.Errorf("replay diverged from the golden file.\n--- want ---\n%s\n--- got ---\n%s", want, got) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/game/ -run 'TestSnapshot|TestReplay'` +Expected: FAIL — `g.Snapshot undefined`. + +- [ ] **Step 3: Write the implementation** + +Append to `internal/game/game.go` (and add `"fmt"` and `"strings"` to its import block): + +```go +// Snapshot returns a stable, human-readable dump of the whole logical state. +// It is the assertion target for the seeded replay test and the fastest way to +// see what the engine actually did. Empty cells print as '.', filled cells as +// the letter of the kind that filled them. +func (g *Game) Snapshot() string { + var b strings.Builder + hold := "-" + if g.Hold != nil { + hold = string(g.Hold.Letter()) + } + next := make([]byte, 0, len(g.Next)) + for _, k := range g.Next { + next = append(next, k.Letter()) + } + fmt.Fprintf(&b, "seed: %d\n", g.Seed) + fmt.Fprintf(&b, "state: %s\n", g.State) + fmt.Fprintf(&b, "score: %d\n", g.Score) + fmt.Fprintf(&b, "lines: %d\n", g.Lines) + fmt.Fprintf(&b, "level: %d\n", g.Level) + fmt.Fprintf(&b, "combo: %d\n", g.Combo) + fmt.Fprintf(&b, "active: %c r%d (%d,%d)\n", g.Active.Kind.Letter(), g.Active.Rotation&3, g.Active.X, g.Active.Y) + fmt.Fprintf(&b, "hold: %s canhold: %t\n", hold, g.CanHold) + fmt.Fprintf(&b, "next: %s\n", next) + fmt.Fprintf(&b, "bagremaining: %d\n", g.Bag.Remaining()) + b.WriteString("board:\n") + for y := 0; y < Height; y++ { + for x := 0; x < Width; x++ { + c := g.CellAt(x, y) + if c.Filled() { + b.WriteByte(c.Kind().Letter()) + } else { + b.WriteByte('.') + } + } + b.WriteByte('\n') + } + return b.String() +} + +// CellAt returns the board cell at (x, y). It exists so Snapshot and the +// renderer read the board through one accessor. +func (g *Game) CellAt(x, y int) Cell { return g.Board.At(x, y) } +``` + +- [ ] **Step 4: Record the golden file and inspect it** + +```bash +go test ./internal/game/ -run TestReplayMatchesGolden -update +cat internal/game/testdata/replay-8675309.golden +``` + +Read the output. It must be a plausible mid-game state: `state: Playing` or `Over`, a non-negative score, a `board:` block of 22 lines of 10 characters, and a stack that looks like pieces were dropped (debris near the bottom, not scattered floating cells). If the board looks impossible, the bug is in the engine, not the golden file — fix the engine and re-record. + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v -run 'TestSnapshot|TestReplay'` +Expected: PASS, 6 tests. + +- [ ] **Step 6: Run the whole package with the race detector** + +Run: `go test ./internal/game/ -race -count=2` +Expected: PASS. `-count=2` catches state accidentally held in package-level variables. + +- [ ] **Step 7: Commit** + +```bash +gofmt -l . +git add internal/game/game.go internal/game/replay_test.go internal/game/testdata/replay-8675309.golden +git commit -m "test(game): snapshot dump and seeded replay determinism test" +``` + +--- + +### Task 14: Engine documentation and Phase 1 gate + +**Files:** +- Create: `README.md` +- Create: `LICENSE` +- Modify: `internal/game/game.go` (package-level doc only if anything is missing) + +**Interfaces:** +- Consumes: the whole engine. +- Produces: nothing new in code. This task is the gate design §42 puts at the end of Phase 1 — "tests must pass before proceeding". + +- [ ] **Step 1: Verify the Phase 1 checklist** + +Run each and confirm: + +```bash +go vet ./... +go test ./... -race +gofmt -l . +go doc ./internal/game +``` + +Expected: `go vet` silent, tests PASS, `gofmt -l` prints nothing, `go doc` lists `Advance`, `Apply`, `Board`, `Event`, `Game`, `New`, `Piece`, `Snapshot` with doc comments. Any exported identifier without a doc comment gets one now. + +- [ ] **Step 2: Confirm the engine is clean of clocks and outside dependencies** + +```bash +grep -rn 'time.Now\|os\.\|fmt.Print\|log\.' internal/game/*.go | grep -v _test.go +``` + +Expected: no output. (`fmt.Fprintf` into a `strings.Builder` in `Snapshot` is fine; `fmt.Print*` to stdout is not.) If anything is listed, remove it — design §49.2 and the global constraints forbid it. + +- [ ] **Step 3: Write the README** + +Create `README.md`: + +```markdown +# Cosmic Tetris + +A falling-block puzzle game occurring during a completely unnecessary +cosmological emergency. Runs entirely in your terminal. + +``` +cosmic-tetris +cosmic-tetris --seed 1234 +cosmic-tetris --ascii +cosmic-tetris --no-fx +cosmic-tetris --reduced-motion +``` + +## Layout + +``` +cmd/cosmic-tetris/ the binary +internal/game/ the rules engine: deterministic, headless, no clock +internal/app/ Bubble Tea model, update loop, key map +internal/render/ everything that turns state into terminal cells +internal/fx/ the spectacle: particles, starfield, shockwaves +internal/flavor/ mission control's commentary +``` + +## The engine + +`internal/game` is deliberately boring and completely deterministic. It never +reads a clock. Elapsed time arrives through `Advance(dt)` and player actions +through `Apply(Input)`; both report what happened as `[]Event`. The same seed +plus the same input and timing stream always reproduces the same state, which +`internal/game/replay_test.go` asserts against a recorded golden file. + +The engine owns one random generator, used only by the seven-bag. The effects +system owns a separate one, so particle randomness can never shift piece order. + +Effects observe game events. They never write back. + +```bash +go test ./... # everything +go test ./internal/game -run TestReplay -v # determinism +go test ./internal/game -update # re-record the replay golden +``` + +## Design + +`design.md` is the build spec. Section 49 pins the decisions that earlier +sections left open; where they disagree, section 49 wins. +``` + +- [ ] **Step 4: Add the license** + +```bash +curl -sL https://raw.githubusercontent.com/anthropics/.github/main/LICENSE -o /dev/null 2>/dev/null || true +``` + +That fetch is not required. Write `LICENSE` directly as the MIT license text with `Copyright (c) 2026 Jesse Vincent`, taken from https://opensource.org/license/mit — the standard 21-line MIT text, unmodified apart from the copyright line. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . +git add README.md LICENSE internal/game/ +git commit -m "docs: README and license; Phase 1 engine gate passed" +``` + +- [ ] **Step 6: Report the gate** + +State plainly whether every Phase 1 item in design §42 is covered and tested: pieces, board, bag, movement, rotation, gravity, locking, line clearing, hold, scoring, game over. If any is not, say so rather than proceeding to Plan 2. + +--- + +## Notes for the next plan + +Plan 2 (`plans/2026-09-17-cosmic-tetris-2-terminal.md`) builds the playable terminal on top of this package. The contract it depends on: + +```go +g := game.New(seed) // *game.Game +evs := g.Apply(game.InputLeft) // []game.Event +evs = g.Advance(16 * time.Millisecond) +g.Ghost() // game.Piece at its landing position +g.Board.At(x, y) // game.Cell +g.Active, g.Hold, g.Next // game.Piece, *game.PieceKind, []game.PieceKind +g.Score, g.Lines, g.Level, g.Combo, g.State, g.Seed +game.Width, game.Height, game.VisibleHeight, game.HiddenRows +game.Offsets[kind][rotation] // for drawing HOLD and NEXT previews +``` + +Nothing in Plan 2 or Plan 3 may add a `time.Now()` call to `internal/game`, and nothing may give `internal/game` a second random generator. diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/Q1-reader-cosmic-21/2026-09-17-cosmic-tetris-2-terminal.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/Q1-reader-cosmic-21/2026-09-17-cosmic-tetris-2-terminal.md new file mode 100644 index 000000000..4c9066fd5 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/Q1-reader-cosmic-21/2026-09-17-cosmic-tetris-2-terminal.md @@ -0,0 +1,2515 @@ +# Cosmic Tetris — Plan 2: The Playable Terminal + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Put the Plan 1 engine on screen as a genuinely good, fully playable terminal Tetris — board, ghost, next queue, hold, stats, adaptive layout, pause, help, game over, restart, ASCII fallback — with no cosmic effects yet. + +**Architecture:** `internal/render` owns a small cell buffer (`Grid`) of runes plus comparable `Ink` styling. Everything — board, panels, chrome, overlays — writes into that one buffer, which then coalesces styling runs into a single string. `Grid.Plain()` produces the unstyled text used by the golden tests, so the layout contract of design §41 is checked without ANSI noise. `internal/app` is the Bubble Tea Elm loop: one 60 Hz `FrameMsg` clock feeds elapsed time to `game.Advance(dt)`, while key presses call `game.Apply` immediately and never wait for a tick. + +**Tech Stack:** Go 1.26, `charm.land/bubbletea/v2` v2.0.9, `charm.land/lipgloss/v2` v2.0.6, `charm.land/bubbles/v2` v2.2.1 (`key` package), `github.com/charmbracelet/x/ansi` (`ansi.Strip`, test-only). + +**Spec:** `design.md` — the approved build spec. Section 49 pins decisions that earlier sections left open; where they disagree, §49 wins. §49.7 is load-bearing for this plan: the §4 mockup is mood and intent, **not** geometry, and the ANSI-stripped goldens written here are the binding layout contract. + +**Prerequisite:** Plan 1 (`plans/2026-09-17-cosmic-tetris-1-engine.md`) complete — `internal/game` exists and its tests pass. + +## Scope note: three sections pulled forward from design §42 + +Design §42 lists `palette` under Phase 3 and `help` plus `ASCII fallback` under +Phase 5. This plan implements all three, because §41 requires golden tests for +"help" and "ASCII mode" and §46 ships `--ascii` on day one: deferring them would +mean shipping a renderer whose own contract tests cannot be written. What stays +out of this plan is every *moving* thing — starfield, trails, animated border, +particles, supernova, shake, shockwaves, hyperdrive, four-line sequence, boot +sequence, black-hole collapse, and the mission-control message engine. Those are +Plan 3. Mission control renders here as a static line so its row exists in the +layout goldens. + +## Global Constraints + +- Module path `cosmic-tetris`; `go 1.26` directive. Dependencies are exactly the four in Tech Stack — add nothing else. +- `internal/render` may import `internal/game`, the standard library, and Lip Gloss. It must **not** import `internal/app` or `charm.land/bubbletea/v2`. +- `internal/app` may import everything. `cmd/cosmic-tetris` imports only `internal/app` and `internal/render` (for the mode flags) plus stdlib. +- **Rendering must never mutate game state** (§37). `internal/render` takes `*game.Game` and calls only reading methods on it. +- No `time.Now()` anywhere in `internal/render`. Time enters `internal/app` only, through `FrameMsg`. +- Nothing may add a `time.Now()` call to `internal/game`, and nothing may give `internal/game` a second random generator (§49.2, §49.6). +- One animation clock (§36): a single `FrameMsg` at `FrameInterval = 16ms` (~60 Hz). Input is handled the moment it arrives and never queued to a tick (§8, §44 "never make controls lag"). +- Every glyph written into a `Grid` cell must be exactly one terminal cell wide. Two-column blocks are written as two separate cells. +- Glyphs are pinned by §49.4: ghost `░░` in full/reduced and `··` in ASCII; pieces `██` in full/reduced and `[]` in ASCII; bright foreground, never a foreground+background pair; the active piece one step brighter than locked cells. +- Board geometry comes from `internal/game` constants, never from literals: `game.Width` (10), `game.Height` (22), `game.VisibleHeight` (20), `game.HiddenRows` (2). +- Minimum usable terminal is 40×24 (§31). Below that, the too-small notice. Resizing must never panic (§31, §41). +- Golden files live in `internal/render/testdata/*.golden`, hold `Grid.Plain()` output, and are refreshed with `go test ./internal/render -update`. +- Doc comment on every exported identifier. `gofmt -l .` must print nothing before every commit. + +## Review Focus + +Five things design.md implies but never says, ordered by how likely they are to +bite a player. Each has a test in the task that owns the code. + +1. **A zero or one-cell terminal size.** Bubble Tea delivers `WindowSizeMsg` before the first frame, and terminals under `tmux` or during a window drag report `0×0`. `Measure(0, 0)` and `Measure(1, 1)` must return `KindTooSmall` and render without panicking or dividing by zero — Task 3, and the notice itself in Task 6. +2. **A key pressed before the first `WindowSizeMsg`.** The model starts at width 0. A left-arrow arriving then must move the piece and must not panic when the resulting frame renders — Task 10. +3. **A frame after the process was suspended.** `ctrl-z` then `fg`, or a sleeping laptop, produces one `FrameMsg` whose elapsed time is minutes. That must not fast-forward the game through dozens of pieces; elapsed time per frame is clamped — Task 10. +4. **An overlay wider than the terminal.** The help box is 39 columns; a player at 40 columns who drags one column narrower must see it clipped, not a corrupted board or a panic — Task 8. +5. **A nine-digit score.** `Score` has no ceiling and the stat field is 8 columns. `100000000` must not widen the panel or bleed into the board — Task 5. + +--- + +## File Structure + +| File | Responsibility | +| --- | --- | +| `internal/render/grid.go` | `Ink`, `Grid`: the cell buffer, clipping, run-coalesced output | +| `internal/render/palette.go` | `Mode`, `Palette`, `Glyphs`: colors and glyphs per rendering mode | +| `internal/render/layout.go` | `Kind`, `Layout`, `Measure`: adaptive geometry (§31, §49.3) | +| `internal/render/board.go` | locked cells, ghost, active piece, board border | +| `internal/render/hud.go` | HOLD / NEXT previews, score / lines / level / combo | +| `internal/render/chrome.go` | titled frame, mission-control line, controls line, too-small notice | +| `internal/render/overlay.go` | pause, help, and game-over boxes | +| `internal/render/render.go` | `Phase`, `Options`, `RenderGrid`, `Render`: frame assembly | +| `internal/app/keys.go` | `KeyMap` and the §8 bindings | +| `internal/app/messages.go` | `FrameMsg`, `FrameInterval`, the frame command | +| `internal/app/model.go` | `Config`, `Model`, `New`, `Init`, `View` | +| `internal/app/update.go` | `Update`: keys, resize, frame stepping, restart | +| `cmd/cosmic-tetris/main.go` | flag parsing, `--help` text, program start | + +Test files sit beside their subjects (`grid_test.go`, `layout_test.go`, …), with +the frame-level golden tests in `internal/render/render_test.go` and +`internal/render/overlay_test.go`. + +--- + +## Task 1: The cell buffer + +Everything drawn in this plan goes through `Grid`. It exists so that panels +physically cannot corrupt the board (§41): a write outside the buffer is +dropped, and the last writer to a cell wins, so the compositing order of §37 is +the whole story. + +**Files:** +- Create: `internal/render/grid.go` +- Test: `internal/render/grid_test.go` + +**Interfaces:** +- Consumes: nothing from Plan 1. +- Produces: + - `type Ink struct { Fg string; Bold bool; Dim bool }` — comparable, zero value means terminal default + - `type Grid struct { W, H int; ... }` + - `func NewGrid(w, h int) *Grid` + - `func (g *Grid) Set(x, y int, r rune, ink Ink)` — silent no-op out of bounds + - `func (g *Grid) SetString(x, y int, s string, ink Ink) int` — returns the x after the last rune + - `func (g *Grid) At(x, y int) (rune, Ink)` — `(' ', Ink{})` out of bounds + - `func (g *Grid) Plain() string` + - `func (g *Grid) String() string` + +- [ ] **Step 1: Write the failing test** + +`internal/render/grid_test.go`: + +```go +package render + +import ( + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" +) + +func TestGridStartsBlank(t *testing.T) { + g := NewGrid(4, 2) + if g.W != 4 || g.H != 2 { + t.Fatalf("size = %dx%d, want 4x2", g.W, g.H) + } + if got, want := g.Plain(), "\n"; got != want { + t.Errorf("Plain() = %q, want %q", got, want) + } + r, ink := g.At(0, 0) + if r != ' ' || ink != (Ink{}) { + t.Errorf("At(0,0) = %q,%+v, want space and zero ink", r, ink) + } +} + +func TestGridSetAndRead(t *testing.T) { + g := NewGrid(4, 2) + g.Set(1, 1, 'X', Ink{Fg: "#FF0000"}) + r, ink := g.At(1, 1) + if r != 'X' || ink.Fg != "#FF0000" { + t.Errorf("At(1,1) = %q,%+v, want X and #FF0000", r, ink) + } + if got, want := g.Plain(), "\n X"; got != want { + t.Errorf("Plain() = %q, want %q", got, want) + } +} + +func TestGridSetStringClipsAtRightEdge(t *testing.T) { + g := NewGrid(4, 1) + if next := g.SetString(2, 0, "ABCD", Ink{}); next != 6 { + t.Errorf("SetString returned %d, want 6", next) + } + if got, want := g.Plain(), " AB"; got != want { + t.Errorf("Plain() = %q, want %q", got, want) + } +} + +func TestGridWritesOutsideBoundsAreDropped(t *testing.T) { + g := NewGrid(2, 2) + g.Set(-1, 0, 'X', Ink{}) + g.Set(0, -1, 'X', Ink{}) + g.Set(2, 0, 'X', Ink{}) + g.Set(0, 2, 'X', Ink{}) + g.SetString(-5, 0, "hello", Ink{}) + g.SetString(0, 99, "hello", Ink{}) + if got, want := g.Plain(), "\n"; got != want { + t.Errorf("Plain() = %q, want %q", got, want) + } +} + +func TestGridZeroSizeIsUsable(t *testing.T) { + g := NewGrid(0, 0) + g.Set(0, 0, 'X', Ink{}) + if got := g.Plain(); got != "" { + t.Errorf("Plain() = %q, want empty", got) + } + if got := g.String(); got != "" { + t.Errorf("String() = %q, want empty", got) + } +} + +func TestGridNegativeSizeIsClampedToZero(t *testing.T) { + g := NewGrid(-3, -9) + if g.W != 0 || g.H != 0 { + t.Fatalf("size = %dx%d, want 0x0", g.W, g.H) + } +} + +func TestGridLastWriterWins(t *testing.T) { + g := NewGrid(2, 1) + g.Set(0, 0, 'a', Ink{}) + g.Set(0, 0, 'b', Ink{Bold: true}) + r, ink := g.At(0, 0) + if r != 'b' || !ink.Bold { + t.Errorf("At(0,0) = %q,%+v, want b and bold", r, ink) + } +} + +func TestGridStringStrippedEqualsPlain(t *testing.T) { + g := NewGrid(8, 3) + g.SetString(0, 0, "██", Ink{Fg: "#22D3EE"}) + g.SetString(2, 0, "░░", Ink{Fg: "51", Dim: true}) + g.SetString(1, 2, "SCORE", Ink{Bold: true}) + if got, want := ansi.Strip(g.String()), g.Plain(); got != want { + t.Errorf("stripped String() = %q, want Plain() = %q", got, want) + } +} + +func TestGridStringCoalescesRuns(t *testing.T) { + g := NewGrid(6, 1) + cyan := Ink{Fg: "#22D3EE"} + for x := 0; x < 6; x++ { + g.Set(x, 0, '#', cyan) + } + out := g.String() + if n := strings.Count(out, "\x1b["); n == 0 { + t.Skip("lip gloss produced no styling in this environment") + } else if n > 2 { + t.Errorf("six identical cells produced %d escape sequences, want at most 2", n) + } +} + +func TestGridPlainHasOneLinePerRow(t *testing.T) { + g := NewGrid(3, 5) + if got, want := strings.Count(g.Plain(), "\n"), 4; got != want { + t.Errorf("newlines = %d, want %d", got, want) + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +go get charm.land/lipgloss/v2@v2.0.6 +go get github.com/charmbracelet/x/ansi +go test ./internal/render +``` + +Expected: FAIL — `undefined: NewGrid`. + +- [ ] **Step 3: Write the implementation** + +`internal/render/grid.go`: + +```go +// Package render turns game state into terminal output. +// +// Everything is drawn into a Grid: a fixed-size buffer of one-cell runes with +// styling attached per cell. Drawing is therefore order-dependent and +// bounds-safe — the last writer to a cell wins, and writes outside the buffer +// are dropped — which is how the HUD is prevented from corrupting the board and +// how a resize is prevented from panicking. +// +// The renderer never mutates game state. +package render + +import ( + "strings" + + "charm.land/lipgloss/v2" +) + +// Ink is the styling of a single cell. The zero Ink means the terminal's +// default foreground with no attributes. Ink is comparable so that adjacent +// cells sharing one can be emitted as a single styled run. +type Ink struct { + // Fg is a Lip Gloss color specification: a hex string such as "#22D3EE" in + // full mode, or a decimal ANSI palette index such as "51" in reduced and + // ASCII modes. Empty means the terminal default. + Fg string + // Bold brightens the cell. + Bold bool + // Dim faints the cell. + Dim bool +} + +func (i Ink) style() lipgloss.Style { + s := lipgloss.NewStyle() + if i.Fg != "" { + s = s.Foreground(lipgloss.Color(i.Fg)) + } + if i.Bold { + s = s.Bold(true) + } + if i.Dim { + s = s.Faint(true) + } + return s +} + +// Grid is a W by H buffer of terminal cells. +type Grid struct { + // W is the width in cells. + W int + // H is the height in cells. + H int + + runes []rune + inks []Ink +} + +// NewGrid returns a blank w by h grid. Negative dimensions are clamped to zero, +// so a terminal that reports a nonsense size still yields a usable grid. +func NewGrid(w, h int) *Grid { + if w < 0 { + w = 0 + } + if h < 0 { + h = 0 + } + g := &Grid{W: w, H: h, runes: make([]rune, w*h), inks: make([]Ink, w*h)} + for i := range g.runes { + g.runes[i] = ' ' + } + return g +} + +// Set writes one rune. Coordinates outside the grid are silently ignored. +// The rune must occupy exactly one terminal cell. +func (g *Grid) Set(x, y int, r rune, ink Ink) { + if x < 0 || y < 0 || x >= g.W || y >= g.H { + return + } + i := y*g.W + x + g.runes[i] = r + g.inks[i] = ink +} + +// SetString writes s left to right starting at (x, y), one cell per rune, +// clipping at the grid edges. It returns the x coordinate one past the last +// rune, whether or not that rune landed inside the grid. +func (g *Grid) SetString(x, y int, s string, ink Ink) int { + for _, r := range s { + g.Set(x, y, r, ink) + x++ + } + return x +} + +// At returns the rune and ink at (x, y), or a blank cell when out of bounds. +func (g *Grid) At(x, y int) (rune, Ink) { + if x < 0 || y < 0 || x >= g.W || y >= g.H { + return ' ', Ink{} + } + i := y*g.W + x + return g.runes[i], g.inks[i] +} + +// rowLen is the width of row y with trailing default-styled blanks removed, so +// that neither output form emits meaningless trailing whitespace. +func (g *Grid) rowLen(y int) int { + n := g.W + for n > 0 { + r, ink := g.At(n-1, y) + if r != ' ' || ink != (Ink{}) { + break + } + n-- + } + return n +} + +// Plain renders the grid as unstyled text, one line per row, with no ANSI +// escapes at all. This is what the golden tests compare against. +func (g *Grid) Plain() string { + var b strings.Builder + for y := 0; y < g.H; y++ { + if y > 0 { + b.WriteByte('\n') + } + n := g.rowLen(y) + for x := 0; x < n; x++ { + r, _ := g.At(x, y) + b.WriteRune(r) + } + } + return b.String() +} + +// String renders the grid for the terminal, coalescing horizontal runs of cells +// that share an Ink into a single styled string. +func (g *Grid) String() string { + var b, run strings.Builder + for y := 0; y < g.H; y++ { + if y > 0 { + b.WriteByte('\n') + } + n := g.rowLen(y) + for x := 0; x < n; { + _, ink := g.At(x, y) + run.Reset() + for x < n { + r, cur := g.At(x, y) + if cur != ink { + break + } + run.WriteRune(r) + x++ + } + b.WriteString(ink.style().Render(run.String())) + } + } + return b.String() +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +go test ./internal/render -v +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . +git add go.mod go.sum internal/render/grid.go internal/render/grid_test.go +git commit -m "feat(render): cell grid with clipping and run-coalesced output" +``` + +--- + +## Task 2: Rendering modes, colors, and glyphs + +Three modes (§32) and the pinned glyphs of §49.4. The width-guard test is the +important one: a single double-width rune sneaking into the glyph table shifts +every column to its right and silently destroys the board, and it would pass +every other test in this plan. + +**Files:** +- Create: `internal/render/palette.go` +- Test: `internal/render/palette_test.go` + +**Interfaces:** +- Consumes: `game.KindCount`, `game.PieceKind` and its `KindI…KindZ` constants; `Ink` from Task 1. +- Produces: + - `type Mode uint8` with `ModeFull`, `ModeReduced`, `ModeASCII`; `func (Mode) String() string` + - `type Glyphs struct { ... }` — `Block`, `Ghost` (two cells each), the board and frame box-drawing runes, `Comet`, `Star`, `ArrowLeftRight`, `ArrowUp`, `ArrowDown` + - `type Palette struct { Mode Mode; Glyphs Glyphs; Locked, Active [game.KindCount]Ink; Ghost, Border, Title, Label, Value, Mission, Controls, Overlay, Star Ink }` + - `func NewPalette(m Mode) Palette` + +- [ ] **Step 1: Write the failing test** + +`internal/render/palette_test.go`: + +```go +package render + +import ( + "reflect" + "testing" + + "charm.land/lipgloss/v2" + + "cosmic-tetris/internal/game" +) + +func TestModeString(t *testing.T) { + for _, tc := range []struct { + mode Mode + want string + }{ + {ModeFull, "full"}, + {ModeReduced, "reduced"}, + {ModeASCII, "ascii"}, + } { + if got := tc.mode.String(); got != tc.want { + t.Errorf("Mode(%d).String() = %q, want %q", tc.mode, got, tc.want) + } + } +} + +func TestPinnedGlyphs(t *testing.T) { + for _, tc := range []struct { + mode Mode + block, ghost string + }{ + {ModeFull, "██", "░░"}, + {ModeReduced, "██", "░░"}, + {ModeASCII, "[]", "··"}, + } { + p := NewPalette(tc.mode) + if p.Glyphs.Block != tc.block { + t.Errorf("%s block = %q, want %q", tc.mode, p.Glyphs.Block, tc.block) + } + if p.Glyphs.Ghost != tc.ghost { + t.Errorf("%s ghost = %q, want %q", tc.mode, p.Glyphs.Ghost, tc.ghost) + } + } +} + +// TestGlyphWidths is the guard that keeps the board from shearing sideways: a +// double-width rune in the glyph table would shift every column after it. +func TestGlyphWidths(t *testing.T) { + for _, mode := range []Mode{ModeFull, ModeReduced, ModeASCII} { + gl := reflect.ValueOf(NewPalette(mode).Glyphs) + typ := gl.Type() + for i := 0; i < gl.NumField(); i++ { + name := typ.Field(i).Name + switch f := gl.Field(i); f.Kind() { + case reflect.String: + s := f.String() + want := 1 + if name == "Block" || name == "Ghost" { + want = 2 + } + if got := lipgloss.Width(s); got != want { + t.Errorf("%s glyph %s = %q, width %d, want %d", mode, name, s, got, want) + } + case reflect.Int32: + s := string(rune(f.Int())) + if got := lipgloss.Width(s); got != 1 { + t.Errorf("%s glyph %s = %q, width %d, want 1", mode, name, s, got) + } + default: + t.Errorf("glyph field %s has unexpected kind %s", name, f.Kind()) + } + } + } +} + +func TestASCIIModeGlyphsAreLatin1(t *testing.T) { + gl := reflect.ValueOf(NewPalette(ModeASCII).Glyphs) + for i := 0; i < gl.NumField(); i++ { + f := gl.Field(i) + var runes []rune + if f.Kind() == reflect.String { + runes = []rune(f.String()) + } else { + runes = []rune{rune(f.Int())} + } + for _, r := range runes { + if r > 0xFF { + t.Errorf("ASCII glyph %s contains U+%04X, above Latin-1", gl.Type().Field(i).Name, r) + } + } + } +} + +func TestEveryKindHasDistinctLockedAndActiveInk(t *testing.T) { + for _, mode := range []Mode{ModeFull, ModeReduced, ModeASCII} { + p := NewPalette(mode) + seen := map[string]game.PieceKind{} + for k := game.PieceKind(0); k < game.KindCount; k++ { + locked, active := p.Locked[k], p.Active[k] + if locked.Fg == "" { + t.Errorf("%s: kind %c has no locked color", mode, k.Letter()) + } + if active.Fg == "" { + t.Errorf("%s: kind %c has no active color", mode, k.Letter()) + } + if locked == active { + t.Errorf("%s: kind %c renders identically locked and active", mode, k.Letter()) + } + if prev, dup := seen[locked.Fg]; dup { + t.Errorf("%s: kinds %c and %c share locked color %s", mode, prev.Letter(), k.Letter(), locked.Fg) + } + seen[locked.Fg] = k + } + } +} + +func TestChromeInksArePopulated(t *testing.T) { + for _, mode := range []Mode{ModeFull, ModeReduced, ModeASCII} { + p := NewPalette(mode) + for name, ink := range map[string]Ink{ + "Ghost": p.Ghost, "Border": p.Border, "Title": p.Title, + "Label": p.Label, "Value": p.Value, "Mission": p.Mission, + "Controls": p.Controls, "Overlay": p.Overlay, "Star": p.Star, + } { + if ink.Fg == "" { + t.Errorf("%s: chrome ink %s has no color", mode, name) + } + } + if p.Mode != mode { + t.Errorf("palette reports mode %s, want %s", p.Mode, mode) + } + } +} + +func TestGhostIsDim(t *testing.T) { + // Ghost must never compete with a locked block for attention (§10, §44). + if p := NewPalette(ModeFull); !p.Ghost.Dim { + t.Error("full-mode ghost ink is not dim") + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +go test ./internal/render -run 'Mode|Glyph|Palette|Kind|Chrome|Ghost' +``` + +Expected: FAIL — `undefined: NewPalette`. + +- [ ] **Step 3: Write the implementation** + +`internal/render/palette.go`: + +```go +package render + +import "cosmic-tetris/internal/game" + +// Mode is a rendering capability level (design §32). +type Mode uint8 + +// The three rendering modes. Full is the default; ASCII is selected with +// --ascii and assumes nothing about Unicode support beyond Latin-1. +const ( + ModeFull Mode = iota + ModeReduced + ModeASCII +) + +// String returns the mode's flag-style name. +func (m Mode) String() string { + switch m { + case ModeReduced: + return "reduced" + case ModeASCII: + return "ascii" + default: + return "full" + } +} + +// Glyphs are the characters a mode draws with. Block and Ghost are two terminal +// cells wide, matching one logical board cell (§5); every other glyph is one +// cell. Nothing here may be wider than stated — a wide rune shifts every column +// after it and corrupts the board. +type Glyphs struct { + // Block is a filled board cell (§49.4). + Block string + // Ghost is a landing-preview cell (§49.4). + Ghost string + + // The board's machinery border (§25). + BoardTL, BoardTR, BoardBL, BoardBR, BoardH, BoardV rune + // The outer titled frame (§4). + FrameTL, FrameTR, FrameBL, FrameBR, FrameH, FrameV rune + + // Comet prefixes the mission-control line (§27). + Comet rune + // Star decorates the title (§4). + Star rune + // Times separates terminal dimensions in the too-small notice (§31). + Times rune + + // Control-hint arrows for the controls line and help overlay (§8). + ArrowLeftRight, ArrowUp, ArrowDown string +} + +// Palette is a complete set of colors and glyphs for one rendering mode. +// Locked and Active are indexed by game.PieceKind; Active is the brighter of +// the pair, as pinned in §49.4. +type Palette struct { + // Mode is the mode this palette was built for. + Mode Mode + // Glyphs are the drawing characters for this mode. + Glyphs Glyphs + + // Locked styles settled board cells. + Locked [game.KindCount]Ink + // Active styles the falling piece, one step brighter. + Active [game.KindCount]Ink + + // Chrome inks. + Ghost, Border, Title, Label, Value, Mission, Controls, Overlay, Star Ink +} + +var unicodeGlyphs = Glyphs{ + Block: "██", + Ghost: "░░", + BoardTL: '╔', BoardTR: '╗', BoardBL: '╚', BoardBR: '╝', BoardH: '═', BoardV: '║', + FrameTL: '╭', FrameTR: '╮', FrameBL: '╰', FrameBR: '╯', FrameH: '─', FrameV: '│', + Comet: '☄', Star: '✦', Times: '×', + ArrowLeftRight: "←→", ArrowUp: "↑", ArrowDown: "↓", +} + +var asciiGlyphs = Glyphs{ + Block: "[]", + Ghost: "··", + BoardTL: '+', BoardTR: '+', BoardBL: '+', BoardBR: '+', BoardH: '=', BoardV: '|', + FrameTL: '+', FrameTR: '+', FrameBL: '+', FrameBR: '+', FrameH: '-', FrameV: '|', + Comet: '>', Star: '*', Times: 'x', + ArrowLeftRight: "<>", ArrowUp: "^", ArrowDown: "v", +} + +// The neon space palette of §26, one entry per kind in game.PieceKind order +// (I, J, L, O, S, T, Z): plasma cyan, deep electric blue, solar orange, +// stellar gold, alien green, ultraviolet, supernova pink. +var ( + fullLocked = [game.KindCount]string{"#22D3EE", "#3B5BFF", "#FF8A1F", "#FFD227", "#39FF7A", "#A855F7", "#FF3D71"} + fullActive = [game.KindCount]string{"#A5F3FC", "#93B4FF", "#FFC489", "#FFEE9C", "#A7FFC4", "#D8B4FE", "#FF9EB8"} + + reducedLocked = [game.KindCount]string{"51", "63", "208", "220", "47", "141", "197"} + reducedActive = [game.KindCount]string{"87", "105", "215", "229", "120", "183", "218"} + + asciiLocked = [game.KindCount]string{"6", "4", "3", "11", "2", "5", "1"} + asciiActive = [game.KindCount]string{"14", "12", "11", "15", "10", "13", "9"} +) + +// NewPalette returns the palette for a rendering mode. +func NewPalette(m Mode) Palette { + p := Palette{Mode: m, Glyphs: unicodeGlyphs} + locked, active := fullLocked, fullActive + + switch m { + case ModeReduced: + locked, active = reducedLocked, reducedActive + p.Ghost = Ink{Fg: "240", Dim: true} + p.Border = Ink{Fg: "93"} + p.Title = Ink{Fg: "189", Bold: true} + p.Label = Ink{Fg: "244"} + p.Value = Ink{Fg: "252", Bold: true} + p.Mission = Ink{Fg: "51"} + p.Controls = Ink{Fg: "244"} + p.Overlay = Ink{Fg: "189"} + p.Star = Ink{Fg: "220"} + case ModeASCII: + p.Glyphs = asciiGlyphs + locked, active = asciiLocked, asciiActive + p.Ghost = Ink{Fg: "8", Dim: true} + p.Border = Ink{Fg: "5"} + p.Title = Ink{Fg: "7", Bold: true} + p.Label = Ink{Fg: "8"} + p.Value = Ink{Fg: "7", Bold: true} + p.Mission = Ink{Fg: "6"} + p.Controls = Ink{Fg: "8"} + p.Overlay = Ink{Fg: "7"} + p.Star = Ink{Fg: "3"} + default: + p.Ghost = Ink{Fg: "#4B5563", Dim: true} + p.Border = Ink{Fg: "#7C3AED"} + p.Title = Ink{Fg: "#E0E7FF", Bold: true} + p.Label = Ink{Fg: "#64748B"} + p.Value = Ink{Fg: "#E2E8F0", Bold: true} + p.Mission = Ink{Fg: "#22D3EE"} + p.Controls = Ink{Fg: "#64748B"} + p.Overlay = Ink{Fg: "#E0E7FF"} + p.Star = Ink{Fg: "#FBBF24"} + } + + for k := 0; k < game.KindCount; k++ { + p.Locked[k] = Ink{Fg: locked[k]} + p.Active[k] = Ink{Fg: active[k], Bold: true} + } + return p +} +``` + +Note on `··`: U+00B7 is Latin-1, not strict 7-bit ASCII, but it is the glyph +pinned in §49.4 and it measures one cell. `TestASCIIModeGlyphsAreLatin1` holds +the line there so nothing more exotic creeps in. + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +go test ./internal/render -v +``` + +Expected: PASS. If `TestEveryKindHasDistinctLockedAndActiveInk` reports a shared +color, change the offending value in this file — do not weaken the test. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . +git add internal/render/palette.go internal/render/palette_test.go +git commit -m "feat(render): neon space palette and per-mode glyph tables" +``` + +--- + +## Task 3: Adaptive layout geometry + +Pure arithmetic: terminal size in, cell coordinates out. Everything drawn later +reads its position from a `Layout`, so this is the single place where §31 and +§49.3 are decided, and the sweep test at the end is what makes "resize doesn't +panic" (§41) true by construction rather than by luck. + +The §49.3 drop order is implemented as follows, and one honest consequence is +worth writing down: at the 40×24 floor, board (22 rows) + mission (1) + +controls (1) is exactly 24, so **the title frame is the only element that ever +actually drops for height** — mission control never has to. Stat labels drop in +the Small layout instead, for width: an 8-column panel has no room for +`LINES 042`, so it shows `042` alone, which is exactly what §49.3 asks for. + +**Files:** +- Create: `internal/render/layout.go` +- Test: `internal/render/layout_test.go` + +**Interfaces:** +- Consumes: `game.Width`, `game.VisibleHeight`. +- Produces: + - `type Kind uint8` with `KindTooSmall`, `KindSmall`, `KindMedium`, `KindWide`; `func (Kind) String() string` + - `const BoardW = 2*game.Width + 2` (22), `const BoardH = game.VisibleHeight + 2` (22) + - `const MinWidth = 40`, `const MinHeight = 24` + - `type Layout struct { ... }` with the fields listed in the implementation below — including `Frame`, `Mission`, `StatLabels`, `NextCount`, `SplitPanels`, `PanelW`, `ContentX/ContentW`, `FrameX/FrameY/FrameW/FrameH`, `BoardX/BoardY`, `LeftX`, `RightX`, `MissionY`, `ControlsY` + - `func Measure(w, h int) Layout` + +- [ ] **Step 1: Write the failing test** + +`internal/render/layout_test.go`: + +```go +package render + +import ( + "testing" + + "cosmic-tetris/internal/game" +) + +func TestBoardBoxDimensions(t *testing.T) { + if BoardW != 22 { + t.Errorf("BoardW = %d, want 22 (%d cells at two columns each plus two border columns)", BoardW, game.Width) + } + if BoardH != 22 { + t.Errorf("BoardH = %d, want 22 (%d visible rows plus two border rows)", BoardH, game.VisibleHeight) + } +} + +func TestKindString(t *testing.T) { + for _, tc := range []struct { + kind Kind + want string + }{ + {KindTooSmall, "too-small"}, + {KindSmall, "small"}, + {KindMedium, "medium"}, + {KindWide, "wide"}, + } { + if got := tc.kind.String(); got != tc.want { + t.Errorf("Kind(%d).String() = %q, want %q", tc.kind, got, tc.want) + } + } +} + +// Review Focus 1: a terminal that reports nothing, or almost nothing. +func TestDegenerateSizesAreTooSmall(t *testing.T) { + for _, size := range [][2]int{{0, 0}, {1, 1}, {-1, -1}, {39, 24}, {40, 23}, {39, 23}, {20, 60}, {200, 5}} { + l := Measure(size[0], size[1]) + if l.Kind != KindTooSmall { + t.Errorf("Measure(%d,%d).Kind = %s, want too-small", size[0], size[1], l.Kind) + } + if l.W != size[0] || l.H != size[1] { + t.Errorf("Measure(%d,%d) did not record its size: %+v", size[0], size[1], l) + } + } +} + +func TestFloorTerminalIsSmall(t *testing.T) { + l := Measure(MinWidth, MinHeight) + if l.Kind != KindSmall { + t.Fatalf("Kind = %s, want small", l.Kind) + } + if l.Frame { + t.Error("Frame is on at the 40x24 floor; the title border is the first thing to drop (§49.3)") + } + if !l.Mission { + t.Error("Mission dropped at the floor; board+mission+controls is exactly 24 rows") + } + if l.StatLabels { + t.Error("StatLabels on in the small layout; §49.3 keeps values and drops labels") + } + if l.NextCount != 3 { + t.Errorf("NextCount = %d, want 3 (§49.3 truncates NEXT beside the board)", l.NextCount) + } + if l.SplitPanels { + t.Error("SplitPanels on in the small layout; there is one panel, beside the board") + } + if l.PanelW != 8 { + t.Errorf("PanelW = %d, want 8", l.PanelW) + } + if l.ContentW != 31 { + t.Errorf("ContentW = %d, want 31 (22 board + 1 gap + 8 panel)", l.ContentW) + } + if l.BoardX != 4 || l.BoardY != 0 { + t.Errorf("board at (%d,%d), want (4,0)", l.BoardX, l.BoardY) + } + if l.RightX != 4+BoardW+1 { + t.Errorf("RightX = %d, want %d", l.RightX, 4+BoardW+1) + } + if l.MissionY != 22 || l.ControlsY != 23 { + t.Errorf("mission/controls at %d/%d, want 22/23", l.MissionY, l.ControlsY) + } + if l.FrameY != -1 { + t.Errorf("FrameY = %d, want -1 when there is no frame", l.FrameY) + } +} + +func TestMediumLayout(t *testing.T) { + l := Measure(44, 24) + if l.Kind != KindMedium { + t.Fatalf("Kind = %s, want medium", l.Kind) + } + if !l.StatLabels || l.NextCount != 5 || l.PanelW != 10 || l.SplitPanels { + t.Errorf("unexpected medium configuration: %+v", l) + } + if l.ContentW != 34 { + t.Errorf("ContentW = %d, want 34 (22 board + 2 gap + 10 panel)", l.ContentW) + } + if l.BoardX != 5 { + t.Errorf("BoardX = %d, want 5", l.BoardX) + } + if l.RightX != 5+BoardW+2 { + t.Errorf("RightX = %d, want %d", l.RightX, 5+BoardW+2) + } +} + +func TestWideLayoutWithFrame(t *testing.T) { + l := Measure(52, 26) + if l.Kind != KindWide { + t.Fatalf("Kind = %s, want wide", l.Kind) + } + if !l.SplitPanels { + t.Error("wide layout should split HOLD/stats left from NEXT right") + } + if !l.Frame { + t.Error("Frame off at 52x26, which is exactly the wide frame's size") + } + if l.ContentW != 46 { + t.Errorf("ContentW = %d, want 46 (10 + 2 + 22 + 2 + 10)", l.ContentW) + } + if l.FrameW != 52 || l.FrameX != 0 { + t.Errorf("frame at x=%d width %d, want x=0 width 52", l.FrameX, l.FrameW) + } + if l.ContentX != 3 || l.LeftX != 3 || l.BoardX != 15 || l.RightX != 39 { + t.Errorf("columns: content %d left %d board %d right %d, want 3/3/15/39", + l.ContentX, l.LeftX, l.BoardX, l.RightX) + } + if l.FrameY != 0 || l.FrameH != 26 { + t.Errorf("frame rows %d..%d, want 0..25", l.FrameY, l.FrameY+l.FrameH-1) + } + if l.BoardY != 1 || l.MissionY != 23 || l.ControlsY != 24 { + t.Errorf("rows: board %d mission %d controls %d, want 1/23/24", + l.BoardY, l.MissionY, l.ControlsY) + } +} + +// At 30 rows the whole content block floats, centered, inside the terminal +// rather than stretching the frame to the edges and stranding the controls. +func TestWideLayoutIsCenteredWhenRoomy(t *testing.T) { + l := Measure(80, 30) + if l.Kind != KindWide || !l.Frame { + t.Fatalf("Kind = %s, Frame = %v, want wide with a frame", l.Kind, l.Frame) + } + if l.FrameW != 52 || l.FrameX != 14 { + t.Errorf("frame at x=%d width %d, want x=14 width 52", l.FrameX, l.FrameW) + } + if l.FrameY != 1 || l.FrameH != 28 { + t.Errorf("frame rows %d..%d, want 1..28", l.FrameY, l.FrameY+l.FrameH-1) + } + if l.BoardY != 3 { + t.Errorf("BoardY = %d, want 3 (frame row 1, padding row 2, board from 3)", l.BoardY) + } + if l.MissionY != 26 || l.ControlsY != 27 { + t.Errorf("mission/controls at %d/%d, want 26/27", l.MissionY, l.ControlsY) + } +} + +func TestFrameDropsForHeightOnly(t *testing.T) { + if l := Measure(52, 25); l.Frame { + t.Error("Frame on at 25 rows; the frame costs two rows on top of the 24-row content") + } + if l := Measure(51, 40); l.Kind == KindWide { + t.Error("51 columns should not select the wide layout") + } + for _, w := range []int{40, 43, 44, 51, 52, 200} { + if l := Measure(w, 26); !l.Frame { + t.Errorf("Frame off at %dx26; every layout's frame fits its own minimum width", w) + } + } +} + +// The sweep is the real contract: nothing Measure returns may point outside the +// terminal, at any size, ever (§41 "resize doesn't panic"). +func TestMeasureNeverEscapesTheTerminal(t *testing.T) { + for w := -2; w <= 140; w++ { + for h := -2; h <= 60; h++ { + l := Measure(w, h) + if l.Kind == KindTooSmall { + continue + } + if l.BoardX < 0 || l.BoardX+BoardW > w { + t.Fatalf("%dx%d: board columns %d..%d escape the terminal", w, h, l.BoardX, l.BoardX+BoardW-1) + } + if l.BoardY < 0 || l.BoardY+BoardH > h { + t.Fatalf("%dx%d: board rows %d..%d escape the terminal", w, h, l.BoardY, l.BoardY+BoardH-1) + } + if l.RightX+l.PanelW > w { + t.Fatalf("%dx%d: right panel ends at %d, past the terminal", w, h, l.RightX+l.PanelW) + } + if l.SplitPanels && (l.LeftX < 0 || l.LeftX+l.PanelW > l.BoardX) { + t.Fatalf("%dx%d: left panel %d..%d overlaps the board at %d", w, h, l.LeftX, l.LeftX+l.PanelW-1, l.BoardX) + } + if l.ControlsY >= h || l.ControlsY <= l.BoardY+BoardH-1 { + t.Fatalf("%dx%d: controls row %d collides with the board or the bottom edge", w, h, l.ControlsY) + } + if l.Mission && (l.MissionY >= l.ControlsY || l.MissionY < l.BoardY+BoardH) { + t.Fatalf("%dx%d: mission row %d is not between the board and the controls", w, h, l.MissionY) + } + if l.Frame { + if l.FrameX < 0 || l.FrameX+l.FrameW > w { + t.Fatalf("%dx%d: frame columns %d..%d escape the terminal", w, h, l.FrameX, l.FrameX+l.FrameW-1) + } + if l.FrameY < 0 || l.FrameY+l.FrameH > h { + t.Fatalf("%dx%d: frame rows %d..%d escape the terminal", w, h, l.FrameY, l.FrameY+l.FrameH-1) + } + if l.ContentX < l.FrameX+1 { + t.Fatalf("%dx%d: content at %d is inside the frame border at %d", w, h, l.ContentX, l.FrameX) + } + if l.BoardY <= l.FrameY || l.ControlsY >= l.FrameY+l.FrameH-1 { + t.Fatalf("%dx%d: board row %d / controls row %d fall outside frame rows %d..%d", + w, h, l.BoardY, l.ControlsY, l.FrameY, l.FrameY+l.FrameH-1) + } + } else if l.FrameY != -1 { + t.Fatalf("%dx%d: FrameY = %d without a frame, want -1", w, h, l.FrameY) + } + } + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +go test ./internal/render -run 'Board|Kind|Degenerate|Floor|Medium|Wide|Frame|Measure' +``` + +Expected: FAIL — `undefined: Measure`. + +- [ ] **Step 3: Write the implementation** + +`internal/render/layout.go`: + +```go +package render + +import "cosmic-tetris/internal/game" + +// Board box dimensions in terminal cells. A logical board cell is two columns +// wide and one row tall (§5), and the board is wrapped in a one-cell border. +const ( + // BoardW is the width of the bordered board box. + BoardW = 2*game.Width + 2 + // BoardH is the height of the bordered board box. + BoardH = game.VisibleHeight + 2 +) + +// The minimum usable terminal (§31). Anything smaller gets the too-small notice. +const ( + // MinWidth is the narrowest terminal the game will draw in. + MinWidth = 40 + // MinHeight is the shortest terminal the game will draw in. + MinHeight = 24 +) + +// Content widths, before the frame's border and padding. +const ( + widePanelW = 10 + smallPanelW = 8 + + wideContentW = widePanelW + 2 + BoardW + 2 + widePanelW // 46 + mediumContentW = BoardW + 2 + widePanelW // 34 + smallContentW = BoardW + 1 + smallPanelW // 31 + + // frameChrome is the columns the outer frame adds: a border column and two + // padding columns on each side. + frameChrome = 6 + + // The narrowest terminal each layout will accept. Wide asks for the columns + // its frame needs so that the roomiest layout always gets its title. + wideMinWidth = wideContentW + frameChrome // 52 + mediumMinWidth = 44 +) + +// Kind is which of the four layout shapes fits the terminal. +type Kind uint8 + +// The layout shapes, narrowest first. +const ( + // KindTooSmall means the terminal is below 40x24 and only the notice is drawn. + KindTooSmall Kind = iota + // KindSmall is board plus a narrow panel: three NEXT pieces, no stat labels. + KindSmall + // KindMedium is board plus one compact HUD column (§31). + KindMedium + // KindWide is HOLD and stats left, board centered, NEXT right (§31). + KindWide +) + +// String returns the layout's name, as used in golden file names. +func (k Kind) String() string { + switch k { + case KindSmall: + return "small" + case KindMedium: + return "medium" + case KindWide: + return "wide" + default: + return "too-small" + } +} + +// Layout is the resolved geometry of one frame: which elements are present and +// where each one starts. All coordinates are cell coordinates inside a Grid of +// the terminal's size, and Measure guarantees every one of them is inside it. +type Layout struct { + // Kind is the layout shape. + Kind Kind + // W and H are the terminal size this layout was measured for. + W, H int + + // Frame is true when the outer titled frame is drawn (§4). + Frame bool + // Mission is true when the mission-control line is drawn (§27). + Mission bool + // StatLabels is true when stats show their labels as well as their values. + StatLabels bool + // NextCount is how many upcoming pieces the NEXT panel shows (§49.3). + NextCount int + // SplitPanels is true when HOLD and stats sit left of the board and NEXT + // sits right of it; otherwise a single panel sits right of the board. + SplitPanels bool + // PanelW is the width of a panel column. + PanelW int + + // ContentX and ContentW bound the block the frame wraps, and are what the + // mission and controls lines center themselves on. + ContentX, ContentW int + // FrameX, FrameY, FrameW and FrameH bound the outer frame box. FrameY is -1 + // when there is no frame; the others are then meaningless. + FrameX, FrameY, FrameW, FrameH int + + // BoardX and BoardY are the top-left cell of the bordered board box. + BoardX, BoardY int + // LeftX is the left panel column. Meaningless unless SplitPanels. + LeftX int + // RightX is the right panel column. + RightX int + + // MissionY is the mission-control row, or -1 when there is none. + MissionY int + // ControlsY is the controls row, or -1 when the terminal is too small. + ControlsY int +} + +// Measure resolves the layout for a terminal of w by h cells. It never panics +// and never returns a coordinate outside the terminal; for anything below +// 40x24 it returns KindTooSmall with only W and H set. +func Measure(w, h int) Layout { + l := Layout{W: w, H: h, Kind: KindTooSmall, FrameY: -1, MissionY: -1, ControlsY: -1} + if w < MinWidth || h < MinHeight { + return l + } + + gap := 2 + switch { + case w >= wideMinWidth: + l.Kind = KindWide + l.ContentW, l.PanelW = wideContentW, widePanelW + l.SplitPanels, l.StatLabels, l.NextCount = true, true, game.NextQueueLen + case w >= mediumMinWidth: + l.Kind = KindMedium + l.ContentW, l.PanelW = mediumContentW, widePanelW + l.StatLabels, l.NextCount = true, game.NextQueueLen + default: + l.Kind = KindSmall + l.ContentW, l.PanelW = smallContentW, smallPanelW + l.NextCount = 3 + gap = 1 + } + + // Mission control always fits: 22 board rows + mission + controls is + // exactly the 24-row floor. The frame is the only element that ever drops + // for height (§49.3), and every layout's frame fits its own minimum width by + // construction — 37 columns at 40, 40 at 44, 52 at 52 — so height decides. + l.Mission = true + l.Frame = h >= MinHeight+2 + + if l.Frame { + l.FrameW = l.ContentW + frameChrome + l.FrameX = (w - l.FrameW) / 2 + l.ContentX = l.FrameX + 3 + } else { + l.ContentX = (w - l.ContentW) / 2 + } + + if l.SplitPanels { + l.LeftX = l.ContentX + l.BoardX = l.ContentX + l.PanelW + gap + } else { + l.BoardX = l.ContentX + } + l.RightX = l.BoardX + BoardW + gap + + // The rows form one content block, top to bottom: frame top, a padding row, + // the board, a blank row, mission control, controls, frame bottom. The + // padding row and the blank row are dropped, in that order, when the + // terminal is too short for them; the block is then centered vertically so a + // tall terminal does not strand the controls at the very bottom. + frameRows, padTop, gapRows := 0, 0, 1 + if l.Frame { + frameRows, padTop = 2, 1 + } + need := frameRows + padTop + BoardH + gapRows + 2 // + mission + controls + if need > h { + gapRows, need = 0, need-1 + } + if need > h && padTop > 0 { + padTop, need = 0, need-1 + } + + y := (h - need) / 2 + if l.Frame { + l.FrameY, l.FrameH = y, need + y++ + } + y += padTop + l.BoardY = y + y += BoardH + gapRows + l.MissionY = y + l.ControlsY = y + 1 + + return l +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +go test ./internal/render -v +``` + +Expected: PASS, including the full sweep. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . +git add internal/render/layout.go internal/render/layout_test.go +git commit -m "feat(render): adaptive layout geometry for the four terminal sizes" +``` + +--- + +## Task 4: The board, ghost, and active piece + +Steps 3 through 7 of the §37 pipeline. Locked cells, then ghost, then the active +piece, then the border — in that order, because the buffer's last writer wins and +that is what makes "ghost never obscures locked blocks" (§10) and "never obscure +the active piece" (§44) true. + +**Files:** +- Create: `internal/render/board.go` +- Test: `internal/render/board_test.go` + +**Interfaces:** +- Consumes: `Grid`, `Ink`, `Palette`, `Layout`, `BoardW`, `BoardH`; `game.Game` and its `Board`, `Active`, `State`, `Ghost()`; `game.Width`, `game.Height`, `game.HiddenRows`; `game.Cell.Filled()`, `game.Cell.Kind()`; `game.Piece.Blocks()`; `game.StatePlaying`. +- Produces: + - `func DrawBoard(gr *Grid, g *game.Game, l Layout, p Palette)` + - `func drawBox(gr *Grid, x, y, w, h int, tl, tr, bl, br, hz, vt rune, ink Ink)` — unexported, reused by the frame and the overlays + - `func CellOrigin(l Layout, x, y int) (gx, gy int, visible bool)` — exported because Plan 3's board-local effects need the same mapping + +- [ ] **Step 1: Write the failing test** + +`internal/render/board_test.go`: + +```go +package render + +import ( + "math/rand/v2" + "testing" + + "cosmic-tetris/internal/game" +) + +// testGame returns a game with a known active piece and an empty board, so +// renderer tests never depend on what the bag produced. +func testGame(t *testing.T) *game.Game { + t.Helper() + g := game.New(1) + g.Active = game.Piece{Kind: game.KindO, Rotation: 0, X: 4, Y: 2} + g.Board = game.Board{} + return g +} + +func TestCellOriginMapsVisibleRows(t *testing.T) { + l := Measure(80, 30) + // Row 2 is the first visible row; row 21 is the floor. + if gx, gy, ok := CellOrigin(l, 0, game.HiddenRows); !ok || gx != l.BoardX+1 || gy != l.BoardY+1 { + t.Errorf("CellOrigin(0,%d) = %d,%d,%v; want %d,%d,true", game.HiddenRows, gx, gy, ok, l.BoardX+1, l.BoardY+1) + } + if gx, gy, ok := CellOrigin(l, game.Width-1, game.Height-1); !ok || + gx != l.BoardX+1+2*(game.Width-1) || gy != l.BoardY+BoardH-2 { + t.Errorf("bottom-right cell = %d,%d,%v", gx, gy, ok) + } +} + +func TestCellOriginRejectsHiddenAndOutOfRange(t *testing.T) { + l := Measure(80, 30) + for _, c := range [][2]int{{0, 0}, {0, 1}, {0, -1}, {-1, 5}, {game.Width, 5}, {0, game.Height}} { + if _, _, ok := CellOrigin(l, c[0], c[1]); ok { + t.Errorf("CellOrigin(%d,%d) reported visible", c[0], c[1]) + } + } +} + +func TestBoardBorderIsDrawn(t *testing.T) { + l := Measure(80, 30) + gr := NewGrid(l.W, l.H) + p := NewPalette(ModeFull) + DrawBoard(gr, testGame(t), l, p) + + corners := []struct { + x, y int + want rune + }{ + {l.BoardX, l.BoardY, '╔'}, + {l.BoardX + BoardW - 1, l.BoardY, '╗'}, + {l.BoardX, l.BoardY + BoardH - 1, '╚'}, + {l.BoardX + BoardW - 1, l.BoardY + BoardH - 1, '╝'}, + {l.BoardX + 1, l.BoardY, '═'}, + {l.BoardX, l.BoardY + 1, '║'}, + } + for _, c := range corners { + if r, ink := gr.At(c.x, c.y); r != c.want { + t.Errorf("at (%d,%d) got %q, want %q", c.x, c.y, r, c.want) + } else if ink != p.Border { + t.Errorf("border ink at (%d,%d) = %+v, want %+v", c.x, c.y, ink, p.Border) + } + } +} + +func TestNothingIsDrawnOutsideTheBoardBox(t *testing.T) { + l := Measure(80, 30) + gr := NewGrid(l.W, l.H) + g := testGame(t) + for x := 0; x < game.Width; x++ { + g.Board.Set(x, game.Height-1, game.FilledCell(game.KindZ)) + } + DrawBoard(gr, g, l, NewPalette(ModeFull)) + + for y := 0; y < l.H; y++ { + for x := 0; x < l.W; x++ { + inside := x >= l.BoardX && x < l.BoardX+BoardW && y >= l.BoardY && y < l.BoardY+BoardH + if inside { + continue + } + if r, _ := gr.At(x, y); r != ' ' { + t.Fatalf("DrawBoard wrote %q at (%d,%d), outside the board box", r, x, y) + } + } + } +} + +func TestLockedCellsRenderAtTheirCoordinates(t *testing.T) { + l := Measure(80, 30) + gr := NewGrid(l.W, l.H) + p := NewPalette(ModeFull) + g := testGame(t) + g.Board.Set(0, game.Height-1, game.FilledCell(game.KindI)) + g.Board.Set(game.Width-1, game.Height-1, game.FilledCell(game.KindZ)) + DrawBoard(gr, g, l, p) + + gx, gy, _ := CellOrigin(l, 0, game.Height-1) + r0, ink0 := gr.At(gx, gy) + r1, _ := gr.At(gx+1, gy) + if r0 != '█' || r1 != '█' { + t.Errorf("locked cell rendered %q%q, want two full blocks", r0, r1) + } + if ink0 != p.Locked[game.KindI] { + t.Errorf("locked I ink = %+v, want %+v", ink0, p.Locked[game.KindI]) + } + gx, gy, _ = CellOrigin(l, game.Width-1, game.Height-1) + if _, ink := gr.At(gx, gy); ink != p.Locked[game.KindZ] { + t.Errorf("locked Z ink = %+v, want %+v", ink, p.Locked[game.KindZ]) + } +} + +func TestHiddenRowsAreNeverDrawn(t *testing.T) { + l := Measure(80, 30) + gr := NewGrid(l.W, l.H) + g := testGame(t) + g.State = game.StateOver // no ghost, no active piece: only the board itself + for x := 0; x < game.Width; x++ { + g.Board.Set(x, 0, game.FilledCell(game.KindT)) + g.Board.Set(x, 1, game.FilledCell(game.KindT)) + } + DrawBoard(gr, g, l, NewPalette(ModeFull)) + + for y := l.BoardY + 1; y < l.BoardY+BoardH-1; y++ { + for x := l.BoardX + 1; x < l.BoardX+BoardW-1; x++ { + if r, _ := gr.At(x, y); r != ' ' { + t.Fatalf("hidden board rows leaked %q into the visible board at (%d,%d)", r, x, y) + } + } + } +} + +// A piece lifted above the ceiling by a (0,-1) wall kick has blocks at negative +// y. Drawing must skip them rather than write outside the board. +func TestActivePieceAboveTheCeilingDrawsNothing(t *testing.T) { + l := Measure(80, 30) + gr := NewGrid(l.W, l.H) + g := testGame(t) + g.Active = game.Piece{Kind: game.KindI, Rotation: 1, X: 3, Y: -4} + DrawBoard(gr, g, l, NewPalette(ModeFull)) + + for y := l.BoardY; y < l.BoardY+3; y++ { + for x := l.BoardX + 1; x < l.BoardX+BoardW-1; x++ { + if r, _ := gr.At(x, y); r == '█' { + t.Fatalf("a block from above the ceiling rendered at (%d,%d)", x, y) + } + } + } +} + +func TestGhostSitsBeneathTheActivePieceAndNotOnLockedCells(t *testing.T) { + l := Measure(80, 30) + gr := NewGrid(l.W, l.H) + p := NewPalette(ModeFull) + g := testGame(t) + g.Active = game.Piece{Kind: game.KindO, X: 4, Y: 2} + DrawBoard(gr, g, l, p) + + // The active O renders in bright ink at its own rows. + for _, b := range g.Active.Blocks() { + gx, gy, ok := CellOrigin(l, b[0], b[1]) + if !ok { + continue + } + if r, ink := gr.At(gx, gy); r != '█' || ink != p.Active[game.KindO] { + t.Errorf("active block at (%d,%d) = %q %+v, want a bright block", b[0], b[1], r, ink) + } + } + // The ghost renders in dim ink at the landing position. + ghost := g.Ghost() + if ghost.Y <= g.Active.Y { + t.Fatalf("ghost Y = %d, not below active Y = %d", ghost.Y, g.Active.Y) + } + for _, b := range ghost.Blocks() { + gx, gy, ok := CellOrigin(l, b[0], b[1]) + if !ok { + continue + } + if r, ink := gr.At(gx, gy); r != '░' || ink != p.Ghost { + t.Errorf("ghost block at (%d,%d) = %q %+v, want a dim shade", b[0], b[1], r, ink) + } + } +} + +// §10: ghost rendering must never obscure locked blocks. A legal ghost position +// is collision-free by construction, so this asserts the property across a lot of +// debris-filled boards rather than trying to hand-build an illegal overlap — it +// is the test that would catch a future effect drawing over settled cells. +func TestLockedCellsAlwaysSurviveGhostAndActiveDrawing(t *testing.T) { + l := Measure(80, 30) + p := NewPalette(ModeFull) + rng := rand.New(rand.NewPCG(7, 11)) + + for trial := 0; trial < 200; trial++ { + g := game.New(int64(trial)) + for y := game.Height / 2; y < game.Height; y++ { + for x := 0; x < game.Width; x++ { + if rng.IntN(3) == 0 { + g.Board.Set(x, y, game.FilledCell(game.PieceKind(rng.IntN(game.KindCount)))) + } + } + } + if g.Board.Collides(g.Active) { + continue + } + + gr := NewGrid(l.W, l.H) + DrawBoard(gr, g, l, p) + + for y := game.HiddenRows; y < game.Height; y++ { + for x := 0; x < game.Width; x++ { + c := g.Board.At(x, y) + if !c.Filled() { + continue + } + gx, gy, _ := CellOrigin(l, x, y) + r, ink := gr.At(gx, gy) + if r != '█' || ink != p.Locked[c.Kind()] { + t.Fatalf("trial %d: locked cell (%d,%d) rendered as %q %+v", trial, x, y, r, ink) + } + } + } + } +} + +func TestActivePieceOverwritesTheGhost(t *testing.T) { + l := Measure(80, 30) + gr := NewGrid(l.W, l.H) + p := NewPalette(ModeFull) + g := testGame(t) + // A piece already at rest: ghost and active occupy the same cells. + g.Active = game.Piece{Kind: game.KindO, X: 4, Y: game.Height - 2} + DrawBoard(gr, g, l, p) + for _, b := range g.Active.Blocks() { + gx, gy, _ := CellOrigin(l, b[0], b[1]) + if r, ink := gr.At(gx, gy); r != '█' || ink != p.Active[game.KindO] { + t.Errorf("grounded piece at (%d,%d) = %q %+v, want the active piece on top", b[0], b[1], r, ink) + } + } +} + +func TestGameOverDrawsNoGhostOrActivePiece(t *testing.T) { + l := Measure(80, 30) + gr := NewGrid(l.W, l.H) + g := testGame(t) + g.State = game.StateOver + g.Board.Set(0, game.Height-1, game.FilledCell(game.KindI)) + DrawBoard(gr, g, l, NewPalette(ModeFull)) + + if r, _ := gr.At(CellOriginXY(l, 0, game.Height-1)); r != '█' { + t.Error("locked cells should survive game over (§28: do not instantly replace the board)") + } + blocks := 0 + for y := l.BoardY + 1; y < l.BoardY+BoardH-1; y++ { + for x := l.BoardX + 1; x < l.BoardX+BoardW-1; x++ { + if r, _ := gr.At(x, y); r == '█' || r == '░' { + blocks++ + } + } + } + if blocks != 2 { + t.Errorf("%d block halves drawn at game over, want 2 (the single locked cell)", blocks) + } +} + +func TestASCIIModeUsesASCIIBlocks(t *testing.T) { + l := Measure(80, 30) + gr := NewGrid(l.W, l.H) + g := testGame(t) + g.Board.Set(0, game.Height-1, game.FilledCell(game.KindI)) + DrawBoard(gr, g, l, NewPalette(ModeASCII)) + + gx, gy, _ := CellOrigin(l, 0, game.Height-1) + r0, _ := gr.At(gx, gy) + r1, _ := gr.At(gx+1, gy) + if r0 != '[' || r1 != ']' { + t.Errorf("ASCII locked cell = %q%q, want []", r0, r1) + } + ghost := g.Ghost() + gx, gy, _ = CellOrigin(l, ghost.Blocks()[0][0], ghost.Blocks()[0][1]) + if r, _ := gr.At(gx, gy); r != '·' { + t.Errorf("ASCII ghost = %q, want ·", r) + } + if r, _ := gr.At(l.BoardX, l.BoardY); r != '+' { + t.Errorf("ASCII board corner = %q, want +", r) + } +} +``` + +`CellOriginXY` is a two-value convenience the test above uses; add it beside +`CellOrigin`: + +```go +// CellOriginXY is CellOrigin without the visibility flag, for callers that have +// already checked the row is on screen. +func CellOriginXY(l Layout, x, y int) (int, int) { + gx, gy, _ := CellOrigin(l, x, y) + return gx, gy +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +go test ./internal/render -run 'CellOrigin|Board|Ghost|Active|GameOver|ASCII' +``` + +Expected: FAIL — `undefined: DrawBoard`. + +- [ ] **Step 3: Write the implementation** + +`internal/render/board.go`: + +```go +package render + +import "cosmic-tetris/internal/game" + +// CellOrigin maps a logical board coordinate to the grid coordinate of the left +// half of that cell, reporting false when the cell is off the board or in one of +// the two hidden spawn rows, which are never drawn. +func CellOrigin(l Layout, x, y int) (gx, gy int, visible bool) { + if x < 0 || x >= game.Width || y < game.HiddenRows || y >= game.Height { + return 0, 0, false + } + return l.BoardX + 1 + 2*x, l.BoardY + 1 + (y - game.HiddenRows), true +} + +// CellOriginXY is CellOrigin without the visibility flag, for callers that have +// already checked the row is on screen. +func CellOriginXY(l Layout, x, y int) (int, int) { + gx, gy, _ := CellOrigin(l, x, y) + return gx, gy +} + +// drawBoardCell paints one logical board cell, skipping hidden and off-board +// coordinates. glyph must be exactly two cells wide. +func drawBoardCell(gr *Grid, l Layout, x, y int, glyph string, ink Ink) { + gx, gy, ok := CellOrigin(l, x, y) + if !ok { + return + } + gr.SetString(gx, gy, glyph, ink) +} + +// drawBox outlines a w by h rectangle with its top-left corner at (x, y). The +// board border, the outer frame, and the overlays all use it. +func drawBox(gr *Grid, x, y, w, h int, tl, tr, bl, br, hz, vt rune, ink Ink) { + if w < 2 || h < 2 { + return + } + for i := 1; i < w-1; i++ { + gr.Set(x+i, y, hz, ink) + gr.Set(x+i, y+h-1, hz, ink) + } + for j := 1; j < h-1; j++ { + gr.Set(x, y+j, vt, ink) + gr.Set(x+w-1, y+j, vt, ink) + } + gr.Set(x, y, tl, ink) + gr.Set(x+w-1, y, tr, ink) + gr.Set(x, y+h-1, bl, ink) + gr.Set(x+w-1, y+h-1, br, ink) +} + +// DrawBoard draws the board box and its contents: locked cells, then the ghost, +// then the active piece — steps 3 through 7 of the §37 pipeline. Later writes +// win, which is how the ghost stays under the active piece and off locked cells. +// It reads game state and never modifies it. +func DrawBoard(gr *Grid, g *game.Game, l Layout, p Palette) { + gl := p.Glyphs + drawBox(gr, l.BoardX, l.BoardY, BoardW, BoardH, + gl.BoardTL, gl.BoardTR, gl.BoardBL, gl.BoardBR, gl.BoardH, gl.BoardV, p.Border) + + for y := game.HiddenRows; y < game.Height; y++ { + for x := 0; x < game.Width; x++ { + if c := g.Board.At(x, y); c.Filled() { + drawBoardCell(gr, l, x, y, gl.Block, p.Locked[c.Kind()]) + } + } + } + + if g.State != game.StatePlaying { + return + } + + for _, b := range g.Ghost().Blocks() { + x, y := b[0], b[1] + if y < game.HiddenRows || g.Board.At(x, y).Filled() { + continue + } + drawBoardCell(gr, l, x, y, gl.Ghost, p.Ghost) + } + for _, b := range g.Active.Blocks() { + drawBoardCell(gr, l, b[0], b[1], gl.Block, p.Active[g.Active.Kind]) + } +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +go test ./internal/render -v +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . +git add internal/render/board.go internal/render/board_test.go +git commit -m "feat(render): board box, locked cells, ghost, and active piece" +``` + +--- + +## Task 5: HOLD, NEXT, and the stats + +Step 8 of the §37 pipeline. Panels are drawn strictly inside their own columns — +`Layout` already guarantees those columns do not overlap the board, so the +"HUD doesn't corrupt board" goal of §41 reduces to "never write outside your +panel", which the tests here check exhaustively. + +Piece previews always draw rotation 0, and every kind's rotation 0 occupies only +rows 0 and 1 of its 4×4 box, so a preview is exactly `PreviewH` rows tall. + +**Files:** +- Create: `internal/render/hud.go` +- Test: `internal/render/hud_test.go` + +**Interfaces:** +- Consumes: `Grid`, `Palette`, `Layout`; `game.Game` fields `Hold`, `CanHold`, `Next`, `Score`, `Lines`, `Level`, `Combo`; `game.Offsets`; `game.PieceKind`. +- Produces: + - `const PreviewW = 8`, `const PreviewH = 2`, `const StatFieldW = 8` + - `func DrawPanels(gr *Grid, g *game.Game, l Layout, p Palette)` + - `func StatText(n, width int) string` — exported so the game-over overlay and tests share one formatter + - `func PanelContentX(x, panelW int) int` + +- [ ] **Step 1: Write the failing test** + +`internal/render/hud_test.go`: + +```go +package render + +import ( + "strings" + "testing" + + "cosmic-tetris/internal/game" +) + +func TestStatText(t *testing.T) { + for _, tc := range []struct { + n, width int + want string + }{ + {0, 8, "00000000"}, + {129340, 8, "00129340"}, + {42, 3, "042"}, + {7, 2, "07"}, + {99999999, 8, "99999999"}, + // Review Focus 5: nine digits and beyond must still fit the field. + {100000000, 8, " 100M"}, + {123456789, 8, " 123M"}, + {4000000000, 8, " 4B"}, + {123, 2, "99"}, + } { + got := StatText(tc.n, tc.width) + if got != tc.want { + t.Errorf("StatText(%d, %d) = %q, want %q", tc.n, tc.width, got, tc.want) + } + if len(got) != tc.width { + t.Errorf("StatText(%d, %d) = %q, width %d, want width %d", tc.n, tc.width, got, len(got), tc.width) + } + } +} + +// hudGame is a game with every HUD-visible field set to a known value. +func hudGame(t *testing.T) *game.Game { + t.Helper() + g := game.New(1) + g.Board = game.Board{} + g.Active = game.Piece{Kind: game.KindT, X: 3, Y: 2} + g.Next = []game.PieceKind{game.KindI, game.KindJ, game.KindL, game.KindO, game.KindS} + hold := game.KindZ + g.Hold = &hold + g.CanHold = true + g.Score, g.Lines, g.Level, g.Combo = 129340, 42, 7, 3 + return g +} + +// panelText returns rows [y0,y1) of the panel column starting at x, trimmed. +func panelText(gr *Grid, x, w, y0, y1 int) []string { + var out []string + for y := y0; y < y1; y++ { + var b strings.Builder + for i := 0; i < w; i++ { + r, _ := gr.At(x+i, y) + b.WriteRune(r) + } + out = append(out, strings.TrimRight(b.String(), " ")) + } + return out +} + +func TestWidePanelsCarryEveryStat(t *testing.T) { + l := Measure(80, 30) + gr := NewGrid(l.W, l.H) + p := NewPalette(ModeFull) + DrawPanels(gr, hudGame(t), l, p) + + left := panelText(gr, l.LeftX, l.PanelW, l.BoardY, l.BoardY+BoardH) + for _, want := range []struct { + row int + text string + }{ + {1, " HOLD"}, + {7, " SCORE"}, + {8, " 00129340"}, + {10, " LINES"}, + {11, " 042"}, + {13, " LEVEL"}, + {14, " 07"}, + {16, " COMBO"}, + {17, " 03"}, + } { + if left[want.row] != want.text { + t.Errorf("left panel row %d = %q, want %q", want.row, left[want.row], want.text) + } + } + + right := panelText(gr, l.RightX, l.PanelW, l.BoardY, l.BoardY+BoardH) + if right[1] != " NEXT" { + t.Errorf("right panel row 1 = %q, want \" NEXT\"", right[1]) + } + // Five previews, two rows each, one blank row between them. + for i, row := range []int{3, 6, 9, 12, 15} { + if strings.TrimSpace(right[row]+right[row+1]) == "" { + t.Errorf("next preview %d (rows %d..%d) is blank", i, row, row+1) + } + if row+2 < len(right) && strings.TrimSpace(right[row+2]) != "" { + t.Errorf("row %d should be the blank row after preview %d, got %q", row+2, i, right[row+2]) + } + } +} + +func TestMediumPanelIsOneColumn(t *testing.T) { + l := Measure(44, 30) + gr := NewGrid(l.W, l.H) + DrawPanels(gr, hudGame(t), l, NewPalette(ModeFull)) + + rows := panelText(gr, l.RightX, l.PanelW, l.BoardY, l.BoardY+BoardH) + if rows[0] != " NEXT" { + t.Errorf("row 0 = %q, want \" NEXT\"", rows[0]) + } + if rows[12] != " HOLD" { + t.Errorf("row 12 = %q, want \" HOLD\"", rows[12]) + } + for _, want := range []struct { + row int + text string + }{{16, " SCORE"}, {17, " 00129340"}, {18, " LINES"}, {19, " 042"}, {20, " LEVEL"}, {21, " 07"}} { + if rows[want.row] != want.text { + t.Errorf("row %d = %q, want %q", want.row, rows[want.row], want.text) + } + } +} + +func TestSmallPanelDropsLabelsAndTruncatesNext(t *testing.T) { + l := Measure(40, 24) + gr := NewGrid(l.W, l.H) + DrawPanels(gr, hudGame(t), l, NewPalette(ModeFull)) + + rows := panelText(gr, l.RightX, l.PanelW, l.BoardY, l.BoardY+BoardH) + joined := strings.Join(rows, "\n") + for _, label := range []string{"SCORE", "LINES", "LEVEL", "COMBO"} { + if strings.Contains(joined, label) { + t.Errorf("small panel still shows the %q label (§49.3 keeps values only)", label) + } + } + if rows[0] != "NEXT" || rows[8] != "HOLD" { + t.Errorf("headings = %q / %q, want NEXT / HOLD", rows[0], rows[8]) + } + for _, want := range []struct { + row int + text string + }{{13, "00129340"}, {15, "042"}, {17, "07"}} { + if rows[want.row] != want.text { + t.Errorf("row %d = %q, want %q", want.row, rows[want.row], want.text) + } + } + // Only three previews: rows 7 and beyond hold no fourth or fifth piece. + if strings.TrimSpace(rows[7]) != "" { + t.Errorf("row 7 = %q, want blank: NEXT truncates to three pieces", rows[7]) + } +} + +func TestEmptyHoldDrawsNothing(t *testing.T) { + l := Measure(80, 30) + gr := NewGrid(l.W, l.H) + g := hudGame(t) + g.Hold = nil + DrawPanels(gr, g, l, NewPalette(ModeFull)) + + rows := panelText(gr, l.LeftX, l.PanelW, l.BoardY+3, l.BoardY+5) + for i, row := range rows { + if strings.TrimSpace(row) != "" { + t.Errorf("hold preview row %d = %q, want blank", i, row) + } + } +} + +func TestSpentHoldIsDimmed(t *testing.T) { + l := Measure(80, 30) + p := NewPalette(ModeFull) + g := hudGame(t) + g.CanHold = false + gr := NewGrid(l.W, l.H) + DrawPanels(gr, g, l, p) + + x := PanelContentX(l.LeftX, l.PanelW) + found := false + for y := l.BoardY + 3; y < l.BoardY+5; y++ { + for i := 0; i < PreviewW; i++ { + if r, ink := gr.At(x+i, y); r == '█' { + found = true + if ink != p.Ghost { + t.Errorf("spent hold ink = %+v, want the dim ghost ink %+v", ink, p.Ghost) + } + } + } + } + if !found { + t.Error("no hold preview drawn at all") + } +} + +// Review Focus 5, and the §41 promise that the HUD cannot corrupt the board: at +// every layout, with absurd values, panels write only inside their own columns. +func TestPanelsNeverWriteOutsideTheirColumns(t *testing.T) { + for _, size := range [][2]int{{40, 24}, {44, 30}, {80, 30}, {200, 60}} { + l := Measure(size[0], size[1]) + g := hudGame(t) + g.Score, g.Lines, g.Level, g.Combo = 4000000000, 99999, 999, 999 + gr := NewGrid(l.W, l.H) + DrawPanels(gr, g, l, NewPalette(ModeFull)) + + for y := 0; y < l.H; y++ { + for x := 0; x < l.W; x++ { + r, _ := gr.At(x, y) + if r == ' ' { + continue + } + inLeft := l.SplitPanels && x >= l.LeftX && x < l.LeftX+l.PanelW + inRight := x >= l.RightX && x < l.RightX+l.PanelW + inRows := y >= l.BoardY && y < l.BoardY+BoardH + if !inRows || !(inLeft || inRight) { + t.Fatalf("%dx%d: panel wrote %q at (%d,%d), outside its columns", l.W, l.H, r, x, y) + } + } + } + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +go test ./internal/render -run 'Stat|Panel|Hold' +``` + +Expected: FAIL — `undefined: DrawPanels`. + +- [ ] **Step 3: Write the implementation** + +`internal/render/hud.go`: + +```go +package render + +import ( + "strconv" + "strings" + + "cosmic-tetris/internal/game" +) + +// Piece preview and stat field geometry, in terminal cells. +const ( + // PreviewW is the width of a piece preview: four board cells at two columns each. + PreviewW = 2 * 4 + // PreviewH is the height of a piece preview. Every kind's spawn rotation + // occupies only the top two rows of its 4x4 box. + PreviewH = 2 + // StatFieldW is the width of a stat value field, sized for an eight-digit score. + StatFieldW = 8 +) + +// PanelContentX returns the column where a panel's content starts, centering the +// eight-cell content area inside a panel of panelW columns. +func PanelContentX(x, panelW int) int { + return x + (panelW-StatFieldW)/2 +} + +// StatText renders n into exactly width characters: zero-padded when the digits +// fit, as in the 00129340 of §4, and magnitude-suffixed and right-aligned when +// they do not, so that no score can ever widen a panel into the board. +func StatText(n, width int) string { + if width < 1 { + return "" + } + if s := strconv.Itoa(n); len(s) <= width { + return strings.Repeat("0", width-len(s)) + s + } + for _, u := range []struct { + div int + suf string + }{{1_000_000_000, "B"}, {1_000_000, "M"}, {1_000, "K"}} { + if n >= u.div { + if s := strconv.Itoa(n/u.div) + u.suf; len(s) <= width { + return strings.Repeat(" ", width-len(s)) + s + } + } + } + return strings.Repeat("9", width) +} + +// comboText shows a combo only once it is worth points (§49.1: the first +// clearing placement is combo 1 and scores no bonus). +func comboText(c int) string { + if c < 2 { + return "--" + } + return StatText(c, 2) +} + +// drawPreview draws one piece in its spawn rotation with its top-left at (x, y). +// A spent hold is drawn dim, which is the only signal a player gets that hold is +// unavailable until the active piece locks (§9). +func drawPreview(gr *Grid, x, y int, kind game.PieceKind, p Palette, dim bool) { + ink := p.Locked[kind] + if dim { + ink = p.Ghost + } + for _, b := range game.Offsets[kind][0] { + gr.SetString(x+2*b[0], y+b[1], p.Glyphs.Block, ink) + } +} + +// DrawPanels draws HOLD, NEXT, and the stats in whichever arrangement the layout +// calls for. Every write stays inside the panel columns Measure reserved. +func DrawPanels(gr *Grid, g *game.Game, l Layout, p Palette) { + switch l.Kind { + case KindWide: + drawWidePanels(gr, g, l, p) + case KindMedium: + drawMediumPanel(gr, g, l, p) + case KindSmall: + drawSmallPanel(gr, g, l, p) + } +} + +func drawHold(gr *Grid, x, y int, g *game.Game, p Palette) { + if g.Hold == nil { + return + } + drawPreview(gr, x, y, *g.Hold, p, !g.CanHold) +} + +func drawNext(gr *Grid, x, y, stride int, g *game.Game, l Layout, p Palette) { + for i := 0; i < l.NextCount && i < len(g.Next); i++ { + drawPreview(gr, x, y+i*stride, g.Next[i], p, false) + } +} + +// drawWidePanels: HOLD and the stats on the left, NEXT on the right (§31). +func drawWidePanels(gr *Grid, g *game.Game, l Layout, p Palette) { + lx, rx, top := PanelContentX(l.LeftX, l.PanelW), PanelContentX(l.RightX, l.PanelW), l.BoardY + + gr.SetString(lx, top+1, "HOLD", p.Label) + drawHold(gr, lx, top+3, g, p) + gr.SetString(lx, top+7, "SCORE", p.Label) + gr.SetString(lx, top+8, StatText(g.Score, StatFieldW), p.Value) + gr.SetString(lx, top+10, "LINES", p.Label) + gr.SetString(lx, top+11, StatText(g.Lines, 3), p.Value) + gr.SetString(lx, top+13, "LEVEL", p.Label) + gr.SetString(lx, top+14, StatText(g.Level, 2), p.Value) + gr.SetString(lx, top+16, "COMBO", p.Label) + gr.SetString(lx, top+17, comboText(g.Combo), p.Value) + + gr.SetString(rx, top+1, "NEXT", p.Label) + drawNext(gr, rx, top+3, PreviewH+1, g, l, p) +} + +// drawMediumPanel: one compact column holding NEXT, HOLD, and three stats. +// The previews sit on consecutive two-row slots — there is no room for gaps +// between them once HOLD and the stats have their rows. +func drawMediumPanel(gr *Grid, g *game.Game, l Layout, p Palette) { + x, top := PanelContentX(l.RightX, l.PanelW), l.BoardY + + gr.SetString(x, top, "NEXT", p.Label) + drawNext(gr, x, top+1, PreviewH, g, l, p) + gr.SetString(x, top+12, "HOLD", p.Label) + drawHold(gr, x, top+13, g, p) + gr.SetString(x, top+16, "SCORE", p.Label) + gr.SetString(x, top+17, StatText(g.Score, StatFieldW), p.Value) + gr.SetString(x, top+18, "LINES", p.Label) + gr.SetString(x, top+19, StatText(g.Lines, 3), p.Value) + gr.SetString(x, top+20, "LEVEL", p.Label) + gr.SetString(x, top+21, StatText(g.Level, 2), p.Value) +} + +// drawSmallPanel: three NEXT pieces, HOLD, and bare stat values (§49.3). +func drawSmallPanel(gr *Grid, g *game.Game, l Layout, p Palette) { + x, top := PanelContentX(l.RightX, l.PanelW), l.BoardY + + gr.SetString(x, top, "NEXT", p.Label) + drawNext(gr, x, top+1, PreviewH, g, l, p) + gr.SetString(x, top+8, "HOLD", p.Label) + drawHold(gr, x, top+9, g, p) + gr.SetString(x, top+13, StatText(g.Score, StatFieldW), p.Value) + gr.SetString(x, top+15, StatText(g.Lines, 3), p.Value) + gr.SetString(x, top+17, StatText(g.Level, 2), p.Value) +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +go test ./internal/render -v +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . +git add internal/render/hud.go internal/render/hud_test.go +git commit -m "feat(render): HOLD, NEXT, and stat panels for all three layouts" +``` + +--- + +## Task 6: Frame, mission control, controls, and the too-small notice + +The chrome around the board: steps 11 and 12 of §37, the titled frame of §4, and +the below-minimum notice of §31. The controls line has to shrink with the +layout — the mockup's 61-character hint does not fit a 40-column terminal — so +each layout gets its own string, and a test pins each one inside its content +width. + +**Files:** +- Create: `internal/render/chrome.go` +- Test: `internal/render/chrome_test.go` + +**Interfaces:** +- Consumes: `Grid`, `Ink`, `Palette`, `Layout`, `drawBox`. +- Produces: + - `const DefaultMission = "NOMINALISH"` + - `func UniverseID(seed int64) string` + - `func ControlsText(l Layout, p Palette) string` + - `func DrawFrame(gr *Grid, l Layout, p Palette, seed int64)` + - `func DrawMission(gr *Grid, l Layout, p Palette, msg string)` + - `func DrawControls(gr *Grid, l Layout, p Palette)` + - `func DrawTooSmall(gr *Grid, w, h int, p Palette)` + - `func Cells(s string) int`, `func Truncate(s string, n int) string`, `func DrawCentered(gr *Grid, x, y, w int, s string, ink Ink)` — shared text helpers, also used by the overlays in Task 8 + +- [ ] **Step 1: Write the failing test** + +`internal/render/chrome_test.go`: + +```go +package render + +import ( + "strings" + "testing" +) + +func TestUniverseIDIsFourHexDigitsOfTheSeed(t *testing.T) { + for _, tc := range []struct { + seed int64 + want string + }{{0, "0000"}, {0x7F3A, "7F3A"}, {1234, "04D2"}, {-1, "FFFF"}, {0x1234ABCD, "ABCD"}} { + if got := UniverseID(tc.seed); got != tc.want { + t.Errorf("UniverseID(%d) = %q, want %q", tc.seed, got, tc.want) + } + } +} + +// rowText reads a whole grid row as a string, trailing blanks trimmed. +func rowText(gr *Grid, y int) string { + var b strings.Builder + for x := 0; x < gr.W; x++ { + r, _ := gr.At(x, y) + b.WriteRune(r) + } + return strings.TrimRight(b.String(), " ") +} + +func TestFrameTitleRow(t *testing.T) { + l := Measure(80, 30) + gr := NewGrid(l.W, l.H) + p := NewPalette(ModeFull) + DrawFrame(gr, l, p, 0x7F3A) + + title := rowText(gr, l.FrameY) + if !strings.HasPrefix(strings.TrimLeft(title, " "), "╭─ ✦ COSMIC TETRIS ─") { + t.Errorf("title row = %q, want it to open with the framed title", title) + } + if !strings.Contains(title, "LOCAL UNIVERSE 7F3A") { + t.Errorf("title row = %q, want the universe label", title) + } + if !strings.HasSuffix(title, "╮") { + t.Errorf("title row = %q, want it to close with a corner", title) + } + if got := Cells(strings.TrimLeft(title, " ")); got != l.FrameW { + t.Errorf("title row spans %d cells, want FrameW = %d", got, l.FrameW) + } +} + +func TestFrameSidesAndBottom(t *testing.T) { + l := Measure(80, 30) + gr := NewGrid(l.W, l.H) + DrawFrame(gr, l, NewPalette(ModeFull), 1) + + for y := l.FrameY + 1; y < l.FrameY+l.FrameH-1; y++ { + if r, _ := gr.At(l.FrameX, y); r != '│' { + t.Errorf("left border at row %d = %q, want │", y, r) + } + if r, _ := gr.At(l.FrameX+l.FrameW-1, y); r != '│' { + t.Errorf("right border at row %d = %q, want │", y, r) + } + } + bottom := rowText(gr, l.FrameY+l.FrameH-1) + if !strings.HasPrefix(strings.TrimLeft(bottom, " "), "╰─") || !strings.HasSuffix(bottom, "╯") { + t.Errorf("bottom row = %q, want a closed frame", bottom) + } +} + +func TestNarrowFrameDropsTheUniverseLabel(t *testing.T) { + l := Measure(40, 26) // small layout: a 37-column frame + gr := NewGrid(l.W, l.H) + DrawFrame(gr, l, NewPalette(ModeFull), 0x7F3A) + + title := rowText(gr, l.FrameY) + if !strings.Contains(title, "COSMIC TETRIS") { + t.Errorf("title row = %q, want the title even in a narrow frame", title) + } + if strings.Contains(title, "LOCAL UNIVERSE") { + t.Errorf("title row = %q, want the universe label dropped when it does not fit", title) + } +} + +func TestFrameIsNotDrawnWhenAbsent(t *testing.T) { + l := Measure(40, 24) + gr := NewGrid(l.W, l.H) + DrawFrame(gr, l, NewPalette(ModeFull), 1) + if got := gr.Plain(); strings.TrimSpace(got) != "" { + t.Errorf("DrawFrame drew something with Frame off:\n%s", got) + } +} + +func TestMissionLine(t *testing.T) { + l := Measure(80, 30) + gr := NewGrid(l.W, l.H) + p := NewPalette(ModeFull) + DrawMission(gr, l, p, "GRAVITY TAX INCREASED") + + got := strings.TrimLeft(rowText(gr, l.MissionY), " ") + if got != "☄ MISSION CONTROL: GRAVITY TAX INCREASED" { + t.Errorf("mission row = %q", got) + } + if r, ink := gr.At(l.ContentX, l.MissionY); r != '☄' || ink != p.Star { + t.Errorf("comet = %q %+v, want the star ink", r, ink) + } +} + +func TestMissionLineDefaultsAndTruncates(t *testing.T) { + l := Measure(40, 24) + gr := NewGrid(l.W, l.H) + DrawMission(gr, l, NewPalette(ModeFull), "") + if got := rowText(gr, l.MissionY); !strings.Contains(got, DefaultMission) { + t.Errorf("mission row = %q, want the default message", got) + } + + gr = NewGrid(l.W, l.H) + DrawMission(gr, l, NewPalette(ModeFull), strings.Repeat("LONG ", 40)) + row := strings.TrimLeft(rowText(gr, l.MissionY), " ") + if Cells(row) > l.ContentW { + t.Errorf("mission row spans %d cells, want at most ContentW = %d", Cells(row), l.ContentW) + } +} + +func TestMissionLineInASCIIMode(t *testing.T) { + l := Measure(80, 30) + gr := NewGrid(l.W, l.H) + DrawMission(gr, l, NewPalette(ModeASCII), "MOON NOTIFIED") + if got := strings.TrimLeft(rowText(gr, l.MissionY), " "); got != "> MISSION CONTROL: MOON NOTIFIED" { + t.Errorf("ASCII mission row = %q", got) + } +} + +func TestControlsTextFitsEveryLayout(t *testing.T) { + for _, size := range [][2]int{{40, 24}, {44, 30}, {80, 30}, {200, 60}} { + l := Measure(size[0], size[1]) + for _, mode := range []Mode{ModeFull, ModeASCII} { + p := NewPalette(mode) + text := ControlsText(l, p) + if n := Cells(text); n > l.ContentW { + t.Errorf("%s %s controls %q span %d cells, want at most %d", l.Kind, mode, text, n, l.ContentW) + } + for _, must := range []string{"SPACE", "?"} { + if !strings.Contains(text, must) { + t.Errorf("%s %s controls %q omit %q", l.Kind, mode, text, must) + } + } + } + } +} + +func TestControlsRowIsCenteredAndModeAware(t *testing.T) { + l := Measure(80, 30) + gr := NewGrid(l.W, l.H) + DrawControls(gr, l, NewPalette(ModeFull)) + row := rowText(gr, l.ControlsY) + if !strings.Contains(row, "←→") || !strings.Contains(row, "SPACE yeet") { + t.Errorf("controls row = %q", row) + } + lead := len(row) - len(strings.TrimLeft(row, " ")) + if lead <= l.ContentX { + t.Errorf("controls row starts at %d, want it centered inside the content block at %d", lead, l.ContentX) + } + + gr = NewGrid(l.W, l.H) + DrawControls(gr, l, NewPalette(ModeASCII)) + row = rowText(gr, l.ControlsY) + if strings.ContainsAny(row, "←→↑↓") { + t.Errorf("ASCII controls row = %q, want no Unicode arrows", row) + } + if !strings.Contains(row, "<>") { + t.Errorf("ASCII controls row = %q, want the ASCII arrows", row) + } +} + +func TestTooSmallNotice(t *testing.T) { + gr := NewGrid(34, 19) + DrawTooSmall(gr, 34, 19, NewPalette(ModeFull)) + out := gr.Plain() + for _, want := range []string{"THIS UNIVERSE IS TOO SMALL", "resize terminal to continue", "34 × 19", "40 × 24"} { + if !strings.Contains(out, want) { + t.Errorf("notice is missing %q:\n%s", want, out) + } + } +} + +func TestTooSmallNoticeInASCIIMode(t *testing.T) { + gr := NewGrid(34, 19) + DrawTooSmall(gr, 34, 19, NewPalette(ModeASCII)) + out := gr.Plain() + if strings.Contains(out, "×") { + t.Errorf("ASCII notice contains a Unicode multiplication sign:\n%s", out) + } + if !strings.Contains(out, "34 x 19") { + t.Errorf("ASCII notice is missing the current size:\n%s", out) + } +} + +// Review Focus 1: the notice itself must survive a terminal with no room at all. +func TestTooSmallNoticeAtDegenerateSizes(t *testing.T) { + for _, size := range [][2]int{{0, 0}, {1, 1}, {2, 8}, {80, 1}} { + gr := NewGrid(size[0], size[1]) + DrawTooSmall(gr, size[0], size[1], NewPalette(ModeFull)) + lines := strings.Split(gr.Plain(), "\n") + if size[1] > 0 && len(lines) != size[1] { + t.Errorf("%dx%d: notice produced %d lines, want %d", size[0], size[1], len(lines), size[1]) + } + for _, line := range lines { + if Cells(line) > size[0] { + t.Errorf("%dx%d: notice line %q is wider than the terminal", size[0], size[1], line) + } + } + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +go test ./internal/render -run 'Universe|Frame|Mission|Controls|TooSmall' +``` + +Expected: FAIL — `undefined: UniverseID`. + +- [ ] **Step 3: Write the implementation** + +`internal/render/chrome.go`: + +```go +package render + +import ( + "fmt" + "unicode/utf8" +) + +// DefaultMission is the mission-control message shown when nothing else has +// anything to say. Plan 3 replaces the source of these strings; the row itself +// exists from here on so the layout never shifts underneath it. +const DefaultMission = "NOMINALISH" + +// Cells is how many terminal cells a string occupies in a Grid: one per rune, +// which every glyph in this package is required to be. +func Cells(s string) int { return utf8.RuneCountInString(s) } + +// Truncate cuts s to at most n cells. +func Truncate(s string, n int) string { + if n <= 0 { + return "" + } + if Cells(s) <= n { + return s + } + return string([]rune(s)[:n]) +} + +// DrawCentered writes s centered in the span [x, x+w), truncated to fit. +func DrawCentered(gr *Grid, x, y, w int, s string, ink Ink) { + s = Truncate(s, w) + gr.SetString(x+(w-Cells(s))/2, y, s, ink) +} + +// UniverseID is the four hex digits the title bar labels this universe with, +// taken from the seed so that the same universe always has the same name (§4). +func UniverseID(seed int64) string { + return fmt.Sprintf("%04X", uint16(seed)) +} + +// DrawFrame draws the outer titled frame, including the universe label when +// there is room for it. It draws nothing when the layout has no frame. +func DrawFrame(gr *Grid, l Layout, p Palette, seed int64) { + if !l.Frame { + return + } + gl := p.Glyphs + drawBox(gr, l.FrameX, l.FrameY, l.FrameW, l.FrameH, + gl.FrameTL, gl.FrameTR, gl.FrameBL, gl.FrameBR, gl.FrameH, gl.FrameV, p.Border) + + y := l.FrameY + x := gr.SetString(l.FrameX+2, y, " ", p.Border) + gr.Set(x, y, gl.Star, p.Star) + x = gr.SetString(x+1, y, " ", p.Border) + x = gr.SetString(x, y, "COSMIC TETRIS", p.Title) + titleEnd := gr.SetString(x, y, " ", p.Border) + + label := "LOCAL UNIVERSE " + UniverseID(seed) + lx := l.FrameX + l.FrameW - 4 - (Cells(label) + 2) + if lx >= titleEnd+2 { + lx = gr.SetString(lx, y, " ", p.Border) + lx = gr.SetString(lx, y, label, p.Title) + gr.SetString(lx, y, " ", p.Border) + } +} + +// DrawMission draws the one-line status channel (§27), left-aligned on the +// content block and truncated to it. +func DrawMission(gr *Grid, l Layout, p Palette, msg string) { + if !l.Mission || l.MissionY < 0 { + return + } + if msg == "" { + msg = DefaultMission + } + gr.Set(l.ContentX, l.MissionY, p.Glyphs.Comet, p.Star) + gr.SetString(l.ContentX+2, l.MissionY, Truncate("MISSION CONTROL: "+msg, l.ContentW-2), p.Mission) +} + +// ControlsText is the key hint for a layout, sized to that layout's content +// width. The 61-character line in the §4 mockup does not fit a 40-column +// terminal, so each layout gets as much of it as it can hold. +func ControlsText(l Layout, p Palette) string { + gl := p.Glyphs + move := gl.ArrowLeftRight + " " + gl.ArrowUp + " " + gl.ArrowDown + switch l.Kind { + case KindWide: + return move + " SPACE yeet c hold p pause ? help" + case KindMedium: + return move + " SPACE c hold ? help" + default: + return move + " SPACE c ?" + } +} + +// DrawControls draws the key hints, centered on the content block. +func DrawControls(gr *Grid, l Layout, p Palette) { + if l.ControlsY < 0 { + return + } + DrawCentered(gr, l.ContentX, l.ControlsY, l.ContentW, ControlsText(l, p), p.Controls) +} + +// DrawTooSmall draws the below-minimum notice of §31, centered in whatever space +// exists. It is the one thing drawn when Measure reports KindTooSmall, and it +// must survive a terminal that reports no usable size at all. +func DrawTooSmall(gr *Grid, w, h int, p Palette) { + times := string(p.Glyphs.Times) + lines := []string{ + "THIS UNIVERSE IS TOO SMALL", + "", + "resize terminal to continue", + "", + fmt.Sprintf("current: %d %s %d", w, times, h), + fmt.Sprintf("needed: approximately %d %s %d", MinWidth, times, MinHeight), + } + y := (h - len(lines)) / 2 + if y < 0 { + y = 0 + } + for i, line := range lines { + DrawCentered(gr, 0, y+i, w, line, p.Overlay) + } +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +go test ./internal/render -v +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . +git add internal/render/chrome.go internal/render/chrome_test.go internal/render/palette.go +git commit -m "feat(render): titled frame, mission line, controls, too-small notice" +``` + + diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/Q1-reader-cosmic-22/2026-09-17-phase-1-game-engine.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/Q1-reader-cosmic-22/2026-09-17-phase-1-game-engine.md new file mode 100644 index 000000000..4d7583a7f --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/Q1-reader-cosmic-22/2026-09-17-phase-1-game-engine.md @@ -0,0 +1,2963 @@ +# Cosmic Tetris Phase 1 — Game Engine Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build `internal/game` — a headless, deterministic falling-block engine with no terminal, no rendering, and no clock — fully covered by unit tests. + +**Architecture:** One package, `internal/game`, exposing a `*Game` that is driven by exactly two calls: `Apply(Input)` for player actions and `Advance(dt)` for elapsed time. Both return a slice of `Event` values describing what happened, which later phases feed to the FX system. The package owns a single `*rand.Rand` used only by the 7-bag. Nothing in the package reads a clock, so "same seed + same inputs + same timings ⇒ same state" is a testable property rather than a hope. + +**Tech Stack:** Go 1.25.0, standard library only. No dependencies in this phase. + +**Spec:** [`design.md`](../design.md) — this phase implements §5, §6, §7, §9, §11, §12, §13, §34, §35, §40 (game-logic sections), and §49.1/§49.2/§49.6. + +## Global Constraints + +These apply to every task in every Cosmic Tetris plan. + +- Module path is `cosmic-tetris`; `go.mod` declares `go 1.25.0` (the floor required by the Charm v2 libraries). +- Direct dependencies are limited to `charm.land/bubbletea/v2 v2.0.9`, `charm.land/lipgloss/v2 v2.0.6`, `charm.land/bubbles/v2 v2.2.1`. Nothing else. Phase 1 adds none of them. +- Package layout is exactly §33's tree. Do not add packages. +- Board geometry is fixed: `width 10`, `height 22`, `visible rows 20`, `hidden spawn rows 2` (§5). +- **Nothing under `internal/game` may call `time.Now()`** (§49.2). Time enters only as the `dt` argument to `Advance`. +- The game RNG and the FX RNG are separate objects and never share state (§49.6, §35). +- The FX system may observe game events and may never modify game state (§14). +- Screen shake never exceeds roughly one terminal cell (§44). +- Animation never delays gameplay and never blocks input (§44). +- No networking, no profiles, no achievements, no plugin system, no database (§2). +- No filesystem operations during gameplay (§38). +- Every task ends green: `go build ./... && go vet ./... && go test ./...`. + +## Review Focus + +Failure modes this phase's code owns that the spec implies but never states. Each has a test added to the task that owns the code — listed here once, with the owning task named. + +- **Level far past the gravity clamp.** A long game reaches level 40+; `800ms × 0.86^(level-1)` underflows toward zero. A zero or negative drop interval makes `Advance`'s gravity loop spin forever and hangs the game. Expected: the interval floors at 60ms and stays there for every level, including absurd ones. → Task 9. +- **Negative and large rotation indices.** `Rotation` is a plain `int`; repeated CCW rotation drives it negative and repeated CW drives it past 3. Expected: rotation is read modulo 4 with negatives normalized, never an index panic. → Task 2. +- **Collision queries outside the board.** Movement, rotation kicks, and drop-distance all probe cells beyond every edge, including above the top. Expected: out-of-bounds is treated as occupied on all four sides, so nothing indexes past the array. → Task 3. +- **Clearing non-contiguous rows in one placement.** An I-piece can complete rows 8 and 11 while 9 and 10 stay partial. Expected: both clear, the two partial rows survive intact and land in the right order, and exactly two empty rows appear at the top. → Task 4. +- **Input and time after game over.** The app keeps ticking for the ~1300ms game-over animation (§28) and the player keeps mashing keys. Expected: `Apply` and `Advance` are no-ops once `Over` is set, and `GameOver` is emitted exactly once. → Task 13. + +--- + +## File Structure + +| File | Responsibility | +|------|----------------| +| `go.mod` | Module declaration. | +| `.gitignore` | Ignore the built binary. | +| `internal/game/piece.go` | `PieceKind`, `Cell`, the shape table, `Piece` and its cell geometry, spawn positions. | +| `internal/game/board.go` | The 10×22 grid: bounds, occupancy, row completion, clearing and collapse. | +| `internal/game/bag.go` | The 7-bag generator. | +| `internal/game/rules.go` | Tunable rule constants and pure rule functions: kick offsets, drop interval, lock delay. | +| `internal/game/event.go` | `Event`, `EventKind`, and the emit helpers. | +| `internal/game/scoring.go` | Pure scoring arithmetic: line values, combo bonus, level from lines. | +| `internal/game/game.go` | The `Game` aggregate: state, `Apply`, `Advance`, spawn, hold, lock. | + +**Deviation from §33:** §33's tree does not list `internal/game/event.go`. Events are a distinct responsibility with their own vocabulary and they are the whole contract between the engine and the FX system, so they get their own file rather than swelling `game.go`. This is the only file added to the spec's tree in this phase. + +--- + +### Task 1: Module bootstrap, piece kinds, and board cells + +**Files:** +- Create: `go.mod` +- Create: `.gitignore` +- Create: `internal/game/piece.go` +- Test: `internal/game/piece_test.go` + +**Interfaces:** +- Consumes: nothing. +- Produces: `type PieceKind uint8` with constants `KindI KindJ KindL KindO KindS KindT KindZ` and `KindCount = 7`; `func (PieceKind) String() string`; `type Cell uint8` with `CellEmpty Cell = 0`, `func CellOf(PieceKind) Cell`, `func (Cell) Filled() bool`, `func (Cell) Kind() PieceKind`. + +- [ ] **Step 1: Create the module and gitignore** + +```bash +go mod init cosmic-tetris +printf 'cosmic-tetris\n/cosmic-tetris\n' > .gitignore +``` + +Then edit `go.mod` so the version line reads exactly: + +``` +go 1.25.0 +``` + +- [ ] **Step 2: Write the failing test** + +Create `internal/game/piece_test.go`: + +```go +package game + +import "testing" + +func TestPieceKindString(t *testing.T) { + want := []string{"I", "J", "L", "O", "S", "T", "Z"} + for k := PieceKind(0); int(k) < KindCount; k++ { + if got := k.String(); got != want[k] { + t.Errorf("PieceKind(%d).String() = %q, want %q", k, got, want[k]) + } + } +} + +func TestCellRoundTrip(t *testing.T) { + if CellEmpty.Filled() { + t.Error("CellEmpty.Filled() = true, want false") + } + for k := PieceKind(0); int(k) < KindCount; k++ { + c := CellOf(k) + if !c.Filled() { + t.Errorf("CellOf(%v).Filled() = false, want true", k) + } + if got := c.Kind(); got != k { + t.Errorf("CellOf(%v).Kind() = %v, want %v", k, got, k) + } + } +} +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `go test ./internal/game/ -run 'TestPieceKindString|TestCellRoundTrip' -v` +Expected: FAIL — build error, `undefined: PieceKind`. + +- [ ] **Step 4: Write minimal implementation** + +Create `internal/game/piece.go`: + +```go +// Package game is the Cosmic Tetris rules engine. It is headless and +// deterministic: nothing here reads a clock, renders anything, or knows a +// terminal exists. Time enters the engine only as the dt argument to +// (*Game).Advance. +package game + +// PieceKind identifies one of the seven tetromino families (§6). +type PieceKind uint8 + +// The seven tetromino families. +const ( + KindI PieceKind = iota + KindJ + KindL + KindO + KindS + KindT + KindZ +) + +// KindCount is the number of tetromino families, which is also the size of one +// bag (§6). +const KindCount = 7 + +var kindNames = [KindCount]string{"I", "J", "L", "O", "S", "T", "Z"} + +// String returns the single-letter name of the family. +func (k PieceKind) String() string { + if int(k) >= KindCount { + return "?" + } + return kindNames[k] +} + +// Cell is the content of one board cell: either empty or a locked block +// belonging to a piece family. Storing the family keeps the renderer able to +// colour locked blocks by origin (§26) without a parallel colour grid. +type Cell uint8 + +// CellEmpty is an unoccupied board cell. It is the zero value, so a zeroed +// Board is an empty Board. +const CellEmpty Cell = 0 + +// CellOf returns the locked-block cell for a piece family. +func CellOf(k PieceKind) Cell { return Cell(k) + 1 } + +// Filled reports whether the cell holds a block. +func (c Cell) Filled() bool { return c != CellEmpty } + +// Kind returns the family whose block occupies the cell. It is meaningless for +// CellEmpty. +func (c Cell) Kind() PieceKind { return PieceKind(c - 1) } +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `go test ./internal/game/ -v` +Expected: PASS, both tests. + +- [ ] **Step 6: Commit** + +```bash +git add go.mod .gitignore internal/game/piece.go internal/game/piece_test.go +git commit -m "feat(game): module bootstrap, piece kinds, and board cells" +``` + +--- + +### Task 2: Piece shapes and cell geometry + +**Files:** +- Modify: `internal/game/piece.go` +- Test: `internal/game/piece_test.go` + +**Interfaces:** +- Consumes: `PieceKind`, `KindCount` (Task 1). +- Produces: `type Piece struct { Kind PieceKind; Rotation int; X, Y int }`; `func (Piece) Cells() [4][2]int` returning absolute board `{x, y}` pairs; `func SpawnPiece(PieceKind) Piece`; `func boxSize(PieceKind) int` (unexported, used by tests in-package). + +**Coordinate system.** `X` and `Y` are the board coordinates of the **top-left corner of the piece's bounding box**. `Y` increases *downward*: row 0 is the top hidden spawn row, row 21 is the floor. Shape offsets are `{dx, dy}` from that corner. This convention is why §7's kick offset `(0,-1)` lifts a piece. + +**Decision (§6 leaves it open).** All seven families spawn at `X=3, Y=0`. With that origin every family's rotation-0 cells land inside the two hidden rows (§5), so a new piece is invisible until gravity walks it down, and the horizontal placement matches the conventional centring: I occupies columns 3–6, O occupies 4–5, and JLSTZ occupy 3–5. + +- [ ] **Step 1: Write the failing test** + +Append to `internal/game/piece_test.go`: + +```go +func TestShapesHaveFourDistinctCells(t *testing.T) { + for k := PieceKind(0); int(k) < KindCount; k++ { + for r := 0; r < 4; r++ { + p := Piece{Kind: k, Rotation: r} + seen := map[[2]int]bool{} + for _, c := range p.Cells() { + if seen[c] { + t.Errorf("%v rotation %d: duplicate cell %v", k, r, c) + } + seen[c] = true + } + if len(seen) != 4 { + t.Errorf("%v rotation %d: %d distinct cells, want 4", k, r, len(seen)) + } + } + } +} + +// TestRotationsAreClockwiseImages checks all 28 shape-table entries with one +// rule: rotation r+1 must be rotation r turned 90° clockwise inside the +// family's bounding box, i.e. (x, y) -> (n-1-y, x). O is exempt because §6 +// allows it to stay visually identical. +func TestRotationsAreClockwiseImages(t *testing.T) { + for k := PieceKind(0); int(k) < KindCount; k++ { + if k == KindO { + continue + } + n := boxSize(k) + for r := 0; r < 4; r++ { + want := map[[2]int]bool{} + for _, c := range (Piece{Kind: k, Rotation: r}).Cells() { + want[[2]int{n - 1 - c[1], c[0]}] = true + } + for _, c := range (Piece{Kind: k, Rotation: r + 1}).Cells() { + if !want[c] { + t.Errorf("%v rotation %d: cell %v is not the clockwise image of rotation %d", + k, (r+1)%4, c, r) + } + } + } + } +} + +func TestKindOIsRotationInvariant(t *testing.T) { + base := (Piece{Kind: KindO, Rotation: 0}).Cells() + for r := 1; r < 4; r++ { + if got := (Piece{Kind: KindO, Rotation: r}).Cells(); got != base { + t.Errorf("O rotation %d = %v, want %v", r, got, base) + } + } +} + +// Review Focus: Rotation is a plain int, so repeated CCW drives it negative and +// repeated CW drives it past 3. Both must normalise instead of panicking. +func TestRotationIndexNormalises(t *testing.T) { + for k := PieceKind(0); int(k) < KindCount; k++ { + base := (Piece{Kind: k, Rotation: 1}).Cells() + for _, r := range []int{-3, -7, 5, 9, 401} { + if ((r % 4) + 4) % 4 != 1 { + continue + } + if got := (Piece{Kind: k, Rotation: r}).Cells(); got != base { + t.Errorf("%v rotation %d = %v, want rotation 1 = %v", k, r, got, base) + } + } + } +} + +func TestSpawnPieceStartsInHiddenRows(t *testing.T) { + for k := PieceKind(0); int(k) < KindCount; k++ { + p := SpawnPiece(k) + if p.Rotation != 0 { + t.Errorf("SpawnPiece(%v).Rotation = %d, want 0", k, p.Rotation) + } + for _, c := range p.Cells() { + if c[1] < 0 || c[1] >= HiddenRows { + t.Errorf("SpawnPiece(%v) cell %v is outside the hidden rows [0,%d)", k, c, HiddenRows) + } + if c[0] < 0 || c[0] >= BoardWidth { + t.Errorf("SpawnPiece(%v) cell %v is outside columns [0,%d)", k, c, BoardWidth) + } + } + } +} +``` + +`HiddenRows` and `BoardWidth` arrive in Task 3. To keep this task green on its own, add them to `piece.go` now as part of Step 3 and let Task 3 use them. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/game/ -run 'Shapes|Clockwise|Invariant|Normalises|Spawn' -v` +Expected: FAIL — `undefined: Piece`, `undefined: boxSize`, `undefined: HiddenRows`. + +- [ ] **Step 3: Write minimal implementation** + +Append to `internal/game/piece.go`: + +```go +// Board geometry (§5). Row 0 is the top of the board and row BoardHeight-1 is +// the floor; the first HiddenRows rows are the off-screen spawn area. +const ( + BoardWidth = 10 + BoardHeight = 22 + VisibleRows = 20 + HiddenRows = BoardHeight - VisibleRows // 2 +) + +// Piece is the active tetromino. X and Y are the board coordinates of the +// top-left corner of the family's bounding box. +type Piece struct { + Kind PieceKind + Rotation int + X int + Y int +} + +// boxSize is the width of a family's square bounding box. I and O rotate in a +// 4×4 box, everything else in 3×3. +func boxSize(k PieceKind) int { + switch k { + case KindI, KindO: + return 4 + default: + return 3 + } +} + +// shapes lists, for every family and every rotation, the four {dx, dy} offsets +// from the piece origin. Rotation r+1 is rotation r turned clockwise inside the +// family's bounding box; O is deliberately identical in all four (§6). +var shapes = [KindCount][4][4][2]int{ + KindI: { + {{0, 1}, {1, 1}, {2, 1}, {3, 1}}, + {{2, 0}, {2, 1}, {2, 2}, {2, 3}}, + {{0, 2}, {1, 2}, {2, 2}, {3, 2}}, + {{1, 0}, {1, 1}, {1, 2}, {1, 3}}, + }, + KindJ: { + {{0, 0}, {0, 1}, {1, 1}, {2, 1}}, + {{1, 0}, {2, 0}, {1, 1}, {1, 2}}, + {{0, 1}, {1, 1}, {2, 1}, {2, 2}}, + {{1, 0}, {1, 1}, {0, 2}, {1, 2}}, + }, + KindL: { + {{2, 0}, {0, 1}, {1, 1}, {2, 1}}, + {{1, 0}, {1, 1}, {1, 2}, {2, 2}}, + {{0, 1}, {1, 1}, {2, 1}, {0, 2}}, + {{0, 0}, {1, 0}, {1, 1}, {1, 2}}, + }, + KindO: { + {{1, 0}, {2, 0}, {1, 1}, {2, 1}}, + {{1, 0}, {2, 0}, {1, 1}, {2, 1}}, + {{1, 0}, {2, 0}, {1, 1}, {2, 1}}, + {{1, 0}, {2, 0}, {1, 1}, {2, 1}}, + }, + KindS: { + {{1, 0}, {2, 0}, {0, 1}, {1, 1}}, + {{1, 0}, {1, 1}, {2, 1}, {2, 2}}, + {{1, 1}, {2, 1}, {0, 2}, {1, 2}}, + {{0, 0}, {0, 1}, {1, 1}, {1, 2}}, + }, + KindT: { + {{1, 0}, {0, 1}, {1, 1}, {2, 1}}, + {{1, 0}, {1, 1}, {2, 1}, {1, 2}}, + {{0, 1}, {1, 1}, {2, 1}, {1, 2}}, + {{1, 0}, {0, 1}, {1, 1}, {1, 2}}, + }, + KindZ: { + {{0, 0}, {1, 0}, {1, 1}, {2, 1}}, + {{2, 0}, {1, 1}, {2, 1}, {1, 2}}, + {{0, 1}, {1, 1}, {1, 2}, {2, 2}}, + {{1, 0}, {0, 1}, {1, 1}, {0, 2}}, + }, +} + +// normRotation folds any rotation index, including negative ones, into [0,4). +func normRotation(r int) int { return ((r % 4) + 4) % 4 } + +// Cells returns the four absolute board coordinates the piece occupies, as +// {x, y} pairs. Coordinates may fall outside the board; callers treat that as +// a collision. +func (p Piece) Cells() [4][2]int { + offsets := shapes[p.Kind][normRotation(p.Rotation)] + var out [4][2]int + for i, o := range offsets { + out[i] = [2]int{p.X + o[0], p.Y + o[1]} + } + return out +} + +// spawnX and spawnY place a new piece so that its rotation-0 cells sit entirely +// inside the hidden rows and are horizontally centred (§5, §6). +const ( + spawnX = 3 + spawnY = 0 +) + +// SpawnPiece returns a piece of the given family at the spawn position in +// rotation 0. Held pieces re-enter through this function, which is what gives +// §9 its "newly spawned held pieces return to spawn rotation" rule. +func SpawnPiece(k PieceKind) Piece { + return Piece{Kind: k, Rotation: 0, X: spawnX, Y: spawnY} +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/game/ -v` +Expected: PASS. `TestRotationsAreClockwiseImages` passing is the proof that all 28 shape entries are mutually consistent. + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/piece.go internal/game/piece_test.go +git commit -m "feat(game): tetromino shape table with clockwise-consistent rotations" +``` + +--- + +### Task 3: Board bounds, occupancy, and cell access + +**Files:** +- Create: `internal/game/board.go` +- Test: `internal/game/board_test.go` + +**Interfaces:** +- Consumes: `Cell`, `CellEmpty`, `BoardWidth`, `BoardHeight` (Tasks 1–2). +- Produces: `type Board struct { Cells [BoardHeight][BoardWidth]Cell }`; `func (*Board) At(x, y int) Cell`; `func (*Board) Set(x, y int, c Cell)`; `func (*Board) InBounds(x, y int) bool`; `func (*Board) Occupied(x, y int) bool`. + +- [ ] **Step 1: Write the failing test** + +Create `internal/game/board_test.go`: + +```go +package game + +import "testing" + +func TestBoardInBounds(t *testing.T) { + var b Board + cases := []struct { + x, y int + want bool + }{ + {0, 0, true}, + {BoardWidth - 1, BoardHeight - 1, true}, + {-1, 0, false}, + {0, -1, false}, + {BoardWidth, 0, false}, + {0, BoardHeight, false}, + } + for _, c := range cases { + if got := b.InBounds(c.x, c.y); got != c.want { + t.Errorf("InBounds(%d,%d) = %v, want %v", c.x, c.y, got, c.want) + } + } +} + +func TestBoardSetAndAt(t *testing.T) { + var b Board + if b.At(4, 20) != CellEmpty { + t.Error("fresh board is not empty") + } + b.Set(4, 20, CellOf(KindT)) + if got := b.At(4, 20); got != CellOf(KindT) { + t.Errorf("At(4,20) = %v, want %v", got, CellOf(KindT)) + } + if b.At(5, 20) != CellEmpty { + t.Error("Set wrote outside its cell") + } +} + +// Review Focus: movement, rotation kicks, and drop-distance all probe cells +// beyond every edge, including above the top. Every one of those must read as +// occupied rather than panic. +func TestBoardOccupiedOutsideEveryEdge(t *testing.T) { + var b Board + for _, c := range [][2]int{ + {-1, 10}, {BoardWidth, 10}, {5, -1}, {5, BoardHeight}, + {-99, -99}, {9999, 9999}, + } { + if !b.Occupied(c[0], c[1]) { + t.Errorf("Occupied(%d,%d) = false, want true (outside the board)", c[0], c[1]) + } + } + if b.Occupied(5, 10) { + t.Error("Occupied(5,10) = true on an empty board, want false") + } + b.Set(5, 10, CellOf(KindZ)) + if !b.Occupied(5, 10) { + t.Error("Occupied(5,10) = false after Set, want true") + } +} + +func TestBoardSetOutOfBoundsIsNoOp(t *testing.T) { + var b Board + b.Set(-1, -1, CellOf(KindI)) + b.Set(BoardWidth, BoardHeight, CellOf(KindI)) + if b.At(-1, -1) != CellEmpty || b.At(0, 0) != CellEmpty { + t.Error("out-of-bounds Set corrupted the board") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/game/ -run TestBoard -v` +Expected: FAIL — `undefined: Board`. + +- [ ] **Step 3: Write minimal implementation** + +Create `internal/game/board.go`: + +```go +package game + +// Board is the logical playfield: 10 columns by 22 rows, of which the top +// HiddenRows are the off-screen spawn area (§5). The zero value is an empty +// board. Board holds only logical cells — never glyphs, colours, or effect +// state (§5: "Never use visual effects as collision data"). +type Board struct { + Cells [BoardHeight][BoardWidth]Cell +} + +// InBounds reports whether the coordinate names a real board cell. +func (b *Board) InBounds(x, y int) bool { + return x >= 0 && x < BoardWidth && y >= 0 && y < BoardHeight +} + +// At returns the cell at the coordinate, or CellEmpty if it is out of bounds. +func (b *Board) At(x, y int) Cell { + if !b.InBounds(x, y) { + return CellEmpty + } + return b.Cells[y][x] +} + +// Set writes a cell. Out-of-bounds writes are silently dropped so that callers +// never have to bounds-check first. +func (b *Board) Set(x, y int, c Cell) { + if !b.InBounds(x, y) { + return + } + b.Cells[y][x] = c +} + +// Occupied reports whether a coordinate blocks a piece. Everything outside the +// board counts as occupied, on all four sides: that single rule turns wall +// collisions, floor collisions, and the ceiling into one check. +func (b *Board) Occupied(x, y int) bool { + if !b.InBounds(x, y) { + return true + } + return b.Cells[y][x].Filled() +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/board.go internal/game/board_test.go +git commit -m "feat(game): board bounds and occupancy" +``` + +--- + +### Task 4: Row completion, clearing, and collapse + +**Files:** +- Modify: `internal/game/board.go` +- Test: `internal/game/board_test.go` + +**Interfaces:** +- Consumes: `Board` (Task 3). +- Produces: `func (*Board) RowFull(y int) bool`; `func (*Board) FullRows() []int` (ascending, `nil` when none); `func (*Board) ClearRows(rows []int)`. + +- [ ] **Step 1: Write the failing test** + +Append to `internal/game/board_test.go`: + +```go +// fillRow fills every column of row y except the columns listed in gaps. +func fillRow(b *Board, y int, k PieceKind, gaps ...int) { + skip := map[int]bool{} + for _, g := range gaps { + skip[g] = true + } + for x := 0; x < BoardWidth; x++ { + if !skip[x] { + b.Set(x, y, CellOf(k)) + } + } +} + +func TestRowFull(t *testing.T) { + var b Board + if b.RowFull(21) { + t.Error("empty row reported full") + } + fillRow(&b, 21, KindI, 3) + if b.RowFull(21) { + t.Error("row with a gap reported full") + } + b.Set(3, 21, CellOf(KindI)) + if !b.RowFull(21) { + t.Error("complete row reported not full") + } +} + +func TestFullRowsAscending(t *testing.T) { + var b Board + if got := b.FullRows(); len(got) != 0 { + t.Errorf("FullRows() on empty board = %v, want empty", got) + } + fillRow(&b, 19, KindI) + fillRow(&b, 21, KindI) + fillRow(&b, 20, KindI, 0) + got := b.FullRows() + want := []int{19, 21} + if len(got) != len(want) { + t.Fatalf("FullRows() = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("FullRows() = %v, want %v", got, want) + } + } +} + +// Review Focus: an I-piece can complete rows 8 and 11 while 9 and 10 stay +// partial. Both must clear, the partial rows must survive in order, and +// exactly two empty rows must appear at the top. +func TestClearRowsNonContiguousCollapse(t *testing.T) { + var b Board + fillRow(&b, 8, KindI) // full + b.Set(0, 9, CellOf(KindJ)) // marker A + b.Set(1, 10, CellOf(KindL)) // marker B + fillRow(&b, 11, KindI) // full + b.Set(2, 12, CellOf(KindT)) // marker C + + b.ClearRows([]int{8, 11}) + + // Rows 9 and 10 fell two; row 12 stayed put (only rows above it went). + if got := b.At(0, 11); got != CellOf(KindJ) { + t.Errorf("marker A at (0,11) = %v, want J", got) + } + if got := b.At(1, 12); got != CellOf(KindL) { + t.Errorf("marker B at (1,12) = %v, want L", got) + } + if got := b.At(2, 12); got != CellOf(KindT) { + t.Errorf("marker C at (2,12) = %v, want T", got) + } + for y := 0; y < 2; y++ { + if !rowEmpty(&b, y) { + t.Errorf("row %d is not empty after the clear", y) + } + } + if len(b.FullRows()) != 0 { + t.Error("full rows remain after ClearRows") + } +} + +func TestClearRowsFourAtOnce(t *testing.T) { + var b Board + for y := 18; y <= 21; y++ { + fillRow(&b, y, KindI) + } + b.Set(0, 17, CellOf(KindZ)) + b.ClearRows([]int{18, 19, 20, 21}) + if got := b.At(0, 21); got != CellOf(KindZ) { + t.Errorf("survivor at (0,21) = %v, want Z", got) + } + for y := 0; y < BoardHeight-1; y++ { + if !rowEmpty(&b, y) { + t.Errorf("row %d is not empty after a four-line clear", y) + } + } +} + +func TestClearRowsEmptyIsNoOp(t *testing.T) { + var b Board + b.Set(4, 20, CellOf(KindO)) + b.ClearRows(nil) + if b.At(4, 20) != CellOf(KindO) { + t.Error("ClearRows(nil) moved the board") + } +} + +func rowEmpty(b *Board, y int) bool { + for x := 0; x < BoardWidth; x++ { + if b.At(x, y).Filled() { + return false + } + } + return true +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/game/ -run 'RowFull|FullRows|ClearRows' -v` +Expected: FAIL — `b.RowFull undefined`. + +- [ ] **Step 3: Write minimal implementation** + +Append to `internal/game/board.go`: + +```go +// RowFull reports whether every column of the row holds a block. +func (b *Board) RowFull(y int) bool { + if y < 0 || y >= BoardHeight { + return false + } + for x := 0; x < BoardWidth; x++ { + if !b.Cells[y][x].Filled() { + return false + } + } + return true +} + +// FullRows returns the indices of every complete row, ascending (top-most +// first). It returns nil when nothing is complete. +func (b *Board) FullRows() []int { + var rows []int + for y := 0; y < BoardHeight; y++ { + if b.RowFull(y) { + rows = append(rows, y) + } + } + return rows +} + +// ClearRows removes the given rows and collapses everything above them +// downward, refilling the top with empty rows. The rows need not be +// contiguous. Rows outside the board are ignored. +func (b *Board) ClearRows(rows []int) { + if len(rows) == 0 { + return + } + cleared := map[int]bool{} + for _, y := range rows { + if y >= 0 && y < BoardHeight { + cleared[y] = true + } + } + if len(cleared) == 0 { + return + } + + // Walk bottom-up, copying surviving rows down into a write cursor. Rows + // above the cursor when the walk ends are zeroed. + write := BoardHeight - 1 + for read := BoardHeight - 1; read >= 0; read-- { + if cleared[read] { + continue + } + b.Cells[write] = b.Cells[read] + write-- + } + for ; write >= 0; write-- { + b.Cells[write] = [BoardWidth]Cell{} + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/board.go internal/game/board_test.go +git commit -m "feat(game): row completion, clearing, and collapse" +``` + +--- + +### Task 5: The 7-bag + +**Files:** +- Create: `internal/game/bag.go` +- Test: `internal/game/bag_test.go` + +**Interfaces:** +- Consumes: `PieceKind`, `KindCount` (Task 1). +- Produces: `type Bag struct { ... }`; `func (*Bag) Next(rng *rand.Rand) PieceKind`. + +**Decision.** `Bag` does not hold an RNG. It takes one per draw. §49.6 makes `Game` the sole owner of the game RNG; passing it in at the call site makes it impossible for a second generator to sneak into piece order. + +- [ ] **Step 1: Write the failing test** + +Create `internal/game/bag_test.go`: + +```go +package game + +import ( + "math/rand" + "sort" + "testing" +) + +func drawN(b *Bag, rng *rand.Rand, n int) []PieceKind { + out := make([]PieceKind, n) + for i := range out { + out[i] = b.Next(rng) + } + return out +} + +func TestBagEachSevenIsAPermutation(t *testing.T) { + var b Bag + rng := rand.New(rand.NewSource(1)) + for round := 0; round < 20; round++ { + got := drawN(&b, rng, KindCount) + sort.Slice(got, func(i, j int) bool { return got[i] < got[j] }) + for i, k := range got { + if int(k) != i { + t.Fatalf("round %d: bag contents %v are not one of each family", round, got) + } + } + } +} + +func TestBagSeededGenerationIsReproducible(t *testing.T) { + var b1, b2 Bag + a := drawN(&b1, rand.New(rand.NewSource(8675309)), 40) + c := drawN(&b2, rand.New(rand.NewSource(8675309)), 40) + for i := range a { + if a[i] != c[i] { + t.Fatalf("draw %d differs between identically seeded bags: %v vs %v", i, a[i], c[i]) + } + } +} + +func TestBagIsUniformOverManyBags(t *testing.T) { + var b Bag + rng := rand.New(rand.NewSource(42)) + counts := [KindCount]int{} + for _, k := range drawN(&b, rng, 100*KindCount) { + counts[k]++ + } + for k, n := range counts { + if n != 100 { + t.Errorf("%v drawn %d times over 100 bags, want exactly 100", PieceKind(k), n) + } + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/game/ -run TestBag -v` +Expected: FAIL — `undefined: Bag`. + +- [ ] **Step 3: Write minimal implementation** + +Create `internal/game/bag.go`: + +```go +package game + +import "math/rand" + +// Bag is the 7-bag piece generator from §6: one of every family goes in, the +// bag is shuffled, it is consumed, and it refills when empty. The zero value +// is an empty bag that refills on first use. +// +// Bag deliberately holds no RNG. Callers pass the game's single generator in, +// which is what keeps piece order dependent on the game seed alone (§49.6). +type Bag struct { + remaining []PieceKind +} + +// Next returns the next family, refilling and shuffling from rng when the bag +// runs out. +func (b *Bag) Next(rng *rand.Rand) PieceKind { + if len(b.remaining) == 0 { + b.refill(rng) + } + last := len(b.remaining) - 1 + k := b.remaining[last] + b.remaining = b.remaining[:last] + return k +} + +func (b *Bag) refill(rng *rand.Rand) { + b.remaining = b.remaining[:0] + for k := PieceKind(0); int(k) < KindCount; k++ { + b.remaining = append(b.remaining, k) + } + rng.Shuffle(len(b.remaining), func(i, j int) { + b.remaining[i], b.remaining[j] = b.remaining[j], b.remaining[i] + }) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/bag.go internal/game/bag_test.go +git commit -m "feat(game): 7-bag piece generator" +``` + +--- + +### Task 6: Events + +**Files:** +- Create: `internal/game/event.go` +- Test: `internal/game/event_test.go` + +**Interfaces:** +- Consumes: `Piece`, `Cell`, `BoardWidth` (Tasks 1–3). +- Produces: `type EventKind int` with constants `EventPieceMoved EventPieceRotated EventPieceHardDropped EventPieceLocked EventHoldUsed EventLinesCleared EventComboChanged EventLevelChanged EventGameOver`; `func (EventKind) String() string`; `type Event struct { Kind EventKind; Piece Piece; Outgoing Piece; Rows []int; ClearedCells [][BoardWidth]Cell; Distance int; Value int }`. + +**Why `ClearedCells`.** §19's supernova animation has to draw debris in the colours of the blocks that just vanished, but the board has already collapsed by the time the FX system sees the event. Carrying the row contents on the event is the only way the FX system can know them without reaching into game state, which §14 forbids. + +- [ ] **Step 1: Write the failing test** + +Create `internal/game/event_test.go`: + +```go +package game + +import "testing" + +func TestEventKindStrings(t *testing.T) { + want := map[EventKind]string{ + EventPieceMoved: "PieceMoved", + EventPieceRotated: "PieceRotated", + EventPieceHardDropped: "PieceHardDropped", + EventPieceLocked: "PieceLocked", + EventHoldUsed: "HoldUsed", + EventLinesCleared: "LinesCleared", + EventComboChanged: "ComboChanged", + EventLevelChanged: "LevelChanged", + EventGameOver: "GameOver", + } + if len(want) != int(eventKindCount) { + t.Fatalf("test covers %d kinds, package declares %d", len(want), eventKindCount) + } + for k, s := range want { + if got := k.String(); got != s { + t.Errorf("EventKind(%d).String() = %q, want %q", k, got, s) + } + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/game/ -run TestEventKind -v` +Expected: FAIL — `undefined: EventKind`. + +- [ ] **Step 3: Write minimal implementation** + +Create `internal/game/event.go`: + +```go +package game + +// EventKind names something the engine did. Events are the entire contract +// between the engine and the effects system (§14): the engine emits, the FX +// world observes, and nothing flows back. +type EventKind int + +// The event vocabulary from §14. +const ( + EventPieceMoved EventKind = iota + EventPieceRotated + EventPieceHardDropped + EventPieceLocked + EventHoldUsed + EventLinesCleared + EventComboChanged + EventLevelChanged + EventGameOver + + eventKindCount +) + +var eventKindNames = [eventKindCount]string{ + "PieceMoved", "PieceRotated", "PieceHardDropped", "PieceLocked", + "HoldUsed", "LinesCleared", "ComboChanged", "LevelChanged", "GameOver", +} + +// String returns the event name. +func (k EventKind) String() string { + if k < 0 || k >= eventKindCount { + return "Unknown" + } + return eventKindNames[k] +} + +// Event describes one thing that happened during Apply or Advance. Which +// fields carry meaning depends on Kind; the rest are zero. +type Event struct { + Kind EventKind + + // Piece is the piece the event concerns, in its post-event position. + // Meaningful for PieceMoved, PieceRotated, PieceHardDropped, PieceLocked, + // and HoldUsed (where it is the piece that just became active). + Piece Piece + + // Outgoing is the piece that went into hold, in the position it held when + // the swap happened. Meaningful for HoldUsed only, and it is what §9's + // "compressed, streaked sideways, gone" animation animates. + Outgoing Piece + + // Rows lists the cleared board rows, ascending. Meaningful for + // LinesCleared. + Rows []int + + // ClearedCells holds the contents of each row in Rows, in the same order, + // captured immediately before the clear. Meaningful for LinesCleared; it + // lets the supernova animation in §19 use the right colours. + ClearedCells [][BoardWidth]Cell + + // Distance is how many cells the piece travelled. Meaningful for + // PieceHardDropped. + Distance int + + // Value is the new combo count for ComboChanged and the new level for + // LevelChanged. + Value int +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/event.go internal/game/event_test.go +git commit -m "feat(game): event vocabulary" +``` + +--- + +### Task 7: Rule constants and pure rule functions + +**Files:** +- Create: `internal/game/rules.go` +- Test: `internal/game/rules_test.go` + +**Interfaces:** +- Consumes: nothing. +- Produces: `var KickOffsets [8][2]int`; `const LockDelay time.Duration`; `const MaxLockResets int`; `const NextQueueLen int`; `const BaseDropInterval time.Duration`; `const MinDropInterval time.Duration`; `const GravityDecay float64`; `func DropInterval(level int) time.Duration`. + +- [ ] **Step 1: Write the failing test** + +Create `internal/game/rules_test.go`: + +```go +package game + +import ( + "testing" + "time" +) + +func TestKickOffsetOrder(t *testing.T) { + want := [8][2]int{ + {0, 0}, {-1, 0}, {1, 0}, {-2, 0}, {2, 0}, {0, -1}, {-1, -1}, {1, -1}, + } + if KickOffsets != want { + t.Errorf("KickOffsets = %v, want the §7 order %v", KickOffsets, want) + } +} + +func TestDropIntervalLevelOne(t *testing.T) { + if got := DropInterval(1); got != 800*time.Millisecond { + t.Errorf("DropInterval(1) = %v, want 800ms", got) + } +} + +func TestDropIntervalDecaysByFactor(t *testing.T) { + got := DropInterval(2) + want := 688 * time.Millisecond // 800 × 0.86 + if d := got - want; d > time.Millisecond || d < -time.Millisecond { + t.Errorf("DropInterval(2) = %v, want within 1ms of %v", got, want) + } +} + +func TestDropIntervalIsMonotonic(t *testing.T) { + prev := DropInterval(1) + for level := 2; level <= 40; level++ { + cur := DropInterval(level) + if cur > prev { + t.Fatalf("DropInterval(%d) = %v is slower than level %d's %v", level, cur, level-1, prev) + } + prev = cur + } +} + +// Review Focus: a long game reaches level 40+, where 800ms × 0.86^(level-1) +// underflows toward zero. A zero or negative interval makes the gravity loop in +// Advance spin forever. +func TestDropIntervalNeverDropsBelowTheClamp(t *testing.T) { + for _, level := range []int{0, -5, 1, 19, 20, 100, 1000, 1 << 20} { + got := DropInterval(level) + if got < MinDropInterval { + t.Errorf("DropInterval(%d) = %v, below the %v clamp", level, got, MinDropInterval) + } + if got > BaseDropInterval { + t.Errorf("DropInterval(%d) = %v, above the level-1 interval %v", level, got, BaseDropInterval) + } + } + if got := DropInterval(1000); got != MinDropInterval { + t.Errorf("DropInterval(1000) = %v, want the clamp %v", got, MinDropInterval) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/game/ -run 'Kick|DropInterval' -v` +Expected: FAIL — `undefined: KickOffsets`. + +- [ ] **Step 3: Write minimal implementation** + +Create `internal/game/rules.go`: + +```go +package game + +import ( + "math" + "time" +) + +// KickOffsets is §7's wall-kick ladder: on rotation, try these {dx, dy} +// nudges in order and take the first that fits. dy of -1 lifts the piece, +// because y grows downward. This is deliberately a short fixed list rather +// than a full rotation ruleset — forgiving to play, and small enough to hold +// in your head. +var KickOffsets = [8][2]int{ + {0, 0}, + {-1, 0}, + {1, 0}, + {-2, 0}, + {2, 0}, + {0, -1}, + {-1, -1}, + {1, -1}, +} + +// Gravity, locking, and queue tuning (§6, §11, §12). +const ( + // BaseDropInterval is the level-1 gravity period. + BaseDropInterval = 800 * time.Millisecond + // MinDropInterval floors gravity so that high levels stay playable and, + // more importantly, so the gravity loop always terminates. + MinDropInterval = 60 * time.Millisecond + // GravityDecay multiplies the interval once per level. + GravityDecay = 0.86 + // LinesPerLevel is how many cleared lines advance the level. + LinesPerLevel = 10 + + // LockDelay is how long a grounded piece waits before committing. + LockDelay = 500 * time.Millisecond + // MaxLockResets caps how many times movement can restart LockDelay, which + // is what stops infinite stalling. + MaxLockResets = 15 + + // NextQueueLen is how many upcoming pieces the engine keeps visible. + NextQueueLen = 5 +) + +// DropInterval returns the gravity period for a level: BaseDropInterval scaled +// by GravityDecay once per level above 1, floored at MinDropInterval. Levels +// below 1 are treated as level 1. +func DropInterval(level int) time.Duration { + if level < 1 { + level = 1 + } + scaled := float64(BaseDropInterval) * math.Pow(GravityDecay, float64(level-1)) + if scaled <= float64(MinDropInterval) { + return MinDropInterval + } + return time.Duration(scaled) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/rules.go internal/game/rules_test.go +git commit -m "feat(game): gravity curve, lock timing, and wall-kick ladder" +``` + +--- + +### Task 8: Scoring arithmetic + +**Files:** +- Create: `internal/game/scoring.go` +- Test: `internal/game/scoring_test.go` + +**Interfaces:** +- Consumes: `LinesPerLevel` (Task 7). +- Produces: `func LineScore(lines, level int) int`; `func ComboBonus(combo, level int) int`; `func LevelForLines(lines int) int`; `const SoftDropPoints = 1`; `const HardDropPoints = 2`. + +- [ ] **Step 1: Write the failing test** + +Create `internal/game/scoring_test.go`: + +```go +package game + +import "testing" + +func TestLineScore(t *testing.T) { + cases := []struct { + lines, level, want int + }{ + {0, 1, 0}, + {1, 1, 100}, + {2, 1, 300}, + {3, 1, 500}, + {4, 1, 800}, + {1, 7, 700}, + {4, 7, 5600}, + {5, 3, 0}, // impossible; must not index past the table + } + for _, c := range cases { + if got := LineScore(c.lines, c.level); got != c.want { + t.Errorf("LineScore(%d,%d) = %d, want %d", c.lines, c.level, got, c.want) + } + } +} + +// §49.1: bonus = 50 × (combo-1) × level, so a lone clear earns nothing and the +// bonus first appears at combo 2. +func TestComboBonus(t *testing.T) { + cases := []struct { + combo, level, want int + }{ + {0, 5, 0}, + {1, 5, 0}, + {2, 1, 50}, + {2, 5, 250}, + {5, 3, 600}, + {-3, 4, 0}, + } + for _, c := range cases { + if got := ComboBonus(c.combo, c.level); got != c.want { + t.Errorf("ComboBonus(%d,%d) = %d, want %d", c.combo, c.level, got, c.want) + } + } +} + +func TestLevelForLines(t *testing.T) { + cases := []struct{ lines, want int }{ + {0, 1}, {1, 1}, {9, 1}, {10, 2}, {19, 2}, {20, 3}, {127, 13}, + } + for _, c := range cases { + if got := LevelForLines(c.lines); got != c.want { + t.Errorf("LevelForLines(%d) = %d, want %d", c.lines, got, c.want) + } + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/game/ -run 'LineScore|ComboBonus|LevelForLines' -v` +Expected: FAIL — `undefined: LineScore`. + +- [ ] **Step 3: Write minimal implementation** + +Create `internal/game/scoring.go`: + +```go +package game + +// Drop rewards (§11). +const ( + SoftDropPoints = 1 + HardDropPoints = 2 +) + +// lineValues is §13's base clear table, indexed by lines cleared. +var lineValues = [5]int{0, 100, 300, 500, 800} + +// LineScore returns the base value of clearing lines at a level. Counts +// outside 0..4 score nothing. +func LineScore(lines, level int) int { + if lines < 0 || lines >= len(lineValues) { + return 0 + } + if level < 1 { + level = 1 + } + return lineValues[lines] * level +} + +// ComboBonus returns §49.1's combo bonus: 50 × (combo-1) × level. Combo 1 — a +// lone clear — earns nothing, so the bonus first appears at combo 2, exactly +// where §21 starts escalating the effects. +func ComboBonus(combo, level int) int { + if combo < 2 { + return 0 + } + if level < 1 { + level = 1 + } + return 50 * (combo - 1) * level +} + +// LevelForLines returns the level for a total line count: level 1 until the +// tenth line, then one level per LinesPerLevel lines (§11). +func LevelForLines(lines int) int { + if lines < 0 { + return 1 + } + return lines/LinesPerLevel + 1 +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/scoring.go internal/game/scoring_test.go +git commit -m "feat(game): scoring, combo bonus, and level progression" +``` + +--- + +### Task 9: Game construction, next queue, and restart + +**Files:** +- Create: `internal/game/game.go` +- Test: `internal/game/game_test.go` + +**Interfaces:** +- Consumes: everything from Tasks 1–8. +- Produces: `type Game struct { Board Board; Active Piece; Hold *PieceKind; CanHold bool; Next []PieceKind; Score, Lines, Level, Combo int; Over bool; GravityAccumulator, LockAccumulator time.Duration; Seed int64 }` (plus unexported `bag`, `rng`, `lockResets`, `events`); `func New(seed int64) *Game`; `func (*Game) Restart()`. + +**Field-name note.** §34 sketches `Bag Bag` as an exported field; it is unexported here (`bag`) alongside `rng`, because §49.6 makes the pair an implementation detail that nothing outside the package may reach into. `Seed` stays exported: it is displayed in the title bar (§4's `LOCAL UNIVERSE 7F3A`) and reused by restart. + +- [ ] **Step 1: Write the failing test** + +Create `internal/game/game_test.go`: + +```go +package game + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestNewGameInitialState(t *testing.T) { + g := New(1234) + if g.Level != 1 { + t.Errorf("Level = %d, want 1", g.Level) + } + if g.Score != 0 || g.Lines != 0 || g.Combo != 0 { + t.Errorf("Score/Lines/Combo = %d/%d/%d, want 0/0/0", g.Score, g.Lines, g.Combo) + } + if !g.CanHold { + t.Error("CanHold = false on a new game, want true") + } + if g.Hold != nil { + t.Error("Hold is set on a new game, want nil") + } + if g.Over { + t.Error("Over = true on a new game") + } + if len(g.Next) != NextQueueLen { + t.Errorf("len(Next) = %d, want %d", len(g.Next), NextQueueLen) + } + if g.Seed != 1234 { + t.Errorf("Seed = %d, want 1234", g.Seed) + } + if g.Active != SpawnPiece(g.Active.Kind) { + t.Errorf("Active = %+v, want the spawn position for %v", g.Active, g.Active.Kind) + } + for y := 0; y < BoardHeight; y++ { + if !rowEmpty(&g.Board, y) { + t.Fatalf("new game board row %d is not empty", y) + } + } +} + +func TestNewGameSameSeedSameQueue(t *testing.T) { + a, b := New(99), New(99) + if a.Active != b.Active { + t.Errorf("active pieces differ: %+v vs %+v", a.Active, b.Active) + } + for i := range a.Next { + if a.Next[i] != b.Next[i] { + t.Fatalf("Next[%d] differs: %v vs %v", i, a.Next[i], b.Next[i]) + } + } +} + +func TestRestartRestoresTheOpeningPosition(t *testing.T) { + g := New(7) + openingActive := g.Active + openingNext := append([]PieceKind(nil), g.Next...) + + g.Board.Set(0, 21, CellOf(KindZ)) + g.Score, g.Lines, g.Level, g.Combo = 500, 12, 2, 3 + g.Over = true + held := KindT + g.Hold = &held + g.CanHold = false + g.GravityAccumulator = 123 * time.Millisecond + g.LockAccumulator = 45 * time.Millisecond + + g.Restart() + + if g.Over || g.Score != 0 || g.Lines != 0 || g.Level != 1 || g.Combo != 0 { + t.Errorf("after Restart: Over=%v Score=%d Lines=%d Level=%d Combo=%d", g.Over, g.Score, g.Lines, g.Level, g.Combo) + } + if g.Hold != nil || !g.CanHold { + t.Error("Restart did not clear hold") + } + if g.GravityAccumulator != 0 || g.LockAccumulator != 0 { + t.Error("Restart did not clear the timing accumulators") + } + if g.Board.At(0, 21).Filled() { + t.Error("Restart did not clear the board") + } + if g.Seed != 7 { + t.Errorf("Seed = %d after Restart, want 7", g.Seed) + } + if g.Active != openingActive { + t.Errorf("Active = %+v after Restart, want the opening piece %+v", g.Active, openingActive) + } + for i := range openingNext { + if g.Next[i] != openingNext[i] { + t.Fatalf("Next[%d] = %v after Restart, want %v", i, g.Next[i], openingNext[i]) + } + } +} + +// §49.2: the engine must never read a clock, or "same seed + same inputs + +// same timings reproduces the state" is unenforceable. This checks the source, +// because no behavioural test can. +func TestEngineNeverReadsAClock(t *testing.T) { + entries, err := os.ReadDir(".") + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + name := e.Name() + if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + src, err := os.ReadFile(filepath.Join(".", name)) + if err != nil { + t.Fatal(err) + } + for _, banned := range []string{"time.Now(", "time.Since(", "time.Tick(", "time.After("} { + if strings.Contains(string(src), banned) { + t.Errorf("%s calls %s); §49.2 forbids the engine from reading a clock", name, banned) + } + } + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/game/ -run 'TestNewGame|TestRestart|NeverReadsAClock' -v` +Expected: FAIL — `undefined: New`. + +- [ ] **Step 3: Write minimal implementation** + +Create `internal/game/game.go`: + +```go +package game + +import ( + "math/rand" + "time" +) + +// Game is the whole logical state of a run. It is driven by exactly two calls: +// Apply for player input and Advance for elapsed time. Both return the events +// they caused. +// +// Game owns the only RNG that touches piece order (§49.6). The effects system +// has its own, and the two never meet — otherwise particle counts would change +// which pieces you get. +type Game struct { + Board Board + Active Piece + Hold *PieceKind + CanHold bool + + // Next holds the upcoming families, oldest first. It is kept at + // NextQueueLen entries so the HUD can always show five (§6). + Next []PieceKind + + Score int + Lines int + Level int + Combo int + + // Over is set once a spawn is blocked. Apply and Advance become no-ops. + Over bool + + // GravityAccumulator and LockAccumulator carry sub-interval time between + // Advance calls, which is what decouples gravity from render rate (§11). + GravityAccumulator time.Duration + LockAccumulator time.Duration + + // Seed is recorded for display and restart (§49.6). + Seed int64 + + bag Bag + rng *rand.Rand + grounded bool + lockResets int + events []Event +} + +// New starts a run from a seed. +func New(seed int64) *Game { + g := &Game{Seed: seed} + g.reset() + return g +} + +// Restart begins a fresh run with the same seed, so `r` after a game over +// replays the same universe (§28's REBOOT UNIVERSE). +func (g *Game) Restart() { g.reset() } + +func (g *Game) reset() { + seed := g.Seed + *g = Game{Seed: seed} + g.rng = rand.New(rand.NewSource(seed)) + g.Level = 1 + g.CanHold = true + g.Next = make([]PieceKind, 0, NextQueueLen+1) + g.refillNext() + g.Active = SpawnPiece(g.takeNext()) +} + +// refillNext tops the queue back up to NextQueueLen. +func (g *Game) refillNext() { + for len(g.Next) < NextQueueLen { + g.Next = append(g.Next, g.bag.Next(g.rng)) + } +} + +// takeNext pops the head of the queue and refills behind it. +func (g *Game) takeNext() PieceKind { + k := g.Next[0] + g.Next = append(g.Next[:0], g.Next[1:]...) + g.refillNext() + return k +} + +// emit records an event for the caller of Apply or Advance. +func (g *Game) emit(e Event) { g.events = append(g.events, e) } + +// takeEvents hands the accumulated events to the caller and clears the buffer, +// so the caller owns the returned slice. +func (g *Game) takeEvents() []Event { + evs := g.events + g.events = nil + return evs +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/game.go internal/game/game_test.go +git commit -m "feat(game): game construction, next queue, and restart" +``` + +--- + +### Task 10: Collision, horizontal movement, drop distance, and ghost + +**Files:** +- Modify: `internal/game/game.go` +- Test: `internal/game/game_test.go` + +**Interfaces:** +- Consumes: `Game` (Task 9), `Board.Occupied` (Task 3), `Piece.Cells` (Task 2). +- Produces: `type Input int` with constants `InputLeft InputRight InputSoftDrop InputRotateCW InputRotateCCW InputHardDrop InputHold`; `func (*Game) Apply(in Input) []Event`; `func (*Game) DropDistance() int`; `func (*Game) GhostPiece() Piece`; unexported `collides`, `canMove`, `tryMove`, `resetLockOnAction`. + +This task implements `Apply` for `InputLeft` and `InputRight` only. A `default:` branch handles the rest as no-ops; Tasks 11–14 fill them in. + +- [ ] **Step 1: Write the failing test** + +Append to `internal/game/game_test.go`: + +```go +// newTestGame returns a game whose active piece is a known family at a known +// spot, so movement tests do not depend on the bag. +func newTestGame(t *testing.T, k PieceKind, x, y, rot int) *Game { + t.Helper() + g := New(1) + g.Active = Piece{Kind: k, Rotation: rot, X: x, Y: y} + return g +} + +func TestMoveLeftAndRight(t *testing.T) { + g := newTestGame(t, KindT, 4, 10, 0) + evs := g.Apply(InputLeft) + if g.Active.X != 3 { + t.Errorf("X = %d after InputLeft, want 3", g.Active.X) + } + if len(evs) != 1 || evs[0].Kind != EventPieceMoved { + t.Errorf("events = %v, want one PieceMoved", evs) + } + if evs[0].Piece != g.Active { + t.Errorf("event piece = %+v, want the moved piece %+v", evs[0].Piece, g.Active) + } + g.Apply(InputRight) + g.Apply(InputRight) + if g.Active.X != 5 { + t.Errorf("X = %d after two InputRight, want 5", g.Active.X) + } +} + +func TestMoveBlockedByWalls(t *testing.T) { + // T rotation 0 occupies dx 0..2, so X=0 is flush against the left wall. + g := newTestGame(t, KindT, 0, 10, 0) + if evs := g.Apply(InputLeft); len(evs) != 0 { + t.Errorf("events = %v moving into the left wall, want none", evs) + } + if g.Active.X != 0 { + t.Errorf("X = %d, want 0 (unmoved)", g.Active.X) + } + + g = newTestGame(t, KindT, BoardWidth-3, 10, 0) + if evs := g.Apply(InputRight); len(evs) != 0 { + t.Errorf("events = %v moving into the right wall, want none", evs) + } + if g.Active.X != BoardWidth-3 { + t.Errorf("X = %d, want %d (unmoved)", g.Active.X, BoardWidth-3) + } +} + +func TestMoveBlockedByLockedCell(t *testing.T) { + g := newTestGame(t, KindO, 4, 10, 0) + // O rotation 0 fills columns X+1 and X+2 in rows Y and Y+1. + g.Board.Set(4, 10, CellOf(KindI)) // directly left of the O's left column + if evs := g.Apply(InputLeft); len(evs) != 0 { + t.Errorf("events = %v moving into a locked cell, want none", evs) + } + if g.Active.X != 4 { + t.Errorf("X = %d, want 4 (unmoved)", g.Active.X) + } +} + +func TestDropDistanceOnEmptyBoard(t *testing.T) { + g := newTestGame(t, KindO, 4, 0, 0) + // O's lowest cell is at Y+1, so it lands with Y+1 == BoardHeight-1. + want := BoardHeight - 2 + if got := g.DropDistance(); got != want { + t.Errorf("DropDistance() = %d, want %d", got, want) + } +} + +func TestDropDistanceStopsOnTheStack(t *testing.T) { + g := newTestGame(t, KindO, 4, 0, 0) + fillRow(&g.Board, 21, KindI) + fillRow(&g.Board, 20, KindI) + want := BoardHeight - 4 // lowest cell rests on row 19 + if got := g.DropDistance(); got != want { + t.Errorf("DropDistance() = %d, want %d", got, want) + } +} + +func TestGhostPieceIsTheActivePieceAtItsLanding(t *testing.T) { + g := newTestGame(t, KindL, 2, 3, 1) + d := g.DropDistance() + ghost := g.GhostPiece() + want := g.Active + want.Y += d + if ghost != want { + t.Errorf("GhostPiece() = %+v, want %+v", ghost, want) + } + if g.Active.Y != 3 { + t.Error("GhostPiece mutated the active piece") + } +} + +func TestGhostPieceWhenAlreadyLanded(t *testing.T) { + g := newTestGame(t, KindO, 4, BoardHeight-2, 0) + if got := g.DropDistance(); got != 0 { + t.Errorf("DropDistance() = %d for a landed piece, want 0", got) + } + if g.GhostPiece() != g.Active { + t.Error("GhostPiece() should equal the active piece when it cannot fall") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/game/ -run 'TestMove|DropDistance|GhostPiece' -v` +Expected: FAIL — `undefined: InputLeft`. + +- [ ] **Step 3: Write minimal implementation** + +Append to `internal/game/game.go`: + +```go +// Input is one player action. The app layer translates keystrokes into these +// and hands them straight to Apply, so input never waits for a tick (§36). +type Input int + +// The player actions from §8. +const ( + InputLeft Input = iota + InputRight + InputSoftDrop + InputRotateCW + InputRotateCCW + InputHardDrop + InputHold +) + +// Apply performs a player action and returns the events it caused. It is a +// no-op once the game is over. +func (g *Game) Apply(in Input) []Event { + if g.Over { + return nil + } + switch in { + case InputLeft: + g.tryMove(-1, 0) + case InputRight: + g.tryMove(1, 0) + } + return g.takeEvents() +} + +// collides reports whether a piece overlaps the stack or leaves the board. +func (g *Game) collides(p Piece) bool { + for _, c := range p.Cells() { + if g.Board.Occupied(c[0], c[1]) { + return true + } + } + return false +} + +// canMove reports whether the active piece fits after a translation. +func (g *Game) canMove(dx, dy int) bool { + p := g.Active + p.X += dx + p.Y += dy + return !g.collides(p) +} + +// tryMove translates the active piece if it fits, emitting PieceMoved and +// refreshing the lock timer. It reports whether the move happened. +func (g *Game) tryMove(dx, dy int) bool { + if !g.canMove(dx, dy) { + return false + } + g.Active.X += dx + g.Active.Y += dy + g.emit(Event{Kind: EventPieceMoved, Piece: g.Active}) + g.resetLockOnAction() + return true +} + +// resetLockOnAction implements §12's rule that a successful move or rotation +// while grounded restarts the lock timer — capped at MaxLockResets so the +// player cannot stall forever. +func (g *Game) resetLockOnAction() { + if g.canMove(0, 1) { + return // not grounded; the timer is irrelevant + } + if g.lockResets >= MaxLockResets { + return + } + g.lockResets++ + g.LockAccumulator = 0 +} + +// DropDistance reports how many cells the active piece can descend before it +// would collide. +func (g *Game) DropDistance() int { + d := 0 + for g.canMoveBy(0, d+1) { + d++ + } + return d +} + +// canMoveBy is canMove without the +1 stepping, so DropDistance can probe an +// arbitrary offset. +func (g *Game) canMoveBy(dx, dy int) bool { + p := g.Active + p.X += dx + p.Y += dy + return !g.collides(p) +} + +// GhostPiece returns the active piece translated to where it would land. The +// renderer draws it dim underneath the active piece (§10); it is display-only +// and never feeds collision (§5). +func (g *Game) GhostPiece() Piece { + p := g.Active + p.Y += g.DropDistance() + return p +} +``` + +Note: `canMove` and `canMoveBy` are now the same function. Replace `canMove`'s body with `return g.canMoveBy(dx, dy)` and keep `canMove` as the name used elsewhere. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/game.go internal/game/game_test.go +git commit -m "feat(game): collision, horizontal movement, drop distance, and ghost" +``` + +--- + +### Task 11: Rotation with wall kicks + +**Files:** +- Modify: `internal/game/game.go` +- Test: `internal/game/game_test.go` + +**Interfaces:** +- Consumes: `KickOffsets` (Task 7), `Apply`, `collides` (Task 10). +- Produces: `Apply` handles `InputRotateCW` and `InputRotateCCW`; unexported `func (*Game) tryRotate(dir int) bool`. + +- [ ] **Step 1: Write the failing test** + +Append to `internal/game/game_test.go`: + +```go +func TestRotateCycles(t *testing.T) { + g := newTestGame(t, KindT, 4, 10, 0) + for want := 1; want <= 4; want++ { + evs := g.Apply(InputRotateCW) + if len(evs) != 1 || evs[0].Kind != EventPieceRotated { + t.Fatalf("events = %v, want one PieceRotated", evs) + } + if g.Active.Rotation != want%4 { + t.Fatalf("Rotation = %d, want %d", g.Active.Rotation, want%4) + } + } + g.Apply(InputRotateCCW) + if g.Active.Rotation != 3 { + t.Errorf("Rotation = %d after CCW from 0, want 3", g.Active.Rotation) + } +} + +// §7's ladder starts (0,0), (-1,0), (1,0): a rotation that only fits after +// shifting right must be found, and the offset actually used must be the first +// that fits. +func TestRotateKicksOffTheLeftWall(t *testing.T) { + // I in rotation 1 is a vertical bar in box column 2, so X=-2 puts it in + // board column 0. Rotating to horizontal needs cells at X+0..X+3 = -2..1, + // which collides; the ladder must shift it right. + g := newTestGame(t, KindI, -2, 10, 1) + if evs := g.Apply(InputRotateCW); len(evs) != 1 || evs[0].Kind != EventPieceRotated { + t.Fatalf("events = %v, want one PieceRotated", evs) + } + if g.Active.Rotation != 2 { + t.Fatalf("Rotation = %d, want 2", g.Active.Rotation) + } + for _, c := range g.Active.Cells() { + if c[0] < 0 || c[0] >= BoardWidth { + t.Errorf("cell %v is off the board after the kick", c) + } + } +} + +// The ladder must be walked in order, not merely searched. This sets up a +// rotation where (0,0) is blocked but BOTH (-1,0) and (1,0) would fit, so the +// resulting X proves which rung was taken first. +// +// L at X=4 Y=9 rotation 0 occupies (6,9),(4,10),(5,10),(6,10). +// Rotating to rotation 1 wants (5,9),(5,10),(5,11),(6,11). +// Cell (5,9) is in that footprint and in neither the (-1,0) nor the (1,0) +// footprint, so blocking it alone rules out exactly the first rung. +func TestRotateKickPrefersTheEarlierOffset(t *testing.T) { + g := newTestGame(t, KindL, 4, 9, 0) + if g.collides(g.Active) { + t.Fatal("the starting position already collides; fix the fixture") + } + g.Board.Set(5, 9, CellOf(KindI)) + + if !g.tryRotate(1) { + t.Fatal("rotation failed, want a successful kick") + } + if g.Active.Rotation != 1 { + t.Errorf("Rotation = %d, want 1", g.Active.Rotation) + } + if g.Active.X != 3 { + t.Errorf("X = %d after the kick, want 3 — the ladder must reach (-1,0) before (1,0)", g.Active.X) + } +} + +func TestRotateFailsWhenNoOffsetFits(t *testing.T) { + // Box a T into a 3-wide, 2-tall pocket at the floor so no rung fits. + g := newTestGame(t, KindT, 4, BoardHeight-2, 0) + for y := 0; y < BoardHeight; y++ { + for x := 0; x < BoardWidth; x++ { + g.Board.Set(x, y, CellOf(KindI)) + } + } + for _, c := range g.Active.Cells() { + g.Board.Set(c[0], c[1], CellEmpty) + } + before := g.Active + if evs := g.Apply(InputRotateCW); len(evs) != 0 { + t.Errorf("events = %v for a blocked rotation, want none", evs) + } + if g.Active != before { + t.Errorf("Active = %+v after a failed rotation, want %+v", g.Active, before) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/game/ -run TestRotate -v` +Expected: FAIL — `g.tryRotate undefined`, and the `Apply` cases return no events. + +- [ ] **Step 3: Write minimal implementation** + +In `internal/game/game.go`, extend `Apply`'s switch: + +```go + case InputRotateCW: + g.tryRotate(1) + case InputRotateCCW: + g.tryRotate(-1) +``` + +and append: + +```go +// tryRotate rotates the active piece by dir (+1 clockwise, -1 counter- +// clockwise), walking §7's KickOffsets ladder and accepting the first offset +// that fits. If none fit the rotation simply fails and the piece is untouched — +// forgiving to play, and no rotation subsystem required. +func (g *Game) tryRotate(dir int) bool { + for _, k := range KickOffsets { + p := g.Active + p.Rotation = normRotation(p.Rotation + dir) + p.X += k[0] + p.Y += k[1] + if g.collides(p) { + continue + } + g.Active = p + g.emit(Event{Kind: EventPieceRotated, Piece: g.Active}) + g.resetLockOnAction() + return true + } + return false +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/game.go internal/game/game_test.go +git commit -m "feat(game): rotation with the simple wall-kick ladder" +``` + +--- + +### Task 12: Gravity, soft drop, and Advance + +**Files:** +- Modify: `internal/game/game.go` +- Test: `internal/game/game_test.go` + +**Interfaces:** +- Consumes: `DropInterval`, `LockDelay` (Task 7), `tryMove` (Task 10). +- Produces: `func (g *Game) Advance(dt time.Duration) []Event`; `Apply` handles `InputSoftDrop`. + +`Advance` accrues `dt` into `GravityAccumulator`, steps the piece down once per drop interval, then — if the piece is resting — accrues `LockAccumulator` and locks when it reaches `LockDelay`. Locking itself lands in Task 13; until then `Advance` calls a `lockPiece` stub that this task defines as "commit the piece and spawn the next", with clearing and scoring added next. + +**Decision.** The gravity interval is read once per `Advance` call, before the loop. A lock inside one call cannot change the level mid-loop (locking happens after the loop), so this is both correct and one fewer moving part. + +- [ ] **Step 1: Write the failing test** + +Append to `internal/game/game_test.go`: + +```go +func TestAdvanceGravityStepsOncePerInterval(t *testing.T) { + g := newTestGame(t, KindO, 4, 0, 0) + iv := DropInterval(1) + + if evs := g.Advance(iv - time.Millisecond); len(evs) != 0 { + t.Errorf("events = %v just short of one interval, want none", evs) + } + if g.Active.Y != 0 { + t.Errorf("Y = %d, want 0", g.Active.Y) + } + + evs := g.Advance(time.Millisecond) + if len(evs) != 1 || evs[0].Kind != EventPieceMoved { + t.Errorf("events = %v at the interval boundary, want one PieceMoved", evs) + } + if g.Active.Y != 1 { + t.Errorf("Y = %d after one interval, want 1", g.Active.Y) + } +} + +func TestAdvanceStepsMultipleTimesForALargeDT(t *testing.T) { + g := newTestGame(t, KindO, 4, 0, 0) + iv := DropInterval(1) + g.Advance(3*iv + iv/2) + if g.Active.Y != 3 { + t.Errorf("Y = %d after 3.5 intervals, want 3", g.Active.Y) + } + if g.GravityAccumulator != iv/2 { + t.Errorf("GravityAccumulator = %v, want the %v remainder", g.GravityAccumulator, iv/2) + } +} + +func TestAdvanceUsesTheCurrentLevelsInterval(t *testing.T) { + g := newTestGame(t, KindO, 4, 0, 0) + g.Level = 5 + g.Advance(DropInterval(5)) + if g.Active.Y != 1 { + t.Errorf("Y = %d after one level-5 interval, want 1", g.Active.Y) + } +} + +func TestAdvanceOnAFinishedGameIsANoOp(t *testing.T) { + g := newTestGame(t, KindO, 4, 5, 0) + g.Over = true + before := g.Active + if evs := g.Advance(time.Second); len(evs) != 0 { + t.Errorf("events = %v after game over, want none", evs) + } + if g.Active != before || g.GravityAccumulator != 0 { + t.Error("Advance moved a finished game") + } +} + +func TestSoftDropScoresAndResetsTheGravityClock(t *testing.T) { + g := newTestGame(t, KindO, 4, 5, 0) + g.GravityAccumulator = 700 * time.Millisecond + evs := g.Apply(InputSoftDrop) + if g.Active.Y != 6 { + t.Errorf("Y = %d after a soft drop, want 6", g.Active.Y) + } + if g.Score != SoftDropPoints { + t.Errorf("Score = %d after one soft-drop cell, want %d", g.Score, SoftDropPoints) + } + if g.GravityAccumulator != 0 { + t.Errorf("GravityAccumulator = %v after a soft drop, want 0", g.GravityAccumulator) + } + if len(evs) != 1 || evs[0].Kind != EventPieceMoved { + t.Errorf("events = %v, want one PieceMoved", evs) + } +} + +func TestSoftDropOnALandedPieceScoresNothing(t *testing.T) { + g := newTestGame(t, KindO, 4, BoardHeight-2, 0) + if evs := g.Apply(InputSoftDrop); len(evs) != 0 { + t.Errorf("events = %v soft-dropping a landed piece, want none", evs) + } + if g.Score != 0 { + t.Errorf("Score = %d, want 0", g.Score) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/game/ -run 'TestAdvance|SoftDrop' -v` +Expected: FAIL — `g.Advance undefined`. + +- [ ] **Step 3: Write minimal implementation** + +Extend `Apply`'s switch in `internal/game/game.go`: + +```go + case InputSoftDrop: + if g.tryMove(0, 1) { + g.Score += SoftDropPoints + g.GravityAccumulator = 0 + } +``` + +and append: + +```go +// Advance steps the simulation forward by dt and returns the events it caused. +// This is the only way time enters the engine (§49.2): Bubble Tea owns the +// clock and passes elapsed time inward, which is what makes §35's determinism +// promise testable. +func (g *Game) Advance(dt time.Duration) []Event { + if g.Over || dt <= 0 { + return g.takeEvents() + } + + interval := DropInterval(g.Level) + g.GravityAccumulator += dt + for g.GravityAccumulator >= interval { + g.GravityAccumulator -= interval + if !g.tryMove(0, 1) { + break // resting; the lock timer below takes over + } + } + + if g.canMove(0, 1) { + // Airborne: nothing to lock, so the timer stays at zero. + g.grounded = false + g.LockAccumulator = 0 + return g.takeEvents() + } + + g.grounded = true + g.LockAccumulator += dt + if g.LockAccumulator >= LockDelay { + g.lockPiece() + } + return g.takeEvents() +} + +// lockPiece commits the active piece and spawns the next. Line clearing and +// scoring join it in the next task. +func (g *Game) lockPiece() { + for _, c := range g.Active.Cells() { + g.Board.Set(c[0], c[1], CellOf(g.Active.Kind)) + } + g.emit(Event{Kind: EventPieceLocked, Piece: g.Active}) + g.spawnNext() +} + +// spawnNext makes the head of the queue active, restoring the hold allowance +// and the lock budget. A spawn that does not fit ends the run (§28). +func (g *Game) spawnNext() { + g.Active = SpawnPiece(g.takeNext()) + g.CanHold = true + g.grounded = false + g.LockAccumulator = 0 + g.GravityAccumulator = 0 + g.lockResets = 0 + if g.collides(g.Active) { + g.Over = true + g.emit(Event{Kind: EventGameOver, Piece: g.Active}) + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/game.go internal/game/game_test.go +git commit -m "feat(game): elapsed-time gravity, soft drop, and Advance" +``` + +--- + +### Task 13: Locking, lock resets, line clearing, and scoring + +**Files:** +- Modify: `internal/game/game.go` +- Test: `internal/game/game_test.go` + +**Interfaces:** +- Consumes: `lockPiece` (Task 12), `Board.FullRows`/`ClearRows` (Task 4), `LineScore`/`ComboBonus`/`LevelForLines` (Task 8). +- Produces: `lockPiece` gains clearing, scoring, combo, and level handling; emits `LinesCleared` (with `Rows` and `ClearedCells`), `ComboChanged`, `LevelChanged`. + +**Decision.** A clear is scored at the level *in force when the piece locked*, then the level is recomputed and `LevelChanged` emitted. Scoring the clear at the new level would pay the level-up twice. + +- [ ] **Step 1: Write the failing test** + +Append to `internal/game/game_test.go`: + +```go +func findEvent(evs []Event, k EventKind) (Event, bool) { + for _, e := range evs { + if e.Kind == k { + return e, true + } + } + return Event{}, false +} + +func TestGroundedPieceLocksAfterLockDelay(t *testing.T) { + g := newTestGame(t, KindO, 4, BoardHeight-2, 0) + if evs := g.Advance(LockDelay - time.Millisecond); func() bool { _, ok := findEvent(evs, EventPieceLocked); return ok }() { + t.Error("piece locked before LockDelay elapsed") + } + if g.Board.At(5, BoardHeight-1).Filled() { + t.Error("piece committed early") + } + evs := g.Advance(time.Millisecond) + if _, ok := findEvent(evs, EventPieceLocked); !ok { + t.Fatalf("events = %v at LockDelay, want a PieceLocked", evs) + } + for _, c := range [][2]int{{5, 20}, {6, 20}, {5, 21}, {6, 21}} { + if got := g.Board.At(c[0], c[1]); got != CellOf(KindO) { + t.Errorf("board at %v = %v, want a locked O", c, got) + } + } + if !g.CanHold { + t.Error("CanHold = false after a lock, want true") + } +} + +func TestMovementWhileGroundedResetsTheLockTimer(t *testing.T) { + g := newTestGame(t, KindO, 4, BoardHeight-2, 0) + g.Advance(400 * time.Millisecond) + if g.LockAccumulator != 400*time.Millisecond { + t.Fatalf("LockAccumulator = %v, want 400ms", g.LockAccumulator) + } + g.Apply(InputLeft) + if g.LockAccumulator != 0 { + t.Errorf("LockAccumulator = %v after a grounded move, want 0", g.LockAccumulator) + } + if evs := g.Advance(400 * time.Millisecond); func() bool { _, ok := findEvent(evs, EventPieceLocked); return ok }() { + t.Error("piece locked despite the reset") + } +} + +func TestLockResetsAreCappedAtMaxLockResets(t *testing.T) { + g := newTestGame(t, KindO, 4, BoardHeight-2, 0) + for i := 0; i < MaxLockResets; i++ { + g.Advance(100 * time.Millisecond) + if i%2 == 0 { + g.Apply(InputLeft) + } else { + g.Apply(InputRight) + } + if g.LockAccumulator != 0 { + t.Fatalf("reset %d did not clear the timer", i) + } + } + g.Advance(100 * time.Millisecond) + g.Apply(InputLeft) + if g.LockAccumulator == 0 { + t.Error("the 16th reset was granted; MaxLockResets is not enforced") + } +} + +func TestAirborneMovementDoesNotConsumeALockReset(t *testing.T) { + g := newTestGame(t, KindO, 4, 5, 0) + for i := 0; i < 50; i++ { + g.Apply(InputLeft) + g.Apply(InputRight) + } + g.Active = Piece{Kind: KindO, X: 4, Y: BoardHeight - 2} + g.Advance(100 * time.Millisecond) + g.Apply(InputLeft) + if g.LockAccumulator != 0 { + t.Error("airborne moves burned the lock-reset budget") + } +} + +func TestSingleClearScoresBaseValueWithNoComboBonus(t *testing.T) { + g := newTestGame(t, KindO, 4, BoardHeight-2, 0) + fillRow(&g.Board, BoardHeight-1, KindI, 5, 6) + evs := g.Advance(LockDelay) + + cleared, ok := findEvent(evs, EventLinesCleared) + if !ok { + t.Fatalf("events = %v, want a LinesCleared", evs) + } + if len(cleared.Rows) != 1 || cleared.Rows[0] != BoardHeight-1 { + t.Errorf("Rows = %v, want [%d]", cleared.Rows, BoardHeight-1) + } + if len(cleared.ClearedCells) != 1 { + t.Fatalf("len(ClearedCells) = %d, want 1", len(cleared.ClearedCells)) + } + if got := cleared.ClearedCells[0][0]; got != CellOf(KindI) { + t.Errorf("ClearedCells[0][0] = %v, want the pre-clear I block", got) + } + if got := cleared.ClearedCells[0][5]; got != CellOf(KindO) { + t.Errorf("ClearedCells[0][5] = %v, want the O that completed the row", got) + } + if g.Lines != 1 { + t.Errorf("Lines = %d, want 1", g.Lines) + } + if g.Combo != 1 { + t.Errorf("Combo = %d after the first clearing placement, want 1 (§49.1)", g.Combo) + } + if g.Score != 100 { + t.Errorf("Score = %d, want 100 (100×level 1, no combo bonus)", g.Score) + } + combo, ok := findEvent(evs, EventComboChanged) + if !ok || combo.Value != 1 { + t.Errorf("ComboChanged = %+v, want Value 1", combo) + } +} + +func TestConsecutiveClearsAddTheComboBonus(t *testing.T) { + g := New(1) + // First clearing placement. Reset the board each time so the second + // placement's arithmetic does not depend on where the first one collapsed. + g.Board = Board{} + g.Active = Piece{Kind: KindO, X: 4, Y: BoardHeight - 2} + fillRow(&g.Board, BoardHeight-1, KindI, 5, 6) + g.Advance(LockDelay) + scoreAfterFirst := g.Score + + // Second clearing placement, still at level 1. + g.Board = Board{} + g.Active = Piece{Kind: KindO, X: 4, Y: BoardHeight - 2} + fillRow(&g.Board, BoardHeight-1, KindI, 5, 6) + evs := g.Advance(LockDelay) + + if g.Combo != 2 { + t.Errorf("Combo = %d, want 2", g.Combo) + } + gained := g.Score - scoreAfterFirst + if want := 100 + 50; gained != want { + t.Errorf("second clear gained %d, want %d (100 base + 50 combo bonus)", gained, want) + } + combo, ok := findEvent(evs, EventComboChanged) + if !ok || combo.Value != 2 { + t.Errorf("ComboChanged = %+v, want Value 2", combo) + } +} + +func TestNonClearingPlacementResetsCombo(t *testing.T) { + g := newTestGame(t, KindO, 4, BoardHeight-2, 0) + fillRow(&g.Board, BoardHeight-1, KindI, 5, 6) + g.Advance(LockDelay) + if g.Combo != 1 { + t.Fatalf("Combo = %d, want 1", g.Combo) + } + + g.Board = Board{} + g.Active = Piece{Kind: KindO, X: 0, Y: BoardHeight - 2} + evs := g.Advance(LockDelay) + if g.Combo != 0 { + t.Errorf("Combo = %d after an empty placement, want 0", g.Combo) + } + combo, ok := findEvent(evs, EventComboChanged) + if !ok || combo.Value != 0 { + t.Errorf("ComboChanged = %+v, want Value 0", combo) + } +} + +func TestFourLineClearAndLevelUp(t *testing.T) { + g := New(1) + g.Lines = 6 // six more lines would be level 2; four clears crosses it + g.Level = 1 + for y := BoardHeight - 4; y < BoardHeight; y++ { + fillRow(&g.Board, y, KindI, 0) + } + g.Active = Piece{Kind: KindI, Rotation: 1, X: -2, Y: BoardHeight - 4} + evs := g.Advance(LockDelay) + + cleared, ok := findEvent(evs, EventLinesCleared) + if !ok || len(cleared.Rows) != 4 { + t.Fatalf("events = %v, want a LinesCleared with 4 rows", evs) + } + for i, y := range cleared.Rows { + if want := BoardHeight - 4 + i; y != want { + t.Errorf("Rows[%d] = %d, want %d (ascending)", i, y, want) + } + } + if g.Lines != 10 { + t.Errorf("Lines = %d, want 10", g.Lines) + } + if g.Level != 2 { + t.Errorf("Level = %d, want 2", g.Level) + } + if g.Score != 800 { + t.Errorf("Score = %d, want 800 (four lines at the pre-level-up level 1)", g.Score) + } + lvl, ok := findEvent(evs, EventLevelChanged) + if !ok || lvl.Value != 2 { + t.Errorf("LevelChanged = %+v, want Value 2", lvl) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/game/ -run 'Lock|Clear|Combo|LevelUp' -v` +Expected: FAIL — no `LinesCleared` events are emitted and `Score` stays 0. + +- [ ] **Step 3: Write minimal implementation** + +Replace `lockPiece` in `internal/game/game.go`: + +```go +// lockPiece commits the active piece, resolves any completed rows, updates the +// score, and spawns the next piece — the §12 order. Clearing is immediate in +// logic; §19's supernova is a rendering concern that runs afterwards over the +// already-collapsed board. +func (g *Game) lockPiece() { + for _, c := range g.Active.Cells() { + g.Board.Set(c[0], c[1], CellOf(g.Active.Kind)) + } + g.emit(Event{Kind: EventPieceLocked, Piece: g.Active}) + + rows := g.Board.FullRows() + if len(rows) == 0 { + if g.Combo != 0 { + g.Combo = 0 + g.emit(Event{Kind: EventComboChanged, Value: 0}) + } + g.spawnNext() + return + } + + // Snapshot the row contents before collapsing so the FX system can colour + // the supernova without reaching into game state (§14). + contents := make([][BoardWidth]Cell, len(rows)) + for i, y := range rows { + contents[i] = g.Board.Cells[y] + } + + g.Board.ClearRows(rows) + g.Lines += len(rows) + g.Combo++ + + // Score at the level in force when the piece locked, then level up. Paying + // the clear at the new level would reward the level-up twice. + g.Score += LineScore(len(rows), g.Level) + ComboBonus(g.Combo, g.Level) + + g.emit(Event{Kind: EventLinesCleared, Rows: rows, ClearedCells: contents, Value: len(rows)}) + g.emit(Event{Kind: EventComboChanged, Value: g.Combo}) + + if lvl := LevelForLines(g.Lines); lvl != g.Level { + g.Level = lvl + g.emit(Event{Kind: EventLevelChanged, Value: g.Level}) + } + + g.spawnNext() +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/game.go internal/game/game_test.go +git commit -m "feat(game): locking, lock resets, line clearing, and scoring" +``` + +--- + +### Task 14: Hard drop, hold, and game over + +**Files:** +- Modify: `internal/game/game.go` +- Test: `internal/game/game_test.go` + +**Interfaces:** +- Consumes: `lockPiece`, `spawnNext`, `DropDistance` (Tasks 10, 12, 13). +- Produces: `Apply` handles `InputHardDrop` and `InputHold`; unexported `hardDrop`, `hold`. + +**Decision (§12 vs §18).** A hard drop locks immediately rather than waiting out `LockDelay`. §18 wants the impact to land the instant the piece hits, which a 500ms pause would ruin, and instant lock is what "YEET" means everywhere else this genre exists. + +- [ ] **Step 1: Write the failing test** + +Append to `internal/game/game_test.go`: + +```go +func TestHardDropTravelsScoresAndLocks(t *testing.T) { + g := newTestGame(t, KindO, 4, 0, 0) + want := g.DropDistance() + evs := g.Apply(InputHardDrop) + + drop, ok := findEvent(evs, EventPieceHardDropped) + if !ok { + t.Fatalf("events = %v, want a PieceHardDropped", evs) + } + if drop.Distance != want { + t.Errorf("Distance = %d, want %d", drop.Distance, want) + } + if drop.Piece.Y != want { + t.Errorf("event piece Y = %d, want %d (the landing row)", drop.Piece.Y, want) + } + if g.Score != HardDropPoints*want { + t.Errorf("Score = %d, want %d", g.Score, HardDropPoints*want) + } + if _, ok := findEvent(evs, EventPieceLocked); !ok { + t.Error("hard drop did not lock immediately") + } + if got := g.Board.At(5, BoardHeight-1); got != CellOf(KindO) { + t.Errorf("board at (5,%d) = %v, want a locked O", BoardHeight-1, got) + } + // The event order matters to §18: the impact fires on the drop, then the lock. + if idxOf(evs, EventPieceHardDropped) > idxOf(evs, EventPieceLocked) { + t.Errorf("events = %v; PieceHardDropped must precede PieceLocked", evs) + } +} + +func idxOf(evs []Event, k EventKind) int { + for i, e := range evs { + if e.Kind == k { + return i + } + } + return -1 +} + +func TestHardDropOfALandedPieceScoresNothingAndStillLocks(t *testing.T) { + g := newTestGame(t, KindO, 4, BoardHeight-2, 0) + evs := g.Apply(InputHardDrop) + if g.Score != 0 { + t.Errorf("Score = %d, want 0", g.Score) + } + if _, ok := findEvent(evs, EventPieceLocked); !ok { + t.Error("a zero-distance hard drop did not lock") + } +} + +func TestFirstHoldStoresTheActivePieceAndSpawnsTheNext(t *testing.T) { + g := New(5) + stored := g.Active.Kind + incoming := g.Next[0] + evs := g.Apply(InputHold) + + if g.Hold == nil || *g.Hold != stored { + t.Errorf("Hold = %v, want %v", g.Hold, stored) + } + if g.Active.Kind != incoming { + t.Errorf("Active.Kind = %v, want the queue head %v", g.Active.Kind, incoming) + } + if g.Active != SpawnPiece(incoming) { + t.Errorf("Active = %+v, want the spawn position", g.Active) + } + if g.CanHold { + t.Error("CanHold = true right after a hold, want false") + } + h, ok := findEvent(evs, EventHoldUsed) + if !ok { + t.Fatalf("events = %v, want a HoldUsed", evs) + } + if h.Outgoing.Kind != stored { + t.Errorf("Outgoing.Kind = %v, want %v", h.Outgoing.Kind, stored) + } + if h.Piece.Kind != incoming { + t.Errorf("Piece.Kind = %v, want %v", h.Piece.Kind, incoming) + } +} + +func TestSecondHoldBeforeLockIsBlocked(t *testing.T) { + g := New(5) + g.Apply(InputHold) + active := g.Active + held := *g.Hold + if evs := g.Apply(InputHold); len(evs) != 0 { + t.Errorf("events = %v for a second hold, want none", evs) + } + if g.Active != active || *g.Hold != held { + t.Error("the second hold changed state") + } +} + +func TestHoldSwapsAndReturnsToSpawnRotation(t *testing.T) { + g := New(5) + g.Apply(InputHold) + stored := *g.Hold + + // Land and lock so the hold allowance comes back. + g.Apply(InputHardDrop) + if !g.CanHold { + t.Fatal("CanHold = false after a lock, want true") + } + + // Rotate and shove the new active piece, then swap it in. + g.Apply(InputRotateCW) + g.Apply(InputLeft) + swappedOut := g.Active.Kind + g.Apply(InputHold) + + if g.Active.Kind != stored { + t.Errorf("Active.Kind = %v, want the previously held %v", g.Active.Kind, stored) + } + if g.Active != SpawnPiece(stored) { + t.Errorf("Active = %+v, want spawn rotation and position (§9)", g.Active) + } + if *g.Hold != swappedOut { + t.Errorf("Hold = %v, want %v", *g.Hold, swappedOut) + } +} + +// blockSpawnArea fills the rows a new piece would occupy, leaving column 9 +// empty so that none of those rows counts as complete — otherwise the lock +// would clear them instead of ending the run. +func blockSpawnArea(g *Game) { + for y := 0; y <= HiddenRows; y++ { + fillRow(&g.Board, y, KindI, BoardWidth-1) + } +} + +func TestBlockedSpawnEndsTheGame(t *testing.T) { + g := New(3) + blockSpawnArea(g) + g.Active = Piece{Kind: KindO, X: 4, Y: BoardHeight - 2} + evs := g.Advance(LockDelay) + + if !g.Over { + t.Fatal("Over = false after a blocked spawn, want true") + } + if _, ok := findEvent(evs, EventGameOver); !ok { + t.Fatalf("events = %v, want a GameOver", evs) + } +} + +// Review Focus: the app keeps ticking for §28's ~1300ms collapse while the +// player mashes keys. Nothing may move, and GameOver must not fire twice. +func TestNoInputOrTimeAfterGameOver(t *testing.T) { + g := New(3) + blockSpawnArea(g) + g.Active = Piece{Kind: KindO, X: 4, Y: BoardHeight - 2} + first := g.Advance(LockDelay) + if n := countOf(first, EventGameOver); n != 1 { + t.Fatalf("GameOver emitted %d times on the ending placement, want 1", n) + } + + snapshot := *g + for _, in := range []Input{ + InputLeft, InputRight, InputSoftDrop, InputRotateCW, + InputRotateCCW, InputHardDrop, InputHold, + } { + if evs := g.Apply(in); len(evs) != 0 { + t.Errorf("Apply(%v) after game over = %v, want no events", in, evs) + } + } + for i := 0; i < 100; i++ { + if evs := g.Advance(20 * time.Millisecond); len(evs) != 0 { + t.Fatalf("Advance after game over = %v, want no events", evs) + } + } + if g.Board != snapshot.Board || g.Active != snapshot.Active || g.Score != snapshot.Score { + t.Error("the game moved after Over was set") + } +} + +func countOf(evs []Event, k EventKind) int { + n := 0 + for _, e := range evs { + if e.Kind == k { + n++ + } + } + return n +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/game/ -run 'HardDrop|Hold|GameOver|AfterGameOver' -v` +Expected: FAIL — hard drop and hold produce no events. + +- [ ] **Step 3: Write minimal implementation** + +Extend `Apply`'s switch in `internal/game/game.go`: + +```go + case InputHardDrop: + g.hardDrop() + case InputHold: + g.hold() +``` + +and append: + +```go +// hardDrop slams the active piece to its landing row, scores the distance, and +// locks it on the spot. §12's lock delay deliberately does not apply: §18 wants +// the impact the instant the piece arrives. +func (g *Game) hardDrop() { + d := g.DropDistance() + g.Active.Y += d + g.Score += HardDropPoints * d + g.emit(Event{Kind: EventPieceHardDropped, Piece: g.Active, Distance: d}) + g.lockPiece() +} + +// hold swaps the active piece with the held one, or banks it and pulls from the +// queue if nothing is held. It can be used once per piece (§9). The incoming +// piece always arrives in spawn rotation. +func (g *Game) hold() { + if !g.CanHold { + return + } + outgoing := g.Active + + if g.Hold == nil { + g.spawnNext() + } else { + incoming := *g.Hold + g.Active = SpawnPiece(incoming) + g.grounded = false + g.LockAccumulator = 0 + g.GravityAccumulator = 0 + g.lockResets = 0 + if g.collides(g.Active) { + g.Over = true + g.emit(Event{Kind: EventGameOver, Piece: g.Active}) + } + } + + kind := outgoing.Kind + g.Hold = &kind + // spawnNext restores the hold allowance; a hold consumes it again. + g.CanHold = false + + if !g.Over { + g.emit(Event{Kind: EventHoldUsed, Piece: g.Active, Outgoing: outgoing}) + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/game.go internal/game/game_test.go +git commit -m "feat(game): hard drop, hold, and game over" +``` + +--- + +### Task 15: Determinism replay test + +**Files:** +- Create: `internal/game/determinism_test.go` + +**Interfaces:** +- Consumes: `New`, `Apply`, `Advance` (Tasks 9–14). +- Produces: nothing exported. This task adds only tests; it is the gate that §35 and §49.2 are actually true. + +- [ ] **Step 1: Write the failing test** + +Create `internal/game/determinism_test.go`: + +```go +package game + +import ( + "math/rand" + "testing" + "time" +) + +// step is one entry in a canned (input, dt) stream — §49.2's "timing is an +// input" made concrete. +type step struct { + in Input + hasIn bool + dt time.Duration +} + +// cannedStream builds a long, varied, fixed input stream from its own throwaway +// RNG. That RNG is a test fixture only: it never touches the game. +func cannedStream(n int) []step { + r := rand.New(rand.NewSource(20260917)) + inputs := []Input{ + InputLeft, InputRight, InputSoftDrop, InputRotateCW, + InputRotateCCW, InputHardDrop, InputHold, + } + out := make([]step, 0, n) + for i := 0; i < n; i++ { + s := step{dt: time.Duration(r.Intn(60)+1) * time.Millisecond} + if r.Intn(3) != 0 { + s.in, s.hasIn = inputs[r.Intn(len(inputs))], true + } + out = append(out, s) + } + return out +} + +func replay(seed int64, steps []step) *Game { + g := New(seed) + for _, s := range steps { + if s.hasIn { + g.Apply(s.in) + } + g.Advance(s.dt) + } + return g +} + +// §35: same seed + same input sequence + same timing inputs reproduces the +// state. This is the whole point of §49.2's Advance(dt). +func TestReplayIsDeterministic(t *testing.T) { + steps := cannedStream(4000) + a := replay(8675309, steps) + b := replay(8675309, steps) + + if a.Board != b.Board { + t.Error("boards diverged across identical replays") + } + if a.Active != b.Active { + t.Errorf("active pieces diverged: %+v vs %+v", a.Active, b.Active) + } + if a.Score != b.Score || a.Lines != b.Lines || a.Level != b.Level || a.Combo != b.Combo { + t.Errorf("scores diverged: %d/%d/%d/%d vs %d/%d/%d/%d", + a.Score, a.Lines, a.Level, a.Combo, b.Score, b.Lines, b.Level, b.Combo) + } + if a.Over != b.Over { + t.Errorf("Over diverged: %v vs %v", a.Over, b.Over) + } + if (a.Hold == nil) != (b.Hold == nil) || (a.Hold != nil && *a.Hold != *b.Hold) { + t.Error("hold diverged") + } + if len(a.Next) != len(b.Next) { + t.Fatalf("next-queue lengths diverged: %d vs %d", len(a.Next), len(b.Next)) + } + for i := range a.Next { + if a.Next[i] != b.Next[i] { + t.Fatalf("Next[%d] diverged: %v vs %v", i, a.Next[i], b.Next[i]) + } + } + if a.GravityAccumulator != b.GravityAccumulator || a.LockAccumulator != b.LockAccumulator { + t.Error("timing accumulators diverged") + } +} + +// A different seed must reach a different state, or the replay test above would +// pass on a broken RNG that ignores its seed. +func TestDifferentSeedsDiverge(t *testing.T) { + steps := cannedStream(2000) + a := replay(1, steps) + b := replay(2, steps) + if a.Board == b.Board && a.Score == b.Score && a.Active == b.Active { + t.Error("seeds 1 and 2 produced an identical run; the seed is being ignored") + } +} + +// The canned stream must be long enough to have actually exercised the engine. +func TestReplayStreamReachesInterestingState(t *testing.T) { + g := replay(8675309, cannedStream(4000)) + if g.Score == 0 { + t.Error("the replay never scored; the stream is not exercising the engine") + } + if g.Lines == 0 && !g.Over { + t.Error("the replay neither cleared a line nor ended; lengthen the stream") + } +} + +// Splitting a dt into pieces must land in the same place as one big step, or +// frame-rate jitter would change the game. +func TestTimeSubdivisionIsEquivalent(t *testing.T) { + whole := New(11) + whole.Advance(600 * time.Millisecond) + + parts := New(11) + for i := 0; i < 6; i++ { + parts.Advance(100 * time.Millisecond) + } + + if whole.Active != parts.Active { + t.Errorf("one 600ms step gave %+v, six 100ms steps gave %+v", whole.Active, parts.Active) + } + if whole.GravityAccumulator != parts.GravityAccumulator { + t.Errorf("accumulators differ: %v vs %v", whole.GravityAccumulator, parts.GravityAccumulator) + } +} +``` + +- [ ] **Step 2: Run the test** + +Run: `go test ./internal/game/ -run 'Replay|Seeds|Subdivision' -v` +Expected: PASS. If `TestReplayStreamReachesInterestingState` fails, the stream is not producing clears — raise `cannedStream(4000)` to `cannedStream(8000)` rather than weakening the assertion. If `TestReplayIsDeterministic` fails, something in the engine is reading a clock or a second RNG; `TestEngineNeverReadsAClock` from Task 9 narrows it down. + +- [ ] **Step 3: Run the whole suite with the race detector and vet** + +Run: `go build ./... && go vet ./... && go test ./... -race -count=1` +Expected: all PASS. + +- [ ] **Step 4: Commit** + +```bash +git add internal/game/determinism_test.go +git commit -m "test(game): canned-stream determinism replay" +``` + +--- + +## Phase 1 exit criteria + +Before starting Phase 2, all of these hold: + +- `go build ./... && go vet ./... && go test ./... -race -count=1` is green. +- `internal/game` contains no reference to a clock, a terminal, a colour, or a glyph — `TestEngineNeverReadsAClock` enforces the clock half. +- Every §40 "game logic" bullet has a test: board collision, bounds, row completion, removal, collapse; every rotation, wall kicks, failed rotation, spawn position; bag completeness and reproducibility; initial hold, swap, second hold blocked, hold restored after lock; soft drop, hard drop, landing position, lock; line values, combo behaviour, drop scoring, level progression; blocked spawn and state transition; and a canned-stream determinism replay. +- The only files under `internal/game` are `piece.go`, `board.go`, `bag.go`, `rules.go`, `event.go`, `scoring.go`, `game.go`, and their tests. diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/Q1-reader-cosmic-22/2026-09-17-phase-2-playable-terminal.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/Q1-reader-cosmic-22/2026-09-17-phase-2-playable-terminal.md new file mode 100644 index 000000000..a38b93eca --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/Q1-reader-cosmic-22/2026-09-17-phase-2-playable-terminal.md @@ -0,0 +1,2183 @@ +# Cosmic Tetris Phase 2 — Playable Terminal Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Turn the Phase 1 engine into a genuinely good terminal game: a Bubble Tea program with board rendering, ghost, hold, next queue, stats, help, pause, game over, live resize, and a `--seed/--ascii/--no-fx/--reduced-motion` CLI. No cosmic effects yet. + +**Architecture:** Two new packages. `internal/render` is pure: it draws into a homegrown rune `Canvas` and returns a string, given a value snapshot of the game plus a `Params` struct — no Bubble Tea types, no clock, no state. `internal/app` owns the Bubble Tea model: one 60 Hz frame clock whose delta is clamped and handed to `game.Advance`, key presses translated through a `bubbles/key` `KeyMap` and handed to `game.Apply`, and a scene enum for boot/play/pause/help/over/too-small. Because rendering takes a snapshot and returns a string, every layout is testable as an ANSI-free golden. + +**Tech Stack:** Go 1.25.0, `charm.land/bubbletea/v2 v2.0.9`, `charm.land/lipgloss/v2 v2.0.6`, `charm.land/bubbles/v2 v2.2.1` (the `key` package only). + +**Spec:** [`design.md`](../design.md) — this phase implements §4, §5 (rendering side), §8, §9 (mechanics, not the quantum-storage animation), §10, §28 (final panel only), §30, §31, §32, §33 (`app` + `render`), §34 (`Model`), §36, §37, §38, §39, §41, §42 Phase 2, §46, and §49.3/§49.4. + +## Global Constraints + +These apply to every task in every Cosmic Tetris plan. + +- Module path is `cosmic-tetris`; `go.mod` declares `go 1.25.0` (the floor required by the Charm v2 libraries). +- Direct dependencies are limited to `charm.land/bubbletea/v2 v2.0.9`, `charm.land/lipgloss/v2 v2.0.6`, `charm.land/bubbles/v2 v2.2.1`. Nothing else — including in tests. (This is why golden tests compare `Canvas.Plain()` rather than stripping ANSI with `x/ansi`.) +- Package layout is exactly §33's tree. Do not add packages. +- Board geometry is fixed: `width 10`, `height 22`, `visible rows 20`, `hidden spawn rows 2` (§5). +- **Nothing under `internal/game` may call `time.Now()`** (§49.2). Time enters only as the `dt` argument to `Advance`. In this phase, `internal/render` is held to the same rule: it is a pure function of its inputs. +- The game RNG and the FX RNG are separate objects and never share state (§49.6, §35). +- The FX system may observe game events and may never modify game state (§14). +- Screen shake never exceeds roughly one terminal cell (§44). +- Animation never delays gameplay and never blocks input (§44). +- No networking, no profiles, no achievements, no plugin system, no database (§2). +- No filesystem operations during gameplay (§38). Goldens are read in tests only. +- No goroutine per particle and no goroutine per frame (§38). One `tea.Tick` chain is the whole clock. +- Every task ends green: `go build ./... && go vet ./... && go test ./...`. + +## Review Focus + +Failure modes this phase's code owns that the spec implies but never states. Each has a test added to the task that owns the code — listed here once, with the owning task named. + +- **A window size of zero, or a negative one.** Bubble Tea delivers the first `WindowSizeMsg` after `Init`, so the model renders at least once at 0×0; some terminals report 0 during a resize storm. Expected: no panic, no negative slice length, and the too-small notice (or empty output) rather than a crash. → Task 1 (`NewCanvas` clamps) and Task 3 (`Compute` at 0×0). +- **A frame delta of minutes.** `Ctrl-Z` then `fg`, or a laptop sleep, makes `now - LastFrame` enormous; feeding that to `Advance` drops and locks dozens of pieces in one frame and can end the game while the player is away. Expected: the delta is clamped to `MaxFrameDelta` (100ms) so a suspend costs at most one gravity step. → Task 10. +- **Every terminal size from the minimum up.** Layout arithmetic is the easiest thing here to get off by one, and `Canvas.Set` clips silently, so an overflowing layout looks like missing output rather than an error. Expected: for every width 40..200 and height 24..60, the computed layout's every rectangle lies inside the canvas, and the tier boundaries land exactly on 40×24 / 56×26 / 72×28. → Task 12. +- **Ghost piece where the board is already occupied, and ghost identical to the active piece.** When the active piece rests on the stack the ghost coincides with it, and a ghost cell can fall on a locked cell during a kick. Expected: ghost never overwrites a locked cell and never overwrites the active piece — §10's "must never obscure locked blocks" and §44's "never obscure the active piece". → Task 4. +- **Keys pressed while paused, while dead, and while too small.** The player mashes during the game-over sequence and during a resize. Expected: in `ScenePause` only `p`, `?`, `r`, `q` do anything; in `SceneOver` only `r` and `q`; in `SceneTooSmall` only `q`; movement keys never reach `game.Apply`. → Task 10. + +--- + +## File Structure + +| File | Responsibility | +|------|----------------| +| `go.mod` | Add the three Charm v2 requires. | +| `internal/render/canvas.go` | `Cell`, `Canvas`: clipped cell writes, `Blit`, `Plain` for goldens, `Render` for styled output. | +| `internal/render/palette.go` | `Mode`, `DetectMode`, the glyph table, the neon-space palette per mode. | +| `internal/render/layout.go` | Tier selection from `(w,h)` and every rectangle's position. Pure arithmetic. | +| `internal/render/board.go` | The board box: border, locked cells, ghost, active piece. | +| `internal/render/hud.go` | HOLD / NEXT / stats panels, title line, mission-control line, controls line. | +| `internal/render/overlay.go` | Centred boxes: too-small notice, pause, help, game-over panel. | +| `internal/render/render.go` | `Scene`, `Params`, `Draw` — composes everything in §37's order. | +| `internal/render/testdata/*.golden` | ANSI-free layout contract (§41, §49.7). | +| `internal/app/keys.go` | `KeyMap` of `bubbles/key` bindings; key → `game.Input` mapping; help rows. | +| `internal/app/messages.go` | `frameMsg`, the tick command, `FrameDelta` and its clamp. | +| `internal/app/model.go` | `Model`, `New`, `Init`, `View`. | +| `internal/app/update.go` | `Update`: keys, frame, resize, scene transitions. | +| `cmd/cosmic-tetris/main.go` | Flag parsing and program wiring. | + +**Deviations from §33:** the `render` tree gains `canvas.go` and `overlay.go`. The canvas is the one piece of infrastructure everything else in `render` and all of Phase 4 writes through, and overlays are four self-contained panels with their own copy; folding either into `render.go` would make it the largest file in the project. §33 also lists `internal/app/update.go` and `messages.go`, which this phase creates as specified. + +--- + +### Task 1: The render canvas + +**Files:** +- Modify: `go.mod` (add the lipgloss require) +- Create: `internal/render/canvas.go` +- Test: `internal/render/canvas_test.go` + +**Interfaces:** +- Consumes: nothing from Phase 1. +- Produces: `type Cell struct { Rune rune; FG color.Color; Bold bool }`; `type Canvas struct { W, H int; ... }`; `func NewCanvas(w, h int) *Canvas`; `func (*Canvas) Set(x, y int, c Cell)`; `func (*Canvas) At(x, y int) Cell`; `func (*Canvas) SetString(x, y int, s string, fg color.Color, bold bool)`; `func (*Canvas) Blit(src *Canvas, dx, dy int)`; `func (*Canvas) Plain() string`; `func (*Canvas) Render() string`. + +**Why a homegrown canvas rather than `lipgloss.Canvas`:** every later phase needs per-cell compositing (particles land on individual cells), silent clipping (a particle at x=-3 must be dropped, not panic), whole-frame offsetting (screen shake blits the frame one cell over), and plain-text goldens. A rune grid gives all four in ~90 lines. + +- [ ] **Step 1: Add the lipgloss dependency** + +```bash +go get charm.land/lipgloss/v2@v2.0.6 +``` + +Confirm `go.mod` now requires `charm.land/lipgloss/v2 v2.0.6` and that the `go` line is still `go 1.25.0`. + +- [ ] **Step 2: Write the failing test** + +Create `internal/render/canvas_test.go`: + +```go +package render + +import ( + "image/color" + "strings" + "testing" + + "charm.land/lipgloss/v2" +) + +func TestCanvasSetAndAt(t *testing.T) { + c := NewCanvas(4, 3) + if c.W != 4 || c.H != 3 { + t.Fatalf("NewCanvas(4,3) = %dx%d, want 4x3", c.W, c.H) + } + red := lipgloss.Color("#FF0000") + c.Set(1, 2, Cell{Rune: 'x', FG: red, Bold: true}) + got := c.At(1, 2) + if got.Rune != 'x' || got.FG != red || !got.Bold { + t.Errorf("At(1,2) = %+v, want x/red/bold", got) + } + if c.At(0, 0).Rune != 0 { + t.Error("untouched cell should be transparent (Rune == 0)") + } +} + +func TestCanvasClipsEveryWriteAndRead(t *testing.T) { + c := NewCanvas(3, 2) + for _, p := range [][2]int{{-1, 0}, {0, -1}, {3, 0}, {0, 2}, {-100, -100}, {999, 999}} { + c.Set(p[0], p[1], Cell{Rune: '#'}) + if got := c.At(p[0], p[1]); got != (Cell{}) { + t.Errorf("At%v = %+v after out-of-bounds Set, want zero Cell", p, got) + } + } + if strings.TrimSpace(c.Plain()) != "" { + t.Errorf("out-of-bounds writes leaked into the canvas: %q", c.Plain()) + } +} + +func TestNewCanvasClampsNonPositiveSizes(t *testing.T) { + for _, d := range [][2]int{{0, 0}, {-4, 3}, {5, -9}, {-1, -1}} { + c := NewCanvas(d[0], d[1]) + if c.W < 0 || c.H < 0 { + t.Fatalf("NewCanvas%v = %dx%d, want non-negative", d, c.W, c.H) + } + c.Set(0, 0, Cell{Rune: 'a'}) // must not panic + _ = c.Plain() + } +} + +func TestCanvasSetStringWritesOneRunePerCell(t *testing.T) { + c := NewCanvas(8, 1) + c.SetString(2, 0, "HOLD", nil, false) + if got, want := c.Plain(), " HOLD"; got != want { + t.Errorf("Plain() = %q, want %q", got, want) + } + if c.At(2, 0).Rune != 'H' || c.At(5, 0).Rune != 'D' { + t.Error("SetString should advance one cell per rune") + } +} + +func TestCanvasSetStringClipsAtTheRightEdge(t *testing.T) { + c := NewCanvas(4, 1) + c.SetString(2, 0, "ABCDEF", nil, false) + if got, want := c.Plain(), " AB"; got != want { + t.Errorf("Plain() = %q, want %q", got, want) + } +} + +func TestCanvasPlainTrimsTrailingSpaceAndKeepsRows(t *testing.T) { + c := NewCanvas(5, 3) + c.SetString(0, 0, "ab", nil, false) + c.SetString(3, 2, "z", nil, false) + if got, want := c.Plain(), "ab\n\n z"; got != want { + t.Errorf("Plain() = %q, want %q", got, want) + } +} + +func TestCanvasBlitSkipsTransparentCells(t *testing.T) { + dst := NewCanvas(6, 2) + dst.SetString(0, 0, "......", nil, false) + src := NewCanvas(3, 1) + src.Set(0, 0, Cell{Rune: 'X'}) + src.Set(2, 0, Cell{Rune: 'Y'}) // cell 1 stays transparent + dst.Blit(src, 2, 0) + if got, want := dst.Plain(), "..X.Y."; got != want { + t.Errorf("Plain() = %q, want %q", got, want) + } +} + +func TestCanvasBlitClipsPartiallyOffscreenSources(t *testing.T) { + dst := NewCanvas(3, 1) + src := NewCanvas(3, 1) + src.SetString(0, 0, "abc", nil, false) + dst.Blit(src, -1, 0) // "a" falls off the left + if got, want := dst.Plain(), "bc"; got != want { + t.Errorf("Plain() = %q, want %q", got, want) + } +} + +func TestCanvasRenderGroupsRunsOfOneStyle(t *testing.T) { + // Render must emit one escape sequence per run of same-styled cells, not + // one per cell. This is the whole anti-flicker story (§38): the terminal is + // the bottleneck. It relies on color.Color values being comparable with ==, + // which holds for every concrete type lipgloss.Color returns. + c := NewCanvas(4, 1) + cyan := lipgloss.Color("#22E4F7") + same := lipgloss.Color("#22E4F7") + if cyan != same { + t.Fatal("lipgloss.Color values are no longer comparable with ==; Render must group by a comparison function instead") + } + c.Set(0, 0, Cell{Rune: '#', FG: cyan}) + c.Set(1, 0, Cell{Rune: '#', FG: same}) + c.Set(2, 0, Cell{Rune: '#', FG: lipgloss.Color("#FF4D6D")}) + c.Set(3, 0, Cell{Rune: '#', FG: lipgloss.Color("#FF4D6D")}) + got := c.Render() + if n := strings.Count(got, "\x1b["); n > 4 { + t.Errorf("Render() emitted %d escape sequences for 2 runs: %q", n, got) + } + if strings.Count(got, "##") != 2 { + t.Errorf("Render() = %q, want two two-cell runs", got) + } +} + +func TestCanvasRenderIsPlainForUnstyledCells(t *testing.T) { + c := NewCanvas(6, 2) + c.SetString(0, 0, "hi", nil, false) + var noColor color.Color + c.Set(0, 1, Cell{Rune: 'k', FG: noColor}) + if got, want := c.Render(), "hi\nk"; got != want { + t.Errorf("Render() = %q, want %q", got, want) + } +} +``` + +- [ ] **Step 3: Run the tests to verify they fail** + +Run: `go test ./internal/render/ -run TestCanvas -v` +Expected: FAIL — `undefined: NewCanvas`. + +- [ ] **Step 4: Write the implementation** + +Create `internal/render/canvas.go`: + +```go +// Package render draws Cosmic Tetris. Everything here is a pure function of +// its arguments: no clock, no terminal I/O, no game mutation (§37). +package render + +import ( + "image/color" + "strings" + + "charm.land/lipgloss/v2" +) + +// Cell is one terminal cell. A zero Rune means transparent: Blit skips it, +// Plain renders it as a space, and Render leaves it unstyled. +type Cell struct { + Rune rune + FG color.Color + Bold bool +} + +// Canvas is a fixed-size grid of terminal cells. Every write is clipped to the +// grid, so callers may draw wherever they like without bounds checks — which is +// what lets particles and screen shake stay simple (§38). +type Canvas struct { + W, H int + cells []Cell +} + +// NewCanvas allocates a w×h canvas of transparent cells. Non-positive +// dimensions clamp to zero rather than panicking: terminals report 0×0. +func NewCanvas(w, h int) *Canvas { + if w < 0 { + w = 0 + } + if h < 0 { + h = 0 + } + return &Canvas{W: w, H: h, cells: make([]Cell, w*h)} +} + +func (c *Canvas) inBounds(x, y int) bool { + return x >= 0 && y >= 0 && x < c.W && y < c.H +} + +// Set writes one cell. Out-of-bounds writes are dropped. +func (c *Canvas) Set(x, y int, cell Cell) { + if !c.inBounds(x, y) { + return + } + c.cells[y*c.W+x] = cell +} + +// At reads one cell. Out-of-bounds reads return the zero (transparent) Cell. +func (c *Canvas) At(x, y int) Cell { + if !c.inBounds(x, y) { + return Cell{} + } + return c.cells[y*c.W+x] +} + +// SetString writes s left to right from (x, y), one rune per cell. Every glyph +// this program uses is one terminal column wide, so runes and columns agree. +func (c *Canvas) SetString(x, y int, s string, fg color.Color, bold bool) { + i := 0 + for _, r := range s { + c.Set(x+i, y, Cell{Rune: r, FG: fg, Bold: bold}) + i++ + } +} + +// Blit copies src onto c with src's origin at (dx, dy), skipping src's +// transparent cells so layers show through. +func (c *Canvas) Blit(src *Canvas, dx, dy int) { + for y := 0; y < src.H; y++ { + for x := 0; x < src.W; x++ { + cell := src.At(x, y) + if cell.Rune == 0 { + continue + } + c.Set(dx+x, dy+y, cell) + } + } +} + +// lastFilled returns the rightmost non-transparent column in row y, or -1. +func (c *Canvas) lastFilled(y int) int { + for x := c.W - 1; x >= 0; x-- { + if c.At(x, y).Rune != 0 { + return x + } + } + return -1 +} + +// Plain renders the canvas as unstyled text with trailing blanks trimmed. This +// is the form the golden tests compare (§41). +func (c *Canvas) Plain() string { + var b strings.Builder + for y := 0; y < c.H; y++ { + last := c.lastFilled(y) + for x := 0; x <= last; x++ { + r := c.At(x, y).Rune + if r == 0 { + r = ' ' + } + b.WriteRune(r) + } + if y < c.H-1 { + b.WriteByte('\n') + } + } + return b.String() +} + +// Render renders the canvas with colors, emitting one escape sequence per run +// of identically styled cells rather than per cell. +func (c *Canvas) Render() string { + var out strings.Builder + var run strings.Builder + var runFG color.Color + runBold := false + + flush := func() { + if run.Len() == 0 { + return + } + s := run.String() + run.Reset() + if runFG == nil && !runBold { + out.WriteString(s) + return + } + st := lipgloss.NewStyle().Bold(runBold) + if runFG != nil { + st = st.Foreground(runFG) + } + out.WriteString(st.Render(s)) + } + + for y := 0; y < c.H; y++ { + last := c.lastFilled(y) + for x := 0; x <= last; x++ { + cell := c.At(x, y) + r, fg, bold := cell.Rune, cell.FG, cell.Bold + if r == 0 { + r, fg, bold = ' ', nil, false + } + if run.Len() > 0 && (fg != runFG || bold != runBold) { + flush() + } + runFG, runBold = fg, bold + run.WriteRune(r) + } + flush() + if y < c.H-1 { + out.WriteByte('\n') + } + } + return out.String() +} +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `go test ./internal/render/ -v && go vet ./...` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add go.mod go.sum internal/render/canvas.go internal/render/canvas_test.go +git commit -m "feat(render): clipped rune canvas with plain and styled output" +``` + +--- + +### Task 2: Rendering modes, glyphs, and the neon-space palette + +**Files:** +- Create: `internal/render/palette.go` +- Test: `internal/render/palette_test.go` + +**Interfaces:** +- Consumes: `game.KindCount`, `game.PieceKind` and its `KindI…KindZ` constants (Phase 1 Task 1); `Cell` (Task 1). +- Produces: `type Mode int` with `ModeFull, ModeReduced, ModeASCII` and `func (Mode) String() string`; `func DetectMode(ascii bool, term, colorterm string) Mode`; `type GlyphSet` with the fields listed below and `func GlyphsFor(Mode) GlyphSet`; `type Palette` with the fields listed below and `func PaletteFor(Mode) Palette`. + +**Decision — detection is a pure function of three strings.** `DetectMode` takes the `--ascii` flag and the values of `TERM` and `COLORTERM` as arguments rather than reading the environment itself, so every branch is a table test and `render` keeps its no-side-effects property. `main.go` does the `os.Getenv` calls (Task 11). + +**Decision — ASCII ghost stays `··` (U+00B7), per §49.4.** U+00B7 is Latin-1 rather than strict ASCII, which sits slightly awkwardly against §32's "no special Unicode assumptions". §49 wins over earlier sections by its own rule, `·` is a single-column glyph everywhere, and the alternative (`..`) reads as a typo next to `[]` pieces. + +- [ ] **Step 1: Write the failing test** + +Create `internal/render/palette_test.go`: + +```go +package render + +import ( + "fmt" + "testing" + "unicode/utf8" + + "cosmic-tetris/internal/game" +) + +func TestDetectMode(t *testing.T) { + cases := []struct { + name string + ascii bool + term string + colorterm string + want Mode + }{ + {"flag wins over everything", true, "xterm-256color", "truecolor", ModeASCII}, + {"no TERM at all", false, "", "truecolor", ModeASCII}, + {"dumb terminal", false, "dumb", "truecolor", ModeASCII}, + {"truecolor", false, "xterm-256color", "truecolor", ModeFull}, + {"24bit", false, "xterm-256color", "24bit", ModeFull}, + {"truecolor upper case", false, "xterm", "TrueColor", ModeFull}, + {"256 color, no COLORTERM", false, "xterm-256color", "", ModeReduced}, + {"unknown COLORTERM", false, "screen", "yes", ModeReduced}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := DetectMode(tc.ascii, tc.term, tc.colorterm); got != tc.want { + t.Errorf("DetectMode(%v, %q, %q) = %v, want %v", tc.ascii, tc.term, tc.colorterm, got, tc.want) + } + }) + } +} + +func TestModeString(t *testing.T) { + want := map[Mode]string{ModeFull: "full", ModeReduced: "reduced", ModeASCII: "ascii"} + for m, s := range want { + if got := m.String(); got != s { + t.Errorf("Mode(%d).String() = %q, want %q", m, got, s) + } + } +} + +// Every glyph must be exactly one terminal column per rune, or the whole +// layout shears. Cell glyphs are two runes (one logical block is 2×1, §5); +// decorations are one. Display width was verified against the same width +// tables lipgloss uses; this test pins the rune counts that make that true. +func TestGlyphMetrics(t *testing.T) { + for _, m := range []Mode{ModeFull, ModeReduced, ModeASCII} { + g := GlyphsFor(m) + for name, s := range map[string]string{"Block": g.Block, "Ghost": g.Ghost, "Empty": g.Empty} { + if n := utf8.RuneCountInString(s); n != CellCols { + t.Errorf("%v %s = %q: %d runes, want %d", m, name, s, n, CellCols) + } + } + ones := map[string]string{ + "BoardTL": g.BoardTL, "BoardTR": g.BoardTR, "BoardBL": g.BoardBL, "BoardBR": g.BoardBR, + "BoardH": g.BoardH, "BoardV": g.BoardV, + "BoxTL": g.BoxTL, "BoxTR": g.BoxTR, "BoxBL": g.BoxBL, "BoxBR": g.BoxBR, + "BoxH": g.BoxH, "BoxV": g.BoxV, + "Star": g.Star, "Comet": g.Comet, + } + for name, s := range ones { + if n := utf8.RuneCountInString(s); n != 1 { + t.Errorf("%v %s = %q: %d runes, want 1", m, name, s, n) + } + } + } +} + +func TestASCIIModeUsesTheSpecGlyphs(t *testing.T) { + g := GlyphsFor(ModeASCII) + if g.Block != "[]" { + t.Errorf("ASCII Block = %q, want %q (§5, §49.4)", g.Block, "[]") + } + if g.Ghost != "··" { + t.Errorf("ASCII Ghost = %q, want %q (§49.4)", g.Ghost, "··") + } + if g.BoardH != "-" || g.BoardV != "|" || g.BoardTL != "+" { + t.Error("ASCII board border should be built from + - |") + } +} + +func TestUnicodeModesUseTheSpecGlyphs(t *testing.T) { + for _, m := range []Mode{ModeFull, ModeReduced} { + g := GlyphsFor(m) + if g.Block != "██" || g.Ghost != "░░" { + t.Errorf("%v: Block/Ghost = %q/%q, want ██/░░ (§5, §49.4)", m, g.Block, g.Ghost) + } + if g.BoardTL != "╔" || g.BoardH != "═" || g.BoardV != "║" || g.BoardBR != "╝" { + t.Errorf("%v: board border should be the double box of §25", m) + } + if g.BoxTL != "╭" || g.BoxBR != "╯" { + t.Errorf("%v: overlay boxes should be the rounded box of §30", m) + } + } +} + +func TestPaletteCoversEveryPieceKindDistinctly(t *testing.T) { + for _, m := range []Mode{ModeFull, ModeReduced, ModeASCII} { + p := PaletteFor(m) + seen := map[string]game.PieceKind{} + for k := game.PieceKind(0); int(k) < game.KindCount; k++ { + if p.Piece[k] == nil { + t.Fatalf("%v: Piece[%v] is nil", m, k) + } + if p.PieceLock[k] == nil { + t.Fatalf("%v: PieceLock[%v] is nil", m, k) + } + if m == ModeFull { + r, g, b, _ := p.Piece[k].RGBA() + key := fmt.Sprintf("%d/%d/%d", r, g, b) + if prev, dup := seen[key]; dup { + t.Errorf("full mode: %v and %v share a color", prev, k) + } + seen[key] = k + } + } + } +} + +func TestPaletteChromeIsPopulated(t *testing.T) { + for _, m := range []Mode{ModeFull, ModeReduced, ModeASCII} { + p := PaletteFor(m) + for name, c := range map[string]any{ + "Ghost": p.Ghost, "Border": p.Border, "Title": p.Title, "Universe": p.Universe, + "Label": p.Label, "Value": p.Value, "Mission": p.Mission, "Controls": p.Controls, + "OverlayBorder": p.OverlayBorder, "OverlayText": p.OverlayText, "OverlayDim": p.OverlayDim, + } { + if c == nil { + t.Errorf("%v: palette field %s is nil", m, name) + } + } + } +} + +// §49.4: the active piece renders one step brighter than locked cells. In full +// mode that is a darkened locked color; the 256-color and ASCII ramps are too +// coarse to darken, so brightness there comes from bold on the active cell. +func TestLockedCellsAreDarkerInFullMode(t *testing.T) { + p := PaletteFor(ModeFull) + for k := game.PieceKind(0); int(k) < game.KindCount; k++ { + ar, ag, ab, _ := p.Piece[k].RGBA() + lr, lg, lb, _ := p.PieceLock[k].RGBA() + if lr+lg+lb >= ar+ag+ab { + t.Errorf("full mode %v: locked color is not darker than active", k) + } + } + for _, m := range []Mode{ModeReduced, ModeASCII} { + q := PaletteFor(m) + for k := game.PieceKind(0); int(k) < game.KindCount; k++ { + if q.Piece[k] != q.PieceLock[k] { + t.Errorf("%v %v: expected the same color for active and locked", m, k) + } + } + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/render/ -run 'TestDetectMode|TestGlyph|TestPalette|TestMode|TestASCII|TestUnicode|TestLocked' -v` +Expected: FAIL — `undefined: DetectMode`. + +- [ ] **Step 3: Write the implementation** + +Create `internal/render/palette.go`: + +```go +package render + +import ( + "image/color" + "strings" + + "charm.land/lipgloss/v2" + "cosmic-tetris/internal/game" +) + +// CellCols is how many terminal columns one logical board cell occupies (§5). +const CellCols = 2 + +// Mode is the rendering capability tier (§32). +type Mode int + +const ( + ModeFull Mode = iota // Unicode, truecolor, all effects + ModeReduced // Unicode, 256 color, simplified gradients + ModeASCII // ASCII glyphs, 8 colors, no Unicode assumptions +) + +func (m Mode) String() string { + switch m { + case ModeFull: + return "full" + case ModeReduced: + return "reduced" + default: + return "ascii" + } +} + +// DetectMode picks a rendering mode from the --ascii flag and the TERM and +// COLORTERM values. It is pure: the caller reads the environment. +func DetectMode(ascii bool, term, colorterm string) Mode { + if ascii || term == "" || term == "dumb" { + return ModeASCII + } + switch strings.ToLower(colorterm) { + case "truecolor", "24bit": + return ModeFull + } + return ModeReduced +} + +// GlyphSet is every glyph the renderer draws, so a mode change is one lookup. +// Block, Ghost and Empty are CellCols runes wide; everything else is one rune. +type GlyphSet struct { + Block string // a filled board cell (§49.4) + Ghost string // a landing-preview cell (§10, §49.4) + Empty string // an empty board cell + + BoardTL, BoardTR, BoardBL, BoardBR, BoardH, BoardV string // §25 machinery + BoxTL, BoxTR, BoxBL, BoxBR, BoxH, BoxV string // §30/§39 overlays + + Star string // title sparkle + Comet string // mission-control prefix (§27) +} + +func GlyphsFor(m Mode) GlyphSet { + if m == ModeASCII { + return GlyphSet{ + Block: "[]", Ghost: "··", Empty: " ", + BoardTL: "+", BoardTR: "+", BoardBL: "+", BoardBR: "+", BoardH: "-", BoardV: "|", + BoxTL: "+", BoxTR: "+", BoxBL: "+", BoxBR: "+", BoxH: "-", BoxV: "|", + Star: "*", Comet: ">", + } + } + return GlyphSet{ + Block: "██", Ghost: "░░", Empty: " ", + BoardTL: "╔", BoardTR: "╗", BoardBL: "╚", BoardBR: "╝", BoardH: "═", BoardV: "║", + BoxTL: "╭", BoxTR: "╮", BoxBL: "╰", BoxBR: "╯", BoxH: "─", BoxV: "│", + Star: "✦", Comet: "☄", + } +} + +// Palette is every color the renderer uses. Piece colors follow §26's neon +// space intent; PieceLock is the locked-cell variant (§49.4). +type Palette struct { + Piece [game.KindCount]color.Color + PieceLock [game.KindCount]color.Color + + Ghost color.Color + Border color.Color + Title color.Color + Universe color.Color + Label color.Color + Value color.Color + Mission color.Color + Controls color.Color + + OverlayBorder color.Color + OverlayText color.Color + OverlayDim color.Color +} + +// lockedRatio is how far full-mode locked cells are darkened from active ones. +const lockedRatio = 0.25 + +func PaletteFor(m Mode) Palette { + var p Palette + switch m { + case ModeFull: + p.Piece = [game.KindCount]color.Color{ + game.KindI: lipgloss.Color("#22E4F7"), // plasma cyan + game.KindJ: lipgloss.Color("#3A6BFF"), // deep electric blue + game.KindL: lipgloss.Color("#FF8A2B"), // solar orange + game.KindO: lipgloss.Color("#FFD447"), // stellar gold + game.KindS: lipgloss.Color("#4BE08A"), // alien green + game.KindT: lipgloss.Color("#B46BFF"), // ultraviolet + game.KindZ: lipgloss.Color("#FF4D6D"), // supernova pink + } + for k := range p.Piece { + p.PieceLock[k] = lipgloss.Darken(p.Piece[k], lockedRatio) + } + p.Ghost = lipgloss.Color("#3A4A63") + p.Border = lipgloss.Color("#7A5CFF") + p.Title = lipgloss.Color("#22E4F7") + p.Universe = lipgloss.Color("#B46BFF") + p.Label = lipgloss.Color("#7C8CA8") + p.Value = lipgloss.Color("#E6F0FF") + p.Mission = lipgloss.Color("#FFD447") + p.Controls = lipgloss.Color("#7C8CA8") + p.OverlayBorder = lipgloss.Color("#22E4F7") + p.OverlayText = lipgloss.Color("#E6F0FF") + p.OverlayDim = lipgloss.Color("#7C8CA8") + case ModeReduced: + p.Piece = [game.KindCount]color.Color{ + game.KindI: lipgloss.Color("51"), + game.KindJ: lipgloss.Color("33"), + game.KindL: lipgloss.Color("208"), + game.KindO: lipgloss.Color("220"), + game.KindS: lipgloss.Color("84"), + game.KindT: lipgloss.Color("141"), + game.KindZ: lipgloss.Color("204"), + } + p.PieceLock = p.Piece + p.Ghost = lipgloss.Color("238") + p.Border = lipgloss.Color("99") + p.Title = lipgloss.Color("51") + p.Universe = lipgloss.Color("141") + p.Label = lipgloss.Color("245") + p.Value = lipgloss.Color("255") + p.Mission = lipgloss.Color("220") + p.Controls = lipgloss.Color("245") + p.OverlayBorder = lipgloss.Color("51") + p.OverlayText = lipgloss.Color("255") + p.OverlayDim = lipgloss.Color("245") + default: // ModeASCII: the 8 ANSI colors only + p.Piece = [game.KindCount]color.Color{ + game.KindI: lipgloss.Color("6"), + game.KindJ: lipgloss.Color("4"), + game.KindL: lipgloss.Color("3"), + game.KindO: lipgloss.Color("3"), + game.KindS: lipgloss.Color("2"), + game.KindT: lipgloss.Color("5"), + game.KindZ: lipgloss.Color("1"), + } + p.PieceLock = p.Piece + p.Ghost = lipgloss.Color("8") + p.Border = lipgloss.Color("4") + p.Title = lipgloss.Color("6") + p.Universe = lipgloss.Color("5") + p.Label = lipgloss.Color("8") + p.Value = lipgloss.Color("7") + p.Mission = lipgloss.Color("3") + p.Controls = lipgloss.Color("8") + p.OverlayBorder = lipgloss.Color("6") + p.OverlayText = lipgloss.Color("7") + p.OverlayDim = lipgloss.Color("8") + } + return p +} +``` + +Note that ASCII mode reuses color `3` for both `L` and `O`; the distinctness +assertion in `TestPaletteCoversEveryPieceKindDistinctly` only applies to full +mode, because eight colors cannot separate seven pieces plus chrome. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/palette.go internal/render/palette_test.go +git commit -m "feat(render): rendering modes, glyph table, and neon space palette" +``` + +--- + +### Task 3: Adaptive layout + +**Files:** +- Create: `internal/render/layout.go` +- Test: `internal/render/layout_test.go` + +**Interfaces:** +- Consumes: `CellCols` (Task 2); `game.BoardWidth`, `game.VisibleRows` (Phase 1 Task 1). +- Produces: `type Rect struct { X, Y, W, H int }` with `Right()`, `Bottom()`, `Contains(x, y int) bool`; `type Tier int` with `TierTooSmall, TierSmall, TierMedium, TierLarge` and `String()`; `type ItemKind int` with `ItemHoldLabel, ItemHoldPreview, ItemNextLabel, ItemNextPreview, ItemScoreLabel, ItemScoreValue, ItemLinesLabel, ItemLinesValue, ItemLevelLabel, ItemLevelValue`; `type Item struct { Kind ItemKind; Index, X, Y, W int }`; `type Layout` (fields below); `func Compute(w, h int) Layout`; the geometry constants `BoardCols, BoardRows, BoxCols, BoxRows, PanelCols, SmallPanelCols, Gap, PreviewCols, PreviewRows, MinCols, MinRows`. + +**Design — the layout is a display list.** `Compute` returns absolute terminal coordinates for the board, the panel rectangles, the three chrome rows, and a flat `[]Item` naming every HUD element and where it goes. All three tiers differ only in what `Compute` puts in that list, so `hud.go` has no tier logic at all, and the tests can assert "nothing overlaps" (§41) by checking rectangles rather than diffing pictures. + +**Geometry, fixed by §5 and §49.3:** + +```text +one board cell 2 cols × 1 row → play area 20 × 20 +board with border 22 × 22 +panel (large/medium) 12 cols +panel (small) 9 cols +gap between columns 1 col +piece preview 8 cols × 2 rows + +column group: large 12+1+22+1+12 = 48 | medium 22+1+12 = 35 | small 22+1+9 = 32 + +large w ≥ 72 and h ≥ 28 HOLD | BOARD | NEXT, stats under HOLD frame 68 × 28 +medium w ≥ 56 and h ≥ 26 BOARD | one combined panel frame 48 × 26 +small otherwise BOARD | narrow panel, 3 NEXT, no labels frame 32 × 23 +below w < 40 or h < 24 the §31 notice +``` + +**Why the frame is wider than the column group at large and medium sizes.** The +chrome lines are wider than the board group: §4's controls line is 61 columns +and its mission-control line is around 40, against a 48-column group. So +`Content` is the frame — the box the title border draws and the chrome lines +live in — and `Group` is the column group, centred inside it. That reproduces +§4's generous side margins, and gives the chrome its own `TextX`/`TextW` text +area instead of forcing the controls line to be cryptic on an 80-column +terminal. + +Row budgets, dropping in §49.3's order (title border, then mission control, then +stat labels): + +```text +large 0 title box top | 1 blank | 2..23 board | 24 blank | 25 mission | 26 controls | 27 box bottom +medium 0 title line | 1..22 board | 23 blank | 24 mission | 25 controls +small 0..21 board | 22 controls +``` + +- [ ] **Step 1: Write the failing test** + +Create `internal/render/layout_test.go`: + +```go +package render + +import "testing" + +func TestTierThresholds(t *testing.T) { + cases := []struct { + w, h int + want Tier + }{ + {0, 0, TierTooSmall}, + {-10, -10, TierTooSmall}, + {39, 24, TierTooSmall}, + {40, 23, TierTooSmall}, + {40, 24, TierSmall}, + {55, 26, TierSmall}, + {56, 25, TierSmall}, + {56, 26, TierMedium}, + {71, 28, TierMedium}, + {72, 27, TierMedium}, + {72, 28, TierLarge}, + {200, 60, TierLarge}, + } + for _, tc := range cases { + if got := Compute(tc.w, tc.h).Tier; got != tc.want { + t.Errorf("Compute(%d,%d).Tier = %v, want %v", tc.w, tc.h, got, tc.want) + } + } +} + +func TestTooSmallLayoutHasNoGeometry(t *testing.T) { + l := Compute(0, 0) + if l.Board.W != 0 || l.Board.H != 0 { + t.Errorf("too-small board rect = %+v, want zero", l.Board) + } + if len(l.Items) != 0 { + t.Errorf("too-small layout has %d HUD items, want 0", len(l.Items)) + } + if l.TitleRow != -1 || l.MissionRow != -1 || l.ControlsRow != -1 { + t.Errorf("too-small layout should have no chrome rows, got %+v", l) + } + if l.TextW != 0 || l.Group.W != 0 { + t.Errorf("too-small layout should have no text area or group, got TextW=%d group=%+v", l.TextW, l.Group) + } + if l.Width != 0 || l.Height != 0 { + t.Errorf("Compute should record the terminal size it was given, got %dx%d", l.Width, l.Height) + } +} + +func TestBoardIsAlwaysTwentyTwoBySquare(t *testing.T) { + for _, d := range [][2]int{{40, 24}, {60, 26}, {80, 30}, {200, 60}} { + l := Compute(d[0], d[1]) + if l.Board.W != BoxCols || l.Board.H != BoxRows { + t.Errorf("Compute%v board = %dx%d, want %dx%d", d, l.Board.W, l.Board.H, BoxCols, BoxRows) + } + if l.Play.W != BoardCols || l.Play.H != BoardRows { + t.Errorf("Compute%v play = %dx%d, want %dx%d", d, l.Play.W, l.Play.H, BoardCols, BoardRows) + } + if l.Play.X != l.Board.X+1 || l.Play.Y != l.Board.Y+1 { + t.Errorf("Compute%v play origin %+v not inside board %+v", d, l.Play, l.Board) + } + } +} + +func TestLargeLayoutColumns(t *testing.T) { + l := Compute(80, 30) + if l.Content.W != 68 || l.Content.H != 28 { + t.Fatalf("large frame = %dx%d, want 68x28", l.Content.W, l.Content.H) + } + if l.Content.X != (80-68)/2 || l.Content.Y != (30-28)/2 { + t.Errorf("large frame should be centred, got %+v in 80x30", l.Content) + } + if l.Group.W != 48 { + t.Errorf("large group = %d cols, want 48", l.Group.W) + } + if l.Group.X != l.Content.X+(l.Content.W-l.Group.W)/2 { + t.Errorf("the group should be centred in the frame: group=%+v frame=%+v", l.Group, l.Content) + } + if l.Left.W != PanelCols || l.Right.W != PanelCols { + t.Errorf("large panels = %d/%d cols, want %d each", l.Left.W, l.Right.W, PanelCols) + } + if l.Left.X != l.Group.X { + t.Errorf("HOLD panel should start the group: %+v vs %+v", l.Left, l.Group) + } + if l.Board.X != l.Left.Right()+Gap { + t.Errorf("board should follow the left panel by one gap: board.X=%d left.Right=%d", l.Board.X, l.Left.Right()) + } + if l.Right.X != l.Board.Right()+Gap { + t.Errorf("NEXT panel should follow the board by one gap: right.X=%d board.Right=%d", l.Right.X, l.Board.Right()) + } + if l.Right.Right() != l.Group.Right() { + t.Errorf("NEXT panel should end the group: %d vs %d", l.Right.Right(), l.Group.Right()) + } +} + +func TestChromeTextAreaFitsTheSpecLines(t *testing.T) { + // §4's controls line is 61 columns and its mission line about 40. The text + // area has to hold them at large size, and stay inside the frame at every + // size. + for _, d := range [][2]int{{40, 24}, {60, 26}, {80, 30}, {200, 60}} { + l := Compute(d[0], d[1]) + if l.TextX < l.Content.X || l.TextX+l.TextW-1 > l.Content.Right() { + t.Errorf("Compute%v: text area %d..%d escapes the frame %+v", d, l.TextX, l.TextX+l.TextW-1, l.Content) + } + if l.TextW <= 0 { + t.Errorf("Compute%v: TextW = %d", d, l.TextW) + } + } + if got := Compute(80, 30).TextW; got < 61 { + t.Errorf("large TextW = %d, want at least 61 for §4's controls line", got) + } + if got := Compute(60, 26).TextW; got < 40 { + t.Errorf("medium TextW = %d, want at least 40 for the mission-control line", got) + } +} + +func TestGroupStaysInsideTheFrame(t *testing.T) { + for _, d := range [][2]int{{40, 24}, {56, 26}, {72, 28}, {120, 45}} { + l := Compute(d[0], d[1]) + if !containsRect(l.Content, l.Group) { + t.Errorf("Compute%v: group %+v escapes frame %+v", d, l.Group, l.Content) + } + if !containsRect(l.Content, l.Board) { + t.Errorf("Compute%v: board %+v escapes frame %+v", d, l.Board, l.Content) + } + if l.Content.X < 0 || l.Content.Y < 0 || l.Content.Right() >= d[0] || l.Content.Bottom() >= d[1] { + t.Errorf("Compute%v: frame %+v escapes the terminal", d, l.Content) + } + } +} + +func TestLargeLayoutRows(t *testing.T) { + l := Compute(80, 30) + top := l.Content.Y + if l.TitleRow != top || !l.TitleBorder { + t.Errorf("large: TitleRow=%d TitleBorder=%v, want %d/true", l.TitleRow, l.TitleBorder, top) + } + if l.Board.Y != top+2 { + t.Errorf("large: board Y = %d, want %d", l.Board.Y, top+2) + } + if l.MissionRow != top+25 || l.ControlsRow != top+26 { + t.Errorf("large: mission=%d controls=%d, want %d/%d", l.MissionRow, l.ControlsRow, top+25, top+26) + } + if l.NextCount != 5 || !l.ShowLabels { + t.Errorf("large: NextCount=%d ShowLabels=%v, want 5/true", l.NextCount, l.ShowLabels) + } + if l.Left.Y != l.Board.Y || l.Left.H != BoxRows { + t.Errorf("panels should span the board rows: left=%+v board=%+v", l.Left, l.Board) + } +} + +func TestMediumLayoutDropsTheTitleBorder(t *testing.T) { + l := Compute(60, 26) + if l.Content.W != 48 || l.Content.H != 26 { + t.Fatalf("medium frame = %dx%d, want 48x26", l.Content.W, l.Content.H) + } + if l.Group.W != 35 { + t.Errorf("medium group = %d cols, want 35", l.Group.W) + } + if l.Left.W != 0 { + t.Errorf("medium should have no left panel, got %+v", l.Left) + } + if l.Board.X != l.Group.X { + t.Errorf("medium: board should start the group, got %+v vs %+v", l.Board, l.Group) + } + if l.Right.X != l.Board.Right()+Gap || l.Right.W != PanelCols { + t.Errorf("medium panel = %+v, want %d cols one gap right of the board", l.Right, PanelCols) + } + top := l.Content.Y + if l.TitleRow != top || l.TitleBorder { + t.Errorf("medium: TitleRow=%d TitleBorder=%v, want %d/false (§49.3)", l.TitleRow, l.TitleBorder, top) + } + if l.Board.Y != top+1 || l.MissionRow != top+24 || l.ControlsRow != top+25 { + t.Errorf("medium rows: board=%d mission=%d controls=%d", l.Board.Y, l.MissionRow, l.ControlsRow) + } + if l.NextCount != 5 || !l.ShowLabels { + t.Errorf("medium: NextCount=%d ShowLabels=%v, want 5/true", l.NextCount, l.ShowLabels) + } +} + +func TestSmallLayoutDropsMissionControlAndLabels(t *testing.T) { + l := Compute(40, 24) + if l.Content.W != 32 || l.Content.H != 23 { + t.Fatalf("small frame = %dx%d, want 32x23", l.Content.W, l.Content.H) + } + if l.Group.W != 32 || l.Group.X != l.Content.X { + t.Errorf("small: frame and group should coincide horizontally, got %+v vs %+v", l.Group, l.Content) + } + if l.TitleRow != -1 || l.MissionRow != -1 { + t.Errorf("small should drop title and mission control (§49.3), got title=%d mission=%d", l.TitleRow, l.MissionRow) + } + if l.Board.Y != l.Content.Y { + t.Errorf("small: board should start at the top of the group, got %+v", l.Board) + } + if l.ControlsRow != l.Content.Y+22 { + t.Errorf("small: controls row = %d, want %d", l.ControlsRow, l.Content.Y+22) + } + if l.NextCount != 3 { + t.Errorf("small: NextCount = %d, want 3 (§49.3)", l.NextCount) + } + if l.ShowLabels { + t.Error("small: stat labels should be dropped (§49.3)") + } + if l.Right.W != SmallPanelCols || l.Right.X != l.Board.Right()+Gap { + t.Errorf("small: NEXT panel must sit beside the board, never above or below it (§49.3): %+v", l.Right) + } +} + +func TestSmallLayoutHasNoStatLabelItems(t *testing.T) { + l := Compute(40, 24) + for _, it := range l.Items { + switch it.Kind { + case ItemScoreLabel, ItemLinesLabel, ItemLevelLabel: + t.Errorf("small layout emitted %v", it.Kind) + } + } + want := map[ItemKind]int{ItemNextLabel: 1, ItemNextPreview: 3, ItemScoreValue: 1, ItemLinesValue: 1, ItemLevelValue: 1, ItemHoldLabel: 1, ItemHoldPreview: 1} + got := map[ItemKind]int{} + for _, it := range l.Items { + got[it.Kind]++ + } + for k, n := range want { + if got[k] != n { + t.Errorf("small layout has %d × %v, want %d", got[k], k, n) + } + } +} + +func TestNextPreviewsAreOrderedAndSpaced(t *testing.T) { + for _, d := range [][2]int{{80, 30}, {60, 26}, {40, 24}} { + l := Compute(d[0], d[1]) + var prev *Item + n := 0 + for i := range l.Items { + it := l.Items[i] + if it.Kind != ItemNextPreview { + continue + } + if it.Index != n { + t.Errorf("Compute%v: NEXT preview #%d has Index %d", d, n, it.Index) + } + if prev != nil && it.Y < prev.Y+PreviewRows { + t.Errorf("Compute%v: NEXT previews %d and %d overlap (%d, %d)", d, n-1, n, prev.Y, it.Y) + } + if it.W < PreviewCols { + t.Errorf("Compute%v: NEXT preview #%d has %d cols, need %d", d, n, it.W, PreviewCols) + } + prev = &l.Items[i] + n++ + } + if n != l.NextCount { + t.Errorf("Compute%v: %d preview items for NextCount %d", d, n, l.NextCount) + } + } +} + +// §41: nothing overlaps. Every HUD item must live inside a panel rect, and no +// item may touch the board. +func TestHUDItemsStayInsidePanelsAndOffTheBoard(t *testing.T) { + for _, d := range [][2]int{{40, 24}, {56, 26}, {72, 28}, {100, 40}, {200, 60}} { + l := Compute(d[0], d[1]) + for _, it := range l.Items { + h := 1 + if it.Kind == ItemHoldPreview || it.Kind == ItemNextPreview { + h = PreviewRows + } + box := Rect{X: it.X, Y: it.Y, W: it.W, H: h} + inPanel := containsRect(l.Left, box) || containsRect(l.Right, box) + if !inPanel { + t.Errorf("Compute%v: item %v at %+v is outside both panels (left=%+v right=%+v)", d, it.Kind, box, l.Left, l.Right) + } + if overlaps(box, l.Board) { + t.Errorf("Compute%v: item %v at %+v overlaps the board %+v", d, it.Kind, box, l.Board) + } + } + } +} + +func containsRect(outer, inner Rect) bool { + if outer.W == 0 || outer.H == 0 { + return false + } + return inner.X >= outer.X && inner.Y >= outer.Y && + inner.Right() <= outer.Right() && inner.Bottom() <= outer.Bottom() +} + +func overlaps(a, b Rect) bool { + if a.W == 0 || a.H == 0 || b.W == 0 || b.H == 0 { + return false + } + return a.X <= b.Right() && b.X <= a.Right() && a.Y <= b.Bottom() && b.Y <= a.Bottom() +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/render/ -run 'TestTier|TestLarge|TestMedium|TestSmall|TestBoardIs|TestNextPreviews|TestHUD|TestTooSmall' -v` +Expected: FAIL — `undefined: Compute`. + +- [ ] **Step 3: Write the implementation** + +Create `internal/render/layout.go`: + +```go +package render + +import "cosmic-tetris/internal/game" + +// Geometry, in terminal cells (§5, §49.3). +const ( + BoardCols = game.BoardWidth * CellCols // 20: the play area + BoardRows = game.VisibleRows // 20 + BoxCols = BoardCols + 2 // 22: play area plus border + BoxRows = BoardRows + 2 // 22 + + PanelCols = 12 // HOLD / NEXT / stats panel + SmallPanelCols = 9 // the narrow panel at small sizes + Gap = 1 // between columns + PreviewCols = 8 // a 4-cell-wide piece preview + PreviewRows = 2 + + MinCols = 40 // §31 minimum usable target + MinRows = 24 +) + +// Rect is a half-open-free rectangle: X..Right() and Y..Bottom() inclusive. +type Rect struct{ X, Y, W, H int } + +func (r Rect) Right() int { return r.X + r.W - 1 } +func (r Rect) Bottom() int { return r.Y + r.H - 1 } + +func (r Rect) Contains(x, y int) bool { + return x >= r.X && y >= r.Y && x <= r.Right() && y <= r.Bottom() +} + +// Tier is which of §31's layouts fits the terminal. +type Tier int + +const ( + TierTooSmall Tier = iota + TierSmall + TierMedium + TierLarge +) + +func (t Tier) String() string { + switch t { + case TierSmall: + return "small" + case TierMedium: + return "medium" + case TierLarge: + return "large" + default: + return "too-small" + } +} + +// ItemKind names one HUD element. +type ItemKind int + +const ( + ItemHoldLabel ItemKind = iota + ItemHoldPreview + ItemNextLabel + ItemNextPreview + ItemScoreLabel + ItemScoreValue + ItemLinesLabel + ItemLinesValue + ItemLevelLabel + ItemLevelValue +) + +func (k ItemKind) String() string { + return [...]string{ + "hold-label", "hold-preview", "next-label", "next-preview", + "score-label", "score-value", "lines-label", "lines-value", + "level-label", "level-value", + }[k] +} + +// Item is one HUD element placed at absolute terminal coordinates. Index +// distinguishes NEXT previews; W is the space the element may use. +type Item struct { + Kind ItemKind + Index int + X, Y int + W int +} + +// Layout is a complete frame plan. Rows are absolute terminal rows; -1 means +// the element was dropped for lack of height (§49.3). +type Layout struct { + Tier Tier + Width, Height int // the terminal it was computed for + Content Rect // the whole frame, centred in the terminal + Group Rect // the column group (panels + board), centred in the frame + + Board Rect // the bordered box + Play Rect // its interior + + Left Rect // HOLD/stats panel; zero-width when there isn't one + Right Rect // NEXT (and, at medium and below, everything else) + + TextX, TextW int // the chrome text area: title, mission control, controls + + TitleRow int + TitleBorder bool // draw the title as a box around Content (large only) + MissionRow int + ControlsRow int + + NextCount int // upcoming pieces to draw: 5, or 3 at small sizes + ShowLabels bool // SCORE/LINES/LEVEL labels, dropped first at small sizes + + Items []Item +} + +// Compute plans a frame for a w×h terminal. It never panics and never returns +// negative geometry, whatever the terminal claims its size is. +func Compute(w, h int) Layout { + l := Layout{Width: w, Height: h, TitleRow: -1, MissionRow: -1, ControlsRow: -1} + if w < MinCols || h < MinRows { + l.Tier = TierTooSmall + return l + } + + // frameW/frameH is the box the chrome lives in; groupW is the column group + // centred inside it. The frame is wider than the group so §4's chrome lines + // fit; textPad is the frame's inner margin for those lines. + var frameW, frameH, groupW, textPad int + switch { + case w >= 72 && h >= 28: + l.Tier = TierLarge + frameW, frameH, groupW, textPad = 68, 28, PanelCols+Gap+BoxCols+Gap+PanelCols, 2 + case w >= 56 && h >= 26: + l.Tier = TierMedium + frameW, frameH, groupW, textPad = 48, 26, BoxCols+Gap+PanelCols, 1 + default: + l.Tier = TierSmall + frameW, frameH, groupW, textPad = 32, 23, BoxCols+Gap+SmallPanelCols, 0 + } + l.Content = Rect{X: (w - frameW) / 2, Y: (h - frameH) / 2, W: frameW, H: frameH} + l.TextX, l.TextW = l.Content.X+textPad, frameW-2*textPad + + gx := l.Content.X + (frameW-groupW)/2 + oy := l.Content.Y + switch l.Tier { + case TierLarge: + l.Group = Rect{X: gx, Y: oy + 2, W: groupW, H: BoxRows} + l.Left = Rect{X: gx, Y: oy + 2, W: PanelCols, H: BoxRows} + l.Board = Rect{X: l.Left.Right() + Gap, Y: oy + 2, W: BoxCols, H: BoxRows} + l.Right = Rect{X: l.Board.Right() + Gap, Y: oy + 2, W: PanelCols, H: BoxRows} + l.TitleRow, l.TitleBorder = oy, true + l.MissionRow, l.ControlsRow = oy+25, oy+26 + l.NextCount, l.ShowLabels = 5, true + case TierMedium: + l.Group = Rect{X: gx, Y: oy + 1, W: groupW, H: BoxRows} + l.Board = Rect{X: gx, Y: oy + 1, W: BoxCols, H: BoxRows} + l.Right = Rect{X: l.Board.Right() + Gap, Y: oy + 1, W: PanelCols, H: BoxRows} + l.TitleRow = oy + l.MissionRow, l.ControlsRow = oy+24, oy+25 + l.NextCount, l.ShowLabels = 5, true + default: + l.Group = Rect{X: gx, Y: oy, W: groupW, H: BoxRows} + l.Board = Rect{X: gx, Y: oy, W: BoxCols, H: BoxRows} + l.Right = Rect{X: l.Board.Right() + Gap, Y: oy, W: SmallPanelCols, H: BoxRows} + l.ControlsRow = oy + 22 + l.NextCount, l.ShowLabels = 3, false + } + l.Play = Rect{X: l.Board.X + 1, Y: l.Board.Y + 1, W: BoardCols, H: BoardRows} + l.Items = items(l) + return l +} + +// items places the HUD elements. Every row offset below is relative to the top +// of the panel, which is the top of the board box; the totals are chosen so the +// last element lands on or above the panel's final row. +func items(l Layout) []Item { + // One column of padding inside a wide panel; the narrow panel pads only on + // the left, because a preview needs all 8 remaining columns. + wide := PanelCols - 2 + narrow := SmallPanelCols - 1 + + var out []Item + add := func(k ItemKind, idx, x, y, w int) { + out = append(out, Item{Kind: k, Index: idx, X: x, Y: y, W: w}) + } + + switch l.Tier { + case TierLarge: + lx, rx := l.Left.X+1, l.Right.X+1 + top := l.Board.Y + add(ItemHoldLabel, 0, lx, top, wide) + add(ItemHoldPreview, 0, lx, top+2, wide) + add(ItemScoreLabel, 0, lx, top+5, wide) + add(ItemScoreValue, 0, lx, top+6, wide) + add(ItemLinesLabel, 0, lx, top+8, wide) + add(ItemLinesValue, 0, lx, top+9, wide) + add(ItemLevelLabel, 0, lx, top+11, wide) + add(ItemLevelValue, 0, lx, top+12, wide) + add(ItemNextLabel, 0, rx, top, wide) + for i := 0; i < l.NextCount; i++ { + add(ItemNextPreview, i, rx, top+2+3*i, wide) + } + case TierMedium: + rx, top := l.Right.X+1, l.Board.Y + add(ItemHoldLabel, 0, rx, top, wide) + add(ItemHoldPreview, 0, rx, top+1, wide) + add(ItemNextLabel, 0, rx, top+4, wide) + for i := 0; i < l.NextCount; i++ { + add(ItemNextPreview, i, rx, top+5+2*i, wide) + } + add(ItemScoreLabel, 0, rx, top+16, wide) + add(ItemScoreValue, 0, rx, top+17, wide) + add(ItemLinesLabel, 0, rx, top+18, wide) + add(ItemLinesValue, 0, rx, top+19, wide) + add(ItemLevelLabel, 0, rx, top+20, wide) + add(ItemLevelValue, 0, rx, top+21, wide) + case TierSmall: + rx, top := l.Right.X+1, l.Board.Y + add(ItemNextLabel, 0, rx, top, narrow) + for i := 0; i < l.NextCount; i++ { + add(ItemNextPreview, i, rx, top+2+3*i, narrow) + } + add(ItemScoreValue, 0, rx, top+11, narrow) + add(ItemLinesValue, 0, rx, top+12, narrow) + add(ItemLevelValue, 0, rx, top+13, narrow) + add(ItemHoldLabel, 0, rx, top+15, narrow) + add(ItemHoldPreview, 0, rx, top+16, narrow) + } + return out +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/render/ -v && go vet ./...` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/layout.go internal/render/layout_test.go +git commit -m "feat(render): adaptive layout tiers as a placed display list" +``` + +--- + +### Task 4: The board — border, locked cells, ghost, active piece + +**Files:** +- Create: `internal/render/board.go` +- Test: `internal/render/board_test.go` + +**Interfaces:** +- Consumes: `Canvas`, `Cell` (Task 1); `GlyphSet`, `Palette`, `CellCols` (Task 2); `Layout`, `Rect` (Task 3); `game.Board`, `game.Piece`, `game.Cell`, `game.HiddenRows`, `game.BoardWidth`, `game.BoardHeight` and `Piece.Cells()` (Phase 1 Tasks 1–3). +- Produces: `type BoardView struct { Board game.Board; Active, Ghost game.Piece; ShowActive, ShowGhost bool }`; `func DrawBoard(c *Canvas, l Layout, v BoardView, gs GlyphSet, p Palette)`; `func BoardCellOrigin(l Layout, bx, by int) (x, y int, visible bool)`. + +**Design — the view is a value copy.** `BoardView` holds a copy of the board and the two pieces rather than a `*game.Game`, so §37's "the rendering process must not mutate game state" is enforced by the type system rather than by discipline. A `game.Board` is 220 bytes; copying one per frame is free next to writing to a terminal. + +**Draw order is §37's, steps 3–5, and it is what makes the two obscuring rules hold:** locked cells first; then ghost cells, skipped wherever the board is already occupied (§10: "must never obscure locked blocks"); then the active piece, which paints over any ghost cell it shares (§44: "never obscure the active piece"). + +- [ ] **Step 1: Write the failing test** + +Create `internal/render/board_test.go`: + +```go +package render + +import ( + "strings" + "testing" + + "cosmic-tetris/internal/game" +) + +// glyphAt reads the two-column glyph drawn for board cell (bx, by). +func glyphAt(t *testing.T, c *Canvas, l Layout, bx, by int) string { + t.Helper() + x, y, visible := BoardCellOrigin(l, bx, by) + if !visible { + t.Fatalf("board cell (%d,%d) is not visible", bx, by) + } + return string([]rune{c.At(x, y).Rune, c.At(x+1, y).Rune}) +} + +func drawTestBoard(v BoardView, m Mode) (*Canvas, Layout) { + l := Compute(80, 30) + c := NewCanvas(80, 30) + DrawBoard(c, l, v, GlyphsFor(m), PaletteFor(m)) + return c, l +} + +func TestBoardBorderIsTheDoubleBox(t *testing.T) { + c, l := drawTestBoard(BoardView{}, ModeFull) + if got := c.At(l.Board.X, l.Board.Y).Rune; got != '╔' { + t.Errorf("top-left border rune = %q, want ╔", got) + } + if got := c.At(l.Board.Right(), l.Board.Y).Rune; got != '╗' { + t.Errorf("top-right border rune = %q, want ╗", got) + } + if got := c.At(l.Board.X, l.Board.Bottom()).Rune; got != '╚' { + t.Errorf("bottom-left border rune = %q, want ╚", got) + } + if got := c.At(l.Board.Right(), l.Board.Bottom()).Rune; got != '╝' { + t.Errorf("bottom-right border rune = %q, want ╝", got) + } + for x := l.Board.X + 1; x < l.Board.Right(); x++ { + if c.At(x, l.Board.Y).Rune != '═' { + t.Fatalf("top border has a gap at x=%d", x) + } + } + for y := l.Board.Y + 1; y < l.Board.Bottom(); y++ { + if c.At(l.Board.X, y).Rune != '║' || c.At(l.Board.Right(), y).Rune != '║' { + t.Fatalf("side border has a gap at y=%d", y) + } + } +} + +func TestEmptyBoardInteriorIsBlank(t *testing.T) { + c, l := drawTestBoard(BoardView{}, ModeFull) + for by := game.HiddenRows; by < game.BoardHeight; by++ { + for bx := 0; bx < game.BoardWidth; bx++ { + if got := glyphAt(t, c, l, bx, by); got != " " { + t.Fatalf("empty cell (%d,%d) = %q, want two spaces", bx, by, got) + } + } + } +} + +func TestLockedCellsUseTheBlockGlyphAndLockedColor(t *testing.T) { + var v BoardView + v.Board.Set(3, 21, game.CellOf(game.KindT)) + c, l := drawTestBoard(v, ModeFull) + if got := glyphAt(t, c, l, 3, 21); got != "██" { + t.Errorf("locked cell = %q, want ██", got) + } + x, y, _ := BoardCellOrigin(l, 3, 21) + p := PaletteFor(ModeFull) + cell := c.At(x, y) + if cell.FG != p.PieceLock[game.KindT] { + t.Error("locked cell should use the locked variant of the T color") + } + if cell.Bold { + t.Error("locked cells are not bold; only the active piece is (§49.4)") + } +} + +func TestActivePieceIsOneStepBrighterThanLocked(t *testing.T) { + var v BoardView + v.Board.Set(0, 21, game.CellOf(game.KindI)) + v.Active = game.Piece{Kind: game.KindI, Rotation: 0, X: 3, Y: 10} + v.ShowActive = true + c, l := drawTestBoard(v, ModeFull) + p := PaletteFor(ModeFull) + + var active Cell + for _, off := range v.Active.Cells() { + x, y, visible := BoardCellOrigin(l, v.Active.X+off[0], v.Active.Y+off[1]) + if !visible { + continue + } + active = c.At(x, y) + } + if active.Rune == 0 { + t.Fatal("the active piece was not drawn") + } + if active.FG != p.Piece[game.KindI] || !active.Bold { + t.Error("active cells should use the bright piece color and bold (§49.4)") + } + + lx, ly, _ := BoardCellOrigin(l, 0, 21) + if locked := c.At(lx, ly); locked.Bold || locked.FG == active.FG { + t.Error("locked cells must be dimmer than the active piece") + } +} + +func TestGhostNeverOverwritesLockedCells(t *testing.T) { + // An O piece hovering over a stack; the ghost lands on row 20-21 where + // column 4 is already occupied by a locked cell. + var v BoardView + v.Board.Set(4, 21, game.CellOf(game.KindZ)) + v.Active = game.Piece{Kind: game.KindO, Rotation: 0, X: 3, Y: 5} + v.Ghost = game.Piece{Kind: game.KindO, Rotation: 0, X: 3, Y: 19} + v.ShowActive, v.ShowGhost = true, true + c, l := drawTestBoard(v, ModeFull) + + if got := glyphAt(t, c, l, 4, 21); got != "██" { + t.Errorf("locked cell under the ghost = %q, want ██ (§10)", got) + } + x, y, _ := BoardCellOrigin(l, 4, 21) + if c.At(x, y).FG != PaletteFor(ModeFull).PieceLock[game.KindZ] { + t.Error("the ghost painted over a locked cell (§10)") + } +} + +func TestActivePieceDrawsOverItsOwnGhost(t *testing.T) { + // A piece already resting on the floor: ghost and active coincide exactly. + v := BoardView{ + Active: game.Piece{Kind: game.KindO, Rotation: 0, X: 3, Y: 20}, + Ghost: game.Piece{Kind: game.KindO, Rotation: 0, X: 3, Y: 20}, + ShowActive: true, ShowGhost: true, + } + c, l := drawTestBoard(v, ModeFull) + if got := glyphAt(t, c, l, 4, 21); got != "██" { + t.Errorf("cell under a resting piece = %q, want ██ not ░░ (§44)", got) + } + x, y, _ := BoardCellOrigin(l, 4, 21) + if !c.At(x, y).Bold { + t.Error("the active piece should still be the bright layer when it coincides with its ghost") + } +} + +func TestGhostUsesTheGhostGlyphAndColor(t *testing.T) { + v := BoardView{ + Active: game.Piece{Kind: game.KindO, Rotation: 0, X: 3, Y: 6}, + Ghost: game.Piece{Kind: game.KindO, Rotation: 0, X: 3, Y: 20}, + ShowActive: true, ShowGhost: true, + } + c, l := drawTestBoard(v, ModeFull) + if got := glyphAt(t, c, l, 4, 21); got != "░░" { + t.Errorf("ghost cell = %q, want ░░ (§49.4)", got) + } + x, y, _ := BoardCellOrigin(l, 4, 21) + if c.At(x, y).FG != PaletteFor(ModeFull).Ghost { + t.Error("ghost cells should use the ghost color") + } +} + +func TestHiddenSpawnRowsAreNeverDrawn(t *testing.T) { + var v BoardView + for by := 0; by < game.HiddenRows; by++ { + for bx := 0; bx < game.BoardWidth; bx++ { + v.Board.Set(bx, by, game.CellOf(game.KindS)) + } + } + v.Active = game.Piece{Kind: game.KindI, Rotation: 0, X: 3, Y: 0} + v.ShowActive = true + c, l := drawTestBoard(v, ModeFull) + + for by := 0; by < game.HiddenRows; by++ { + if _, _, visible := BoardCellOrigin(l, 0, by); visible { + t.Fatalf("board row %d should not be visible", by) + } + } + interior := 0 + for y := l.Play.Y; y <= l.Play.Bottom(); y++ { + for x := l.Play.X; x <= l.Play.Right(); x++ { + if c.At(x, y).Rune != 0 { + interior++ + } + } + } + if interior != 0 { + t.Errorf("%d cells drawn inside the play area, want 0: hidden rows must stay hidden", interior) + } + if strings.Contains(c.Plain(), "██") { + t.Error("hidden-row content leaked into the frame") + } +} + +func TestASCIIModeDrawsBracketsAndDots(t *testing.T) { + var v BoardView + v.Board.Set(0, 21, game.CellOf(game.KindL)) + v.Active = game.Piece{Kind: game.KindO, Rotation: 0, X: 3, Y: 6} + v.Ghost = game.Piece{Kind: game.KindO, Rotation: 0, X: 3, Y: 20} + v.ShowActive, v.ShowGhost = true, true + c, l := drawTestBoard(v, ModeASCII) + if got := glyphAt(t, c, l, 0, 21); got != "[]" { + t.Errorf("ASCII locked cell = %q, want []", got) + } + if got := glyphAt(t, c, l, 4, 21); got != "··" { + t.Errorf("ASCII ghost cell = %q, want ·· (§49.4)", got) + } + if got := c.At(l.Board.X, l.Board.Y).Rune; got != '+' { + t.Errorf("ASCII border corner = %q, want +", got) + } +} + +func TestDrawBoardIsANoOpForATooSmallLayout(t *testing.T) { + l := Compute(20, 10) + c := NewCanvas(20, 10) + var v BoardView + v.Board.Set(0, 21, game.CellOf(game.KindI)) + v.ShowActive = true + DrawBoard(c, l, v, GlyphsFor(ModeFull), PaletteFor(ModeFull)) + if c.Plain() != strings.Repeat("\n", 9) { + t.Errorf("DrawBoard drew something for a too-small layout: %q", c.Plain()) + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/render/ -run 'TestBoard|TestEmpty|TestLocked|TestActive|TestGhost|TestHidden|TestASCIIMode|TestDrawBoard' -v` +Expected: FAIL — `undefined: DrawBoard`. + +- [ ] **Step 3: Write the implementation** + +Create `internal/render/board.go`: + +```go +package render + +import ( + "image/color" + + "cosmic-tetris/internal/game" +) + +// BoardView is everything the board drawing needs, by value. Copying keeps the +// renderer structurally incapable of mutating game state (§37). +type BoardView struct { + Board game.Board + Active game.Piece + Ghost game.Piece + + ShowActive bool + ShowGhost bool +} + +// BoardCellOrigin maps a board coordinate to the canvas column and row of the +// cell's left half. Cells in the hidden spawn rows report visible == false. +func BoardCellOrigin(l Layout, bx, by int) (x, y int, visible bool) { + if l.Tier == TierTooSmall { + return 0, 0, false + } + if by < game.HiddenRows || by >= game.BoardHeight || bx < 0 || bx >= game.BoardWidth { + return 0, 0, false + } + return l.Play.X + bx*CellCols, l.Play.Y + (by - game.HiddenRows), true +} + +// DrawBoard draws §37 steps 3–7 except the FX composite: border, locked cells, +// ghost, active piece. +func DrawBoard(c *Canvas, l Layout, v BoardView, gs GlyphSet, p Palette) { + if l.Tier == TierTooSmall { + return + } + drawBoardFrame(c, l, gs, p) + + for by := game.HiddenRows; by < game.BoardHeight; by++ { + for bx := 0; bx < game.BoardWidth; bx++ { + if cell := v.Board.At(bx, by); cell.Filled() { + drawBoardCell(c, l, bx, by, gs.Block, p.PieceLock[cell.Kind()], false) + } + } + } + + // The ghost goes under everything else: skip any cell the stack owns (§10). + if v.ShowGhost { + for _, off := range v.Ghost.Cells() { + bx, by := v.Ghost.X+off[0], v.Ghost.Y+off[1] + if bx < 0 || bx >= game.BoardWidth || by < 0 || by >= game.BoardHeight { + continue + } + if v.Board.At(bx, by).Filled() { + continue + } + drawBoardCell(c, l, bx, by, gs.Ghost, p.Ghost, false) + } + } + + // The active piece paints last and brightest, so nothing can obscure it (§44). + if v.ShowActive { + for _, off := range v.Active.Cells() { + bx, by := v.Active.X+off[0], v.Active.Y+off[1] + drawBoardCell(c, l, bx, by, gs.Block, p.Piece[v.Active.Kind], true) + } + } +} + +func drawBoardCell(c *Canvas, l Layout, bx, by int, glyph string, fg color.Color, bold bool) { + x, y, visible := BoardCellOrigin(l, bx, by) + if !visible { + return + } + c.SetString(x, y, glyph, fg, bold) +} + +func drawBoardFrame(c *Canvas, l Layout, gs GlyphSet, p Palette) { + b := l.Board + c.SetString(b.X, b.Y, gs.BoardTL, p.Border, false) + c.SetString(b.Right(), b.Y, gs.BoardTR, p.Border, false) + c.SetString(b.X, b.Bottom(), gs.BoardBL, p.Border, false) + c.SetString(b.Right(), b.Bottom(), gs.BoardBR, p.Border, false) + for x := b.X + 1; x < b.Right(); x++ { + c.SetString(x, b.Y, gs.BoardH, p.Border, false) + c.SetString(x, b.Bottom(), gs.BoardH, p.Border, false) + } + for y := b.Y + 1; y < b.Bottom(); y++ { + c.SetString(b.X, y, gs.BoardV, p.Border, false) + c.SetString(b.Right(), y, gs.BoardV, p.Border, false) + } +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/render/ -v && go vet ./...` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/board.go internal/render/board_test.go +git commit -m "feat(render): draw the board frame, locked cells, ghost, and active piece" +``` + +--- + +### Task 5: HOLD, NEXT, and the stat panels + +**Files:** +- Create: `internal/render/hud.go` +- Test: `internal/render/hud_test.go` + +**Interfaces:** +- Consumes: `Canvas` (Task 1); `GlyphSet`, `Palette`, `CellCols` (Task 2); `Layout`, `Item`, `ItemKind`, `PreviewCols`, `PreviewRows` (Task 3); `game.PieceKind`, `game.Piece.Cells()` (Phase 1). +- Produces: `type HUDView struct { Hold *game.PieceKind; Next []game.PieceKind; Score, Lines, Level int }`; `func DrawHUD(c *Canvas, l Layout, v HUDView, gs GlyphSet, p Palette)`; `func FormatScore(int) string`; `func FormatLines(int) string`; `func FormatLevel(int) string`; `func PreviewShape(game.PieceKind) (cells [][2]int, w, h int)`. + +**Design — `DrawHUD` walks `Layout.Items` and knows nothing about tiers.** Each item kind has one drawing rule; where the item goes and whether it exists at all was decided in Task 3. `NextCount` items exist, so a short `Next` slice simply draws fewer previews and never indexes past the end. + +**Value formats,** from the §4 mockup: score `00129340` (8 digits, zero-padded), lines `042` (3), level `07` (2). Longer values grow rather than wrap, and are clipped to the item width so nothing spills out of its panel. + +- [ ] **Step 1: Write the failing test** + +Create `internal/render/hud_test.go`: + +```go +package render + +import ( + "strings" + "testing" + + "cosmic-tetris/internal/game" +) + +func textAt(c *Canvas, x, y, n int) string { + rs := make([]rune, 0, n) + for i := 0; i < n; i++ { + r := c.At(x+i, y).Rune + if r == 0 { + r = ' ' + } + rs = append(rs, r) + } + return string(rs) +} + +func itemOf(t *testing.T, l Layout, k ItemKind, index int) Item { + t.Helper() + for _, it := range l.Items { + if it.Kind == k && it.Index == index { + return it + } + } + t.Fatalf("layout has no %v #%d", k, index) + return Item{} +} + +func drawTestHUD(v HUDView, w, h int, m Mode) (*Canvas, Layout) { + l := Compute(w, h) + c := NewCanvas(w, h) + DrawHUD(c, l, v, GlyphsFor(m), PaletteFor(m)) + return c, l +} + +func TestValueFormats(t *testing.T) { + if got := FormatScore(129340); got != "00129340" { + t.Errorf("FormatScore(129340) = %q, want %q", got, "00129340") + } + if got := FormatScore(0); got != "00000000" { + t.Errorf("FormatScore(0) = %q, want 8 zeroes", got) + } + if got := FormatScore(123456789); got != "123456789" { + t.Errorf("FormatScore(123456789) = %q: long scores grow, they do not truncate", got) + } + if got := FormatLines(42); got != "042" { + t.Errorf("FormatLines(42) = %q, want %q", got, "042") + } + if got := FormatLevel(7); got != "07" { + t.Errorf("FormatLevel(7) = %q, want %q", got, "07") + } + if got := FormatLevel(13); got != "13" { + t.Errorf("FormatLevel(13) = %q, want %q", got, "13") + } +} + +func TestLabelsAreDrawnAtLargeSize(t *testing.T) { + c, l := drawTestHUD(HUDView{}, 80, 30, ModeFull) + for _, tc := range []struct { + kind ItemKind + want string + }{ + {ItemHoldLabel, "HOLD"}, + {ItemNextLabel, "NEXT"}, + {ItemScoreLabel, "SCORE"}, + {ItemLinesLabel, "LINES"}, + {ItemLevelLabel, "LEVEL"}, + } { + it := itemOf(t, l, tc.kind, 0) + if got := strings.TrimRight(textAt(c, it.X, it.Y, len(tc.want)), " "); got != tc.want { + t.Errorf("%v = %q, want %q", tc.kind, got, tc.want) + } + } +} + +func TestStatValuesAreDrawn(t *testing.T) { + v := HUDView{Score: 129340, Lines: 42, Level: 7} + c, l := drawTestHUD(v, 80, 30, ModeFull) + for _, tc := range []struct { + kind ItemKind + want string + }{ + {ItemScoreValue, "00129340"}, + {ItemLinesValue, "042"}, + {ItemLevelValue, "07"}, + } { + it := itemOf(t, l, tc.kind, 0) + if got := textAt(c, it.X, it.Y, len(tc.want)); got != tc.want { + t.Errorf("%v = %q, want %q", tc.kind, got, tc.want) + } + } +} + +func TestSmallLayoutDrawsValuesWithoutLabels(t *testing.T) { + v := HUDView{Score: 1, Lines: 2, Level: 3} + c, l := drawTestHUD(v, 40, 24, ModeFull) + it := itemOf(t, l, ItemScoreValue, 0) + if got := textAt(c, it.X, it.Y, 8); got != "00000001" { + t.Errorf("small score = %q, want %q", got, "00000001") + } + if strings.Contains(c.Plain(), "SCORE") || strings.Contains(c.Plain(), "LINES") { + t.Error("small layout should draw values without stat labels (§49.3)") + } + if !strings.Contains(c.Plain(), "NEXT") { + t.Error("the NEXT label survives at small sizes; only stat labels are dropped") + } +} + +func TestOversizedValuesAreClippedToTheirItem(t *testing.T) { + v := HUDView{Score: 999999999999, Lines: 999999, Level: 4321} + c, l := drawTestHUD(v, 40, 24, ModeFull) + it := itemOf(t, l, ItemScoreValue, 0) + // Nothing may appear to the right of the item's own width. + if got := c.At(it.X+it.W, it.Y).Rune; got != 0 { + t.Errorf("an oversized score spilled past its %d-column item: %q", it.W, got) + } + if it.X+it.W > l.Content.Right()+1 { + t.Fatalf("item %+v is not inside the content group %+v", it, l.Content) + } +} + +func TestPreviewShapeBoundingBoxes(t *testing.T) { + cases := []struct { + kind game.PieceKind + w, h int + }{ + {game.KindI, 4, 1}, + {game.KindO, 2, 2}, + {game.KindJ, 3, 2}, + {game.KindL, 3, 2}, + {game.KindS, 3, 2}, + {game.KindT, 3, 2}, + {game.KindZ, 3, 2}, + } + for _, tc := range cases { + cells, w, h := PreviewShape(tc.kind) + if w != tc.w || h != tc.h { + t.Errorf("PreviewShape(%v) box = %dx%d, want %dx%d", tc.kind, w, h, tc.w, tc.h) + } + if len(cells) != 4 { + t.Errorf("PreviewShape(%v) has %d cells, want 4", tc.kind, len(cells)) + } + for _, cell := range cells { + if cell[0] < 0 || cell[0] >= w || cell[1] < 0 || cell[1] >= h { + t.Errorf("PreviewShape(%v) cell %v is outside its own %dx%d box", tc.kind, cell, w, h) + } + } + } +} + +func TestPreviewsAreCentredInTheirBox(t *testing.T) { + // I fills all 8 preview columns; O is 4 columns wide and so is inset by 2. + for _, tc := range []struct { + kind game.PieceKind + startX int + width int + }{ + {game.KindI, 0, 8}, + {game.KindO, 2, 4}, + {game.KindT, 1, 6}, + } { + hold := tc.kind + c, l := drawTestHUD(HUDView{Hold: &hold}, 80, 30, ModeFull) + it := itemOf(t, l, ItemHoldPreview, 0) + // Measure the union of both preview rows: a T's top row holds one cell + // and its bottom row three, so a single row does not describe the box. + minX, maxX := PreviewCols, -1 + for dy := 0; dy < PreviewRows; dy++ { + for dx := 0; dx < PreviewCols; dx++ { + if c.At(it.X+dx, it.Y+dy).Rune == 0 { + continue + } + minX, maxX = min(minX, dx), max(maxX, dx) + } + } + if maxX < 0 { + t.Fatalf("%v preview was not drawn", tc.kind) + } + if minX != tc.startX { + t.Errorf("%v preview starts at column %d, want %d", tc.kind, minX, tc.startX) + } + if got := maxX - minX + 1; got != tc.width { + t.Errorf("%v preview is %d columns wide, want %d", tc.kind, got, tc.width) + } + } +} + +func TestEmptyHoldDrawsNoPreview(t *testing.T) { + c, l := drawTestHUD(HUDView{}, 80, 30, ModeFull) + it := itemOf(t, l, ItemHoldPreview, 0) + for dy := 0; dy < PreviewRows; dy++ { + if got := strings.TrimSpace(textAt(c, it.X, it.Y+dy, PreviewCols)); got != "" { + t.Errorf("empty hold drew %q", got) + } + } + if !strings.Contains(c.Plain(), "HOLD") { + t.Error("the HOLD label should be drawn even when nothing is held") + } +} + +func TestNextQueueDrawsInOrderWithEachPieceColor(t *testing.T) { + v := HUDView{Next: []game.PieceKind{game.KindZ, game.KindI, game.KindT, game.KindO, game.KindS}} + c, l := drawTestHUD(v, 80, 30, ModeFull) + p := PaletteFor(ModeFull) + for i, kind := range v.Next { + it := itemOf(t, l, ItemNextPreview, i) + found := false + for dy := 0; dy < PreviewRows && !found; dy++ { + for dx := 0; dx < PreviewCols; dx++ { + cell := c.At(it.X+dx, it.Y+dy) + if cell.Rune == 0 { + continue + } + if cell.FG != p.Piece[kind] { + t.Errorf("NEXT slot %d is not drawn in the %v color", i, kind) + } + found = true + break + } + } + if !found { + t.Errorf("NEXT slot %d was not drawn", i) + } + } +} + +func TestNextQueueHonoursTheLayoutCountAndShortSlices(t *testing.T) { + full := []game.PieceKind{game.KindI, game.KindJ, game.KindL, game.KindO, game.KindS} + c, l := drawTestHUD(HUDView{Next: full}, 40, 24, ModeFull) + if l.NextCount != 3 { + t.Fatalf("small NextCount = %d, want 3", l.NextCount) + } + drawn := 0 + for _, it := range l.Items { + if it.Kind != ItemNextPreview { + continue + } + for dy := 0; dy < PreviewRows; dy++ { + if strings.TrimSpace(textAt(c, it.X, it.Y+dy, PreviewCols)) != "" { + drawn++ + break + } + } + } + if drawn != 3 { + t.Errorf("%d previews drawn at small size, want 3 (§49.3)", drawn) + } + + // A short queue must not panic or draw a phantom piece. + c2, l2 := drawTestHUD(HUDView{Next: full[:1]}, 80, 30, ModeFull) + it := itemOf(t, l2, ItemNextPreview, 4) + for dy := 0; dy < PreviewRows; dy++ { + if got := strings.TrimSpace(textAt(c2, it.X, it.Y+dy, PreviewCols)); got != "" { + t.Errorf("slot 4 drew %q for a one-element queue", got) + } + } +} + +func TestHUDIsANoOpForATooSmallLayout(t *testing.T) { + v := HUDView{Score: 1, Next: []game.PieceKind{game.KindI}} + c, _ := drawTestHUD(v, 20, 10, ModeFull) + if strings.TrimSpace(c.Plain()) != "" { + t.Errorf("DrawHUD drew %q for a too-small layout", c.Plain()) + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/render/ -run 'TestValueFormats|TestLabels|TestStat|TestSmallLayoutDraws|TestOversized|TestPreview|TestEmptyHold|TestNextQueue|TestHUDIs' -v` +Expected: FAIL — `undefined: DrawHUD`. + +- [ ] **Step 3: Write the implementation** + +Create `internal/render/hud.go`: + +```go +package render + +import ( + "fmt" + + "cosmic-tetris/internal/game" +) + +// HUDView is everything the panels need, by value. +type HUDView struct { + Hold *game.PieceKind + Next []game.PieceKind + + Score int + Lines int + Level int +} + +// Value formats follow the §4 mockup. Longer values grow rather than truncate. +func FormatScore(n int) string { return fmt.Sprintf("%08d", n) } +func FormatLines(n int) string { return fmt.Sprintf("%03d", n) } +func FormatLevel(n int) string { return fmt.Sprintf("%02d", n) } + +// PreviewShape returns the piece's spawn-rotation cells translated so the +// bounding box starts at (0,0), with that box's size in board cells. +func PreviewShape(k game.PieceKind) (cells [][2]int, w, h int) { + offs := game.Piece{Kind: k}.Cells() + minX, minY := offs[0][0], offs[0][1] + maxX, maxY := minX, minY + for _, o := range offs { + minX, maxX = min(minX, o[0]), max(maxX, o[0]) + minY, maxY = min(minY, o[1]), max(maxY, o[1]) + } + cells = make([][2]int, 0, len(offs)) + for _, o := range offs { + cells = append(cells, [2]int{o[0] - minX, o[1] - minY}) + } + return cells, maxX - minX + 1, maxY - minY + 1 +} + +// clip trims s to at most w runes so no value escapes its panel. +func clip(s string, w int) string { + if w <= 0 { + return "" + } + rs := []rune(s) + if len(rs) <= w { + return s + } + return string(rs[:w]) +} + +// DrawHUD draws §37 step 8 by walking the layout's display list. Tier +// differences live entirely in Compute. +func DrawHUD(c *Canvas, l Layout, v HUDView, gs GlyphSet, p Palette) { + if l.Tier == TierTooSmall { + return + } + for _, it := range l.Items { + switch it.Kind { + case ItemHoldLabel: + c.SetString(it.X, it.Y, clip("HOLD", it.W), p.Label, false) + case ItemNextLabel: + c.SetString(it.X, it.Y, clip("NEXT", it.W), p.Label, false) + case ItemScoreLabel: + c.SetString(it.X, it.Y, clip("SCORE", it.W), p.Label, false) + case ItemLinesLabel: + c.SetString(it.X, it.Y, clip("LINES", it.W), p.Label, false) + case ItemLevelLabel: + c.SetString(it.X, it.Y, clip("LEVEL", it.W), p.Label, false) + case ItemScoreValue: + c.SetString(it.X, it.Y, clip(FormatScore(v.Score), it.W), p.Value, true) + case ItemLinesValue: + c.SetString(it.X, it.Y, clip(FormatLines(v.Lines), it.W), p.Value, false) + case ItemLevelValue: + c.SetString(it.X, it.Y, clip(FormatLevel(v.Level), it.W), p.Value, false) + case ItemHoldPreview: + if v.Hold != nil { + drawPreview(c, it, *v.Hold, gs, p) + } + case ItemNextPreview: + if it.Index < len(v.Next) { + drawPreview(c, it, v.Next[it.Index], gs, p) + } + } + } +} + +// drawPreview centres a piece in the item's PreviewCols × PreviewRows box. +func drawPreview(c *Canvas, it Item, k game.PieceKind, gs GlyphSet, p Palette) { + cells, w, h := PreviewShape(k) + box := min(it.W, PreviewCols) + offX := (box - w*CellCols) / 2 + offY := (PreviewRows - h) / 2 + if offX < 0 { + offX = 0 + } + if offY < 0 { + offY = 0 + } + for _, cell := range cells { + x := it.X + offX + cell[0]*CellCols + y := it.Y + offY + cell[1] + if x+CellCols-1 > it.X+it.W-1 { + continue + } + c.SetString(x, y, gs.Block, p.Piece[k], false) + } +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/render/ -v && go vet ./...` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/hud.go internal/render/hud_test.go +git commit -m "feat(render): HOLD, NEXT, and stat panels driven by the display list" +``` + +--- diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/Q1-reader-cosmic-22/README.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/Q1-reader-cosmic-22/README.md new file mode 100644 index 000000000..c3afc6599 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/Q1-reader-cosmic-22/README.md @@ -0,0 +1,18 @@ +# Cosmic Tetris — implementation plans + +`design.md` is the spec. It is split into five plans, matching the build order +the spec itself lays out in §42. Execute them in order; each one ends with +working, testable software. + +| # | Plan | Spec phase | Deliverable | +|---|------|-----------|-------------| +| 1 | [phase-1-game-engine.md](2026-09-17-phase-1-game-engine.md) | §42 Phase 1 | Headless, deterministic, fully tested falling-block engine. No terminal. | +| 2 | [phase-2-playable-terminal.md](2026-09-17-phase-2-playable-terminal.md) | §42 Phase 2 | A genuinely good terminal game: Bubble Tea loop, canvas, layout tiers, HUD, CLI. | +| 3 | [phase-3-cosmic-foundation.md](2026-09-17-phase-3-cosmic-foundation.md) | §42 Phase 3 | The FX world: starfield, animated border, piece trails, mission control. | +| 4 | [phase-4-violence.md](2026-09-17-phase-4-violence.md) | §42 Phase 4 | Particles, hard-drop impact, supernova clears, shake, shockwaves, hyperdrive, the four-line event. | +| 5 | [phase-5-absurd-polish.md](2026-09-17-phase-5-absurd-polish.md) | §42 Phase 5 | Boot sequence, black-hole game over, help, ASCII fallback, `--reduced-motion`, flavor, full golden suite. | + +Cross-plan interface decisions live in each plan's **Global Constraints** and +**Interfaces** blocks. Where the spec left a choice open, §49 of the spec pins +it; where §49 is silent and the plan had to choose, the plan says so inline +under a **Decision** note. diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/Q1-reader-ledgerlite-61.plan.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/Q1-reader-ledgerlite-61.plan.md new file mode 100644 index 000000000..e3947b6f4 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/Q1-reader-ledgerlite-61.plan.md @@ -0,0 +1,1181 @@ +# ledgerlite Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build `ledgerlite`, a standard-library-only command-line tool that reads a transactions CSV, categorizes each row with a rules file, and prints per-category totals plus the closing balance. + +**Architecture:** Six small modules under `ledgerlite/`, each with one responsibility and no upward dependencies: `model` (data), `parse` (CSV text → transactions), `rules` (rules text → matchers), `balance` (ordering and running balance), `report` (totals and text formatting), `cli` (argparse, file I/O, exit codes). Pure functions take *text*, not paths — `cli` owns all file reading so it can own the exit-code-1 path, and every other module is testable with string literals. Money is `decimal.Decimal` end to end; no float ever touches an amount. + +**Tech Stack:** Python 3.11+, standard library only (`argparse`, `csv`, `dataclasses`, `datetime`, `decimal`, `io`, `pathlib`, `sys`). Tests are `unittest`, at the repo root, run with `python3 -m unittest`. + +**Spec:** `design.md` (same directory as this plan) + +## Global Constraints + +- Python 3.11 or newer. Standard library only — no third-party dependencies, no `pyproject.toml` needed. +- Amounts are `decimal.Decimal` everywhere. Never `float`, not even transiently. +- Test files live at the repo root as `test_.py` and are discovered by bare `python3 -m unittest`. +- Package layout is fixed by the spec: `ledgerlite/{__init__,model,parse,rules,balance,report,cli}.py`. One addition: `ledgerlite/__main__.py`, a three-line shim so the tool is runnable as `python3 -m ledgerlite report ...` (the spec names the command line but no entry point). +- Exit codes: `0` success, `1` a file could not be read, `2` a malformed input file (nothing written to stdout). +- User-facing messages are prefixed `ledgerlite: ` and go to stderr. +- Amounts are printed with exactly two fractional digits, a leading `-` for negatives, and no thousands separators. + +## Review Focus + +Five input classes the spec implies but does not spell out, most likely to bite first. Each already has its test assigned to the task that owns the code: + +1. **A CSV with no header row** — data on line 1 would be silently swallowed as a header, producing a quietly wrong closing balance. Task 2 validates the header and exits 2 instead. +2. **A transactions file that is not UTF-8 text** (a binary file, a mis-encoded export) — must produce `ledgerlite: cannot read : ...` and exit 1, not a `UnicodeDecodeError` traceback. Task 6. +3. **`--opening 1.005` or `--opening abc`** — must be rejected rather than silently rounded or crashing; the CSV's amount rules apply to the opening amount too. Task 6. +4. **A transactions file with no data rows** (header only, or completely empty) — must print just `closing balance: ` with no stray leading blank line and no crash. Tasks 5 and 6. +5. **An amount of `-0.00` in the input** — the spec prints zero as `0.00`; naive formatting yields `-0.00`. Task 5. + +--- + +### Task 1: Package skeleton and the Transaction model + +**Files:** +- Create: `ledgerlite/__init__.py` +- Create: `ledgerlite/model.py` +- Create: `.gitignore` +- Test: `test_model.py` + +**Interfaces:** +- Consumes: nothing. +- Produces: `ledgerlite.model.Transaction`, a frozen dataclass with fields `date: datetime.date`, `amount: decimal.Decimal`, `description: str`, in that positional order. Every later task uses it. + +- [ ] **Step 1: Write the failing test** + +Create `test_model.py`: + +```python +import datetime +import unittest +from dataclasses import FrozenInstanceError +from decimal import Decimal + +from ledgerlite.model import Transaction + + +class TransactionTest(unittest.TestCase): + def test_holds_date_amount_and_description(self): + transaction = Transaction(datetime.date(2026, 3, 4), Decimal("-7.50"), "Coffee Shop") + self.assertEqual(transaction.date, datetime.date(2026, 3, 4)) + self.assertEqual(transaction.amount, Decimal("-7.50")) + self.assertEqual(transaction.description, "Coffee Shop") + + def test_amount_is_a_decimal_not_a_float(self): + transaction = Transaction(datetime.date(2026, 3, 4), Decimal("0.10"), "x") + self.assertIsInstance(transaction.amount, Decimal) + + def test_is_frozen(self): + transaction = Transaction(datetime.date(2026, 3, 4), Decimal("1.00"), "x") + with self.assertRaises(FrozenInstanceError): + transaction.amount = Decimal("2.00") + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python3 -m unittest test_model -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite'` + +- [ ] **Step 3: Write minimal implementation** + +Create `.gitignore`: + +``` +__pycache__/ +*.pyc +``` + +Create `ledgerlite/__init__.py`: + +```python +"""ledgerlite: categorize bank transactions and report per-category totals.""" +``` + +Create `ledgerlite/model.py`: + +```python +"""The transaction record shared by every other module.""" + +import datetime +from dataclasses import dataclass +from decimal import Decimal + + +@dataclass(frozen=True) +class Transaction: + date: datetime.date + amount: Decimal + description: str +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python3 -m unittest -v` +Expected: PASS, 3 tests + +- [ ] **Step 5: Commit** + +```bash +git add .gitignore ledgerlite/__init__.py ledgerlite/model.py test_model.py +git commit -m "feat: add Transaction model and package skeleton" +``` + +--- + +### Task 2: Parse the transactions CSV + +**Files:** +- Create: `ledgerlite/parse.py` +- Test: `test_parse.py` + +**Interfaces:** +- Consumes: `Transaction(date, amount, description)` from Task 1. +- Produces: + - `ledgerlite.parse.ParseError(line: int, message: str)`, an `Exception` subclass with attributes `.line` and `.message`. Task 3 raises it for the rules file; Task 6 formats it. + - `parse_transactions(text: str) -> list[Transaction]` — takes CSV *contents*, returns transactions in file order, raises `ParseError` on the first bad row. + - `parse_amount(text: str) -> Decimal` — strips whitespace, returns a `Decimal` with at most two fractional digits, raises `ValueError` whose `str()` is the user-facing message. Task 6 reuses this for `--opening`. + +Decisions this task locks in, all pinned by tests below: + +- The first row is the header and must be `date,amount,description` (per-cell whitespace stripped, case-insensitive). A mismatch is a line-1 `ParseError` — this is what stops a headerless CSV from silently losing its first transaction. +- Completely empty text is zero transactions, not a header error: there is no row to lose. +- Any row that is not exactly 3 columns is malformed, including a blank line in the middle of the file (0 columns). +- Line numbers come from `csv.reader.line_num`, so a quoted description containing a newline does not skew them. +- "More than two fractional digits" is judged by the parsed `Decimal`'s exponent, which also rejects `1e-3`. `NaN` and `Infinity` parse as `Decimal` but are rejected as not-a-number. + +- [ ] **Step 1: Write the failing test** + +Create `test_parse.py`: + +```python +import datetime +import unittest +from decimal import Decimal + +from ledgerlite.model import Transaction +from ledgerlite.parse import ParseError, parse_amount, parse_transactions + +HEADER = "date,amount,description\n" + + +class ParseTransactionsTest(unittest.TestCase): + def test_parses_rows_in_file_order(self): + text = HEADER + "2026-03-05,-900.00,Rent March\n2026-03-04,-7.50,Coffee Shop\n" + self.assertEqual( + parse_transactions(text), + [ + Transaction(datetime.date(2026, 3, 5), Decimal("-900.00"), "Rent March"), + Transaction(datetime.date(2026, 3, 4), Decimal("-7.50"), "Coffee Shop"), + ], + ) + + def test_amounts_are_decimals(self): + transactions = parse_transactions(HEADER + "2026-03-04,0.10,a\n") + self.assertIsInstance(transactions[0].amount, Decimal) + + def test_accepts_zero_one_and_two_fractional_digits_and_a_leading_plus(self): + text = HEADER + "2026-03-04,1200,a\n2026-03-04,1.5,b\n2026-03-04,+1.50,c\n" + self.assertEqual( + [t.amount for t in parse_transactions(text)], + [Decimal("1200"), Decimal("1.5"), Decimal("1.50")], + ) + + def test_header_only_file_has_no_transactions(self): + self.assertEqual(parse_transactions(HEADER), []) + + def test_empty_file_has_no_transactions(self): + self.assertEqual(parse_transactions(""), []) + + def test_description_keeps_its_spacing_and_commas(self): + text = HEADER + '2026-03-04,-7.50," Coffee, Shop "\n' + self.assertEqual(parse_transactions(text)[0].description, " Coffee, Shop ") + + def test_rejects_a_missing_header(self): + text = "2026-03-04,-7.50,Coffee Shop\n" + with self.assertRaises(ParseError) as caught: + parse_transactions(text) + self.assertEqual(caught.exception.line, 1) + self.assertEqual(caught.exception.message, "expected header date,amount,description") + + def test_accepts_a_header_with_padding_and_different_case(self): + text = "Date, Amount , DESCRIPTION\n2026-03-04,-7.50,a\n" + self.assertEqual(len(parse_transactions(text)), 1) + + def test_rejects_wrong_column_count(self): + text = HEADER + "2026-03-04,-7.50,a\n2026-03-05,-1.00\n" + with self.assertRaises(ParseError) as caught: + parse_transactions(text) + self.assertEqual(caught.exception.line, 3) + self.assertEqual(caught.exception.message, "expected 3 columns, got 2") + + def test_rejects_a_blank_line(self): + text = HEADER + "2026-03-04,-7.50,a\n\n" + with self.assertRaises(ParseError) as caught: + parse_transactions(text) + self.assertEqual(caught.exception.line, 3) + self.assertEqual(caught.exception.message, "expected 3 columns, got 0") + + def test_rejects_an_unparseable_date(self): + text = HEADER + "04/03/2026,-7.50,a\n" + with self.assertRaises(ParseError) as caught: + parse_transactions(text) + self.assertEqual(caught.exception.line, 2) + self.assertEqual(caught.exception.message, "invalid date '04/03/2026'") + + def test_rejects_an_impossible_date(self): + text = HEADER + "2026-13-40,-7.50,a\n" + with self.assertRaises(ParseError) as caught: + parse_transactions(text) + self.assertEqual(caught.exception.message, "invalid date '2026-13-40'") + + def test_rejects_a_non_numeric_amount(self): + text = HEADER + "2026-03-04,twelve,a\n" + with self.assertRaises(ParseError) as caught: + parse_transactions(text) + self.assertEqual(caught.exception.line, 2) + self.assertEqual(caught.exception.message, "invalid amount 'twelve'") + + def test_rejects_an_empty_amount(self): + text = HEADER + "2026-03-04,,a\n" + with self.assertRaises(ParseError) as caught: + parse_transactions(text) + self.assertEqual(caught.exception.message, "invalid amount ''") + + def test_rejects_three_fractional_digits(self): + text = HEADER + "2026-03-04,1.005,a\n" + with self.assertRaises(ParseError) as caught: + parse_transactions(text) + self.assertEqual( + caught.exception.message, + "amount '1.005' has more than two fractional digits", + ) + + def test_line_number_survives_a_newline_inside_a_quoted_description(self): + text = HEADER + '2026-03-04,-7.50,"two\nlines"\n2026-03-05,nope,b\n' + with self.assertRaises(ParseError) as caught: + parse_transactions(text) + self.assertEqual(caught.exception.line, 4) + + +class ParseAmountTest(unittest.TestCase): + def test_strips_surrounding_whitespace(self): + self.assertEqual(parse_amount(" -12.50 "), Decimal("-12.50")) + + def test_rejects_exponent_notation_below_two_places(self): + with self.assertRaises(ValueError) as caught: + parse_amount("1e-3") + self.assertEqual(str(caught.exception), "amount '1e-3' has more than two fractional digits") + + def test_rejects_not_a_number(self): + for text in ("NaN", "Infinity", "-Infinity"): + with self.subTest(text=text), self.assertRaises(ValueError) as caught: + parse_amount(text) + self.assertEqual(str(caught.exception), f"invalid amount '{text}'") + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python3 -m unittest test_parse -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.parse'` + +- [ ] **Step 3: Write minimal implementation** + +Create `ledgerlite/parse.py`: + +```python +"""Turn transactions-CSV text into Transaction records.""" + +import csv +import datetime +import io +from decimal import Decimal, InvalidOperation + +from ledgerlite.model import Transaction + +HEADER = ("date", "amount", "description") + + +class ParseError(Exception): + """A line of an input file could not be parsed.""" + + def __init__(self, line: int, message: str) -> None: + super().__init__(f"{line}: {message}") + self.line = line + self.message = message + + +def parse_transactions(text: str) -> list[Transaction]: + """Parse CSV text into transactions, in file order. + + Raises ParseError on the first malformed line; the caller is expected to + reject the whole file. + """ + reader = csv.reader(io.StringIO(text)) + transactions: list[Transaction] = [] + for index, row in enumerate(reader): + if index == 0: + if tuple(cell.strip().lower() for cell in row) != HEADER: + raise ParseError(reader.line_num, "expected header date,amount,description") + continue + if len(row) != 3: + raise ParseError(reader.line_num, f"expected 3 columns, got {len(row)}") + raw_date, raw_amount, description = row + date = _parse_date(raw_date, reader.line_num) + try: + amount = parse_amount(raw_amount) + except ValueError as error: + raise ParseError(reader.line_num, str(error)) from None + transactions.append(Transaction(date, amount, description)) + return transactions + + +def parse_amount(text: str) -> Decimal: + """Parse an amount with at most two fractional digits. + + Raises ValueError whose message is fit to show a user. + """ + text = text.strip() + try: + amount = Decimal(text) + except InvalidOperation: + raise ValueError(f"invalid amount '{text}'") from None + if not amount.is_finite(): + raise ValueError(f"invalid amount '{text}'") + if amount.as_tuple().exponent < -2: + raise ValueError(f"amount '{text}' has more than two fractional digits") + return amount + + +def _parse_date(text: str, line: int) -> datetime.date: + stripped = text.strip() + try: + return datetime.date.fromisoformat(stripped) + except ValueError: + raise ParseError(line, f"invalid date '{stripped}'") from None +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python3 -m unittest -v` +Expected: PASS, all tests + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/parse.py test_parse.py +git commit -m "feat: parse transactions CSV with per-line error reporting" +``` + +--- + +### Task 3: Parse and apply categorization rules + +**Files:** +- Create: `ledgerlite/rules.py` +- Test: `test_rules.py` + +**Interfaces:** +- Consumes: `ParseError(line, message)` from Task 2 (`rules.py` imports it; `parse.py` does not import `rules.py`, so there is no cycle). +- Produces: + - `parse_rules(text: str) -> list[tuple[str, str]]` — rules text to `(substring, category)` pairs in file order, raising `ParseError` on a malformed line. + - `categorize(description: str, rules: list[tuple[str, str]]) -> str | None` — first matching rule's category, case-insensitive substring match, or `None`. + +Decisions this task locks in: + +- A line is split on its **first** `=`, so a category may contain `=` but a substring may not. +- Substring and category are stripped of surrounding whitespace. +- Blank and whitespace-only lines are skipped. There is no comment syntax. +- A non-blank line with no `=`, an empty substring, or an empty category is a `ParseError` — silently ignoring a typo'd rule would silently misfile money. Line numbers are 1-based over `splitlines()`. + +- [ ] **Step 1: Write the failing test** + +Create `test_rules.py`: + +```python +import unittest + +from ledgerlite.parse import ParseError +from ledgerlite.rules import categorize, parse_rules + + +class ParseRulesTest(unittest.TestCase): + def test_parses_one_pair_per_line_in_order(self): + self.assertEqual( + parse_rules("coffee=food\nrent=housing\n"), + [("coffee", "food"), ("rent", "housing")], + ) + + def test_strips_whitespace_around_substring_and_category(self): + self.assertEqual(parse_rules(" coffee = food \n"), [("coffee", "food")]) + + def test_skips_blank_lines(self): + self.assertEqual(parse_rules("\ncoffee=food\n \n"), [("coffee", "food")]) + + def test_empty_text_has_no_rules(self): + self.assertEqual(parse_rules(""), []) + + def test_splits_on_the_first_equals_so_a_category_may_contain_one(self): + self.assertEqual(parse_rules("a=b=c\n"), [("a", "b=c")]) + + def test_rejects_a_line_without_an_equals(self): + with self.assertRaises(ParseError) as caught: + parse_rules("coffee=food\nrent housing\n") + self.assertEqual(caught.exception.line, 2) + self.assertEqual( + caught.exception.message, + "expected '=', got 'rent housing'", + ) + + def test_rejects_an_empty_substring(self): + with self.assertRaises(ParseError) as caught: + parse_rules("=food\n") + self.assertEqual(caught.exception.line, 1) + self.assertEqual(caught.exception.message, "rule has an empty substring") + + def test_rejects_an_empty_category(self): + with self.assertRaises(ParseError) as caught: + parse_rules("coffee=\n") + self.assertEqual(caught.exception.line, 1) + self.assertEqual(caught.exception.message, "rule has an empty category") + + +class CategorizeTest(unittest.TestCase): + RULES = [("coffee", "food"), ("rent", "housing")] + + def test_matches_a_substring_of_the_description(self): + self.assertEqual(categorize("Blue Bottle Coffee Co", self.RULES), "food") + + def test_matching_ignores_case_in_both_directions(self): + self.assertEqual(categorize("COFFEE", self.RULES), "food") + self.assertEqual(categorize("monthly rent", [("RENT", "housing")]), "housing") + + def test_first_matching_rule_wins(self): + rules = [("coffee", "food"), ("coffee shop", "treats")] + self.assertEqual(categorize("Coffee Shop", rules), "food") + + def test_no_match_is_none(self): + self.assertIsNone(categorize("Salary", self.RULES)) + + def test_no_rules_means_no_match(self): + self.assertIsNone(categorize("Coffee", [])) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python3 -m unittest test_rules -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.rules'` + +- [ ] **Step 3: Write minimal implementation** + +Create `ledgerlite/rules.py`: + +```python +"""Rules text and the category lookup it powers.""" + +from ledgerlite.parse import ParseError + + +def parse_rules(text: str) -> list[tuple[str, str]]: + """Parse `=` lines into pairs, in file order.""" + rules: list[tuple[str, str]] = [] + for line_number, line in enumerate(text.splitlines(), start=1): + if not line.strip(): + continue + substring, separator, category = line.partition("=") + if not separator: + raise ParseError( + line_number, + f"expected '=', got '{line.strip()}'", + ) + substring, category = substring.strip(), category.strip() + if not substring: + raise ParseError(line_number, "rule has an empty substring") + if not category: + raise ParseError(line_number, "rule has an empty category") + rules.append((substring, category)) + return rules + + +def categorize(description: str, rules: list[tuple[str, str]]) -> str | None: + """Category of the first rule whose substring appears in description.""" + lowered = description.lower() + for substring, category in rules: + if substring.lower() in lowered: + return category + return None +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python3 -m unittest -v` +Expected: PASS, all tests + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/rules.py test_rules.py +git commit -m "feat: parse rules file and categorize descriptions" +``` + +--- + +### Task 4: Date ordering and the closing balance + +**Files:** +- Create: `ledgerlite/balance.py` +- Test: `test_balance.py` + +**Interfaces:** +- Consumes: `Transaction` from Task 1. +- Produces: + - `in_date_order(transactions: list[Transaction]) -> list[Transaction]` — a new list sorted by date, ties keeping input order (Python's sort is stable). + - `closing_balance(transactions: list[Transaction], opening: Decimal) -> Decimal` — the running balance after the last transaction in date order; `opening` when there are none. + +- [ ] **Step 1: Write the failing test** + +Create `test_balance.py`: + +```python +import datetime +import unittest +from decimal import Decimal + +from ledgerlite.balance import closing_balance, in_date_order +from ledgerlite.model import Transaction + + +def transaction(day, amount, description="x"): + return Transaction(datetime.date(2026, 3, day), Decimal(amount), description) + + +class InDateOrderTest(unittest.TestCase): + def test_orders_by_date(self): + rows = [transaction(5, "1.00", "b"), transaction(4, "2.00", "a")] + self.assertEqual([t.description for t in in_date_order(rows)], ["a", "b"]) + + def test_ties_keep_input_order(self): + rows = [transaction(4, "1.00", "first"), transaction(4, "2.00", "second")] + self.assertEqual( + [t.description for t in in_date_order(rows)], ["first", "second"] + ) + + def test_does_not_mutate_its_argument(self): + rows = [transaction(5, "1.00", "b"), transaction(4, "2.00", "a")] + in_date_order(rows) + self.assertEqual([t.description for t in rows], ["b", "a"]) + + def test_empty_list(self): + self.assertEqual(in_date_order([]), []) + + +class ClosingBalanceTest(unittest.TestCase): + def test_adds_every_amount_to_the_opening_amount(self): + rows = [transaction(4, "-7.50"), transaction(5, "-900.00"), transaction(6, "2500.00")] + self.assertEqual(closing_balance(rows, Decimal("100")), Decimal("1692.50")) + + def test_no_transactions_is_the_opening_amount(self): + self.assertEqual(closing_balance([], Decimal("100")), Decimal("100")) + + def test_default_opening_of_zero(self): + self.assertEqual(closing_balance([transaction(4, "-7.50")], Decimal("0")), Decimal("-7.50")) + + def test_arithmetic_is_exact(self): + rows = [transaction(4, "0.10"), transaction(4, "0.20")] + self.assertEqual(closing_balance(rows, Decimal("0")), Decimal("0.30")) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python3 -m unittest test_balance -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.balance'` + +- [ ] **Step 3: Write minimal implementation** + +Create `ledgerlite/balance.py`: + +```python +"""Date ordering and the running balance it defines.""" + +from decimal import Decimal + +from ledgerlite.model import Transaction + + +def in_date_order(transactions: list[Transaction]) -> list[Transaction]: + """Transactions sorted by date; ties keep their input order.""" + return sorted(transactions, key=lambda transaction: transaction.date) + + +def closing_balance(transactions: list[Transaction], opening: Decimal) -> Decimal: + """The running balance after the last transaction in date order.""" + balance = opening + for transaction in in_date_order(transactions): + balance += transaction.amount + return balance +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python3 -m unittest -v` +Expected: PASS, all tests + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/balance.py test_balance.py +git commit -m "feat: add date ordering and closing balance" +``` + +--- + +### Task 5: Per-category totals and report formatting + +**Files:** +- Create: `ledgerlite/report.py` +- Test: `test_report.py` + +**Interfaces:** +- Consumes: `Transaction` (Task 1), `categorize` (Task 3), `closing_balance` (Task 4). +- Produces: + - `UNCATEGORIZED = "uncategorized"`. + - `format_amount(amount: Decimal) -> str` — exactly two fractional digits, leading `-` for negatives, no thousands separators, and `0.00` (never `-0.00`) for zero. + - `category_totals(transactions: list[Transaction], rules: list[tuple[str, str]]) -> list[tuple[str, Decimal]]` — named categories first, alphabetical (case-insensitive), then `uncategorized` last if present. + - `format_report(transactions: list[Transaction], rules: list[tuple[str, str]], opening: Decimal) -> str` — the whole report, newline-terminated. + +Decisions this task locks in: + +- Category names print exactly as written in the rules file; the alphabetical sort is case-insensitive (`(name.lower(), name)`), so `Food` and `apple` order the way a reader expects. +- A rules file whose category is literally `uncategorized` shares the bucket with unmatched transactions and prints last. +- With no category lines at all, the report is just `closing balance: ...` — no leading blank line. + +- [ ] **Step 1: Write the failing test** + +Create `test_report.py`: + +```python +import datetime +import unittest +from decimal import Decimal + +from ledgerlite.model import Transaction +from ledgerlite.report import category_totals, format_amount, format_report + + +def transaction(day, amount, description): + return Transaction(datetime.date(2026, 3, day), Decimal(amount), description) + + +class FormatAmountTest(unittest.TestCase): + def test_two_fractional_digits(self): + self.assertEqual(format_amount(Decimal("1200")), "1200.00") + self.assertEqual(format_amount(Decimal("1.5")), "1.50") + self.assertEqual(format_amount(Decimal("-12.50")), "-12.50") + + def test_zero(self): + self.assertEqual(format_amount(Decimal("0")), "0.00") + + def test_negative_zero_prints_as_zero(self): + self.assertEqual(format_amount(Decimal("-0.00")), "0.00") + + def test_no_thousands_separators(self): + self.assertEqual(format_amount(Decimal("1234567.89")), "1234567.89") + + +class CategoryTotalsTest(unittest.TestCase): + RULES = [("coffee", "food"), ("rent", "housing")] + + def test_sums_each_category(self): + rows = [ + transaction(4, "-7.50", "Coffee Shop"), + transaction(5, "-2.50", "coffee again"), + transaction(6, "-900.00", "Rent March"), + ] + self.assertEqual( + category_totals(rows, self.RULES), + [("food", Decimal("-10.00")), ("housing", Decimal("-900.00"))], + ) + + def test_unmatched_transactions_are_uncategorized_and_last(self): + rows = [transaction(4, "2500.00", "Salary"), transaction(5, "-900.00", "Rent March")] + self.assertEqual( + category_totals(rows, self.RULES), + [("housing", Decimal("-900.00")), ("uncategorized", Decimal("2500.00"))], + ) + + def test_uncategorized_is_last_even_when_alphabetically_earlier(self): + rows = [transaction(4, "1.00", "Salary"), transaction(5, "-2.00", "zoo trip")] + self.assertEqual( + [name for name, _ in category_totals(rows, [("zoo", "zoo")])], + ["zoo", "uncategorized"], + ) + + def test_no_uncategorized_line_when_everything_matches(self): + rows = [transaction(4, "-7.50", "Coffee Shop")] + self.assertEqual([name for name, _ in category_totals(rows, self.RULES)], ["food"]) + + def test_alphabetical_order_ignores_case(self): + rows = [transaction(4, "1.00", "b thing"), transaction(5, "1.00", "a thing")] + rules = [("a thing", "Apples"), ("b thing", "bananas")] + self.assertEqual([name for name, _ in category_totals(rows, rules)], ["Apples", "bananas"]) + + def test_a_rule_named_uncategorized_shares_the_bucket_and_stays_last(self): + rows = [transaction(4, "1.00", "odd"), transaction(5, "2.00", "Salary")] + rules = [("odd", "uncategorized"), ("nothing", "zebras")] + self.assertEqual(category_totals(rows, rules), [("uncategorized", Decimal("3.00"))]) + + def test_no_transactions_has_no_categories(self): + self.assertEqual(category_totals([], self.RULES), []) + + +class FormatReportTest(unittest.TestCase): + def test_matches_the_design_example(self): + rows = [ + transaction(4, "-7.50", "Coffee Shop"), + transaction(5, "-900.00", "Rent March"), + transaction(6, "2500.00", "Salary"), + ] + rules = [("coffee", "food"), ("rent", "housing")] + self.assertEqual( + format_report(rows, rules, Decimal("100")), + "food: -7.50\n" + "housing: -900.00\n" + "uncategorized: 2500.00\n" + "\n" + "closing balance: 1692.50\n", + ) + + def test_no_transactions_prints_only_the_closing_balance(self): + self.assertEqual(format_report([], [], Decimal("100")), "closing balance: 100.00\n") + + def test_no_rules_means_everything_is_uncategorized(self): + rows = [transaction(4, "-7.50", "Coffee Shop")] + self.assertEqual( + format_report(rows, [], Decimal("0")), + "uncategorized: -7.50\n\nclosing balance: -7.50\n", + ) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python3 -m unittest test_report -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.report'` + +- [ ] **Step 3: Write minimal implementation** + +Create `ledgerlite/report.py`: + +```python +"""Per-category totals and the text of the report.""" + +from decimal import Decimal + +from ledgerlite.balance import closing_balance +from ledgerlite.model import Transaction +from ledgerlite.rules import categorize + +UNCATEGORIZED = "uncategorized" + + +def format_amount(amount: Decimal) -> str: + """Exactly two fractional digits, no thousands separators, no negative zero.""" + text = f"{amount:.2f}" + if text.startswith("-") and Decimal(text) == 0: + return text[1:] + return text + + +def category_totals( + transactions: list[Transaction], rules: list[tuple[str, str]] +) -> list[tuple[str, Decimal]]: + """(category, total) pairs: named categories alphabetically, uncategorized last.""" + totals: dict[str, Decimal] = {} + for transaction in transactions: + name = categorize(transaction.description, rules) + if name is None: + name = UNCATEGORIZED + totals[name] = totals.get(name, Decimal("0")) + transaction.amount + named = sorted( + (name for name in totals if name != UNCATEGORIZED), + key=lambda name: (name.lower(), name), + ) + ordered = [(name, totals[name]) for name in named] + if UNCATEGORIZED in totals: + ordered.append((UNCATEGORIZED, totals[UNCATEGORIZED])) + return ordered + + +def format_report( + transactions: list[Transaction], + rules: list[tuple[str, str]], + opening: Decimal, +) -> str: + """The complete report text, newline-terminated.""" + lines = [ + f"{name}: {format_amount(total)}" + for name, total in category_totals(transactions, rules) + ] + if lines: + lines.append("") + lines.append(f"closing balance: {format_amount(closing_balance(transactions, opening))}") + return "\n".join(lines) + "\n" +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python3 -m unittest -v` +Expected: PASS, all tests + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/report.py test_report.py +git commit -m "feat: add per-category totals and report formatting" +``` + +--- + +### Task 6: Command-line entry point + +**Files:** +- Create: `ledgerlite/cli.py` +- Create: `ledgerlite/__main__.py` +- Test: `test_cli.py` + +**Interfaces:** +- Consumes: `parse_transactions`, `parse_amount`, `ParseError` (Task 2), `parse_rules` (Task 3), `format_report` (Task 5). +- Produces: `main(argv: list[str] | None = None) -> int`, the only file-reading code in the package. + +Decisions this task locks in: + +- `ledgerlite report TRANSACTIONS [--rules RULES] [--opening AMOUNT]` via an argparse subparser named `report`; `prog="ledgerlite"`. +- Files are read as UTF-8 text. `OSError` **and** `UnicodeDecodeError` both mean "cannot read": message `ledgerlite: cannot read : `, exit 1. `reason` is the OS `strerror` when there is one, else `str(error)`. +- A `ParseError` from either file prints `ledgerlite: :: ` with *that file's* path, exit 2. Both files are fully parsed before anything reaches stdout, so a rejected file prints nothing there. +- `--opening` is validated by `parse_amount`, so it inherits the CSV's amount rules; a bad value is an argparse error (usage on stderr, `SystemExit(2)`). Default `Decimal("0")`. + +- [ ] **Step 1: Write the failing test** + +Create `test_cli.py`: + +```python +import contextlib +import io +import tempfile +import unittest +from pathlib import Path + +from ledgerlite.cli import main + +TRANSACTIONS = ( + "date,amount,description\n" + "2026-03-05,-900.00,Rent March\n" + "2026-03-04,-7.50,Coffee Shop\n" + "2026-03-06,2500.00,Salary\n" +) +RULES = "coffee=food\nrent=housing\n" + + +class CliTestCase(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.root = Path(self.directory.name) + + def write(self, name, text): + path = self.root / name + path.write_text(text, encoding="utf-8") + return str(path) + + def run_main(self, argv): + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = main(argv) + return code, out.getvalue(), err.getvalue() + + +class ReportCommandTest(CliTestCase): + def test_prints_the_design_example_and_returns_zero(self): + transactions = self.write("t.csv", TRANSACTIONS) + rules = self.write("r.txt", RULES) + code, out, err = self.run_main( + ["report", transactions, "--rules", rules, "--opening", "100"] + ) + self.assertEqual(code, 0) + self.assertEqual( + out, + "food: -7.50\n" + "housing: -900.00\n" + "uncategorized: 2500.00\n" + "\n" + "closing balance: 1692.50\n", + ) + self.assertEqual(err, "") + + def test_without_rules_everything_is_uncategorized(self): + transactions = self.write("t.csv", TRANSACTIONS) + code, out, _ = self.run_main(["report", transactions]) + self.assertEqual(code, 0) + self.assertEqual(out, "uncategorized: 1592.50\n\nclosing balance: 1592.50\n") + + def test_opening_defaults_to_zero(self): + transactions = self.write("t.csv", "date,amount,description\n2026-03-04,-7.50,a\n") + code, out, _ = self.run_main(["report", transactions]) + self.assertEqual(code, 0) + self.assertEqual(out, "uncategorized: -7.50\n\nclosing balance: -7.50\n") + + def test_header_only_file_prints_only_the_closing_balance(self): + transactions = self.write("t.csv", "date,amount,description\n") + code, out, _ = self.run_main(["report", transactions, "--opening", "12.5"]) + self.assertEqual(code, 0) + self.assertEqual(out, "closing balance: 12.50\n") + + +class UnreadableFileTest(CliTestCase): + def test_missing_transactions_file(self): + missing = str(self.root / "nope.csv") + code, out, err = self.run_main(["report", missing]) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertEqual(err, f"ledgerlite: cannot read {missing}: No such file or directory\n") + + def test_missing_rules_file(self): + transactions = self.write("t.csv", TRANSACTIONS) + missing = str(self.root / "nope.txt") + code, out, err = self.run_main(["report", transactions, "--rules", missing]) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertEqual(err, f"ledgerlite: cannot read {missing}: No such file or directory\n") + + def test_transactions_file_that_is_not_utf8_text(self): + path = self.root / "t.csv" + path.write_bytes(b"date,amount,description\n2026-03-04,-7.50,\xff\xfe\n") + code, out, err = self.run_main(["report", str(path)]) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertTrue(err.startswith(f"ledgerlite: cannot read {path}: ")) + self.assertTrue(err.endswith("\n")) + + +class MalformedFileTest(CliTestCase): + def test_malformed_row_rejects_the_whole_file(self): + transactions = self.write( + "t.csv", "date,amount,description\n2026-03-04,-7.50,a\n2026-03-05,1.005,b\n" + ) + code, out, err = self.run_main(["report", transactions]) + self.assertEqual(code, 2) + self.assertEqual(out, "") + self.assertEqual( + err, + f"ledgerlite: {transactions}:3: amount '1.005' has more than two fractional digits\n", + ) + + def test_missing_header_is_reported_on_line_one(self): + transactions = self.write("t.csv", "2026-03-04,-7.50,a\n") + code, out, err = self.run_main(["report", transactions]) + self.assertEqual(code, 2) + self.assertEqual(out, "") + self.assertEqual( + err, f"ledgerlite: {transactions}:1: expected header date,amount,description\n" + ) + + def test_malformed_rules_line_reports_the_rules_path(self): + transactions = self.write("t.csv", TRANSACTIONS) + rules = self.write("r.txt", "coffee=food\nrent housing\n") + code, out, err = self.run_main(["report", transactions, "--rules", rules]) + self.assertEqual(code, 2) + self.assertEqual(out, "") + self.assertEqual( + err, + f"ledgerlite: {rules}:2: expected '=', got 'rent housing'\n", + ) + + +class OpeningArgumentTest(CliTestCase): + def test_rejects_a_non_numeric_opening(self): + transactions = self.write("t.csv", TRANSACTIONS) + with contextlib.redirect_stderr(io.StringIO()) as err: + with self.assertRaises(SystemExit) as caught: + main(["report", transactions, "--opening", "abc"]) + self.assertEqual(caught.exception.code, 2) + self.assertIn("invalid amount 'abc'", err.getvalue()) + + def test_rejects_an_opening_with_three_fractional_digits(self): + transactions = self.write("t.csv", TRANSACTIONS) + with contextlib.redirect_stderr(io.StringIO()) as err: + with self.assertRaises(SystemExit) as caught: + main(["report", transactions, "--opening", "1.005"]) + self.assertEqual(caught.exception.code, 2) + self.assertIn("more than two fractional digits", err.getvalue()) + + def test_accepts_a_negative_opening(self): + transactions = self.write("t.csv", "date,amount,description\n2026-03-04,1.00,a\n") + code, out, _ = self.run_main(["report", transactions, "--opening", "-2.50"]) + self.assertEqual(code, 0) + self.assertEqual(out, "uncategorized: 1.00\n\nclosing balance: -1.50\n") + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python3 -m unittest test_cli -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.cli'` + +- [ ] **Step 3: Write minimal implementation** + +Create `ledgerlite/cli.py`: + +```python +"""The ledgerlite command line: argument parsing, file I/O, exit codes.""" + +import argparse +import sys +from decimal import Decimal +from pathlib import Path + +from ledgerlite.parse import ParseError, parse_amount, parse_transactions +from ledgerlite.report import format_report +from ledgerlite.rules import parse_rules + + +def main(argv: list[str] | None = None) -> int: + args = _build_parser().parse_args(argv) + + try: + transactions_text = _read_text(args.transactions) + except (OSError, UnicodeDecodeError) as error: + return _cannot_read(args.transactions, error) + + rules_text = "" + if args.rules is not None: + try: + rules_text = _read_text(args.rules) + except (OSError, UnicodeDecodeError) as error: + return _cannot_read(args.rules, error) + + try: + transactions = parse_transactions(transactions_text) + except ParseError as error: + return _malformed(args.transactions, error) + + try: + rules = parse_rules(rules_text) + except ParseError as error: + return _malformed(args.rules, error) + + sys.stdout.write(format_report(transactions, rules, args.opening)) + return 0 + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="ledgerlite", description="Summarize bank transactions by category." + ) + subparsers = parser.add_subparsers(dest="command", required=True) + report = subparsers.add_parser("report", help="print a per-category summary") + report.add_argument("transactions", help="path to the transactions CSV") + report.add_argument("--rules", help="path to the rules file") + report.add_argument( + "--opening", + type=_opening_amount, + default=Decimal("0"), + metavar="AMOUNT", + help="opening balance (default: 0)", + ) + return parser + + +def _opening_amount(text: str) -> Decimal: + try: + return parse_amount(text) + except ValueError as error: + raise argparse.ArgumentTypeError(str(error)) from None + + +def _read_text(path: str) -> str: + return Path(path).read_text(encoding="utf-8") + + +def _cannot_read(path: str, error: Exception) -> int: + reason = getattr(error, "strerror", None) or str(error) + print(f"ledgerlite: cannot read {path}: {reason}", file=sys.stderr) + return 1 + + +def _malformed(path: str, error: ParseError) -> int: + print(f"ledgerlite: {path}:{error.line}: {error.message}", file=sys.stderr) + return 2 +``` + +Create `ledgerlite/__main__.py`: + +```python +"""Allow `python3 -m ledgerlite ...`.""" + +import sys + +from ledgerlite.cli import main + +if __name__ == "__main__": + sys.exit(main()) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python3 -m unittest -v` +Expected: PASS, all tests across all six test files + +- [ ] **Step 5: Check the real command line end to end** + +```bash +printf 'date,amount,description\n2026-03-05,-900.00,Rent March\n2026-03-04,-7.50,Coffee Shop\n2026-03-06,2500.00,Salary\n' > /tmp/ledgerlite-t.csv +printf 'coffee=food\nrent=housing\n' > /tmp/ledgerlite-r.txt +python3 -m ledgerlite report /tmp/ledgerlite-t.csv --rules /tmp/ledgerlite-r.txt --opening 100 +``` + +Expected: the design's example report, and `echo $?` prints `0`. + +```bash +python3 -m ledgerlite report /tmp/does-not-exist.csv; echo "exit=$?" +``` + +Expected: `ledgerlite: cannot read /tmp/does-not-exist.csv: No such file or directory` on stderr and `exit=1`. + +- [ ] **Step 6: Commit** + +```bash +git add ledgerlite/cli.py ledgerlite/__main__.py test_cli.py +git commit -m "feat: add ledgerlite report command line" +``` + +--- + +## Done when + +- `python3 -m unittest` passes with all six test modules green. +- `python3 -m ledgerlite report ... --rules ... --opening 100` reproduces the report in `design.md` byte for byte. +- Exit codes are 0 / 1 / 2 as specified, and a rejected file writes nothing to stdout. diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/Q1-reader-ledgerlite-62.plan.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/Q1-reader-ledgerlite-62.plan.md new file mode 100644 index 000000000..ef56c8d9e --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/Q1-reader-ledgerlite-62.plan.md @@ -0,0 +1,1067 @@ +# ledgerlite Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build `ledgerlite`, a standard-library-only Python CLI that reads a transactions CSV, categorizes each row with a rules file, and prints per-category totals plus the closing balance. + +**Architecture:** Six small modules with one responsibility each, layered bottom-up: `model` (data), `parse` (text → transactions, with precise per-line errors), `rules` (rules text → matcher), `balance` (date ordering and closing balance), `report` (totals and formatting), `cli` (argument parsing, file reading, exit codes and error messages). Money is `decimal.Decimal` end to end; no float ever touches an amount. Every layer is pure except `cli`, which owns all I/O, so each layer is testable in isolation. + +**Tech Stack:** Python 3.11+, standard library only (`csv`, `decimal`, `datetime`, `argparse`, `re`, `io`, `sys`), `unittest` for tests. + +**Spec:** `design.md` (in this directory — read it alongside this plan) + +## Global Constraints + +- Python 3.11+. Standard library only — no third-party dependencies, no `pip install`. +- Amounts are parsed and carried as `decimal.Decimal`, never `float`. +- Package layout is exactly as in the spec: `ledgerlite/{__init__,model,parse,rules,balance,report,cli}.py`, plus `ledgerlite/__main__.py` (see Decisions below). +- Tests live at the repo root as `test_.py` and run with `python3 -m unittest`. +- Exit codes: `0` success, `1` transactions file unreadable, `2` malformed input. +- Error message formats, verbatim from the spec: + - `ledgerlite: cannot read : ` (stderr, exit 1) + - `ledgerlite: :: ` (stderr, exit 2) +- On exit 2 the whole file is rejected: nothing is written to stdout. +- Printed amounts have exactly two fractional digits, a leading `-` for negatives, and no thousands separators. +- Work directly on `main`. This is a local scratch repo with no remote; never `git push`. + +## Decisions the spec leaves open + +These are settled here so no task has to guess. Each is pinned by a test. + +1. **Line numbers** in exit-2 messages are physical file line numbers, header included, so the first data row is line 2. `csv.reader.line_num` provides this and stays correct when a quoted description contains a newline. +2. **The header row is skipped unconditionally and never validated.** The spec lists no header-related malformation, so a file whose first line is anything else simply loses that line. +3. **Wholly empty CSV lines are skipped**, not treated as a column-count error — a trailing blank line is too common to reject. +4. **Dates must be exactly `YYYY-MM-DD`.** `2026-3-4` and `20260304` are malformed. (`strptime` is lenient about zero-padding, so `parse_date` gates on a regex first.) +5. **`nan` and `inf` are malformed amounts.** `Decimal("nan")` parses happily and would poison every total, so `parse_amount` requires a finite value. +6. **Rules-file oddities are ignored, not errors.** Blank lines, lines with no `=`, and lines with an empty substring or empty category are skipped. The spec defines no exit code for a bad rules file, and inventing one would exceed it. A rules file that cannot be *read* reuses the exit-1 `cannot read` path, since the alternative is a traceback. +7. **`--opening` is validated with the same rules as a row amount**, so `--opening 1.005` fails. Rejection goes through argparse, which prints usage to stderr and exits 2 — consistent with the malformed-input code. +8. **A category with no transactions produces no line**, including `uncategorized`. With no transactions at all the report is a blank line followed by `closing balance: `, which is the spec's rule ("one line per category" — there are none) read literally. +9. **"Alphabetically" means case-insensitive**, tie-broken by the raw name, so `apples` precedes `Food`. +10. **`ledgerlite/__main__.py` is added** beyond the spec's layout: without it the package has no way to be invoked as a command, and the spec calls this a command-line tool. + +## Review Focus + +Input classes the spec implies but does not spell out. Each has a test in the task named. + +1. An unreadable transactions path that is not merely missing — a directory, or a file of non-UTF-8 bytes — must print `cannot read` and exit 1, never traceback (Task 5). +2. An empty file, or one with only a header, must exit 0 and print `closing balance: `; "the balance after the last transaction" must not index into an empty list (Tasks 4 and 5). +3. `nan`, `inf`, and an empty amount field must be rejected as malformed with exit 2 and empty stdout, never summed into a balance (Task 1). +4. Dates that are well-formed but nonexistent (`2026-02-30`) or sloppily padded (`2026-3-4`) must be rejected rather than silently mis-sorted (Task 1). +5. A total that lands on negative zero (`Decimal("-0.00")`) must print `0.00`, and mixed-case category names must sort as a person expects (Task 4). + +--- + +### Task 1: Package skeleton, `Transaction` model, and CSV parsing + +**Files:** +- Create: `ledgerlite/__init__.py` +- Create: `ledgerlite/model.py` +- Create: `ledgerlite/parse.py` +- Test: `test_parse.py` + +**Interfaces:** +- Consumes: nothing (first task). +- Produces: + - `ledgerlite.model.Transaction` — frozen dataclass with fields `date: datetime.date`, `amount: decimal.Decimal`, `description: str`, in that positional order. + - `ledgerlite.parse.ParseError(Exception)` with attributes `line: int` and `message: str`. + - `ledgerlite.parse.parse_amount(text: str) -> Decimal` — raises `ValueError` whose `str()` is the exact spec-facing wording. + - `ledgerlite.parse.parse_date(text: str) -> datetime.date` — raises `ValueError` likewise. + - `ledgerlite.parse.parse_transactions(text: str) -> list[Transaction]` — raises `ParseError`; preserves input row order (it does **not** sort). + +- [ ] **Step 1: Write the failing test** + +Create `test_parse.py`: + +```python +import unittest +from datetime import date +from decimal import Decimal + +from ledgerlite.model import Transaction +from ledgerlite.parse import ParseError, parse_amount, parse_date, parse_transactions + + +class TestParseAmount(unittest.TestCase): + def test_parses_negative_two_place_amount(self): + self.assertEqual(parse_amount("-12.50"), Decimal("-12.50")) + + def test_accepts_one_and_zero_fractional_digits(self): + self.assertEqual(parse_amount("1.5"), Decimal("1.5")) + self.assertEqual(parse_amount("1.50"), Decimal("1.50")) + self.assertEqual(parse_amount("2500"), Decimal("2500")) + + def test_rejects_three_fractional_digits(self): + with self.assertRaises(ValueError) as caught: + parse_amount("1.005") + self.assertEqual( + str(caught.exception), + "amount has more than two fractional digits: '1.005'", + ) + + def test_rejects_non_numeric(self): + with self.assertRaises(ValueError) as caught: + parse_amount("abc") + self.assertEqual(str(caught.exception), "invalid amount: 'abc'") + + def test_rejects_empty(self): + with self.assertRaises(ValueError) as caught: + parse_amount("") + self.assertEqual(str(caught.exception), "invalid amount: ''") + + def test_rejects_nan_and_infinity(self): + for text in ("nan", "NaN", "inf", "-Infinity"): + with self.subTest(text=text): + with self.assertRaises(ValueError) as caught: + parse_amount(text) + self.assertEqual(str(caught.exception), f"invalid amount: {text!r}") + + +class TestParseDate(unittest.TestCase): + def test_parses_iso_date(self): + self.assertEqual(parse_date("2026-03-04"), date(2026, 3, 4)) + + def test_rejects_unpadded_date(self): + with self.assertRaises(ValueError) as caught: + parse_date("2026-3-4") + self.assertEqual(str(caught.exception), "invalid date: '2026-3-4'") + + def test_rejects_compact_date(self): + with self.assertRaises(ValueError): + parse_date("20260304") + + def test_rejects_nonexistent_date(self): + with self.assertRaises(ValueError) as caught: + parse_date("2026-02-30") + self.assertEqual(str(caught.exception), "invalid date: '2026-02-30'") + + def test_rejects_garbage(self): + with self.assertRaises(ValueError) as caught: + parse_date("not-a-date") + self.assertEqual(str(caught.exception), "invalid date: 'not-a-date'") + + +class TestParseTransactions(unittest.TestCase): + def test_parses_rows_in_input_order(self): + text = ( + "date,amount,description\n" + "2026-03-05,-900.00,Rent March\n" + "2026-03-04,-7.50,Coffee shop\n" + ) + self.assertEqual( + parse_transactions(text), + [ + Transaction(date(2026, 3, 5), Decimal("-900.00"), "Rent March"), + Transaction(date(2026, 3, 4), Decimal("-7.50"), "Coffee shop"), + ], + ) + + def test_empty_text_yields_no_transactions(self): + self.assertEqual(parse_transactions(""), []) + + def test_header_only_yields_no_transactions(self): + self.assertEqual(parse_transactions("date,amount,description\n"), []) + + def test_skips_blank_lines(self): + text = "date,amount,description\n\n2026-03-04,1.00,Pay\n\n" + self.assertEqual(len(parse_transactions(text)), 1) + + def test_quoted_description_keeps_comma(self): + text = 'date,amount,description\n2026-03-04,1.00,"Acme, Inc."\n' + self.assertEqual(parse_transactions(text)[0].description, "Acme, Inc.") + + def test_wrong_column_count_reports_line(self): + text = "date,amount,description\n2026-03-04,1.00,Pay\n2026-03-05,1.00\n" + with self.assertRaises(ParseError) as caught: + parse_transactions(text) + self.assertEqual(caught.exception.line, 3) + self.assertEqual(caught.exception.message, "expected 3 columns, got 2") + + def test_bad_amount_reports_line_and_reason(self): + text = "date,amount,description\n2026-03-04,x,Pay\n" + with self.assertRaises(ParseError) as caught: + parse_transactions(text) + self.assertEqual(caught.exception.line, 2) + self.assertEqual(caught.exception.message, "invalid amount: 'x'") + + def test_bad_date_reports_line_and_reason(self): + text = "date,amount,description\n2026-13-04,1.00,Pay\n" + with self.assertRaises(ParseError) as caught: + parse_transactions(text) + self.assertEqual(caught.exception.line, 2) + self.assertEqual(caught.exception.message, "invalid date: '2026-13-04'") + + def test_line_number_counts_newline_inside_quoted_field(self): + text = ( + "date,amount,description\n" + '2026-03-04,1.00,"multi\nline"\n' + "2026-03-05,x,Pay\n" + ) + with self.assertRaises(ParseError) as caught: + parse_transactions(text) + self.assertEqual(caught.exception.line, 4) + + def test_surrounding_whitespace_in_date_and_amount_is_tolerated(self): + text = "date,amount,description\n 2026-03-04 , -7.50 ,Coffee\n" + transaction = parse_transactions(text)[0] + self.assertEqual(transaction.date, date(2026, 3, 4)) + self.assertEqual(transaction.amount, Decimal("-7.50")) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python3 -m unittest test_parse -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite'` + +- [ ] **Step 3: Write minimal implementation** + +Create `ledgerlite/__init__.py`: + +```python +"""ledgerlite — categorize bank transactions and report per-category totals.""" +``` + +Create `ledgerlite/model.py`: + +```python +from dataclasses import dataclass +from datetime import date +from decimal import Decimal + + +@dataclass(frozen=True) +class Transaction: + date: date + amount: Decimal + description: str +``` + +Create `ledgerlite/parse.py`: + +```python +import csv +import io +import re +from datetime import date +from decimal import Decimal, InvalidOperation + +from .model import Transaction + +_DATE_PATTERN = re.compile(r"\d{4}-\d{2}-\d{2}\Z") +_COLUMN_COUNT = 3 + + +class ParseError(Exception): + """A row of the transactions file could not be parsed.""" + + def __init__(self, line: int, message: str) -> None: + super().__init__(f"{line}: {message}") + self.line = line + self.message = message + + +def parse_amount(text: str) -> Decimal: + try: + value = Decimal(text) + except InvalidOperation as exc: + raise ValueError(f"invalid amount: {text!r}") from exc + if not value.is_finite(): + raise ValueError(f"invalid amount: {text!r}") + if value.as_tuple().exponent < -2: + raise ValueError(f"amount has more than two fractional digits: {text!r}") + return value + + +def parse_date(text: str) -> date: + if not _DATE_PATTERN.match(text): + raise ValueError(f"invalid date: {text!r}") + try: + return date.fromisoformat(text) + except ValueError as exc: + raise ValueError(f"invalid date: {text!r}") from exc + + +def parse_transactions(text: str) -> list[Transaction]: + """Parse CSV text into transactions, in input order. + + The first row is the header and is skipped. Raises ParseError on the + first malformed row; the caller rejects the whole file. + """ + reader = csv.reader(io.StringIO(text)) + transactions: list[Transaction] = [] + for index, row in enumerate(reader): + if index == 0 or not row: + continue + if len(row) != _COLUMN_COUNT: + raise ParseError( + reader.line_num, f"expected {_COLUMN_COUNT} columns, got {len(row)}" + ) + date_text, amount_text, description = row + try: + transaction_date = parse_date(date_text.strip()) + amount = parse_amount(amount_text.strip()) + except ValueError as exc: + raise ParseError(reader.line_num, str(exc)) from exc + transactions.append(Transaction(transaction_date, amount, description)) + return transactions +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `python3 -m unittest test_parse -v` +Expected: PASS (all tests) + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/__init__.py ledgerlite/model.py ledgerlite/parse.py test_parse.py +git commit -m "feat: parse transactions CSV into Decimal-backed Transaction records" +``` + +--- + +### Task 2: Rules parsing and categorization + +**Files:** +- Create: `ledgerlite/rules.py` +- Test: `test_rules.py` + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: + - `ledgerlite.rules.parse_rules(text: str) -> list[tuple[str, str]]` — ordered `(substring, category)` pairs; substrings are stripped and lowercased, categories stripped. + - `ledgerlite.rules.categorize(description: str, rules: list[tuple[str, str]]) -> str | None` — first matching rule wins, matching case-insensitively; `None` when nothing matches. + +- [ ] **Step 1: Write the failing test** + +Create `test_rules.py`: + +```python +import unittest + +from ledgerlite.rules import categorize, parse_rules + + +class TestParseRules(unittest.TestCase): + def test_parses_rules_in_order_with_lowercased_substrings(self): + self.assertEqual( + parse_rules("Coffee=food\nRENT=housing\n"), + [("coffee", "food"), ("rent", "housing")], + ) + + def test_skips_blank_lines(self): + self.assertEqual(parse_rules("\ncoffee=food\n \n"), [("coffee", "food")]) + + def test_skips_lines_without_separator(self): + self.assertEqual(parse_rules("nonsense\ncoffee=food\n"), [("coffee", "food")]) + + def test_strips_whitespace_around_both_sides(self): + self.assertEqual(parse_rules(" coffee = food \n"), [("coffee", "food")]) + + def test_skips_empty_substring_or_empty_category(self): + self.assertEqual(parse_rules("=food\ncoffee=\n"), []) + + def test_splits_on_first_separator_only(self): + self.assertEqual(parse_rules("a=b=c\n"), [("a", "b=c")]) + + +class TestCategorize(unittest.TestCase): + RULES = [("coffee", "food"), ("rent", "housing")] + + def test_matches_case_insensitively(self): + self.assertEqual(categorize("MORNING COFFEE", self.RULES), "food") + + def test_first_matching_rule_wins(self): + rules = [("coffee", "food"), ("coffee shop", "outings")] + self.assertEqual(categorize("Coffee shop", rules), "food") + + def test_returns_none_when_nothing_matches(self): + self.assertIsNone(categorize("Salary", self.RULES)) + + def test_returns_none_with_no_rules(self): + self.assertIsNone(categorize("Coffee", [])) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python3 -m unittest test_rules -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.rules'` + +- [ ] **Step 3: Write minimal implementation** + +Create `ledgerlite/rules.py`: + +```python +_SEPARATOR = "=" + + +def parse_rules(text: str) -> list[tuple[str, str]]: + """Parse rules text into ordered (lowercased substring, category) pairs. + + Blank lines, lines without a separator, and lines with an empty + substring or category are ignored. + """ + rules: list[tuple[str, str]] = [] + for line in text.splitlines(): + substring, separator, category = line.partition(_SEPARATOR) + if not separator: + continue + substring = substring.strip().lower() + category = category.strip() + if not substring or not category: + continue + rules.append((substring, category)) + return rules + + +def categorize(description: str, rules: list[tuple[str, str]]) -> str | None: + """Return the category of the first rule whose substring occurs in description.""" + lowered = description.lower() + for substring, category in rules: + if substring in lowered: + return category + return None +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `python3 -m unittest test_rules -v` +Expected: PASS (all tests) + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/rules.py test_rules.py +git commit -m "feat: parse rules file and categorize descriptions case-insensitively" +``` + +--- + +### Task 3: Date ordering and closing balance + +**Files:** +- Create: `ledgerlite/balance.py` +- Test: `test_balance.py` + +**Interfaces:** +- Consumes: `ledgerlite.model.Transaction` (fields `date`, `amount`, `description`) from Task 1. +- Produces: + - `ledgerlite.balance.order_transactions(transactions: list[Transaction]) -> list[Transaction]` — sorted by date, ties keeping input order; returns a new list. + - `ledgerlite.balance.closing_balance(transactions: list[Transaction], opening: Decimal) -> Decimal` — the running balance after the last transaction in date order, or `opening` when there are none. + +- [ ] **Step 1: Write the failing test** + +Create `test_balance.py`: + +```python +import unittest +from datetime import date +from decimal import Decimal + +from ledgerlite.balance import closing_balance, order_transactions +from ledgerlite.model import Transaction + + +def txn(date_text: str, amount_text: str, description: str = "x") -> Transaction: + return Transaction(date.fromisoformat(date_text), Decimal(amount_text), description) + + +class TestOrderTransactions(unittest.TestCase): + def test_sorts_by_date(self): + rows = [txn("2026-03-05", "1.00", "b"), txn("2026-03-04", "2.00", "a")] + self.assertEqual([t.description for t in order_transactions(rows)], ["a", "b"]) + + def test_ties_keep_input_order(self): + rows = [ + txn("2026-03-04", "1.00", "first"), + txn("2026-03-04", "2.00", "second"), + txn("2026-03-03", "3.00", "earlier"), + ] + self.assertEqual( + [t.description for t in order_transactions(rows)], + ["earlier", "first", "second"], + ) + + def test_does_not_mutate_input(self): + rows = [txn("2026-03-05", "1.00", "b"), txn("2026-03-04", "2.00", "a")] + order_transactions(rows) + self.assertEqual([t.description for t in rows], ["b", "a"]) + + +class TestClosingBalance(unittest.TestCase): + def test_adds_amounts_to_opening(self): + rows = [ + txn("2026-03-04", "-7.50"), + txn("2026-03-05", "-900.00"), + txn("2026-03-06", "2500.00"), + ] + self.assertEqual(closing_balance(rows, Decimal("100")), Decimal("1692.50")) + + def test_no_transactions_returns_opening(self): + self.assertEqual(closing_balance([], Decimal("100")), Decimal("100")) + + def test_arithmetic_is_exact(self): + rows = [txn("2026-03-04", "0.10") for _ in range(3)] + self.assertEqual(closing_balance(rows, Decimal("0")), Decimal("0.30")) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python3 -m unittest test_balance -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.balance'` + +- [ ] **Step 3: Write minimal implementation** + +Create `ledgerlite/balance.py`: + +```python +from decimal import Decimal + +from .model import Transaction + + +def order_transactions(transactions: list[Transaction]) -> list[Transaction]: + """Return the transactions by date; ties keep input order (stable sort).""" + return sorted(transactions, key=lambda transaction: transaction.date) + + +def closing_balance(transactions: list[Transaction], opening: Decimal) -> Decimal: + """Return the running balance after the last transaction in date order.""" + balance = opening + for transaction in order_transactions(transactions): + balance += transaction.amount + return balance +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `python3 -m unittest test_balance -v` +Expected: PASS (all tests) + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/balance.py test_balance.py +git commit -m "feat: add date-ordered running balance and closing balance" +``` + +--- + +### Task 4: Per-category totals and report formatting + +**Files:** +- Create: `ledgerlite/report.py` +- Test: `test_report.py` + +**Interfaces:** +- Consumes: `Transaction` (Task 1), `categorize(description, rules)` (Task 2), `closing_balance(transactions, opening)` (Task 3). +- Produces: + - `ledgerlite.report.UNCATEGORIZED` — the string `"uncategorized"`. + - `ledgerlite.report.format_amount(value: Decimal) -> str` — two fractional digits, no thousands separators, `0.00` for any zero. + - `ledgerlite.report.category_totals(transactions: list[Transaction], rules: list[tuple[str, str]]) -> list[tuple[str, Decimal]]` — in report order: categories case-insensitively alphabetical, `uncategorized` last, categories with no transactions absent. + - `ledgerlite.report.format_report(transactions: list[Transaction], rules: list[tuple[str, str]], opening: Decimal) -> str` — the whole report, newline-terminated. + +- [ ] **Step 1: Write the failing test** + +Create `test_report.py`: + +```python +import unittest +from datetime import date +from decimal import Decimal + +from ledgerlite.model import Transaction +from ledgerlite.report import UNCATEGORIZED, category_totals, format_amount, format_report + + +def txn(date_text: str, amount_text: str, description: str = "x") -> Transaction: + return Transaction(date.fromisoformat(date_text), Decimal(amount_text), description) + + +RULES = [("coffee", "food"), ("rent", "housing")] + + +class TestFormatAmount(unittest.TestCase): + def test_two_fractional_digits(self): + self.assertEqual(format_amount(Decimal("-12.5")), "-12.50") + self.assertEqual(format_amount(Decimal("0")), "0.00") + self.assertEqual(format_amount(Decimal("1200")), "1200.00") + + def test_no_thousands_separators(self): + self.assertEqual(format_amount(Decimal("1234567.5")), "1234567.50") + + def test_negative_zero_prints_as_zero(self): + self.assertEqual(format_amount(Decimal("-0.00")), "0.00") + + +class TestCategoryTotals(unittest.TestCase): + def test_alphabetical_with_uncategorized_last(self): + rows = [ + txn("2026-03-04", "-7.50", "Coffee shop"), + txn("2026-03-05", "-900.00", "Rent March"), + txn("2026-03-06", "2500.00", "Salary"), + ] + self.assertEqual( + category_totals(rows, RULES), + [ + ("food", Decimal("-7.50")), + ("housing", Decimal("-900.00")), + (UNCATEGORIZED, Decimal("2500.00")), + ], + ) + + def test_sums_repeated_categories(self): + rows = [ + txn("2026-03-04", "-7.50", "Coffee shop"), + txn("2026-03-06", "-2.50", "coffee cart"), + ] + self.assertEqual(category_totals(rows, RULES), [("food", Decimal("-10.00"))]) + + def test_alphabetical_ignores_case(self): + rules = [("a", "zoo"), ("b", "Food"), ("c", "apples")] + rows = [ + txn("2026-03-04", "1.00", "a"), + txn("2026-03-04", "1.00", "b"), + txn("2026-03-04", "1.00", "c"), + ] + self.assertEqual( + [name for name, _ in category_totals(rows, rules)], + ["apples", "Food", "zoo"], + ) + + def test_uncategorized_absent_when_everything_matches(self): + rows = [txn("2026-03-04", "-7.50", "Coffee shop")] + self.assertEqual([name for name, _ in category_totals(rows, RULES)], ["food"]) + + def test_no_rules_means_everything_uncategorized(self): + rows = [txn("2026-03-04", "-7.50", "Coffee shop")] + self.assertEqual(category_totals(rows, []), [(UNCATEGORIZED, Decimal("-7.50"))]) + + def test_no_transactions_yields_no_categories(self): + self.assertEqual(category_totals([], RULES), []) + + +class TestFormatReport(unittest.TestCase): + def test_matches_design_example(self): + rows = [ + txn("2026-03-05", "-900.00", "Rent March"), + txn("2026-03-04", "-7.50", "Coffee shop"), + txn("2026-03-06", "2500.00", "Salary"), + ] + self.assertEqual( + format_report(rows, RULES, Decimal("100")), + "food: -7.50\n" + "housing: -900.00\n" + "uncategorized: 2500.00\n" + "\n" + "closing balance: 1692.50\n", + ) + + def test_no_transactions_reports_opening_balance(self): + self.assertEqual( + format_report([], RULES, Decimal("100")), + "\nclosing balance: 100.00\n", + ) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python3 -m unittest test_report -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.report'` + +- [ ] **Step 3: Write minimal implementation** + +Create `ledgerlite/report.py`: + +```python +from decimal import ROUND_HALF_UP, Decimal + +from .balance import closing_balance +from .model import Transaction +from .rules import categorize + +UNCATEGORIZED = "uncategorized" +_TWO_PLACES = Decimal("0.01") + + +def format_amount(value: Decimal) -> str: + """Format an amount with exactly two fractional digits and no separators.""" + quantized = value.quantize(_TWO_PLACES, rounding=ROUND_HALF_UP) + if quantized == 0: + quantized = abs(quantized) + return f"{quantized:.2f}" + + +def category_totals( + transactions: list[Transaction], rules: list[tuple[str, str]] +) -> list[tuple[str, Decimal]]: + """Return (category, total) pairs in report order: alphabetical, uncategorized last.""" + totals: dict[str, Decimal] = {} + for transaction in transactions: + name = categorize(transaction.description, rules) or UNCATEGORIZED + totals[name] = totals.get(name, Decimal("0")) + transaction.amount + named = sorted( + (name for name in totals if name != UNCATEGORIZED), + key=lambda name: (name.lower(), name), + ) + ordered = [(name, totals[name]) for name in named] + if UNCATEGORIZED in totals: + ordered.append((UNCATEGORIZED, totals[UNCATEGORIZED])) + return ordered + + +def format_report( + transactions: list[Transaction], + rules: list[tuple[str, str]], + opening: Decimal, +) -> str: + """Render the whole report, newline-terminated.""" + lines = [ + f"{name}: {format_amount(total)}" + for name, total in category_totals(transactions, rules) + ] + lines.append("") + lines.append( + f"closing balance: {format_amount(closing_balance(transactions, opening))}" + ) + return "\n".join(lines) + "\n" +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `python3 -m unittest test_report -v` +Expected: PASS (all tests) + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/report.py test_report.py +git commit -m "feat: format per-category totals and closing balance report" +``` + +--- + +### Task 5: CLI entry point, file reading, and exit codes + +**Files:** +- Create: `ledgerlite/cli.py` +- Create: `ledgerlite/__main__.py` +- Test: `test_cli.py` + +**Interfaces:** +- Consumes: `parse.parse_amount`, `parse.parse_transactions`, `parse.ParseError` (Task 1); `rules.parse_rules` (Task 2); `report.format_report` (Task 4). +- Produces: + - `ledgerlite.cli.main(argv: list[str] | None = None) -> int` — returns the process exit code. Invalid command-line arguments raise `SystemExit(2)` from argparse rather than returning. + - `python3 -m ledgerlite report TRANSACTIONS [--rules RULES] [--opening AMOUNT]`. + +- [ ] **Step 1: Write the failing test** + +Create `test_cli.py`: + +```python +import contextlib +import io +import os +import subprocess +import sys +import tempfile +import unittest + +from ledgerlite.cli import main + +TRANSACTIONS = ( + "date,amount,description\n" + "2026-03-05,-900.00,Rent March\n" + "2026-03-04,-7.50,Coffee shop\n" + "2026-03-06,2500.00,Salary\n" +) +RULES = "coffee=food\nrent=housing\n" +EXPECTED = ( + "food: -7.50\n" + "housing: -900.00\n" + "uncategorized: 2500.00\n" + "\n" + "closing balance: 1692.50\n" +) + + +class CliTestCase(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + + def write(self, name, content, *, mode="w"): + path = os.path.join(self.directory.name, name) + with open(path, mode) as handle: + handle.write(content) + return path + + def run_main(self, argv): + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = main(argv) + return code, out.getvalue(), err.getvalue() + + +class TestSuccess(CliTestCase): + def test_reports_with_rules_and_opening(self): + transactions = self.write("t.csv", TRANSACTIONS) + rules = self.write("t.rules", RULES) + code, out, err = self.run_main( + ["report", transactions, "--rules", rules, "--opening", "100"] + ) + self.assertEqual((code, out, err), (0, EXPECTED, "")) + + def test_opening_defaults_to_zero_and_rules_are_optional(self): + transactions = self.write("t.csv", TRANSACTIONS) + code, out, _ = self.run_main(["report", transactions]) + self.assertEqual(code, 0) + self.assertEqual(out, "uncategorized: 1592.50\n\nclosing balance: 1592.50\n") + + def test_header_only_file_reports_opening_balance(self): + transactions = self.write("t.csv", "date,amount,description\n") + code, out, _ = self.run_main(["report", transactions, "--opening", "100"]) + self.assertEqual((code, out), (0, "\nclosing balance: 100.00\n")) + + def test_empty_file_reports_opening_balance(self): + transactions = self.write("t.csv", "") + code, out, _ = self.run_main(["report", transactions]) + self.assertEqual((code, out), (0, "\nclosing balance: 0.00\n")) + + +class TestUnreadable(CliTestCase): + def test_missing_transactions_file(self): + path = os.path.join(self.directory.name, "nope.csv") + code, out, err = self.run_main(["report", path]) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertTrue(err.startswith(f"ledgerlite: cannot read {path}: "), err) + self.assertIn("No such file", err) + + def test_directory_as_transactions_file(self): + code, out, err = self.run_main(["report", self.directory.name]) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertTrue(err.startswith(f"ledgerlite: cannot read {self.directory.name}: "), err) + + def test_non_utf8_transactions_file(self): + path = self.write("t.csv", b"date,amount,description\n2026-03-04,1.00,\xff\n", mode="wb") + code, out, err = self.run_main(["report", path]) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertTrue(err.startswith(f"ledgerlite: cannot read {path}: "), err) + + def test_missing_rules_file(self): + transactions = self.write("t.csv", TRANSACTIONS) + path = os.path.join(self.directory.name, "nope.rules") + code, out, err = self.run_main(["report", transactions, "--rules", path]) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertTrue(err.startswith(f"ledgerlite: cannot read {path}: "), err) + + +class TestMalformed(CliTestCase): + def test_bad_amount_reports_path_and_line(self): + transactions = self.write( + "t.csv", "date,amount,description\n2026-03-04,1.00,Pay\n2026-03-05,x,Pay\n" + ) + code, out, err = self.run_main(["report", transactions]) + self.assertEqual(code, 2) + self.assertEqual(out, "") + self.assertEqual(err, f"ledgerlite: {transactions}:3: invalid amount: 'x'\n") + + def test_three_fractional_digits_rejects_whole_file(self): + transactions = self.write("t.csv", "date,amount,description\n2026-03-04,1.005,Pay\n") + code, out, err = self.run_main(["report", transactions]) + self.assertEqual(code, 2) + self.assertEqual(out, "") + self.assertEqual( + err, + f"ledgerlite: {transactions}:2: amount has more than two " + "fractional digits: '1.005'\n", + ) + + def test_nonexistent_date_rejects_whole_file(self): + transactions = self.write("t.csv", "date,amount,description\n2026-02-30,1.00,Pay\n") + code, out, _ = self.run_main(["report", transactions]) + self.assertEqual((code, out), (2, "")) + + def test_bad_opening_amount_exits_two(self): + transactions = self.write("t.csv", TRANSACTIONS) + with contextlib.redirect_stderr(io.StringIO()): + with self.assertRaises(SystemExit) as caught: + main(["report", transactions, "--opening", "1.005"]) + self.assertEqual(caught.exception.code, 2) + + +class TestModuleInvocation(CliTestCase): + def test_runs_as_python_module(self): + transactions = self.write("t.csv", TRANSACTIONS) + rules = self.write("t.rules", RULES) + completed = subprocess.run( + [ + sys.executable, "-m", "ledgerlite", "report", transactions, + "--rules", rules, "--opening", "100", + ], + capture_output=True, + text=True, + ) + self.assertEqual(completed.returncode, 0, completed.stderr) + self.assertEqual(completed.stdout, EXPECTED) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python3 -m unittest test_cli -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.cli'` + +- [ ] **Step 3: Write minimal implementation** + +Create `ledgerlite/cli.py`: + +```python +import argparse +import sys +from decimal import Decimal + +from . import rules as rules_module +from .parse import ParseError, parse_amount, parse_transactions +from .report import format_report + + +def _opening_amount(text: str) -> Decimal: + try: + return parse_amount(text) + except ValueError as exc: + raise argparse.ArgumentTypeError(str(exc)) from exc + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="ledgerlite") + subparsers = parser.add_subparsers(dest="command", required=True) + report_parser = subparsers.add_parser("report", help="print a categorized report") + report_parser.add_argument("transactions", help="path to the transactions CSV") + report_parser.add_argument("--rules", help="path to the rules file") + report_parser.add_argument( + "--opening", type=_opening_amount, default=Decimal("0"), + help="opening balance (default 0)", + ) + return parser + + +def _read_text(path: str) -> str: + with open(path, encoding="utf-8", newline="") as handle: + return handle.read() + + +def _reason(exc: Exception) -> str: + return getattr(exc, "strerror", None) or str(exc) + + +def main(argv: list[str] | None = None) -> int: + args = _build_parser().parse_args(argv) + + paths = [args.transactions] + ([args.rules] if args.rules else []) + texts = {} + for path in paths: + try: + texts[path] = _read_text(path) + except (OSError, UnicodeDecodeError) as exc: + print(f"ledgerlite: cannot read {path}: {_reason(exc)}", file=sys.stderr) + return 1 + + rules = rules_module.parse_rules(texts[args.rules]) if args.rules else [] + + try: + transactions = parse_transactions(texts[args.transactions]) + except ParseError as exc: + print( + f"ledgerlite: {args.transactions}:{exc.line}: {exc.message}", + file=sys.stderr, + ) + return 2 + + sys.stdout.write(format_report(transactions, rules, args.opening)) + return 0 +``` + +Create `ledgerlite/__main__.py`: + +```python +import sys + +from .cli import main + +sys.exit(main()) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `python3 -m unittest test_cli -v` +Expected: PASS (all tests) + +- [ ] **Step 5: Run the whole suite and the documented example** + +Run: `python3 -m unittest -v` +Expected: PASS — every test in `test_parse`, `test_rules`, `test_balance`, `test_report`, `test_cli`. + +Then confirm the spec's example end to end: + +```bash +printf 'date,amount,description\n2026-03-05,-900.00,Rent March\n2026-03-04,-7.50,Coffee shop\n2026-03-06,2500.00,Salary\n' > /tmp/ledgerlite-demo.csv +printf 'coffee=food\nrent=housing\n' > /tmp/ledgerlite-demo.rules +python3 -m ledgerlite report /tmp/ledgerlite-demo.csv --rules /tmp/ledgerlite-demo.rules --opening 100 +``` + +Expected stdout, exactly: + +``` +food: -7.50 +housing: -900.00 +uncategorized: 2500.00 + +closing balance: 1692.50 +``` + +- [ ] **Step 6: Commit** + +```bash +git add ledgerlite/cli.py ledgerlite/__main__.py test_cli.py +git commit -m "feat: add ledgerlite report CLI with spec exit codes and messages" +``` + +--- + +## Spec coverage + +| Spec requirement | Task | +| --- | --- | +| ISO date, two-place Decimal amount, free-text description | 1 | +| Rows in any order; shared dates allowed | 1 (parse preserves order), 3 (stable date sort) | +| Malformed row → `:: `, exit 2, no stdout | 1 (detection), 5 (message and code) | +| Unreadable transactions file → `cannot read`, exit 1 | 5 | +| Rules file format, case-insensitive match, first rule wins, no match → no category | 2 | +| `--rules` optional, `--opening` defaults to 0 | 5 | +| Date-ordered running balance; closing balance; opening when empty | 3 | +| One line per category, alphabetical, `uncategorized` last, blank line, `closing balance:` | 4 | +| Two-digit amounts, leading `-`, no thousands separators | 4 | +| Package layout, stdlib only, Decimal not float, root `test_.py` under `python3 -m unittest` | all | diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/Q1-reader-ledgerlite-63.plan.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/Q1-reader-ledgerlite-63.plan.md new file mode 100644 index 000000000..69a0bf994 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/Q1-reader-ledgerlite-63.plan.md @@ -0,0 +1,1203 @@ +# ledgerlite Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build `ledgerlite`, a standard-library-only Python CLI that reads a transactions CSV, categorizes each row with a rules file, and prints per-category totals plus the closing balance. + +**Architecture:** Six small modules with one responsibility each, wired together only at the CLI layer: `model` (the `Transaction` record), `parse` (text → transactions, raising `ParseError`), `rules` (rules text → rule list, plus `categorize`), `balance` (date-ordered running/closing balance), `report` (per-category totals and formatting), `cli` (argparse, file reading, exit codes). Every module below `cli` is pure — it takes strings and values, never paths — so all behavior is testable without touching the filesystem, and `cli` owns the only I/O and the mapping from exceptions to exit codes. + +**Tech Stack:** Python 3.11+, standard library only (`csv`, `decimal`, `datetime`, `re`, `argparse`, `dataclasses`, `unittest`). + +**Spec:** `design.md` (in this directory) + +## Global Constraints + +- Python 3.11+. Standard library only — no third-party runtime or test dependencies. +- Money is `decimal.Decimal`, never `float`. No arithmetic on money goes through `float` at any point, including formatting. +- Package lives in `ledgerlite/` with exactly the modules listed in the spec's layout, plus `__main__.py` so `python3 -m ledgerlite` runs the CLI (the spec names the command `ledgerlite report ...` but does not list an entry-point file; `__main__.py` is the standard-library way to provide it). +- Tests live at the repo root as `test_.py` and run with `python3 -m unittest`. +- Amounts are printed with exactly two fractional digits, a leading `-` only for negatives, and no thousands separators: `-12.50`, `0.00`, `1200.00`. +- Exit codes: `0` success, `1` a file cannot be read, `2` the transactions file has a malformed row. Nothing is written to stdout on codes 1 and 2. +- Error message formats, verbatim: + - `ledgerlite: cannot read : ` + - `ledgerlite: :: ` +- Rule matching is case-insensitive on the description; the first matching rule wins. +- Transaction order is by date with ties keeping input order (Python's `sorted` is stable, so no tiebreak field is needed on `Transaction`). + +## Review Focus + +Input classes the spec implies but never spells out. Each has a test in the task named after it. + +1. **`decimal.Decimal` accepts `NaN`, `Infinity`, and `1e3`** — a regex must gate the amount field, or a `NaN` amount silently poisons every total. Also `1,000.00`, `$5.00`, and `""` must be malformed. (Task 2) +2. **An empty transactions file, or one whose header row is wrong** — must exit 2 with a `:1:` message, not raise `StopIteration` or silently treat the header as data. (Task 3) +3. **A transactions file with a header but zero data rows** — must print `closing balance: 0.00` with no leading blank line and no `uncategorized: 0.00` line. (Task 6) +4. **A total of `-0.00`** (e.g. a single `-0.00` amount) — must print `0.00`; `-0.00` is not negative. (Task 6) +5. **A missing or unreadable `--rules` file** — the spec only names the transactions file, but this must exit 1 with `cannot read`, not a traceback. (Task 7) + +--- + +### Task 1: Package skeleton and `Transaction` + +**Files:** +- Create: `ledgerlite/__init__.py` +- Create: `ledgerlite/model.py` +- Create: `.gitignore` +- Test: `test_model.py` + +**Interfaces:** +- Consumes: nothing. +- Produces: `ledgerlite.model.Transaction`, a frozen dataclass with fields `date: datetime.date`, `amount: decimal.Decimal`, `description: str`, constructed by keyword. + +- [ ] **Step 1: Write the failing test** + +Create `test_model.py`: + +```python +import unittest +from dataclasses import FrozenInstanceError +from datetime import date +from decimal import Decimal + +from ledgerlite.model import Transaction + + +class TransactionTest(unittest.TestCase): + def test_holds_date_amount_and_description(self): + txn = Transaction( + date=date(2026, 3, 4), + amount=Decimal("-7.50"), + description="Blue Bottle Coffee", + ) + self.assertEqual(txn.date, date(2026, 3, 4)) + self.assertEqual(txn.amount, Decimal("-7.50")) + self.assertEqual(txn.description, "Blue Bottle Coffee") + + def test_is_frozen(self): + txn = Transaction(date=date(2026, 3, 4), amount=Decimal("1.00"), description="x") + with self.assertRaises(FrozenInstanceError): + txn.amount = Decimal("2.00") + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python3 -m unittest test_model -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite'` + +- [ ] **Step 3: Write minimal implementation** + +Create `ledgerlite/__init__.py`: + +```python +"""ledgerlite: summarize bank transactions by category.""" +``` + +Create `ledgerlite/model.py`: + +```python +"""The transaction record.""" + +from dataclasses import dataclass +from datetime import date +from decimal import Decimal + + +@dataclass(frozen=True) +class Transaction: + """One row of the transactions CSV.""" + + date: date + amount: Decimal + description: str +``` + +Create `.gitignore`: + +``` +__pycache__/ +*.pyc +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python3 -m unittest test_model -v` +Expected: PASS (2 tests) + +- [ ] **Step 5: Commit** + +```bash +git add .gitignore ledgerlite/__init__.py ledgerlite/model.py test_model.py +git commit -m "feat: add Transaction model" +``` + +--- + +### Task 2: Field parsers — `parse_date` and `parse_amount` + +The two validators that decide whether a field is malformed. `parse_amount` is also used by the CLI for `--opening`, so it lands before row parsing. + +**Files:** +- Create: `ledgerlite/parse.py` +- Test: `test_parse.py` + +**Interfaces:** +- Consumes: nothing. +- Produces: + - `ledgerlite.parse.parse_date(text: str) -> datetime.date` — raises `ValueError("invalid date: ")`. + - `ledgerlite.parse.parse_amount(text: str) -> decimal.Decimal` — raises `ValueError("invalid amount: ")`. + - Both strip surrounding whitespace before validating. + +- [ ] **Step 1: Write the failing test** + +Create `test_parse.py`: + +```python +import unittest +from datetime import date +from decimal import Decimal + +from ledgerlite.parse import parse_amount, parse_date + + +class ParseDateTest(unittest.TestCase): + def test_parses_iso_date(self): + self.assertEqual(parse_date("2026-03-04"), date(2026, 3, 4)) + + def test_strips_surrounding_whitespace(self): + self.assertEqual(parse_date(" 2026-03-04 "), date(2026, 3, 4)) + + def test_rejects_non_iso_and_impossible_dates(self): + for text in ["2026-3-4", "20260304", "04/03/2026", "2026-02-30", "2026-13-01", "", "today"]: + with self.subTest(text=text): + with self.assertRaises(ValueError) as caught: + parse_date(text) + self.assertIn("invalid date", str(caught.exception)) + + +class ParseAmountTest(unittest.TestCase): + def test_parses_decimals(self): + self.assertEqual(parse_amount("1.50"), Decimal("1.50")) + self.assertEqual(parse_amount("1.5"), Decimal("1.5")) + self.assertEqual(parse_amount("-900"), Decimal("-900")) + self.assertEqual(parse_amount("+2500.00"), Decimal("2500.00")) + self.assertEqual(parse_amount(".25"), Decimal("0.25")) + self.assertEqual(parse_amount(" -7.50 "), Decimal("-7.50")) + + def test_rejects_more_than_two_fractional_digits(self): + with self.assertRaises(ValueError): + parse_amount("1.005") + + # Review Focus 1: Decimal() itself accepts these; the regex must not. + def test_rejects_non_numeric_and_special_values(self): + for text in ["NaN", "nan", "Infinity", "-inf", "1e3", "1,000.00", "$5.00", "", " ", "1.2.3", "--1"]: + with self.subTest(text=text): + with self.assertRaises(ValueError) as caught: + parse_amount(text) + self.assertIn("invalid amount", str(caught.exception)) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python3 -m unittest test_parse -v` +Expected: FAIL — `ImportError: cannot import name 'parse_amount' from 'ledgerlite.parse'` (module does not exist) + +- [ ] **Step 3: Write minimal implementation** + +Create `ledgerlite/parse.py`: + +```python +"""Parsing the transactions CSV.""" + +import re +from datetime import date +from decimal import Decimal + +_DATE_RE = re.compile(r"\d{4}-\d{2}-\d{2}\Z") +_AMOUNT_RE = re.compile(r"[+-]?(?:\d+(?:\.\d{1,2})?|\.\d{1,2})\Z") + + +def parse_date(text: str) -> date: + """Parse an ISO 8601 calendar date, e.g. 2026-03-04.""" + stripped = text.strip() + if not _DATE_RE.match(stripped): + raise ValueError(f"invalid date: {text!r}") + try: + return date.fromisoformat(stripped) + except ValueError: + raise ValueError(f"invalid date: {text!r}") from None + + +def parse_amount(text: str) -> Decimal: + """Parse a decimal amount with at most two fractional digits.""" + stripped = text.strip() + if not _AMOUNT_RE.match(stripped): + raise ValueError(f"invalid amount: {text!r}") + return Decimal(stripped) +``` + +Note: the regexes are deliberately stricter than `date.fromisoformat` and `Decimal`, both of which accept forms the spec does not (`20260304`, `NaN`, `1e3`). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python3 -m unittest test_parse -v` +Expected: PASS (6 tests) + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/parse.py test_parse.py +git commit -m "feat: add strict date and amount field parsers" +``` + +--- + +### Task 3: `parse_transactions` and `ParseError` + +**Files:** +- Modify: `ledgerlite/parse.py` (append `ParseError`, `HEADER`, `parse_transactions`) +- Test: `test_parse.py` (append two test classes) + +**Interfaces:** +- Consumes: `Transaction(date=..., amount=..., description=...)` from Task 1; `parse_date`, `parse_amount` from Task 2. +- Produces: + - `ledgerlite.parse.ParseError(line: int, message: str)`, an `Exception` with attributes `.line` and `.message`. It carries no path — the CLI owns the path and formats `ledgerlite: :: `. + - `ledgerlite.parse.parse_transactions(text: str) -> list[Transaction]` — returns rows in input order, raises `ParseError` on the first malformed row. + +- [ ] **Step 1: Write the failing test** + +Append to `test_parse.py` (and add `from ledgerlite.parse import ParseError, parse_transactions` to the imports at the top): + +```python +class ParseTransactionsTest(unittest.TestCase): + def test_parses_rows_in_input_order(self): + text = ( + "date,amount,description\n" + "2026-03-05,-900.00,Rent March\n" + "2026-03-04,-7.50,Blue Bottle Coffee\n" + ) + txns = parse_transactions(text) + self.assertEqual(len(txns), 2) + self.assertEqual(txns[0].date, date(2026, 3, 5)) + self.assertEqual(txns[0].amount, Decimal("-900.00")) + self.assertEqual(txns[0].description, "Rent March") + self.assertEqual(txns[1].description, "Blue Bottle Coffee") + + def test_handles_quoted_description_with_comma(self): + text = 'date,amount,description\n2026-03-04,-7.50,"Coffee, large"\n' + self.assertEqual(parse_transactions(text)[0].description, "Coffee, large") + + def test_header_only_file_yields_no_transactions(self): + self.assertEqual(parse_transactions("date,amount,description\n"), []) + + def test_skips_blank_lines(self): + text = "date,amount,description\n\n2026-03-04,-7.50,Coffee\n\n" + self.assertEqual(len(parse_transactions(text)), 1) + + +class ParseTransactionsErrorTest(unittest.TestCase): + def assert_parse_error(self, text, line, message_fragment): + with self.assertRaises(ParseError) as caught: + parse_transactions(text) + self.assertEqual(caught.exception.line, line) + self.assertIn(message_fragment, caught.exception.message) + + # Review Focus 2: empty file and bad header must be reported, not crash. + def test_empty_file_is_missing_header(self): + self.assert_parse_error("", 1, "missing header row") + + def test_wrong_header_is_rejected(self): + self.assert_parse_error("when,how much,what\n2026-03-04,-7.50,Coffee\n", 1, "expected header") + + def test_wrong_column_count_reports_line(self): + text = "date,amount,description\n2026-03-04,-7.50,Coffee\n2026-03-05,-900.00\n" + self.assert_parse_error(text, 3, "expected 3 columns, got 2") + + def test_bad_date_reports_line(self): + text = "date,amount,description\n2026-13-01,-7.50,Coffee\n" + self.assert_parse_error(text, 2, "invalid date") + + def test_bad_amount_reports_line(self): + text = "date,amount,description\n2026-03-04,1.005,Coffee\n" + self.assert_parse_error(text, 2, "invalid amount") + + def test_reports_first_bad_row_only(self): + text = ( + "date,amount,description\n" + "2026-03-04,-7.50,Coffee\n" + "2026-03-05,nope,Rent\n" + "2026-03-06,also-nope,Gym\n" + ) + self.assert_parse_error(text, 3, "invalid amount") +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python3 -m unittest test_parse -v` +Expected: FAIL — `ImportError: cannot import name 'ParseError' from 'ledgerlite.parse'` + +- [ ] **Step 3: Write minimal implementation** + +Append to `ledgerlite/parse.py` (and add `import csv`, `import io`, and `from ledgerlite.model import Transaction` to the imports at the top): + +```python +HEADER = ["date", "amount", "description"] + + +class ParseError(Exception): + """A row of the transactions CSV is malformed. + + ``line`` is 1-based and counts the header row, so the first data row is + line 2. The caller supplies the path when formatting the message. + """ + + def __init__(self, line: int, message: str) -> None: + super().__init__(message) + self.line = line + self.message = message + + +def parse_transactions(text: str) -> list[Transaction]: + """Parse CSV text into transactions, in input order. + + Raises ParseError on the first malformed row; the whole file is then + the caller's to reject. + """ + reader = csv.reader(io.StringIO(text)) + try: + header = next(reader) + except StopIteration: + raise ParseError(1, "missing header row") from None + if [column.strip().lower() for column in header] != HEADER: + raise ParseError(1, "expected header date,amount,description") + + transactions = [] + for row in reader: + line = reader.line_num + if not row or (len(row) == 1 and not row[0].strip()): + continue + if len(row) != 3: + raise ParseError(line, f"expected 3 columns, got {len(row)}") + date_text, amount_text, description = row + try: + when = parse_date(date_text) + amount = parse_amount(amount_text) + except ValueError as exc: + raise ParseError(line, str(exc)) from None + transactions.append(Transaction(date=when, amount=amount, description=description)) + return transactions +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python3 -m unittest test_parse -v` +Expected: PASS (16 tests) + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/parse.py test_parse.py +git commit -m "feat: parse transactions CSV with per-line errors" +``` + +--- + +### Task 4: Rules parsing and categorization + +**Files:** +- Create: `ledgerlite/rules.py` +- Test: `test_rules.py` + +**Interfaces:** +- Consumes: nothing. +- Produces: + - `ledgerlite.rules.parse_rules(text: str) -> list[tuple[str, str]]` — `(substring, category)` pairs in file order, substrings already lowercased for case-insensitive matching. + - `ledgerlite.rules.categorize(description: str, rules: list[tuple[str, str]]) -> str | None` — first match wins, `None` when nothing matches. + +- [ ] **Step 1: Write the failing test** + +Create `test_rules.py`: + +```python +import unittest + +from ledgerlite.rules import categorize, parse_rules + + +class ParseRulesTest(unittest.TestCase): + def test_parses_rules_in_file_order_with_lowercased_substrings(self): + self.assertEqual( + parse_rules("Coffee=food\nRENT=housing\n"), + [("coffee", "food"), ("rent", "housing")], + ) + + def test_strips_whitespace_around_substring_and_category(self): + self.assertEqual(parse_rules(" coffee = food \n"), [("coffee", "food")]) + + def test_splits_on_first_equals_only(self): + self.assertEqual(parse_rules("a=b=c\n"), [("a", "b=c")]) + + def test_skips_blank_incomplete_and_malformed_lines(self): + text = "\n \ncoffee=food\nno-equals-sign\n=food\ncoffee=\n" + self.assertEqual(parse_rules(text), [("coffee", "food")]) + + def test_empty_text_yields_no_rules(self): + self.assertEqual(parse_rules(""), []) + + +class CategorizeTest(unittest.TestCase): + def setUp(self): + self.rules = parse_rules("coffee=food\nrent=housing\n") + + def test_matches_substring_case_insensitively(self): + self.assertEqual(categorize("BLUE BOTTLE COFFEE", self.rules), "food") + self.assertEqual(categorize("Rent March", self.rules), "housing") + + def test_first_matching_rule_wins(self): + rules = parse_rules("coffee=food\ncoffee=drinks\n") + self.assertEqual(categorize("Coffee", rules), "food") + + def test_returns_none_when_nothing_matches(self): + self.assertIsNone(categorize("ACME Payroll", self.rules)) + + def test_returns_none_with_no_rules(self): + self.assertIsNone(categorize("Coffee", [])) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python3 -m unittest test_rules -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.rules'` + +- [ ] **Step 3: Write minimal implementation** + +Create `ledgerlite/rules.py`: + +```python +"""Categorization rules: `=`, one per line.""" + + +def parse_rules(text: str) -> list[tuple[str, str]]: + """Parse rules text into (substring, category) pairs, in file order. + + Substrings are lowercased so matching is case-insensitive. Blank lines + and lines missing either side of the `=` are ignored. + """ + rules: list[tuple[str, str]] = [] + for raw_line in text.splitlines(): + line = raw_line.strip() + if "=" not in line: + continue + substring, _, category = line.partition("=") + substring = substring.strip().lower() + category = category.strip() + if not substring or not category: + continue + rules.append((substring, category)) + return rules + + +def categorize(description: str, rules: list[tuple[str, str]]) -> str | None: + """Return the first matching rule's category, or None if none match.""" + lowered = description.lower() + for substring, category in rules: + if substring in lowered: + return category + return None +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python3 -m unittest test_rules -v` +Expected: PASS (9 tests) + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/rules.py test_rules.py +git commit -m "feat: add rules parsing and categorization" +``` + +--- + +### Task 5: Date-ordered balances + +**Files:** +- Create: `ledgerlite/balance.py` +- Test: `test_balance.py` + +**Interfaces:** +- Consumes: `Transaction` from Task 1. +- Produces: + - `ledgerlite.balance.order_transactions(transactions: list[Transaction]) -> list[Transaction]` — sorted by date, ties keeping input order. + - `ledgerlite.balance.running_balances(transactions: list[Transaction], opening: Decimal = Decimal("0")) -> list[tuple[Transaction, Decimal]]` + - `ledgerlite.balance.closing_balance(transactions: list[Transaction], opening: Decimal = Decimal("0")) -> Decimal` — `opening` when there are no transactions. + +- [ ] **Step 1: Write the failing test** + +Create `test_balance.py`: + +```python +import unittest +from datetime import date +from decimal import Decimal + +from ledgerlite.balance import closing_balance, order_transactions, running_balances +from ledgerlite.model import Transaction + + +def txn(day, amount, description="x"): + return Transaction(date=date(2026, 3, day), amount=Decimal(amount), description=description) + + +class OrderTransactionsTest(unittest.TestCase): + def test_orders_by_date(self): + rows = [txn(5, "-900.00", "rent"), txn(4, "-7.50", "coffee")] + self.assertEqual([t.description for t in order_transactions(rows)], ["coffee", "rent"]) + + def test_ties_keep_input_order(self): + rows = [txn(4, "1.00", "second-in-file"), txn(4, "2.00", "first-was-earlier")] + self.assertEqual( + [t.description for t in order_transactions(rows)], + ["second-in-file", "first-was-earlier"], + ) + + def test_does_not_mutate_input(self): + rows = [txn(5, "1.00", "b"), txn(4, "1.00", "a")] + order_transactions(rows) + self.assertEqual([t.description for t in rows], ["b", "a"]) + + +class RunningBalancesTest(unittest.TestCase): + def test_accumulates_in_date_order_from_opening(self): + rows = [txn(5, "-900.00"), txn(4, "-7.50"), txn(6, "2500.00")] + result = running_balances(rows, Decimal("100")) + self.assertEqual( + [balance for _, balance in result], + [Decimal("92.50"), Decimal("-807.50"), Decimal("1692.50")], + ) + self.assertEqual([t.date.day for t, _ in result], [4, 5, 6]) + + def test_empty_input_has_no_balances(self): + self.assertEqual(running_balances([], Decimal("100")), []) + + +class ClosingBalanceTest(unittest.TestCase): + def test_is_balance_after_last_transaction(self): + rows = [txn(5, "-900.00"), txn(4, "-7.50"), txn(6, "2500.00")] + self.assertEqual(closing_balance(rows, Decimal("100")), Decimal("1692.50")) + + def test_is_opening_when_there_are_no_transactions(self): + self.assertEqual(closing_balance([], Decimal("100")), Decimal("100")) + + def test_defaults_to_zero_opening(self): + self.assertEqual(closing_balance([txn(4, "-7.50")]), Decimal("-7.50")) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python3 -m unittest test_balance -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.balance'` + +- [ ] **Step 3: Write minimal implementation** + +Create `ledgerlite/balance.py`: + +```python +"""Date-ordered running balance and closing balance.""" + +from decimal import Decimal + +from ledgerlite.model import Transaction + +ZERO = Decimal("0") + + +def order_transactions(transactions: list[Transaction]) -> list[Transaction]: + """Return the transactions ordered by date; ties keep input order.""" + return sorted(transactions, key=lambda txn: txn.date) + + +def running_balances( + transactions: list[Transaction], opening: Decimal = ZERO +) -> list[tuple[Transaction, Decimal]]: + """Pair each transaction, in date order, with the balance after it.""" + balance = opening + result: list[tuple[Transaction, Decimal]] = [] + for txn in order_transactions(transactions): + balance += txn.amount + result.append((txn, balance)) + return result + + +def closing_balance(transactions: list[Transaction], opening: Decimal = ZERO) -> Decimal: + """The balance after the last transaction, or `opening` if there are none.""" + balances = running_balances(transactions, opening) + return balances[-1][1] if balances else opening +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python3 -m unittest test_balance -v` +Expected: PASS (8 tests) + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/balance.py test_balance.py +git commit -m "feat: add date-ordered running and closing balances" +``` + +--- + +### Task 6: Report totals and formatting + +**Files:** +- Create: `ledgerlite/report.py` +- Test: `test_report.py` + +**Interfaces:** +- Consumes: `Transaction` (Task 1), `categorize` (Task 4), `closing_balance` (Task 5). +- Produces: + - `ledgerlite.report.UNCATEGORIZED = "uncategorized"` + - `ledgerlite.report.format_amount(value: Decimal) -> str` + - `ledgerlite.report.category_totals(transactions, rules) -> list[tuple[str, Decimal]]` — alphabetical, `uncategorized` last, categories with no transactions absent. + - `ledgerlite.report.format_report(transactions, rules, opening: Decimal = Decimal("0")) -> str` — no trailing newline; the CLI adds it via `print`. + +- [ ] **Step 1: Write the failing test** + +Create `test_report.py`: + +```python +import unittest +from datetime import date +from decimal import Decimal + +from ledgerlite.model import Transaction +from ledgerlite.report import UNCATEGORIZED, category_totals, format_amount, format_report +from ledgerlite.rules import parse_rules + +RULES = parse_rules("coffee=food\nrent=housing\n") + + +def txn(day, amount, description): + return Transaction(date=date(2026, 3, day), amount=Decimal(amount), description=description) + + +class FormatAmountTest(unittest.TestCase): + def test_uses_exactly_two_fractional_digits(self): + self.assertEqual(format_amount(Decimal("-12.5")), "-12.50") + self.assertEqual(format_amount(Decimal("0")), "0.00") + self.assertEqual(format_amount(Decimal("1200")), "1200.00") + self.assertEqual(format_amount(Decimal("-900.00")), "-900.00") + + def test_no_thousands_separators(self): + self.assertEqual(format_amount(Decimal("1234567.89")), "1234567.89") + + # Review Focus 4: -0.00 is not negative. + def test_negative_zero_prints_without_sign(self): + self.assertEqual(format_amount(Decimal("-0.00")), "0.00") + + +class CategoryTotalsTest(unittest.TestCase): + def test_sums_per_category_alphabetically_with_uncategorized_last(self): + rows = [ + txn(4, "2500.00", "ACME Payroll"), + txn(5, "-900.00", "Rent March"), + txn(6, "-7.50", "Blue Bottle Coffee"), + ] + self.assertEqual( + category_totals(rows, RULES), + [("food", Decimal("-7.50")), ("housing", Decimal("-900.00")), (UNCATEGORIZED, Decimal("2500.00"))], + ) + + def test_omits_uncategorized_when_everything_matches(self): + rows = [txn(4, "-7.50", "Coffee"), txn(5, "-2.50", "coffee again")] + self.assertEqual(category_totals(rows, RULES), [("food", Decimal("-10.00"))]) + + def test_without_rules_everything_is_uncategorized(self): + rows = [txn(4, "-7.50", "Coffee"), txn(5, "-900.00", "Rent")] + self.assertEqual(category_totals(rows, []), [(UNCATEGORIZED, Decimal("-907.50"))]) + + def test_no_transactions_yields_no_lines(self): + self.assertEqual(category_totals([], RULES), []) + + +class FormatReportTest(unittest.TestCase): + def test_matches_the_spec_example(self): + rows = [ + txn(4, "-7.50", "Blue Bottle Coffee"), + txn(5, "-900.00", "Rent March"), + txn(6, "2500.00", "ACME Payroll"), + ] + self.assertEqual( + format_report(rows, RULES, Decimal("100")), + "food: -7.50\nhousing: -900.00\nuncategorized: 2500.00\n\nclosing balance: 1692.50", + ) + + # Review Focus 3: no leading blank line, no phantom uncategorized line. + def test_no_transactions_prints_only_the_closing_balance(self): + self.assertEqual(format_report([], RULES, Decimal("0")), "closing balance: 0.00") + + def test_defaults_to_zero_opening(self): + self.assertEqual( + format_report([txn(4, "-7.50", "Coffee")], RULES), + "food: -7.50\n\nclosing balance: -7.50", + ) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python3 -m unittest test_report -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.report'` + +- [ ] **Step 3: Write minimal implementation** + +Create `ledgerlite/report.py`: + +```python +"""Per-category totals and report formatting.""" + +from decimal import Decimal + +from ledgerlite.balance import closing_balance +from ledgerlite.model import Transaction +from ledgerlite.rules import categorize + +UNCATEGORIZED = "uncategorized" +ZERO = Decimal("0") + + +def format_amount(value: Decimal) -> str: + """Format an amount with exactly two fractional digits, no separators.""" + if value == ZERO: + value = abs(value) # a -0.00 total is not negative + return f"{value:.2f}" + + +def category_totals( + transactions: list[Transaction], rules: list[tuple[str, str]] +) -> list[tuple[str, Decimal]]: + """Total each category: alphabetical, with `uncategorized` always last.""" + totals: dict[str, Decimal] = {} + for txn in transactions: + name = categorize(txn.description, rules) or UNCATEGORIZED + totals[name] = totals.get(name, ZERO) + txn.amount + ordered = sorted((name, total) for name, total in totals.items() if name != UNCATEGORIZED) + if UNCATEGORIZED in totals: + ordered.append((UNCATEGORIZED, totals[UNCATEGORIZED])) + return ordered + + +def format_report( + transactions: list[Transaction], + rules: list[tuple[str, str]], + opening: Decimal = ZERO, +) -> str: + """Render the whole report, without a trailing newline.""" + lines = [ + f"{name}: {format_amount(total)}" + for name, total in category_totals(transactions, rules) + ] + if lines: + lines.append("") + lines.append(f"closing balance: {format_amount(closing_balance(transactions, opening))}") + return "\n".join(lines) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python3 -m unittest test_report -v` +Expected: PASS (10 tests) + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/report.py test_report.py +git commit -m "feat: add category totals and report formatting" +``` + +--- + +### Task 7: CLI, exit codes, and entry point + +**Files:** +- Create: `ledgerlite/cli.py` +- Create: `ledgerlite/__main__.py` +- Test: `test_cli.py` + +**Interfaces:** +- Consumes: `parse_amount`, `parse_transactions`, `ParseError` (Tasks 2–3), `parse_rules` (Task 4), `format_report` (Task 6). +- Produces: `ledgerlite.cli.main(argv: list[str] | None = None) -> int`. +- Behavior decisions this task locks in: + - Files are read as UTF-8 with a BOM tolerated (`encoding="utf-8-sig"`). + - Read failures (exit 1) are reported before row parsing (exit 2), and the rules file is read before the transactions file is parsed, so an unreadable rules file wins over a malformed row. + - `--opening` goes through `parse_amount`, so it accepts the same forms as an amount column; a bad value is an argparse usage error. + +- [ ] **Step 1: Write the failing test** + +Create `test_cli.py`: + +```python +import contextlib +import io +import os +import tempfile +import unittest + +from ledgerlite.cli import main + +TRANSACTIONS = ( + "date,amount,description\n" + "2026-03-06,2500.00,ACME Payroll\n" + "2026-03-04,-7.50,Blue Bottle Coffee\n" + "2026-03-05,-900.00,Rent March\n" +) +RULES = "coffee=food\nrent=housing\n" + + +class CliTestCase(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + + def write(self, name, text): + path = os.path.join(self.tmp.name, name) + with open(path, "w", encoding="utf-8") as handle: + handle.write(text) + return path + + def run_main(self, argv): + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = main(argv) + return code, out.getvalue(), err.getvalue() + + +class ReportSuccessTest(CliTestCase): + def test_prints_report_and_returns_zero(self): + txns = self.write("txns.csv", TRANSACTIONS) + rules = self.write("rules.txt", RULES) + code, out, err = self.run_main(["report", txns, "--rules", rules, "--opening", "100"]) + self.assertEqual(code, 0) + self.assertEqual(err, "") + self.assertEqual( + out, + "food: -7.50\nhousing: -900.00\nuncategorized: 2500.00\n\nclosing balance: 1692.50\n", + ) + + def test_without_rules_everything_is_uncategorized(self): + txns = self.write("txns.csv", TRANSACTIONS) + code, out, _ = self.run_main(["report", txns]) + self.assertEqual(code, 0) + self.assertEqual(out, "uncategorized: 1592.50\n\nclosing balance: 1592.50\n") + + def test_opening_defaults_to_zero(self): + txns = self.write("txns.csv", "date,amount,description\n2026-03-04,-7.50,Coffee\n") + code, out, _ = self.run_main(["report", txns]) + self.assertEqual(code, 0) + self.assertEqual(out, "uncategorized: -7.50\n\nclosing balance: -7.50\n") + + def test_tolerates_utf8_bom(self): + path = os.path.join(self.tmp.name, "bom.csv") + with open(path, "w", encoding="utf-8-sig") as handle: + handle.write("date,amount,description\n2026-03-04,-7.50,Coffee\n") + code, out, err = self.run_main(["report", path]) + self.assertEqual((code, err), (0, "")) + self.assertIn("closing balance: -7.50", out) + + +class ReadFailureTest(CliTestCase): + def test_missing_transactions_file_returns_one(self): + missing = os.path.join(self.tmp.name, "nope.csv") + code, out, err = self.run_main(["report", missing]) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertEqual(err, f"ledgerlite: cannot read {missing}: No such file or directory\n") + + def test_directory_as_transactions_file_returns_one(self): + code, out, err = self.run_main(["report", self.tmp.name]) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertTrue(err.startswith(f"ledgerlite: cannot read {self.tmp.name}: ")) + + # Review Focus 5: the spec never mentions this file failing to open. + def test_missing_rules_file_returns_one(self): + txns = self.write("txns.csv", TRANSACTIONS) + missing = os.path.join(self.tmp.name, "nope.txt") + code, out, err = self.run_main(["report", txns, "--rules", missing]) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertEqual(err, f"ledgerlite: cannot read {missing}: No such file or directory\n") + + +class MalformedRowTest(CliTestCase): + def test_malformed_row_returns_two_and_prints_nothing_to_stdout(self): + txns = self.write( + "txns.csv", + "date,amount,description\n2026-03-04,-7.50,Coffee\n2026-03-05,1.005,Rent\n", + ) + code, out, err = self.run_main(["report", txns]) + self.assertEqual(code, 2) + self.assertEqual(out, "") + self.assertEqual(err, f"ledgerlite: {txns}:3: invalid amount: '1.005'\n") + + def test_empty_file_returns_two(self): + txns = self.write("empty.csv", "") + code, out, err = self.run_main(["report", txns]) + self.assertEqual(code, 2) + self.assertEqual(out, "") + self.assertEqual(err, f"ledgerlite: {txns}:1: missing header row\n") + + def test_unreadable_rules_file_wins_over_malformed_row(self): + txns = self.write("txns.csv", "date,amount,description\n2026-03-04,nope,Coffee\n") + missing = os.path.join(self.tmp.name, "nope.txt") + code, _, err = self.run_main(["report", txns, "--rules", missing]) + self.assertEqual(code, 1) + self.assertIn("cannot read", err) + + +class UsageTest(CliTestCase): + def test_bad_opening_value_is_a_usage_error(self): + txns = self.write("txns.csv", TRANSACTIONS) + with contextlib.redirect_stderr(io.StringIO()) as err: + with self.assertRaises(SystemExit) as caught: + main(["report", txns, "--opening", "1.005"]) + self.assertEqual(caught.exception.code, 2) + self.assertIn("invalid amount", err.getvalue()) + + def test_missing_subcommand_is_a_usage_error(self): + with contextlib.redirect_stderr(io.StringIO()): + with self.assertRaises(SystemExit): + main([]) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python3 -m unittest test_cli -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.cli'` + +- [ ] **Step 3: Write minimal implementation** + +Create `ledgerlite/cli.py`: + +```python +"""Command-line entry point.""" + +import argparse +import sys +from decimal import Decimal + +from ledgerlite.parse import ParseError, parse_amount, parse_transactions +from ledgerlite.report import format_report +from ledgerlite.rules import parse_rules + +PROG = "ledgerlite" + + +class _ReadError(Exception): + """A file could not be read.""" + + def __init__(self, path: str, reason: str) -> None: + super().__init__(reason) + self.path = path + self.reason = reason + + +def _read_text(path: str) -> str: + try: + with open(path, encoding="utf-8-sig") as handle: + return handle.read() + except OSError as exc: + raise _ReadError(path, exc.strerror or str(exc)) from None + except UnicodeDecodeError: + raise _ReadError(path, "not valid UTF-8 text") from None + + +def _opening_amount(text: str) -> Decimal: + try: + return parse_amount(text) + except ValueError as exc: + raise argparse.ArgumentTypeError(str(exc)) from None + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog=PROG) + subcommands = parser.add_subparsers(dest="command", required=True) + report = subcommands.add_parser("report", help="print a per-category summary") + report.add_argument("transactions", help="path to the transactions CSV") + report.add_argument("--rules", help="path to the rules file") + report.add_argument( + "--opening", + type=_opening_amount, + default=Decimal("0"), + help="opening balance (default: 0)", + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + """Run the CLI. Returns the process exit code.""" + args = _build_parser().parse_args(argv) + + try: + transactions_text = _read_text(args.transactions) + rules_text = _read_text(args.rules) if args.rules is not None else "" + except _ReadError as exc: + print(f"{PROG}: cannot read {exc.path}: {exc.reason}", file=sys.stderr) + return 1 + + try: + transactions = parse_transactions(transactions_text) + except ParseError as exc: + print(f"{PROG}: {args.transactions}:{exc.line}: {exc.message}", file=sys.stderr) + return 2 + + print(format_report(transactions, parse_rules(rules_text), args.opening)) + return 0 +``` + +Create `ledgerlite/__main__.py`: + +```python +"""Run the CLI with `python3 -m ledgerlite`.""" + +import sys + +from ledgerlite.cli import main + +if __name__ == "__main__": + sys.exit(main()) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python3 -m unittest test_cli -v` +Expected: PASS (12 tests) + +- [ ] **Step 5: Run the whole suite and the real command** + +Run: `python3 -m unittest -v` +Expected: PASS, all tests from all five test modules, no errors. + +Then exercise the real entry point end to end: + +```bash +printf 'date,amount,description\n2026-03-06,2500.00,ACME Payroll\n2026-03-04,-7.50,Blue Bottle Coffee\n2026-03-05,-900.00,Rent March\n' > /tmp/ledgerlite-txns.csv +printf 'coffee=food\nrent=housing\n' > /tmp/ledgerlite-rules.txt +python3 -m ledgerlite report /tmp/ledgerlite-txns.csv --rules /tmp/ledgerlite-rules.txt --opening 100 +echo "exit: $?" +``` + +Expected, matching the spec example exactly: + +``` +food: -7.50 +housing: -900.00 +uncategorized: 2500.00 + +closing balance: 1692.50 +exit: 0 +``` + +- [ ] **Step 6: Commit** + +```bash +git add ledgerlite/cli.py ledgerlite/__main__.py test_cli.py +git commit -m "feat: add ledgerlite report CLI" +``` + +--- + +### Task 8: README + +**Files:** +- Create: `README.md` + +**Interfaces:** +- Consumes: the CLI from Task 7. Produces nothing other tasks use. + +- [ ] **Step 1: Write the README** + +Create `README.md`: + +```markdown +# ledgerlite + +Summarize a CSV of bank transactions by category. Standard library only, +Python 3.11+. + +## Usage + + python3 -m ledgerlite report TRANSACTIONS [--rules RULES] [--opening AMOUNT] + +`TRANSACTIONS` is a CSV with a `date,amount,description` header row. Dates +are ISO 8601 (`2026-03-04`); amounts are decimal with at most two +fractional digits, negative for money out. + +`RULES` is a text file of `=` lines. Matching is +case-insensitive on the description and the first matching rule wins; +transactions matching no rule are reported as `uncategorized`. + +## Example + + $ cat txns.csv + date,amount,description + 2026-03-06,2500.00,ACME Payroll + 2026-03-04,-7.50,Blue Bottle Coffee + 2026-03-05,-900.00,Rent March + $ cat rules.txt + coffee=food + rent=housing + $ python3 -m ledgerlite report txns.csv --rules rules.txt --opening 100 + food: -7.50 + housing: -900.00 + uncategorized: 2500.00 + + closing balance: 1692.50 + +## Exit codes + +| Code | Meaning | +| ---- | ------- | +| 0 | Report printed. | +| 1 | A file could not be read. | +| 2 | The transactions file has a malformed row; the whole file is rejected. | + +## Tests + + python3 -m unittest +``` + +- [ ] **Step 2: Verify the documented example still works** + +Run the example from Task 7 Step 5 again and confirm the output matches the README byte for byte. + +- [ ] **Step 3: Commit** + +```bash +git add README.md +git commit -m "docs: add README" +``` diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/Q2-reader-recipe-cosmic-31/2026-09-17-cosmic-tetris-1-engine-and-playable-terminal.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/Q2-reader-recipe-cosmic-31/2026-09-17-cosmic-tetris-1-engine-and-playable-terminal.md new file mode 100644 index 000000000..c8da47415 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/Q2-reader-recipe-cosmic-31/2026-09-17-cosmic-tetris-1-engine-and-playable-terminal.md @@ -0,0 +1,3057 @@ +# Cosmic Tetris — Plan 1: Engine and Playable Terminal Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the deterministic headless tetromino engine and a genuinely good, effect-free Bubble Tea terminal game on top of it — playable from first piece through game over, with responsive controls, hold, ghost, next queue, pause, restart, and live resize. + +**Architecture:** `internal/game` is a pure, clock-free state machine: every mutation is a method that returns `[]Event`, and time enters only as a `dt` argument to `Advance`. `internal/render` turns a `*game.Game` plus terminal dimensions into a string by painting onto a small styled cell grid (`Canvas`), which later becomes the compositing surface for effects. `internal/app` is the Bubble Tea layer: one 60 Hz frame clock, immediate key handling, and no game logic of its own. + +**Tech Stack:** Go 1.24, `charm.land/bubbletea/v2`, `charm.land/lipgloss/v2`, `charm.land/bubbles/v2`, `math/rand` (v1, for reproducible `rand.New(rand.NewSource(seed))`). + +**Spec:** `design.md` (this plan implements §1–§13, §31–§38, §40–§42 phases 1–2, §46, §49) + +## Global Constraints + +- Module path: `cosmic-tetris`. Go directive: `go 1.24`. +- Imports are exactly `charm.land/bubbletea/v2`, `charm.land/lipgloss/v2`, `charm.land/bubbles/v2` (§3). No other third-party dependencies. +- Nothing under `internal/game` may import `time` for anything but the `time.Duration` type, and nothing under `internal/game` may call `time.Now()` (§49.2). +- `internal/game` must not import `internal/render`, `internal/app`, `internal/fx`, or any Charm package. The engine is headless. +- Rendering must never mutate game state (§37). Render functions take `*game.Game` and treat it as read-only. +- The `Game` RNG (`rand.New(rand.NewSource(seed))`) drives the 7-bag and nothing else (§49.6). +- Board geometry: width 10, height 22, visible rows 20, hidden spawn rows 2 (§5). One logical cell renders as 2 terminal columns × 1 terminal row. +- Glyphs (§49.4): blocks `██` (ASCII `[]`), ghost `░░` (ASCII `··`). Active piece renders one step brighter than locked cells. +- Combo bonus is `50 × (combo - 1) × level`; first clearing placement sets combo to 1 (§49.1). +- Minimum usable terminal is 40 columns × 24 rows; below that show the too-small notice (§31). Never crash from resizing. +- Do not create files beyond the tree in §33 unless this plan names them. This plan names two additions and says why: `internal/game/events.go` (the event vocabulary the engine emits; `fx/events.go` in §33 is the FX-side consumer, added in Plan 2) and `internal/render/canvas.go` (the cell grid every render step paints onto — §37's "composite" steps need a surface). +- Do not build: networking, profiles, achievements, plugins, persistence (§2). +- Every task ends with a commit. Run `gofmt -l .` before each commit; it must print nothing. + +## Review Focus + +These are input classes the spec implies but never names a test for. Each one has a test added to the task that owns the code. + +1. **A frame arrives after a long stall** (laptop sleep, SIGSTOP, a slow terminal): `Advance(5s)` at level 1 would run ~6 gravity steps, but at level 15 it is hundreds, and a catch-up avalanche silently teleports and locks pieces the player never saw. The engine must handle any `dt` correctly, and the app must clamp per-frame `dt` so a stall costs at most one visible drop burst. — Task 8 (engine), Task 15 (clamp). +2. **Resize to degenerate dimensions**: `WindowSizeMsg{Width: 0, Height: 0}` or 1×1 arrives during play (some terminals emit it while dragging, and tmux emits it on detach). Layout must return the too-small mode without allocating a negative-sized canvas or indexing past a row. — Task 12. +3. **Keys pressed in states that do not take them**: movement during pause, `c` after game over, `space` while the game-over panel is up. Each must be ignored without mutating the game — not silently applied to a dead board. — Task 16. +4. **Hard drop on an already-grounded piece**: distance is 0, so the drop must award 0 points (not negative), still lock the piece, and still emit `EventPieceHardDropped` so effects fire. — Task 9. +5. **A very large terminal** (e.g. 400×120): the board must stay 20×20 cells and centered rather than stretching, and the canvas must not be allocated per-cell-per-frame in a way that scales with unused area. — Task 12. + +--- + +## File Structure + +```text +cosmic-tetris/ +├── cmd/cosmic-tetris/main.go flag parsing, program start +├── internal/game/ +│ ├── board.go Board, cells, bounds, occupancy, row clearing +│ ├── piece.go PieceKind, Piece, rotation shape tables +│ ├── bag.go 7-bag generator +│ ├── rules.go constants, DropInterval, TryRotate, DropDistance +│ ├── scoring.go LineScore, ComboBonus, LevelForLines +│ ├── events.go EventKind, Event +│ └── game.go Game, New, Advance, input methods, Restart +├── internal/render/ +│ ├── canvas.go styled cell grid + String() +│ ├── palette.go Mode, Palette, Glyphs +│ ├── layout.go Rect, Layout, Compute +│ ├── board.go board frame, locked cells, ghost, active piece +│ ├── hud.go HOLD, NEXT, stats, controls, overlays +│ └── render.go Frame(): the §37 pipeline, minus FX steps +├── internal/app/ +│ ├── keys.go KeyMap (bubbles/key) +│ ├── messages.go FrameMsg +│ ├── model.go Model, Init, View +│ └── update.go Update +├── go.mod +├── README.md +└── LICENSE +``` + +`internal/flavor` and `internal/fx` arrive in Plan 2. + +--- + +### Task 1: Repo scaffold and Board geometry + +**Files:** +- Create: `go.mod`, `internal/game/board.go` +- Test: `internal/game/board_test.go` + +**Interfaces:** +- Consumes: nothing. +- Produces: `game.Width = 10`, `game.Height = 22`, `game.VisibleRows = 20`, `game.HiddenRows = 2`, `type Cell uint8`, `const Empty Cell = 0`, `type Board struct { Cells [Height][Width]Cell }`, `func (b *Board) At(x, y int) Cell`, `func (b *Board) Set(x, y int, c Cell)`, `func (b *Board) Blocked(x, y int) bool`. + +Coordinates: `y = 0` is the top hidden row, `y = 21` the bottom visible row. Visible rows are `y = 2..21`. `x = 0` is the left column. + +- [ ] **Step 1: Initialize the module** + +```bash +go mod init cosmic-tetris +``` + +- [ ] **Step 2: Write the failing test** + +```go +package game + +import "testing" + +func TestBoardStartsEmpty(t *testing.T) { + var b Board + for y := 0; y < Height; y++ { + for x := 0; x < Width; x++ { + if b.At(x, y) != Empty { + t.Fatalf("cell (%d,%d) = %v, want Empty", x, y, b.At(x, y)) + } + } + } +} + +func TestBoardGeometryConstants(t *testing.T) { + if Width != 10 || Height != 22 || VisibleRows != 20 || HiddenRows != 2 { + t.Fatalf("geometry = %d,%d,%d,%d; want 10,22,20,2", Width, Height, VisibleRows, HiddenRows) + } + if HiddenRows+VisibleRows != Height { + t.Fatal("hidden + visible must equal Height") + } +} + +func TestBlockedOutsideWallsAndFloor(t *testing.T) { + var b Board + cases := []struct { + x, y int + want bool + }{ + {-1, 5, true}, // left wall + {Width, 5, true}, // right wall + {3, Height, true}, // floor + {3, -1, false}, // above the board is free space + {3, 5, false}, // empty cell + } + for _, c := range cases { + if got := b.Blocked(c.x, c.y); got != c.want { + t.Errorf("Blocked(%d,%d) = %v, want %v", c.x, c.y, got, c.want) + } + } +} + +func TestBlockedOnOccupiedCell(t *testing.T) { + var b Board + b.Set(4, 20, Cell(1)) + if !b.Blocked(4, 20) { + t.Fatal("occupied cell must be blocked") + } + if b.Blocked(4, 19) { + t.Fatal("empty cell above must not be blocked") + } +} +``` + +- [ ] **Step 3: Run the test to verify it fails** + +Run: `go test ./internal/game/ -run 'TestBoard|TestBlocked' -v` +Expected: FAIL — undefined: `Board`, `Width`, `At`, `Blocked`. + +- [ ] **Step 4: Implement `board.go`** + +`Blocked` returns true when `x` is outside `[0, Width)`, when `y >= Height`, or when `At(x, y) != Empty`. A negative `y` is free space, so pieces can spawn and rotate partly above the board. `At` returns `Empty` for any out-of-range coordinate; `Set` ignores out-of-range coordinates. + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `go test ./internal/game/ -run 'TestBoard|TestBlocked' -v` +Expected: PASS (4 tests). + +- [ ] **Step 6: Commit** + +```bash +gofmt -l . && git add go.mod internal/game/board.go internal/game/board_test.go +git commit -m "feat(game): board geometry, cell access, and occupancy" +``` + +--- + +### Task 2: Pieces and rotation shapes + +**Files:** +- Create: `internal/game/piece.go` +- Test: `internal/game/piece_test.go` + +**Interfaces:** +- Consumes: `Width` from Task 1. +- Produces: `type PieceKind uint8`, `const (I PieceKind = iota; J; L; O; S; T; Z)`, `var AllKinds = [7]PieceKind{I, J, L, O, S, T, Z}`, `func (k PieceKind) String() string` (returns `"I"`…`"Z"`), `type Piece struct { Kind PieceKind; Rotation, X, Y int }`, `func Shape(k PieceKind, rotation int) [4][2]int` (offsets within a 4×4 box, `[dx, dy]`), `func (p Piece) Cells() [4][2]int` (absolute board coordinates), `func Spawn(k PieceKind) Piece`. + +- [ ] **Step 1: Write the failing test** + +```go +package game + +import "testing" + +func TestShapeHasFourDistinctCellsInBox(t *testing.T) { + for _, k := range AllKinds { + for rot := 0; rot < 4; rot++ { + seen := map[[2]int]bool{} + for _, c := range Shape(k, rot) { + if c[0] < 0 || c[0] > 3 || c[1] < 0 || c[1] > 3 { + t.Errorf("%v rot %d: cell %v outside 4x4 box", k, rot, c) + } + if seen[c] { + t.Errorf("%v rot %d: duplicate cell %v", k, rot, c) + } + seen[c] = true + } + } + } +} + +func TestShapeRotationWrapsAndAcceptsNegative(t *testing.T) { + for _, k := range AllKinds { + if Shape(k, 4) != Shape(k, 0) { + t.Errorf("%v: rotation 4 must equal rotation 0", k) + } + if Shape(k, -1) != Shape(k, 3) { + t.Errorf("%v: rotation -1 must equal rotation 3", k) + } + } +} + +func TestOIsRotationInvariant(t *testing.T) { + for rot := 1; rot < 4; rot++ { + if Shape(O, rot) != Shape(O, 0) { + t.Errorf("O rotation %d differs from rotation 0", rot) + } + } +} + +func TestIPieceRotationsAreHorizontalAndVertical(t *testing.T) { + if Shape(I, 0) != [4][2]int{{0, 1}, {1, 1}, {2, 1}, {3, 1}} { + t.Errorf("I rot 0 = %v", Shape(I, 0)) + } + if Shape(I, 1) != [4][2]int{{2, 0}, {2, 1}, {2, 2}, {2, 3}} { + t.Errorf("I rot 1 = %v", Shape(I, 1)) + } +} + +func TestTPieceAllRotations(t *testing.T) { + want := [4][4][2]int{ + {{1, 0}, {0, 1}, {1, 1}, {2, 1}}, + {{1, 0}, {1, 1}, {2, 1}, {1, 2}}, + {{0, 1}, {1, 1}, {2, 1}, {1, 2}}, + {{1, 0}, {0, 1}, {1, 1}, {1, 2}}, + } + for rot := 0; rot < 4; rot++ { + if Shape(T, rot) != want[rot] { + t.Errorf("T rot %d = %v, want %v", rot, Shape(T, rot), want[rot]) + } + } +} + +func TestCellsAreShapeTranslatedByXY(t *testing.T) { + p := Piece{Kind: T, Rotation: 0, X: 4, Y: 7} + want := [4][2]int{{5, 7}, {4, 8}, {5, 8}, {6, 8}} + if p.Cells() != want { + t.Fatalf("Cells() = %v, want %v", p.Cells(), want) + } +} + +func TestSpawnPositionIsTopCenter(t *testing.T) { + for _, k := range AllKinds { + p := Spawn(k) + if p.X != 3 || p.Y != 0 || p.Rotation != 0 || p.Kind != k { + t.Errorf("Spawn(%v) = %+v, want X=3 Y=0 Rotation=0", k, p) + } + for _, c := range p.Cells() { + if c[0] < 0 || c[0] >= Width { + t.Errorf("Spawn(%v) cell %v outside board width", k, c) + } + if c[1] >= HiddenRows { + t.Errorf("Spawn(%v) cell %v is not in the hidden rows", k, c) + } + } + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/game/ -run 'TestShape|TestO|TestI|TestT|TestCells|TestSpawn' -v` +Expected: FAIL — undefined: `Shape`, `Piece`, `Spawn`. + +- [ ] **Step 3: Implement `piece.go` with these exact shape tables** + +`Shape` normalizes rotation with `((rotation % 4) + 4) % 4` and indexes this table. `Cells` adds `p.X`/`p.Y` to each offset. `Spawn` returns `Piece{Kind: k, Rotation: 0, X: 3, Y: 0}` — `X = 3` centers the 4-wide box in a 10-wide board. + +```go +var shapes = [7][4][4][2]int{ + I: { + {{0, 1}, {1, 1}, {2, 1}, {3, 1}}, + {{2, 0}, {2, 1}, {2, 2}, {2, 3}}, + {{0, 2}, {1, 2}, {2, 2}, {3, 2}}, + {{1, 0}, {1, 1}, {1, 2}, {1, 3}}, + }, + J: { + {{0, 0}, {0, 1}, {1, 1}, {2, 1}}, + {{1, 0}, {2, 0}, {1, 1}, {1, 2}}, + {{0, 1}, {1, 1}, {2, 1}, {2, 2}}, + {{1, 0}, {1, 1}, {0, 2}, {1, 2}}, + }, + L: { + {{2, 0}, {0, 1}, {1, 1}, {2, 1}}, + {{1, 0}, {1, 1}, {1, 2}, {2, 2}}, + {{0, 1}, {1, 1}, {2, 1}, {0, 2}}, + {{0, 0}, {1, 0}, {1, 1}, {1, 2}}, + }, + O: { + {{1, 0}, {2, 0}, {1, 1}, {2, 1}}, + {{1, 0}, {2, 0}, {1, 1}, {2, 1}}, + {{1, 0}, {2, 0}, {1, 1}, {2, 1}}, + {{1, 0}, {2, 0}, {1, 1}, {2, 1}}, + }, + S: { + {{1, 0}, {2, 0}, {0, 1}, {1, 1}}, + {{1, 0}, {1, 1}, {2, 1}, {2, 2}}, + {{1, 1}, {2, 1}, {0, 2}, {1, 2}}, + {{0, 0}, {0, 1}, {1, 1}, {1, 2}}, + }, + T: { + {{1, 0}, {0, 1}, {1, 1}, {2, 1}}, + {{1, 0}, {1, 1}, {2, 1}, {1, 2}}, + {{0, 1}, {1, 1}, {2, 1}, {1, 2}}, + {{1, 0}, {0, 1}, {1, 1}, {1, 2}}, + }, + Z: { + {{0, 0}, {1, 0}, {1, 1}, {2, 1}}, + {{2, 0}, {1, 1}, {2, 1}, {1, 2}}, + {{0, 1}, {1, 1}, {1, 2}, {2, 2}}, + {{1, 0}, {0, 1}, {1, 1}, {0, 2}}, + }, +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `go test ./internal/game/ -run 'TestShape|TestO|TestI|TestT|TestCells|TestSpawn' -v` +Expected: PASS (7 tests). + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . && git add internal/game/piece.go internal/game/piece_test.go +git commit -m "feat(game): tetromino kinds, rotation tables, and spawn position" +``` + +--- + +### Task 3: Fit, wall kicks, and drop distance + +**Files:** +- Create: `internal/game/rules.go` +- Test: `internal/game/rules_test.go` + +**Interfaces:** +- Consumes: `Board.Blocked`, `Piece.Cells`, `Shape` from Tasks 1–2. +- Produces: `func (b *Board) Fits(p Piece) bool`, `var KickOffsets = [8][2]int{{0,0},{-1,0},{1,0},{-2,0},{2,0},{0,-1},{-1,-1},{1,-1}}`, `func TryRotate(b *Board, p Piece, dir int) (Piece, bool)` (`dir` is `+1` clockwise, `-1` counter-clockwise), `func DropDistance(b *Board, p Piece) int`, and the timing constants: `BaseDropInterval = 800 * time.Millisecond`, `MinDropInterval = 60 * time.Millisecond`, `GravityFactor = 0.86`, `LockDelay = 500 * time.Millisecond`, `MaxLockResets = 15`, `LinesPerLevel = 10`, `NextQueueLen = 5`, plus `func DropInterval(level int) time.Duration`. + +- [ ] **Step 1: Write the failing test** + +```go +package game + +import ( + "testing" + "time" +) + +func TestFitsRejectsWallsFloorAndOccupied(t *testing.T) { + var b Board + if b.Fits(Piece{Kind: O, X: -2, Y: 10}) { + t.Error("piece off the left edge must not fit") + } + if b.Fits(Piece{Kind: O, X: Width - 1, Y: 10}) { + t.Error("piece off the right edge must not fit") + } + if b.Fits(Piece{Kind: O, X: 4, Y: Height - 1}) { + t.Error("piece through the floor must not fit") + } + if !b.Fits(Piece{Kind: O, X: 4, Y: 10}) { + t.Error("piece in open space must fit") + } + b.Set(5, 11, Cell(1)) + if b.Fits(Piece{Kind: O, X: 4, Y: 10}) { + t.Error("piece overlapping a locked cell must not fit") + } +} + +func TestFitsAllowsCellsAboveTheBoard(t *testing.T) { + var b Board + if !b.Fits(Piece{Kind: I, Rotation: 1, X: 3, Y: -2}) { + t.Fatal("a piece sticking out above the board must fit") + } +} + +func TestRotateInOpenSpaceUsesZeroOffset(t *testing.T) { + var b Board + p := Piece{Kind: T, Rotation: 0, X: 4, Y: 10} + got, ok := TryRotate(&b, p, 1) + if !ok { + t.Fatal("rotation in open space must succeed") + } + if got.Rotation != 1 || got.X != 4 || got.Y != 10 { + t.Fatalf("got %+v, want rotation 1 at (4,10)", got) + } +} + +func TestRotateCounterClockwiseWrapsToThree(t *testing.T) { + var b Board + got, ok := TryRotate(&b, Piece{Kind: T, Rotation: 0, X: 4, Y: 10}, -1) + if !ok || got.Rotation != 3 { + t.Fatalf("got %+v ok=%v, want rotation 3", got, ok) + } +} + +func TestRotateKicksOffTheRightWall(t *testing.T) { + var b Board + // I at rotation 1 occupies column X+2; place it so rotating to horizontal + // would need columns X..X+3 and overflow the right edge. + p := Piece{Kind: I, Rotation: 1, X: 7, Y: 10} + if !b.Fits(p) { + t.Fatal("precondition: vertical I must fit at X=7") + } + got, ok := TryRotate(&b, p, 1) + if !ok { + t.Fatal("rotation must succeed via a wall kick") + } + if got.X >= 7 { + t.Fatalf("expected a leftward kick, got X=%d", got.X) + } + if !b.Fits(got) { + t.Fatal("kicked result must fit") + } +} + +func TestRotateKickOrderPrefersEarlierOffsets(t *testing.T) { + var b Board + // Block the (0,0) target so the first offset fails and (-1,0) is taken. + p := Piece{Kind: T, Rotation: 0, X: 4, Y: 10} + target, _ := TryRotate(&b, p, 1) // learn the un-kicked cells + for _, c := range target.Cells() { + if c != [2]int{5, 10} && c != [2]int{5, 11} && c != [2]int{6, 11} { + continue + } + } + b.Set(6, 11, Cell(1)) // occupies a cell of T rotation 1 at X=4 + got, ok := TryRotate(&b, p, 1) + if !ok { + t.Fatal("rotation must succeed via a kick") + } + if got.X != 3 { + t.Fatalf("expected the (-1,0) kick to X=3, got X=%d", got.X) + } +} + +func TestRotateFailsWhenNoOffsetFits(t *testing.T) { + var b Board + // Fill every row the T piece could reach, leaving only its current cells. + p := Piece{Kind: T, Rotation: 0, X: 4, Y: 19} + occupied := map[[2]int]bool{} + for _, c := range p.Cells() { + occupied[c] = true + } + for y := 16; y < Height; y++ { + for x := 0; x < Width; x++ { + if !occupied[[2]int{x, y}] { + b.Set(x, y, Cell(1)) + } + } + } + if _, ok := TryRotate(&b, p, 1); ok { + t.Fatal("rotation with no valid offset must fail") + } +} + +func TestDropDistanceToFloorAndOntoStack(t *testing.T) { + var b Board + p := Piece{Kind: O, X: 4, Y: 0} // cells at rows 0 and 1 + if got := DropDistance(&b, p); got != Height-2 { + t.Errorf("DropDistance to floor = %d, want %d", got, Height-2) + } + b.Set(5, 21, Cell(1)) + if got := DropDistance(&b, p); got != Height-3 { + t.Errorf("DropDistance onto stack = %d, want %d", got, Height-3) + } +} + +func TestDropDistanceIsZeroWhenGrounded(t *testing.T) { + var b Board + p := Piece{Kind: O, X: 4, Y: Height - 2} + if got := DropDistance(&b, p); got != 0 { + t.Fatalf("DropDistance = %d, want 0", got) + } +} + +func TestDropIntervalCurveAndClamp(t *testing.T) { + if got := DropInterval(1); got != 800*time.Millisecond { + t.Errorf("level 1 = %v, want 800ms", got) + } + if got := DropInterval(2); got < 685*time.Millisecond || got > 690*time.Millisecond { + t.Errorf("level 2 = %v, want ~688ms (800 * 0.86)", got) + } + if got := DropInterval(40); got != MinDropInterval { + t.Errorf("level 40 = %v, want the %v clamp", got, MinDropInterval) + } + for level := 1; level < 60; level++ { + if DropInterval(level+1) > DropInterval(level) { + t.Fatalf("interval must be non-increasing at level %d", level) + } + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/game/ -run 'TestFits|TestRotate|TestDrop' -v` +Expected: FAIL — undefined: `Fits`, `TryRotate`, `DropDistance`, `DropInterval`. + +- [ ] **Step 3: Implement `rules.go`** + +`Fits` returns false if `Blocked` is true for any of the piece's cells. `TryRotate` builds the candidate `Piece{Kind, Rotation: p.Rotation + dir, X, Y}` and tries each entry of `KickOffsets` in order, returning the first candidate that `Fits`; on failure it returns the original piece and `false`. Rotation is stored normalized into `0..3`. `DropDistance` counts how many times `Y` can increase by 1 and still `Fit`. `DropInterval` computes `800ms × 0.86^(level-1)` in float64 and returns `max(result, MinDropInterval)`. + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `go test ./internal/game/ -run 'TestFits|TestRotate|TestDrop' -v` +Expected: PASS (10 tests). + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . && git add internal/game/rules.go internal/game/rules_test.go +git commit -m "feat(game): collision, wall kicks, drop distance, gravity curve" +``` + +--- + +### Task 4: The 7-bag + +**Files:** +- Create: `internal/game/bag.go` +- Test: `internal/game/bag_test.go` + +**Interfaces:** +- Consumes: `PieceKind`, `AllKinds` from Task 2. +- Produces: `type Bag struct { remaining []PieceKind }`, `func (b *Bag) Next(rng *rand.Rand) PieceKind`. + +The bag takes the RNG as an argument rather than owning one, because §49.6 puts RNG ownership on `Game`. + +- [ ] **Step 1: Write the failing test** + +```go +package game + +import ( + "math/rand" + "testing" +) + +func TestEachBagContainsAllSevenKindsOnce(t *testing.T) { + rng := rand.New(rand.NewSource(1)) + var bag Bag + for round := 0; round < 20; round++ { + counts := map[PieceKind]int{} + for i := 0; i < 7; i++ { + counts[bag.Next(rng)]++ + } + if len(counts) != 7 { + t.Fatalf("round %d: got %d distinct kinds, want 7 (%v)", round, len(counts), counts) + } + for _, k := range AllKinds { + if counts[k] != 1 { + t.Fatalf("round %d: kind %v appeared %d times, want 1", round, k, counts[k]) + } + } + } +} + +func TestSeededBagsAreReproducible(t *testing.T) { + draw := func(seed int64) []PieceKind { + rng := rand.New(rand.NewSource(seed)) + var bag Bag + out := make([]PieceKind, 0, 21) + for i := 0; i < 21; i++ { + out = append(out, bag.Next(rng)) + } + return out + } + a, b := draw(8675309), draw(8675309) + for i := range a { + if a[i] != b[i] { + t.Fatalf("same seed diverged at index %d: %v vs %v", i, a[i], b[i]) + } + } + if c := draw(42); len(c) == len(a) && func() bool { + for i := range a { + if a[i] != c[i] { + return false + } + } + return true + }() { + t.Fatal("different seeds produced an identical sequence") + } +} + +func TestBagShufflesRatherThanReturningCanonicalOrder(t *testing.T) { + rng := rand.New(rand.NewSource(7)) + var bag Bag + canonical := true + for i := 0; i < 7; i++ { + if bag.Next(rng) != AllKinds[i] { + canonical = false + } + } + if canonical { + t.Fatal("first bag came out in canonical order; is it shuffled?") + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/game/ -run TestBag -run 'TestEachBag|TestSeededBags|TestBagShuffles' -v` +Expected: FAIL — undefined: `Bag`. + +- [ ] **Step 3: Implement `bag.go`** + +When `remaining` is empty, refill it with `AllKinds` in canonical order and shuffle with `rng.Shuffle`. `Next` pops the last element. + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `go test ./internal/game/ -run 'TestEachBag|TestSeededBags|TestBagShuffles' -v` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . && git add internal/game/bag.go internal/game/bag_test.go +git commit -m "feat(game): seeded 7-bag piece generator" +``` + +--- + +### Task 5: Row completion, clearing, and collapse + +**Files:** +- Modify: `internal/game/board.go` +- Test: `internal/game/board_test.go` + +**Interfaces:** +- Consumes: `Board` from Task 1. +- Produces: `func (b *Board) CompleteRows() []int` (ascending row indices, `nil` when none), `func (b *Board) ClearRows(rows []int)`. + +- [ ] **Step 1: Write the failing test** + +```go +func fillRow(b *Board, y int, c Cell) { + for x := 0; x < Width; x++ { + b.Set(x, y, c) + } +} + +func TestCompleteRowsFindsFullRowsAscending(t *testing.T) { + var b Board + if got := b.CompleteRows(); len(got) != 0 { + t.Fatalf("empty board returned %v", got) + } + fillRow(&b, 21, Cell(1)) + fillRow(&b, 19, Cell(2)) + got := b.CompleteRows() + if len(got) != 2 || got[0] != 19 || got[1] != 21 { + t.Fatalf("CompleteRows() = %v, want [19 21]", got) + } +} + +func TestCompleteRowsIgnoresRowWithAGap(t *testing.T) { + var b Board + fillRow(&b, 21, Cell(1)) + b.Set(4, 21, Empty) + if got := b.CompleteRows(); len(got) != 0 { + t.Fatalf("row with a gap reported complete: %v", got) + } +} + +func TestClearRowsCollapsesRowsAbove(t *testing.T) { + var b Board + fillRow(&b, 21, Cell(1)) // full, will clear + b.Set(0, 20, Cell(7)) // lone marker above it + b.ClearRows([]int{21}) + if b.At(0, 21) != Cell(7) { + t.Errorf("marker did not fall to row 21: got %v", b.At(0, 21)) + } + if b.At(0, 20) != Empty { + t.Errorf("row 20 should be empty after collapse, got %v", b.At(0, 20)) + } +} + +func TestClearRowsHandlesFourAtOnce(t *testing.T) { + var b Board + for y := 18; y <= 21; y++ { + fillRow(&b, y, Cell(1)) + } + b.Set(3, 17, Cell(5)) + b.ClearRows([]int{18, 19, 20, 21}) + if b.At(3, 21) != Cell(5) { + t.Errorf("marker should land on row 21, got %v at (3,21)", b.At(3, 21)) + } + if len(b.CompleteRows()) != 0 { + t.Error("no complete rows should remain") + } + for y := 0; y <= 20; y++ { + for x := 0; x < Width; x++ { + if b.At(x, y) != Empty { + t.Fatalf("cell (%d,%d) should be empty after collapse", x, y) + } + } + } +} + +func TestClearRowsWithNonAdjacentRows(t *testing.T) { + var b Board + fillRow(&b, 21, Cell(1)) + fillRow(&b, 19, Cell(2)) + b.Set(0, 20, Cell(9)) + b.ClearRows([]int{19, 21}) + if b.At(0, 21) != Cell(9) { + t.Fatalf("row 20's marker should end at row 21, got %v", b.At(0, 21)) + } +} + +func TestClearRowsWithEmptySliceIsNoOp(t *testing.T) { + var b Board + b.Set(2, 15, Cell(3)) + b.ClearRows(nil) + if b.At(2, 15) != Cell(3) { + t.Fatal("ClearRows(nil) must not move anything") + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/game/ -run 'TestCompleteRows|TestClearRows' -v` +Expected: FAIL — undefined: `CompleteRows`, `ClearRows`. + +- [ ] **Step 3: Implement `CompleteRows` and `ClearRows` in `board.go`** + +`ClearRows` copies rows from bottom to top, skipping cleared rows, then blanks the leftover rows at the top. It must be correct for non-adjacent cleared rows, so a write cursor walking upward from `Height-1` is the natural shape. + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `go test ./internal/game/ -run 'TestCompleteRows|TestClearRows' -v` +Expected: PASS (6 tests). + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . && git add internal/game/board.go internal/game/board_test.go +git commit -m "feat(game): row completion detection and collapse" +``` + +--- + +### Task 6: Scoring and level progression + +**Files:** +- Create: `internal/game/scoring.go` +- Test: `internal/game/scoring_test.go` + +**Interfaces:** +- Produces: `func LineScore(lines, level int) int`, `func ComboBonus(combo, level int) int`, `func LevelForLines(lines int) int`, `const SoftDropPoints = 1`, `const HardDropPoints = 2`. + +- [ ] **Step 1: Write the failing test** + +```go +package game + +import "testing" + +func TestLineScoreBaseValues(t *testing.T) { + cases := []struct{ lines, level, want int }{ + {0, 1, 0}, + {1, 1, 100}, + {2, 1, 300}, + {3, 1, 500}, + {4, 1, 800}, + {1, 7, 700}, + {4, 13, 10400}, + } + for _, c := range cases { + if got := LineScore(c.lines, c.level); got != c.want { + t.Errorf("LineScore(%d,%d) = %d, want %d", c.lines, c.level, got, c.want) + } + } +} + +func TestComboBonusStartsAtComboTwo(t *testing.T) { + cases := []struct{ combo, level, want int }{ + {0, 5, 0}, + {1, 5, 0}, // a lone clear earns no combo bonus (§49.1) + {2, 1, 50}, + {2, 5, 250}, + {7, 3, 900}, + } + for _, c := range cases { + if got := ComboBonus(c.combo, c.level); got != c.want { + t.Errorf("ComboBonus(%d,%d) = %d, want %d", c.combo, c.level, got, c.want) + } + } +} + +func TestLevelForLines(t *testing.T) { + cases := []struct{ lines, want int }{ + {0, 1}, {9, 1}, {10, 2}, {19, 2}, {20, 3}, {127, 13}, + } + for _, c := range cases { + if got := LevelForLines(c.lines); got != c.want { + t.Errorf("LevelForLines(%d) = %d, want %d", c.lines, got, c.want) + } + } +} + +func TestDropPointConstants(t *testing.T) { + if SoftDropPoints != 1 || HardDropPoints != 2 { + t.Fatalf("drop points = %d,%d; want 1,2", SoftDropPoints, HardDropPoints) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/game/ -run 'TestLineScore|TestComboBonus|TestLevelForLines|TestDropPoint' -v` +Expected: FAIL — undefined: `LineScore`, `ComboBonus`, `LevelForLines`. + +- [ ] **Step 3: Implement `scoring.go`** + +Base values `{0, 100, 300, 500, 800}` indexed by line count, multiplied by level; `LineScore` returns 0 for any count outside `0..4`. `LevelForLines` is `lines/LinesPerLevel + 1`. `ComboBonus` returns 0 when `combo < 2`. + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `go test ./internal/game/ -run 'TestLineScore|TestComboBonus|TestLevelForLines|TestDropPoint' -v` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . && git add internal/game/scoring.go internal/game/scoring_test.go +git commit -m "feat(game): line, combo, drop, and level scoring rules" +``` + +--- + +### Task 7: Event vocabulary + +**Files:** +- Create: `internal/game/events.go` +- Test: `internal/game/events_test.go` + +**Interfaces:** +- Consumes: `Piece` from Task 2. +- Produces: + +```go +type EventKind uint8 + +const ( + EventPieceMoved EventKind = iota + EventPieceRotated + EventPieceHardDropped + EventPieceLocked + EventHoldUsed + EventLinesCleared + EventComboChanged + EventLevelChanged + EventGameOver +) + +// Event is what the engine tells the outside world happened. FX consume these +// and may never write back (§14). +type Event struct { + Kind EventKind + Piece Piece // the piece involved, where meaningful + Rows []int // EventLinesCleared: cleared rows, ascending + Distance int // EventPieceHardDropped: cells fallen + Value int // EventComboChanged: new combo. EventLevelChanged: new level. +} + +func (k EventKind) String() string +``` + +- [ ] **Step 1: Write the failing test** + +```go +package game + +import "testing" + +func TestEventKindStringsAreDistinctAndNamed(t *testing.T) { + kinds := []EventKind{ + EventPieceMoved, EventPieceRotated, EventPieceHardDropped, EventPieceLocked, + EventHoldUsed, EventLinesCleared, EventComboChanged, EventLevelChanged, EventGameOver, + } + seen := map[string]bool{} + for _, k := range kinds { + s := k.String() + if s == "" || seen[s] { + t.Fatalf("EventKind %d has empty or duplicate name %q", k, s) + } + seen[s] = true + } + if EventPieceMoved.String() != "PieceMoved" { + t.Errorf("EventPieceMoved.String() = %q, want %q", EventPieceMoved.String(), "PieceMoved") + } + if EventGameOver.String() != "GameOver" { + t.Errorf("EventGameOver.String() = %q, want %q", EventGameOver.String(), "GameOver") + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/game/ -run TestEventKind -v` +Expected: FAIL — undefined: `EventKind`. + +- [ ] **Step 3: Implement `events.go`** + +Names in `String()` match §14's list verbatim: `PieceMoved`, `PieceRotated`, `PieceHardDropped`, `PieceLocked`, `HoldUsed`, `LinesCleared`, `ComboChanged`, `LevelChanged`, `GameOver`. + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `go test ./internal/game/ -run TestEventKind -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . && git add internal/game/events.go internal/game/events_test.go +git commit -m "feat(game): event vocabulary emitted by the engine" +``` + +--- + +### Task 8: Game state, spawning, and player movement + +**Files:** +- Create: `internal/game/game.go` +- Test: `internal/game/game_test.go` + +**Interfaces:** +- Consumes: everything from Tasks 1–7. +- Produces: + +```go +type Game struct { + Board Board + Active Piece + Hold *PieceKind + CanHold bool + Next []PieceKind + Bag Bag + + Score, Lines, Level, Combo int + + GravityAccumulator time.Duration + LockAccumulator time.Duration + LockResets int + Grounded bool + Over bool + + Seed int64 + rng *rand.Rand +} + +func New(seed int64) *Game +func (g *Game) MoveLeft() []Event +func (g *Game) MoveRight() []Event +func (g *Game) RotateCW() []Event +func (g *Game) RotateCCW() []Event +func (g *Game) Ghost() Piece +func (g *Game) Restart() +``` + +`New` seeds `rng` with `rand.New(rand.NewSource(seed))`, records `Seed`, fills `Next` to `NextQueueLen`, spawns the first piece, sets `Level = 1` and `CanHold = true`. + +- [ ] **Step 1: Write the failing test** + +```go +package game + +import "testing" + +func TestNewGameInitialState(t *testing.T) { + g := New(99) + if g.Level != 1 || g.Score != 0 || g.Lines != 0 || g.Combo != 0 { + t.Errorf("stats = %d/%d/%d/%d, want level 1 and zeros", g.Level, g.Score, g.Lines, g.Combo) + } + if len(g.Next) != NextQueueLen { + t.Errorf("len(Next) = %d, want %d", len(g.Next), NextQueueLen) + } + if g.Hold != nil || !g.CanHold { + t.Error("a new game has no held piece and may hold") + } + if g.Over { + t.Error("a new game is not over") + } + if g.Seed != 99 { + t.Errorf("Seed = %d, want 99", g.Seed) + } + if !g.Board.Fits(g.Active) { + t.Error("the first piece must fit on an empty board") + } +} + +func TestMoveLeftAndRightEmitPieceMoved(t *testing.T) { + g := New(1) + startX := g.Active.X + evs := g.MoveLeft() + if g.Active.X != startX-1 { + t.Fatalf("X = %d, want %d", g.Active.X, startX-1) + } + if len(evs) != 1 || evs[0].Kind != EventPieceMoved { + t.Fatalf("events = %v, want one PieceMoved", evs) + } + g.MoveRight() + if g.Active.X != startX { + t.Fatalf("X = %d after moving back, want %d", g.Active.X, startX) + } +} + +func TestBlockedMoveChangesNothingAndEmitsNothing(t *testing.T) { + g := New(1) + for i := 0; i < 20; i++ { + g.MoveLeft() + } + before := g.Active + if evs := g.MoveLeft(); len(evs) != 0 { + t.Fatalf("blocked move emitted %v", evs) + } + if g.Active != before { + t.Fatalf("blocked move changed the piece: %+v -> %+v", before, g.Active) + } +} + +func TestRotateEmitsPieceRotatedAndFailureEmitsNothing(t *testing.T) { + g := New(1) + g.Active = Piece{Kind: T, Rotation: 0, X: 4, Y: 10} + evs := g.RotateCW() + if len(evs) != 1 || evs[0].Kind != EventPieceRotated { + t.Fatalf("events = %v, want one PieceRotated", evs) + } + if g.Active.Rotation != 1 { + t.Fatalf("Rotation = %d, want 1", g.Active.Rotation) + } + g.RotateCCW() + if g.Active.Rotation != 0 { + t.Fatalf("Rotation = %d after CCW, want 0", g.Active.Rotation) + } +} + +func TestGhostIsTheActivePieceAtItsLandingPosition(t *testing.T) { + g := New(1) + ghost := g.Ghost() + if ghost.Kind != g.Active.Kind || ghost.Rotation != g.Active.Rotation || ghost.X != g.Active.X { + t.Fatalf("ghost %+v does not match active %+v", ghost, g.Active) + } + if ghost.Y != g.Active.Y+DropDistance(&g.Board, g.Active) { + t.Fatalf("ghost Y = %d, want landing row %d", ghost.Y, g.Active.Y+DropDistance(&g.Board, g.Active)) + } + if !g.Board.Fits(ghost) { + t.Fatal("the ghost position must fit") + } +} + +func TestRestartResetsEverythingAndReusesTheSeed(t *testing.T) { + g := New(4242) + g.Score, g.Lines, g.Level, g.Combo = 500, 12, 3, 4 + fillRow(&g.Board, 21, Cell(1)) + g.Over = true + kind := T + g.Hold = &kind + g.Restart() + if g.Score != 0 || g.Lines != 0 || g.Level != 1 || g.Combo != 0 || g.Over { + t.Errorf("stats not reset: %+v", g) + } + if g.Hold != nil || !g.CanHold { + t.Error("hold not reset") + } + if len(g.Board.CompleteRows()) != 0 || g.Board.At(0, 21) != Empty { + t.Error("board not cleared") + } + fresh := New(4242) + if g.Active.Kind != fresh.Active.Kind { + t.Errorf("restart piece %v != fresh piece %v; restart must reuse the seed", g.Active.Kind, fresh.Active.Kind) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/game/ -run 'TestNewGame|TestMove|TestBlockedMove|TestRotateEmits|TestGhost|TestRestart' -v` +Expected: FAIL — undefined: `New`. + +- [ ] **Step 3: Implement `game.go`: `New`, `Restart`, `MoveLeft`, `MoveRight`, `RotateCW`, `RotateCCW`, `Ghost`, and unexported `spawnNext()`** + +`spawnNext` pops `Next[0]`, refills the queue from `Bag` to `NextQueueLen`, sets `Active = Spawn(kind)`, resets `GravityAccumulator`, `LockAccumulator`, `LockResets`, and `Grounded`, and sets `CanHold = true`. Movement and rotation succeed only when the result `Fits`; a successful one while `Grounded` also resets the lock timer (Task 9 adds that call, so leave a single helper `noteGroundedAction()` here that Task 9 fills in — or implement it now: if `Grounded && LockResets < MaxLockResets`, zero `LockAccumulator` and increment `LockResets`). Every method returns `nil` when `g.Over`. + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `go test ./internal/game/ -run 'TestNewGame|TestMove|TestBlockedMove|TestRotateEmits|TestGhost|TestRestart' -v` +Expected: PASS (6 tests). + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . && git add internal/game/game.go internal/game/game_test.go +git commit -m "feat(game): game state, spawning, movement, rotation, ghost, restart" +``` + +--- + +### Task 9: Gravity, locking, line clears, and game over + +**Files:** +- Modify: `internal/game/game.go` +- Test: `internal/game/game_test.go` + +**Interfaces:** +- Consumes: Task 8's `Game`. +- Produces: `func (g *Game) Advance(dt time.Duration) []Event`, `func (g *Game) SoftDrop() []Event`, `func (g *Game) HardDrop() []Event`, `func (g *Game) lock() []Event`. + +Advance is the only clock-facing method and it never calls `time.Now()` (§49.2). Its shape: + +```text +Advance(dt): + if Over: return nil + Grounded = !Fits(Active moved down 1) + if Grounded: + LockAccumulator += dt + if LockAccumulator >= LockDelay: return lock() + return nil + GravityAccumulator += dt + for GravityAccumulator >= DropInterval(Level): + GravityAccumulator -= DropInterval(Level) + if Fits(Active moved down 1): + Active.Y++; emit PieceMoved + else: + Grounded = true; LockAccumulator = 0; LockResets = 0; break + return events +``` + +`lock()` is §12's pipeline: commit the piece cells as `Cell(Kind + 1)`, emit `PieceLocked`; find complete rows and if any, clear them, add `LineScore + ComboBonus`, add to `Lines`, bump `Combo`, emit `LinesCleared` and `ComboChanged`, and if `LevelForLines(Lines)` differs from `Level`, update it and emit `LevelChanged`; if none, reset `Combo` to 0 and emit `ComboChanged` only if it was non-zero. Then spawn the next piece; if it does not `Fit`, set `Over = true` and emit `GameOver`. + +- [ ] **Step 1: Write the failing test** + +```go +import "time" + +func TestAdvanceDropsAfterOneInterval(t *testing.T) { + g := New(1) + startY := g.Active.Y + if evs := g.Advance(DropInterval(1) - time.Millisecond); len(evs) != 0 || g.Active.Y != startY { + t.Fatalf("piece moved before the interval elapsed: Y=%d evs=%v", g.Active.Y, evs) + } + evs := g.Advance(2 * time.Millisecond) + if g.Active.Y != startY+1 { + t.Fatalf("Y = %d, want %d", g.Active.Y, startY+1) + } + if len(evs) != 1 || evs[0].Kind != EventPieceMoved { + t.Fatalf("events = %v, want one PieceMoved", evs) + } +} + +func TestAdvanceWithALargeDtDropsMultipleRowsWithoutSkippingTheFloor(t *testing.T) { + g := New(1) + evs := g.Advance(5 * time.Second) // ~6 intervals at level 1 + moves := 0 + for _, e := range evs { + if e.Kind == EventPieceMoved { + moves++ + } + } + if moves < 5 || moves > 7 { + t.Fatalf("got %d moves from 5s at level 1, want ~6", moves) + } + if !g.Board.Fits(g.Active) { + t.Fatal("piece must still fit after a large dt") + } + // A dt long enough to cross the whole board must not push the piece through + // the floor, and must never leave it overlapping locked cells. + g2 := New(1) + g2.Advance(10 * time.Minute) + if !g2.Board.Fits(g2.Active) { + t.Fatal("piece must fit after an enormous dt") + } + if g2.Active.Y+DropDistance(&g2.Board, g2.Active) != g2.Active.Y && !g2.Grounded { + t.Log("piece is mid-air but not grounded, which is fine") + } +} + +func TestAdvanceZeroDtChangesNothing(t *testing.T) { + g := New(1) + before := *g + if evs := g.Advance(0); len(evs) != 0 { + t.Fatalf("Advance(0) emitted %v", evs) + } + if g.Active != before.Active || g.GravityAccumulator != before.GravityAccumulator { + t.Fatal("Advance(0) mutated state") + } +} + +func TestGroundedPieceLocksAfterLockDelay(t *testing.T) { + g := New(1) + g.Active = Piece{Kind: O, X: 4, Y: Height - 2} + if evs := g.Advance(LockDelay - time.Millisecond); len(evs) != 0 { + t.Fatalf("locked too early: %v", evs) + } + evs := g.Advance(2 * time.Millisecond) + kinds := map[EventKind]bool{} + for _, e := range evs { + kinds[e.Kind] = true + } + if !kinds[EventPieceLocked] { + t.Fatalf("events = %v, want a PieceLocked", evs) + } + if g.Board.At(5, Height-1) == Empty { + t.Fatal("locked cells were not committed to the board") + } +} + +func TestMovementWhileGroundedResetsLockTimerUpToTheCap(t *testing.T) { + g := New(1) + g.Active = Piece{Kind: O, X: 4, Y: Height - 2} + g.Advance(LockDelay - 10*time.Millisecond) + g.MoveLeft() + if g.LockAccumulator != 0 { + t.Fatalf("LockAccumulator = %v after a grounded move, want 0", g.LockAccumulator) + } + if g.LockResets != 1 { + t.Fatalf("LockResets = %d, want 1", g.LockResets) + } + for i := 0; i < MaxLockResets+5; i++ { + g.Advance(10 * time.Millisecond) + g.MoveRight() + g.MoveLeft() + } + if g.LockResets > MaxLockResets { + t.Fatalf("LockResets = %d, want it capped at %d", g.LockResets, MaxLockResets) + } + // With the cap reached, the piece must lock rather than stall forever. + g.Advance(LockDelay + time.Millisecond) + if !g.Grounded && g.Active.Y == Height-2 { + t.Fatal("piece stalled past the reset cap") + } +} + +func TestSoftDropScoresOnePointPerCell(t *testing.T) { + g := New(1) + before := g.Active.Y + g.SoftDrop() + if g.Active.Y != before+1 { + t.Fatalf("Y = %d, want %d", g.Active.Y, before+1) + } + if g.Score != SoftDropPoints { + t.Fatalf("Score = %d, want %d", g.Score, SoftDropPoints) + } + g.Active = Piece{Kind: O, X: 4, Y: Height - 2} + scoreBefore := g.Score + g.SoftDrop() // grounded: no movement, no points + if g.Score != scoreBefore { + t.Fatalf("blocked soft drop scored %d points", g.Score-scoreBefore) + } +} + +func TestHardDropScoresTwoPerCellAndLocksImmediately(t *testing.T) { + g := New(1) + p := g.Active + dist := DropDistance(&g.Board, p) + evs := g.HardDrop() + var dropped *Event + for i := range evs { + if evs[i].Kind == EventPieceHardDropped { + dropped = &evs[i] + } + } + if dropped == nil { + t.Fatalf("events = %v, want a PieceHardDropped", evs) + } + if dropped.Distance != dist { + t.Fatalf("Distance = %d, want %d", dropped.Distance, dist) + } + if g.Score != dist*HardDropPoints { + t.Fatalf("Score = %d, want %d", g.Score, dist*HardDropPoints) + } + locked := false + for _, e := range evs { + if e.Kind == EventPieceLocked { + locked = true + } + } + if !locked { + t.Fatal("a hard drop must lock the piece in the same call") + } + if g.Active.Kind == p.Kind && g.Active.Y == p.Y+dist { + t.Fatal("a new piece should have spawned") + } +} + +func TestHardDropOnAGroundedPieceScoresZeroAndStillLocks(t *testing.T) { + g := New(1) + g.Active = Piece{Kind: O, X: 4, Y: Height - 2} + evs := g.HardDrop() + if g.Score != 0 { + t.Fatalf("Score = %d, want 0 for a zero-distance hard drop", g.Score) + } + var sawDrop, sawLock bool + for _, e := range evs { + switch e.Kind { + case EventPieceHardDropped: + sawDrop = true + if e.Distance != 0 { + t.Errorf("Distance = %d, want 0", e.Distance) + } + case EventPieceLocked: + sawLock = true + } + } + if !sawDrop || !sawLock { + t.Fatalf("events = %v, want both PieceHardDropped and PieceLocked", evs) + } +} + +func TestSingleLineClearScoresAndCountsLines(t *testing.T) { + g := New(1) + fillRow(&g.Board, Height-1, Cell(1)) + g.Board.Set(4, Height-1, Empty) + g.Board.Set(5, Height-1, Empty) + g.Active = Piece{Kind: O, X: 4, Y: Height - 2} + evs := g.HardDrop() + var cleared *Event + for i := range evs { + if evs[i].Kind == EventLinesCleared { + cleared = &evs[i] + } + } + if cleared == nil { + t.Fatalf("events = %v, want LinesCleared", evs) + } + if len(cleared.Rows) != 1 || cleared.Rows[0] != Height-1 { + t.Fatalf("Rows = %v, want [%d]", cleared.Rows, Height-1) + } + if g.Lines != 1 { + t.Fatalf("Lines = %d, want 1", g.Lines) + } + if g.Score != 100 { // 100 x level 1, no combo bonus at combo 1 + t.Fatalf("Score = %d, want 100", g.Score) + } + if g.Combo != 1 { + t.Fatalf("Combo = %d, want 1", g.Combo) + } +} + +func TestComboIncrementsThenResetsOnANonClearingPlacement(t *testing.T) { + g := New(1) + clearOneRow := func() { + fillRow(&g.Board, Height-1, Cell(1)) + g.Board.Set(4, Height-1, Empty) + g.Board.Set(5, Height-1, Empty) + g.Active = Piece{Kind: O, X: 4, Y: Height - 2} + g.HardDrop() + } + clearOneRow() + scoreAfterFirst := g.Score + clearOneRow() + if g.Combo != 2 { + t.Fatalf("Combo = %d, want 2", g.Combo) + } + // second clear: 100 base + 50 x (2-1) x level 1 = 150 + if g.Score-scoreAfterFirst != 150 { + t.Fatalf("second clear scored %d, want 150", g.Score-scoreAfterFirst) + } + g.Active = Piece{Kind: O, X: 0, Y: 0} + evs := g.HardDrop() + if g.Combo != 0 { + t.Fatalf("Combo = %d after a non-clearing placement, want 0", g.Combo) + } + var sawComboChanged bool + for _, e := range evs { + if e.Kind == EventComboChanged && e.Value == 0 { + sawComboChanged = true + } + } + if !sawComboChanged { + t.Fatalf("events = %v, want ComboChanged with Value 0", evs) + } +} + +func TestFourLineClearScoresEightHundredTimesLevel(t *testing.T) { + g := New(1) + for y := Height - 4; y < Height; y++ { + fillRow(&g.Board, y, Cell(1)) + g.Board.Set(0, y, Empty) + } + g.Active = Piece{Kind: I, Rotation: 1, X: -2, Y: Height - 4} // vertical I in column 0 + if !g.Board.Fits(g.Active) { + t.Skip("adjust the I placement: precondition failed") + } + evs := g.HardDrop() + for _, e := range evs { + if e.Kind == EventLinesCleared && len(e.Rows) != 4 { + t.Fatalf("Rows = %v, want 4 rows", e.Rows) + } + } + if g.Lines != 4 { + t.Fatalf("Lines = %d, want 4", g.Lines) + } + if g.Score < 800 { + t.Fatalf("Score = %d, want at least 800", g.Score) + } +} + +func TestLevelRisesEveryTenLinesAndEmitsLevelChanged(t *testing.T) { + g := New(1) + g.Lines = 9 + fillRow(&g.Board, Height-1, Cell(1)) + g.Board.Set(4, Height-1, Empty) + g.Board.Set(5, Height-1, Empty) + g.Active = Piece{Kind: O, X: 4, Y: Height - 2} + evs := g.HardDrop() + if g.Level != 2 { + t.Fatalf("Level = %d, want 2 at %d lines", g.Level, g.Lines) + } + for _, e := range evs { + if e.Kind == EventLevelChanged { + if e.Value != 2 { + t.Fatalf("LevelChanged.Value = %d, want 2", e.Value) + } + return + } + } + t.Fatalf("events = %v, want a LevelChanged", evs) +} + +func TestBlockedSpawnEndsTheGame(t *testing.T) { + g := New(1) + for y := 0; y < Height; y++ { + fillRow(&g.Board, y, Cell(1)) + g.Board.Set(0, y, Empty) // leave a column so the active piece can exist + } + g.Active = Piece{Kind: I, Rotation: 1, X: -2, Y: Height - 4} + evs := g.HardDrop() + if !g.Over { + t.Fatal("a blocked spawn must set Over") + } + for _, e := range evs { + if e.Kind == EventGameOver { + return + } + } + t.Fatalf("events = %v, want a GameOver", evs) +} + +func TestNoInputMutatesAFinishedGame(t *testing.T) { + g := New(1) + g.Over = true + before := *g + for _, call := range []func() []Event{g.MoveLeft, g.MoveRight, g.RotateCW, g.RotateCCW, g.SoftDrop, g.HardDrop} { + if evs := call(); len(evs) != 0 { + t.Errorf("a call on a finished game emitted %v", evs) + } + } + g.Advance(time.Second) + if g.Active != before.Active || g.Score != before.Score || g.Board != before.Board { + t.Fatal("a finished game was mutated") + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/game/ -v` +Expected: FAIL — undefined: `Advance`, `SoftDrop`, `HardDrop`. + +- [ ] **Step 3: Implement `Advance`, `SoftDrop`, `HardDrop`, and `lock` in `game.go`** + +Follow the pseudocode and the `lock()` pipeline above. `SoftDrop` moves down one cell if it fits, adding `SoftDropPoints` and emitting `PieceMoved`; nothing otherwise. `HardDrop` computes `DropDistance`, adds `distance × HardDropPoints`, sets `Active.Y += distance`, emits `PieceHardDropped` with `Piece` set to the pre-drop piece and `Distance` set, then appends `lock()`'s events. + +- [ ] **Step 4: Run the whole engine suite to verify it passes** + +Run: `go test ./internal/game/ -v` +Expected: PASS, all tests. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . && git add internal/game/game.go internal/game/game_test.go +git commit -m "feat(game): gravity, lock delay, line clears, combo, level, game over" +``` + +--- + +### Task 10: Hold + +**Files:** +- Modify: `internal/game/game.go` +- Test: `internal/game/hold_test.go` + +**Interfaces:** +- Consumes: Task 9's `Game`. +- Produces: `func (g *Game) UseHold() []Event`. + +- [ ] **Step 1: Write the failing test** + +```go +package game + +import "testing" + +func TestFirstHoldStoresActiveAndSpawnsFromTheQueue(t *testing.T) { + g := New(1) + held := g.Active.Kind + wantNext := g.Next[0] + evs := g.UseHold() + if g.Hold == nil || *g.Hold != held { + t.Fatalf("Hold = %v, want %v", g.Hold, held) + } + if g.Active.Kind != wantNext { + t.Fatalf("Active = %v, want the queue head %v", g.Active.Kind, wantNext) + } + if len(g.Next) != NextQueueLen { + t.Fatalf("len(Next) = %d, want %d", len(g.Next), NextQueueLen) + } + if len(evs) != 1 || evs[0].Kind != EventHoldUsed { + t.Fatalf("events = %v, want one HoldUsed", evs) + } +} + +func TestSecondHoldSwapsAndReturnsSpawnRotation(t *testing.T) { + g := New(1) + g.UseHold() + g.RotateCW() + g.MoveLeft() + activeBefore := g.Active.Kind + heldBefore := *g.Hold + g.CanHold = true // allow a second swap for this test + g.UseHold() + if *g.Hold != activeBefore { + t.Fatalf("Hold = %v, want %v", *g.Hold, activeBefore) + } + if g.Active.Kind != heldBefore { + t.Fatalf("Active = %v, want %v", g.Active.Kind, heldBefore) + } + if g.Active != Spawn(heldBefore) { + t.Fatalf("Active = %+v, want spawn state %+v", g.Active, Spawn(heldBefore)) + } +} + +func TestSecondHoldBeforeLockIsBlocked(t *testing.T) { + g := New(1) + g.UseHold() + if g.CanHold { + t.Fatal("CanHold must be false right after a hold") + } + before := g.Active + heldBefore := *g.Hold + if evs := g.UseHold(); len(evs) != 0 { + t.Fatalf("second hold emitted %v", evs) + } + if g.Active != before || *g.Hold != heldBefore { + t.Fatal("a blocked hold changed state") + } +} + +func TestHoldIsRestoredAfterALock(t *testing.T) { + g := New(1) + g.UseHold() + g.HardDrop() + if !g.CanHold { + t.Fatal("CanHold must be true again after the piece locks") + } +} + +func TestHoldOnAFinishedGameDoesNothing(t *testing.T) { + g := New(1) + g.Over = true + if evs := g.UseHold(); len(evs) != 0 || g.Hold != nil { + t.Fatalf("hold worked on a finished game: evs=%v hold=%v", evs, g.Hold) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/game/ -run TestHold -run 'TestFirstHold|TestSecondHold|TestHold' -v` +Expected: FAIL — undefined: `UseHold`. + +- [ ] **Step 3: Implement `UseHold` in `game.go`** + +Returns `nil` when `g.Over` or `!g.CanHold`. With an empty hold, store `Active.Kind` and spawn from the queue; otherwise swap, setting `Active = Spawn(previouslyHeld)`. Either way set `CanHold = false`, reset the gravity/lock accumulators and `LockResets`, and emit one `EventHoldUsed` whose `Piece` is the outgoing piece (Plan 2's quantum-storage effect needs to know what left). + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `go test ./internal/game/ -run 'TestFirstHold|TestSecondHold|TestHold' -v` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . && git add internal/game/game.go internal/game/hold_test.go +git commit -m "feat(game): hold with one-use-per-piece rule" +``` + +--- + +### Task 11: Determinism replay test + +**Files:** +- Test: `internal/game/determinism_test.go` + +**Interfaces:** +- Consumes: the whole engine. +- Produces: nothing (test-only). This is §40's replay test and it is what makes §35 checkable. + +- [ ] **Step 1: Write the failing test** + +```go +package game + +import ( + "reflect" + "testing" + "time" +) + +type step struct { + action string // "left","right","cw","ccw","soft","hard","hold","" + dt time.Duration +} + +// canned is a fixed (input, dt) stream: the timing is an input, per §49.2. +var canned = []step{ + {"", 120 * time.Millisecond}, {"left", 0}, {"cw", 0}, {"", 300 * time.Millisecond}, + {"right", 0}, {"soft", 0}, {"hard", 0}, {"", 90 * time.Millisecond}, + {"hold", 0}, {"ccw", 0}, {"left", 0}, {"left", 0}, {"", 850 * time.Millisecond}, + {"hard", 0}, {"", 40 * time.Millisecond}, {"cw", 0}, {"hard", 0}, + {"hold", 0}, {"", 1200 * time.Millisecond}, {"right", 0}, {"hard", 0}, + {"", 2 * time.Second}, {"soft", 0}, {"soft", 0}, {"hard", 0}, + {"", 700 * time.Millisecond}, {"cw", 0}, {"cw", 0}, {"hard", 0}, +} + +func replay(seed int64) (*Game, []Event) { + g := New(seed) + var log []Event + for _, s := range canned { + if s.dt > 0 { + log = append(log, g.Advance(s.dt)...) + } + switch s.action { + case "left": + log = append(log, g.MoveLeft()...) + case "right": + log = append(log, g.MoveRight()...) + case "cw": + log = append(log, g.RotateCW()...) + case "ccw": + log = append(log, g.RotateCCW()...) + case "soft": + log = append(log, g.SoftDrop()...) + case "hard": + log = append(log, g.HardDrop()...) + case "hold": + log = append(log, g.UseHold()...) + } + } + return g, log +} + +func TestSameSeedAndInputsReproduceTheSameState(t *testing.T) { + a, logA := replay(8675309) + b, logB := replay(8675309) + if a.Board != b.Board { + t.Error("boards diverged") + } + if a.Score != b.Score || a.Lines != b.Lines || a.Level != b.Level || a.Combo != b.Combo { + t.Errorf("stats diverged: %d/%d/%d/%d vs %d/%d/%d/%d", + a.Score, a.Lines, a.Level, a.Combo, b.Score, b.Lines, b.Level, b.Combo) + } + if a.Active != b.Active || !reflect.DeepEqual(a.Next, b.Next) { + t.Error("active piece or next queue diverged") + } + if !reflect.DeepEqual(logA, logB) { + t.Errorf("event logs diverged: %d vs %d events", len(logA), len(logB)) + } +} + +func TestADifferentSeedProducesADifferentGame(t *testing.T) { + a, _ := replay(8675309) + b, _ := replay(11111) + if a.Board == b.Board && a.Score == b.Score { + t.Fatal("different seeds produced an identical game") + } +} + +func TestReplayActuallyExercisedTheGame(t *testing.T) { + g, log := replay(8675309) + if len(log) < 20 { + t.Fatalf("only %d events; the canned stream is too weak to be evidence", len(log)) + } + locks := 0 + for _, e := range log { + if e.Kind == EventPieceLocked { + locks++ + } + } + if locks < 5 { + t.Fatalf("only %d locks; the canned stream should place several pieces", locks) + } + if g.Score == 0 { + t.Fatal("replay scored nothing") + } +} +``` + +- [ ] **Step 2: Run the test to verify it passes** + +Run: `go test ./internal/game/ -run 'TestSameSeed|TestADifferentSeed|TestReplayActually' -v` +Expected: PASS. If `TestReplayActuallyExercisedTheGame` fails, extend `canned` with more steps rather than weakening the assertion. (This task's test may pass first try, since Tasks 1–10 built the behavior; its job is to pin determinism against future changes.) + +- [ ] **Step 3: Run the full engine suite with the race detector and repeated runs** + +Run: `go test ./internal/game/ -race -count=3` +Expected: PASS. Repeated runs catch map-iteration order or unseeded-RNG leaks into game logic. + +- [ ] **Step 4: Commit** + +```bash +gofmt -l . && git add internal/game/determinism_test.go +git commit -m "test(game): canned replay pins seed and timing determinism" +``` + +--- + +### Task 12: Canvas, palette, and layout + +**Files:** +- Create: `internal/render/canvas.go`, `internal/render/palette.go`, `internal/render/layout.go` +- Test: `internal/render/canvas_test.go`, `internal/render/layout_test.go` + +**Interfaces:** +- Consumes: `game.PieceKind`. +- Produces: + +```go +// canvas.go — the surface every render step paints onto. +type Canvas struct { W, H int /* unexported cells */ } +func NewCanvas(w, h int) *Canvas +func (c *Canvas) Set(x, y int, glyph rune, style lipgloss.Style) // out-of-bounds is a no-op +func (c *Canvas) SetString(x, y int, s string, style lipgloss.Style) // writes runes rightward, clipping +func (c *Canvas) Fill(glyph rune, style lipgloss.Style) +func (c *Canvas) Plain() string // rows joined by "\n", no ANSI: the golden-test view +func (c *Canvas) String() string // styled output + +// palette.go +type Mode int +const (ModeFull Mode = iota; ModeReduced; ModeASCII) +type Glyphs struct { Block, Ghost, Empty string } // each exactly 2 columns wide +func GlyphsFor(m Mode) Glyphs +type Palette struct { Mode Mode } +func NewPalette(m Mode) Palette +func (p Palette) Piece(k game.PieceKind, active bool) lipgloss.Style +func (p Palette) Dim() lipgloss.Style +func (p Palette) Border() lipgloss.Style +func (p Palette) Accent() lipgloss.Style + +// layout.go +type Rect struct { X, Y, W, H int } +func (r Rect) Empty() bool +type LayoutMode int +const (LayoutTooSmall LayoutMode = iota; LayoutSmall; LayoutMedium; LayoutWide) +type Layout struct { + Mode LayoutMode + TermW, TermH int + Board Rect // interior only: 20 cols x 20 rows + Hold, Next, Stats, Mission, Controls, Title Rect + NextCount int + ShowStatLabels bool +} +func Compute(termW, termH int) Layout +const MinWidth, MinHeight = 40, 24 +``` + +Layout rules, which the golden tests in Task 16 then freeze: + +| Mode | Requires | Board interior | Side panels | Extras | +|---|---|---|---|---| +| Wide | `w >= 64 && h >= 26` | 20×20, horizontally centered | HOLD + stats left, NEXT right | title row, mission row, controls row | +| Medium | `w >= 50 && h >= 24` | 20×20 | compact HUD column right (NEXT 5, stats, hold) | mission row if `h >= 25`, controls row | +| Small | `w >= 40 && h >= 24` | 20×20 | NEXT (3) beside the board, score value beside it | controls row; mission row only if `h >= 24 + 1`; no title; `ShowStatLabels=false` | +| TooSmall | anything less | zero Rect | zero Rects | — | + +Height budget (§49.3 drop order — title goes first, then mission, then stat labels): board frame 22 rows and the controls row are mandatory (23 rows). NEXT never stacks above or below the board. + +- [ ] **Step 1: Write the failing canvas test** + +```go +package render + +import ( + "strings" + "testing" + + "charm.land/lipgloss/v2" +) + +func TestCanvasStartsBlankAndPlainHasExactDimensions(t *testing.T) { + c := NewCanvas(6, 3) + rows := strings.Split(c.Plain(), "\n") + if len(rows) != 3 { + t.Fatalf("got %d rows, want 3", len(rows)) + } + for i, r := range rows { + if len([]rune(r)) != 6 { + t.Errorf("row %d has %d runes, want 6", i, len([]rune(r))) + } + if strings.TrimSpace(r) != "" { + t.Errorf("row %d is not blank: %q", i, r) + } + } +} + +func TestCanvasSetAndSetStringPaintAtCoordinates(t *testing.T) { + c := NewCanvas(8, 2) + c.Set(0, 0, 'X', lipgloss.NewStyle()) + c.SetString(2, 1, "hey", lipgloss.NewStyle()) + rows := strings.Split(c.Plain(), "\n") + if rows[0][0] != 'X' { + t.Errorf("row 0 = %q, want X first", rows[0]) + } + if !strings.HasPrefix(rows[1], " hey") { + t.Errorf("row 1 = %q, want \" hey\" prefix", rows[1]) + } +} + +func TestCanvasClipsInsteadOfPanicking(t *testing.T) { + c := NewCanvas(4, 2) + c.Set(-1, 0, 'A', lipgloss.NewStyle()) + c.Set(0, -5, 'B', lipgloss.NewStyle()) + c.Set(99, 99, 'C', lipgloss.NewStyle()) + c.SetString(2, 0, "overflowing", lipgloss.NewStyle()) + c.SetString(-3, 1, "left", lipgloss.NewStyle()) + rows := strings.Split(c.Plain(), "\n") + for _, r := range rows { + if len([]rune(r)) != 4 { + t.Fatalf("clipping changed row width: %q", r) + } + } + if strings.ContainsAny(c.Plain(), "ABC") { + t.Error("out-of-bounds writes leaked into the canvas") + } +} + +func TestZeroSizedCanvasIsUsable(t *testing.T) { + c := NewCanvas(0, 0) + c.Set(0, 0, 'X', lipgloss.NewStyle()) + c.Fill('.', lipgloss.NewStyle()) + if got := c.Plain(); got != "" { + t.Fatalf("Plain() = %q, want empty", got) + } + _ = c.String() + neg := NewCanvas(-4, -2) + if neg.W != 0 || neg.H != 0 { + t.Fatalf("negative dimensions became %dx%d, want 0x0", neg.W, neg.H) + } +} + +func TestStringCarriesStylingAndPlainDoesNot(t *testing.T) { + c := NewCanvas(3, 1) + c.Set(1, 0, 'Q', lipgloss.NewStyle().Foreground(lipgloss.Color("201"))) + if strings.Contains(c.Plain(), "\x1b") { + t.Error("Plain() must contain no ANSI escapes") + } + if !strings.Contains(c.String(), "Q") { + t.Error("String() lost the glyph") + } +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `go test ./internal/render/ -run TestCanvas -run 'TestCanvas|TestZeroSized|TestString' -v` +Expected: FAIL — undefined: `NewCanvas`. + +- [ ] **Step 3: Implement `canvas.go` and `palette.go`** + +`NewCanvas` clamps negative dimensions to 0 and allocates one `[]cell` of `w*h`. `String()` walks each row and coalesces runs of adjacent cells that share a style into a single `style.Render(run)` call, so a frame emits a handful of escape sequences per row instead of one per cell (§38). `Plain()` writes glyphs only. Blank cells hold `' '`. + +`GlyphsFor`: `ModeFull` and `ModeReduced` give `Block: "██"`, `Ghost: "░░"`, `Empty: " "`; `ModeASCII` gives `Block: "[]"`, `Ghost: "··"` → no: ASCII ghost is `".."`. Use §49.4's `··` for full/reduced-adjacent modes and, since `·` is not ASCII, `ModeASCII` uses `Ghost: ".."`. Record that refinement in a comment: §49.4's ASCII ghost `··` is non-ASCII, so ASCII mode uses `..`, which is the same two-column shape. + +`Palette.Piece` colors from §26's neon space palette: I plasma cyan, J deep electric blue, L solar orange, O stellar gold, S alien green, T ultraviolet, Z supernova pink. `active` returns the same hue one step brighter (§49.4). `ModeReduced` returns 256-color equivalents; `ModeASCII` returns basic ANSI colors. + +- [ ] **Step 4: Run it to verify it passes** + +Run: `go test ./internal/render/ -run 'TestCanvas|TestZeroSized|TestString' -v` +Expected: PASS (5 tests). + +- [ ] **Step 5: Write the failing layout test** + +```go +package render + +import "testing" + +func TestComputeSelectsModesByTerminalSize(t *testing.T) { + cases := []struct { + w, h int + want LayoutMode + }{ + {100, 40, LayoutWide}, + {64, 26, LayoutWide}, + {63, 26, LayoutMedium}, + {50, 24, LayoutMedium}, + {49, 24, LayoutSmall}, + {40, 24, LayoutSmall}, + {39, 24, LayoutTooSmall}, + {40, 23, LayoutTooSmall}, + } + for _, c := range cases { + if got := Compute(c.w, c.h).Mode; got != c.want { + t.Errorf("Compute(%d,%d).Mode = %v, want %v", c.w, c.h, got, c.want) + } + } +} + +func TestBoardInteriorIsAlwaysTwentyByTwenty(t *testing.T) { + for _, c := range [][2]int{{40, 24}, {50, 24}, {64, 26}, {120, 50}, {400, 120}} { + l := Compute(c[0], c[1]) + if l.Board.W != 20 || l.Board.H != 20 { + t.Errorf("Compute(%d,%d).Board = %dx%d, want 20x20", c[0], c[1], l.Board.W, l.Board.H) + } + } +} + +func TestDegenerateSizesReturnTooSmallWithoutNegativeRects(t *testing.T) { + for _, c := range [][2]int{{0, 0}, {1, 1}, {-5, -5}, {0, 40}, {40, 0}} { + l := Compute(c[0], c[1]) + if l.Mode != LayoutTooSmall { + t.Errorf("Compute(%d,%d).Mode = %v, want LayoutTooSmall", c[0], c[1], l.Mode) + } + for name, r := range map[string]Rect{ + "board": l.Board, "hold": l.Hold, "next": l.Next, + "stats": l.Stats, "mission": l.Mission, "controls": l.Controls, "title": l.Title, + } { + if r.W < 0 || r.H < 0 { + t.Errorf("Compute(%d,%d).%s = %+v has a negative dimension", c[0], c[1], name, r) + } + } + } +} + +func TestPanelsNeverOverlapTheBoardOrEachOther(t *testing.T) { + for _, c := range [][2]int{{40, 24}, {50, 25}, {64, 26}, {100, 40}, {200, 60}} { + l := Compute(c[0], c[1]) + rects := []struct { + name string + r Rect + }{ + {"board", Rect{l.Board.X - 1, l.Board.Y - 1, l.Board.W + 2, l.Board.H + 2}}, // include the frame + {"hold", l.Hold}, {"next", l.Next}, {"stats", l.Stats}, + {"mission", l.Mission}, {"controls", l.Controls}, {"title", l.Title}, + } + for i := 0; i < len(rects); i++ { + for j := i + 1; j < len(rects); j++ { + a, b := rects[i], rects[j] + if a.r.Empty() || b.r.Empty() { + continue + } + if a.r.X < b.r.X+b.r.W && b.r.X < a.r.X+a.r.W && + a.r.Y < b.r.Y+b.r.H && b.r.Y < a.r.Y+a.r.H { + t.Errorf("at %dx%d, %s %+v overlaps %s %+v", c[0], c[1], a.name, a.r, b.name, b.r) + } + } + } + } +} + +func TestEverythingFitsInsideTheTerminal(t *testing.T) { + for _, c := range [][2]int{{40, 24}, {50, 24}, {64, 26}, {80, 30}, {400, 120}} { + l := Compute(c[0], c[1]) + for name, r := range map[string]Rect{ + "board": l.Board, "hold": l.Hold, "next": l.Next, "stats": l.Stats, + "mission": l.Mission, "controls": l.Controls, "title": l.Title, + } { + if r.Empty() { + continue + } + if r.X < 0 || r.Y < 0 || r.X+r.W > c[0] || r.Y+r.H > c[1] { + t.Errorf("at %dx%d, %s %+v escapes the terminal", c[0], c[1], name, r) + } + } + } +} + +func TestSmallModeDropOrderFollowsSection493(t *testing.T) { + small := Compute(40, 24) + if !small.Title.Empty() { + t.Error("small mode must drop the title border first") + } + if small.ShowStatLabels { + t.Error("small mode must drop stat labels") + } + if small.NextCount != 3 { + t.Errorf("small NextCount = %d, want 3", small.NextCount) + } + if small.Controls.Empty() { + t.Error("controls are one of the last two things standing") + } + wide := Compute(100, 40) + if wide.NextCount != 5 { + t.Errorf("wide NextCount = %d, want 5", wide.NextCount) + } + if !wide.ShowStatLabels || wide.Title.Empty() || wide.Mission.Empty() { + t.Error("wide mode keeps title, mission, and stat labels") + } +} + +func TestNextIsBesideTheBoardNeverAboveOrBelow(t *testing.T) { + for _, c := range [][2]int{{40, 24}, {50, 24}, {64, 26}, {120, 40}} { + l := Compute(c[0], c[1]) + if l.Next.Empty() { + t.Fatalf("at %dx%d NEXT is missing; §31 ranks it second", c[0], c[1]) + } + if l.Next.X+l.Next.W <= l.Board.X || l.Next.X >= l.Board.X+l.Board.W { + continue // beside the board: good + } + t.Errorf("at %dx%d NEXT %+v shares columns with the board %+v", c[0], c[1], l.Next, l.Board) + } +} + +func TestBoardIsCenteredInAVeryWideTerminal(t *testing.T) { + l := Compute(400, 120) + leftGap := l.Board.X + rightGap := 400 - (l.Board.X + l.Board.W) + if leftGap < 10 || rightGap < 10 { + t.Fatalf("board not centered: gaps %d/%d", leftGap, rightGap) + } +} +``` + +- [ ] **Step 6: Run it to verify it fails** + +Run: `go test ./internal/render/ -run 'TestCompute|TestBoard|TestDegenerate|TestPanels|TestEverything|TestSmallMode|TestNextIs' -v` +Expected: FAIL — undefined: `Compute`. + +- [ ] **Step 7: Implement `layout.go`** + +Follow the mode table above. `Compute` returns `Layout{Mode: LayoutTooSmall, TermW: w, TermH: h}` with zero Rects whenever `w < MinWidth || h < MinHeight` (which also covers zero and negative input). In the other modes: reserve the mandatory rows first (board frame 22, controls 1), then add mission and title while rows remain, then center the whole block horizontally and vertically. + +- [ ] **Step 8: Run it to verify it passes** + +Run: `go test ./internal/render/ -v` +Expected: PASS. + +- [ ] **Step 9: Commit** + +```bash +gofmt -l . && git add internal/render/ +git commit -m "feat(render): styled canvas, neon palette, responsive layout" +``` + +--- + +### Task 13: Board rendering + +**Files:** +- Create: `internal/render/board.go` +- Test: `internal/render/board_test.go` + +**Interfaces:** +- Consumes: `Canvas`, `Palette`, `Glyphs`, `Layout` from Task 12; `game.Game`. +- Produces: `func DrawBoard(c *Canvas, g *game.Game, l Layout, p Palette)` — paints the frame, the locked cells, the ghost, and the active piece for the 20 visible rows. + +`DrawBoard` reads `g` and never writes to it. Draw order is §37 steps 3–5 and 7: locked cells, then ghost, then active piece, then the border frame. Ghost cells are skipped wherever a locked cell or an active-piece cell already sits (§10: the ghost must never obscure locked blocks). + +- [ ] **Step 1: Write the failing test** + +```go +package render + +import ( + "strings" + "testing" + + "cosmic-tetris/internal/game" +) + +func drawn(g *game.Game, w, h int, m Mode) []string { + l := Compute(w, h) + c := NewCanvas(w, h) + DrawBoard(c, g, l, NewPalette(m)) + return strings.Split(c.Plain(), "\n") +} + +func TestBoardFrameIsDrawnAtTheLayoutRect(t *testing.T) { + g := game.New(1) + l := Compute(100, 40) + rows := drawn(g, 100, 40, ModeFull) + top := []rune(rows[l.Board.Y-1]) + if top[l.Board.X-1] != '╔' { + t.Errorf("top-left corner = %q, want ╔", top[l.Board.X-1]) + } + if top[l.Board.X+l.Board.W] != '╗' { + t.Errorf("top-right corner = %q, want ╗", top[l.Board.X+l.Board.W]) + } + bottom := []rune(rows[l.Board.Y+l.Board.H]) + if bottom[l.Board.X-1] != '╚' || bottom[l.Board.X+l.Board.W] != '╝' { + t.Error("bottom corners are wrong") + } + for y := l.Board.Y; y < l.Board.Y+l.Board.H; y++ { + r := []rune(rows[y]) + if r[l.Board.X-1] != '║' || r[l.Board.X+l.Board.W] != '║' { + t.Fatalf("row %d is missing its side walls: %q", y, rows[y]) + } + } +} + +func TestLockedCellsAppearAtTheRightPlace(t *testing.T) { + g := game.New(1) + g.Board.Set(0, game.Height-1, game.Cell(1)) // bottom-left visible cell + l := Compute(100, 40) + rows := drawn(g, 100, 40, ModeFull) + bottomRow := []rune(rows[l.Board.Y+l.Board.H-1]) + got := string(bottomRow[l.Board.X : l.Board.X+2]) + if got != GlyphsFor(ModeFull).Block { + t.Fatalf("bottom-left cell = %q, want %q", got, GlyphsFor(ModeFull).Block) + } +} + +func TestHiddenRowsAreNotRendered(t *testing.T) { + g := game.New(1) + g.Board.Set(0, 0, game.Cell(1)) // hidden spawn row + g.Board.Set(0, 1, game.Cell(1)) + g.Active = game.Piece{Kind: game.O, X: 8, Y: game.Height - 2} + l := Compute(100, 40) + rows := drawn(g, 100, 40, ModeFull) + firstVisible := []rune(rows[l.Board.Y]) + if string(firstVisible[l.Board.X:l.Board.X+2]) == GlyphsFor(ModeFull).Block { + t.Fatal("a hidden-row cell leaked into the first visible row") + } +} + +func TestGhostIsDrawnBelowTheActivePiece(t *testing.T) { + g := game.New(1) + g.Active = game.Piece{Kind: game.O, X: 4, Y: 5} + rows := drawn(g, 100, 40, ModeFull) + joined := strings.Join(rows, "\n") + if !strings.Contains(joined, GlyphsFor(ModeFull).Ghost) { + t.Fatal("no ghost glyphs rendered") + } + l := Compute(100, 40) + // the ghost sits on the floor row, the active piece does not + floor := []rune(rows[l.Board.Y+l.Board.H-1]) + if string(floor[l.Board.X+8:l.Board.X+10]) == GlyphsFor(ModeFull).Empty { + t.Log("ghost column depends on the piece; assert only that ghosts exist") + } +} + +func TestGhostNeverOverwritesLockedOrActiveCells(t *testing.T) { + g := game.New(1) + g.Active = game.Piece{Kind: game.O, X: 4, Y: game.Height - 2} // grounded: ghost == active + for x := 0; x < game.Width; x++ { + g.Board.Set(x, game.Height-1, game.Cell(3)) + } + g.Board.Set(5, game.Height-1, game.Empty) + g.Board.Set(6, game.Height-1, game.Empty) + rows := drawn(g, 100, 40, ModeFull) + l := Compute(100, 40) + bottom := []rune(rows[l.Board.Y+l.Board.H-1]) + for x := 0; x < game.Width; x++ { + cell := string(bottom[l.Board.X+x*2 : l.Board.X+x*2+2]) + if cell == GlyphsFor(ModeFull).Ghost { + t.Fatalf("ghost glyph drawn over an occupied cell at column %d", x) + } + } +} + +func TestAsciiModeEmitsOnlyAsciiForTheBoardBody(t *testing.T) { + g := game.New(1) + g.Board.Set(3, game.Height-1, game.Cell(2)) + l := Compute(100, 40) + c := NewCanvas(100, 40) + DrawBoard(c, g, l, NewPalette(ModeASCII)) + rows := strings.Split(c.Plain(), "\n") + for y := l.Board.Y; y < l.Board.Y+l.Board.H; y++ { + body := string([]rune(rows[y])[l.Board.X : l.Board.X+l.Board.W]) + for _, r := range body { + if r > 127 { + t.Fatalf("row %d contains non-ASCII rune %q in ASCII mode", y, r) + } + } + } +} + +func TestDrawBoardDoesNotMutateGameState(t *testing.T) { + g := game.New(7) + before := *g + c := NewCanvas(100, 40) + DrawBoard(c, g, Compute(100, 40), NewPalette(ModeFull)) + if g.Board != before.Board || g.Active != before.Active || g.Score != before.Score { + t.Fatal("DrawBoard mutated the game") + } +} + +func TestDrawBoardOnATooSmallLayoutIsANoOp(t *testing.T) { + g := game.New(1) + c := NewCanvas(20, 10) + DrawBoard(c, g, Compute(20, 10), NewPalette(ModeFull)) + if strings.TrimSpace(c.Plain()) != "" { + t.Fatal("DrawBoard painted something on a too-small layout") + } +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `go test ./internal/render/ -run 'TestBoardFrame|TestLocked|TestHidden|TestGhost|TestAscii|TestDrawBoard' -v` +Expected: FAIL — undefined: `DrawBoard`. + +- [ ] **Step 3: Implement `DrawBoard` in `board.go`** + +Visible row `y` of the board maps to canvas row `l.Board.Y + (y - game.HiddenRows)`; board column `x` maps to canvas column `l.Board.X + x*2`. Frame runes: `╔ ═ ╗ ║ ╚ ╝` (ASCII mode: `+ - + | + +`). Return immediately when `l.Mode == LayoutTooSmall`. + +- [ ] **Step 4: Run it to verify it passes** + +Run: `go test ./internal/render/ -run 'TestBoardFrame|TestLocked|TestHidden|TestGhost|TestAscii|TestDrawBoard' -v` +Expected: PASS (8 tests). + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . && git add internal/render/board.go internal/render/board_test.go +git commit -m "feat(render): board frame, locked cells, ghost, active piece" +``` + +--- + +### Task 14: HUD and overlays + +**Files:** +- Create: `internal/render/hud.go` +- Test: `internal/render/hud_test.go` + +**Interfaces:** +- Consumes: Tasks 12–13. +- Produces: `func DrawHUD(c *Canvas, g *game.Game, l Layout, p Palette)` (HOLD, NEXT, stats, controls, and the title when the layout keeps them), `func DrawMission(c *Canvas, text string, l Layout, p Palette)`, `func DrawOverlay(c *Canvas, lines []string, l Layout, p Palette)` (centered bordered panel), `func TooSmallNotice(termW, termH int) string`. + +Copy fixed by the spec: the controls line is `←→ move ↑ rotate ↓ descend SPACE YEET C hold ? help` (§4). Stat labels are `SCORE`, `LINES`, `LEVEL` (§4). Score renders zero-padded to 8 digits (`00129340`), lines to 3 (`042`), level to 2 (`07`). The title reads `✦ COSMIC TETRIS` with `LOCAL UNIVERSE <4 hex digits of the seed, uppercase>` on the right (§4). `TooSmallNotice` renders §31's text verbatim: + +```text +THIS UNIVERSE IS TOO SMALL + +resize terminal to continue + +current: 34 × 19 +needed: approximately 40 × 24 +``` + +- [ ] **Step 1: Write the failing test** + +```go +package render + +import ( + "strings" + "testing" + + "cosmic-tetris/internal/game" +) + +func hudPlain(g *game.Game, w, h int, m Mode) string { + l := Compute(w, h) + c := NewCanvas(w, h) + DrawHUD(c, g, l, NewPalette(m)) + return c.Plain() +} + +func TestWideHudShowsLabelsAndZeroPaddedStats(t *testing.T) { + g := game.New(0x7F3A) + g.Score, g.Lines, g.Level = 129340, 42, 7 + out := hudPlain(g, 100, 40, ModeFull) + for _, want := range []string{"SCORE", "00129340", "LINES", "042", "LEVEL", "07", "HOLD", "NEXT"} { + if !strings.Contains(out, want) { + t.Errorf("wide HUD missing %q", want) + } + } +} + +func TestControlsLineIsPresent(t *testing.T) { + g := game.New(1) + out := hudPlain(g, 100, 40, ModeFull) + for _, want := range []string{"move", "rotate", "YEET", "hold", "help"} { + if !strings.Contains(out, want) { + t.Errorf("controls line missing %q", want) + } + } +} + +func TestTitleCarriesTheSeedAsHex(t *testing.T) { + g := game.New(0x7F3A) + out := hudPlain(g, 100, 40, ModeFull) + if !strings.Contains(out, "COSMIC TETRIS") { + t.Error("title missing") + } + if !strings.Contains(out, "7F3A") { + t.Errorf("title missing the universe id 7F3A:\n%s", out) + } +} + +func TestSmallHudDropsLabelsButKeepsValues(t *testing.T) { + g := game.New(1) + g.Score, g.Lines = 4200, 42 + out := hudPlain(g, 40, 24, ModeFull) + if strings.Contains(out, "LINES") || strings.Contains(out, "SCORE") { + t.Errorf("small HUD kept a stat label:\n%s", out) + } + if !strings.Contains(out, "00004200") { + t.Errorf("small HUD dropped the score value:\n%s", out) + } +} + +func TestNextQueueShowsFiveWideAndThreeSmall(t *testing.T) { + countPieces := func(w, h int) int { + g := game.New(1) + l := Compute(w, h) + c := NewCanvas(w, h) + DrawHUD(c, g, l, NewPalette(ModeFull)) + rows := strings.Split(c.Plain(), "\n") + blocks := 0 + for y := l.Next.Y; y < l.Next.Y+l.Next.H && y < len(rows); y++ { + blocks += strings.Count(string([]rune(rows[y])[l.Next.X:min(l.Next.X+l.Next.W, len([]rune(rows[y])))]), GlyphsFor(ModeFull).Block) + } + return blocks + } + wide := countPieces(100, 40) + small := countPieces(40, 24) + if wide < 5*4 { + t.Errorf("wide NEXT drew %d block glyphs, want at least %d (5 pieces x 4 cells)", wide, 5*4) + } + if small >= wide { + t.Errorf("small NEXT drew %d glyphs, wide drew %d; small should show fewer pieces", small, wide) + } +} + +func TestHeldPieceIsRenderedWhenSet(t *testing.T) { + g := game.New(1) + out := hudPlain(g, 100, 40, ModeFull) + emptyHold := strings.Count(out, GlyphsFor(ModeFull).Block) + g.UseHold() + withHold := strings.Count(hudPlain(g, 100, 40, ModeFull), GlyphsFor(ModeFull).Block) + if withHold <= emptyHold { + t.Fatalf("holding a piece did not add glyphs (%d -> %d)", emptyHold, withHold) + } +} + +func TestMissionLineIsClippedToItsRect(t *testing.T) { + g := game.New(1) + l := Compute(100, 40) + c := NewCanvas(100, 40) + long := strings.Repeat("WE HAVE EXCEEDED THE RECOMMENDED NUMBER OF BLOCKS ", 10) + DrawMission(c, long, l, NewPalette(ModeFull)) + rows := strings.Split(c.Plain(), "\n") + for i, r := range rows { + if len([]rune(r)) != 100 { + t.Fatalf("row %d is %d runes wide, want 100", i, len([]rune(r))) + } + } + for y := 0; y < 40; y++ { + if y == l.Mission.Y { + continue + } + if strings.Contains(rows[y], "EXCEEDED") { + t.Fatalf("mission text leaked onto row %d", y) + } + } + _ = g +} + +func TestOverlayIsCenteredAndBordered(t *testing.T) { + l := Compute(100, 40) + c := NewCanvas(100, 40) + DrawOverlay(c, []string{"TEMPORAL SUSPENSION", "", "SPACE IS PAUSED", "", "p resume"}, l, NewPalette(ModeFull)) + out := c.Plain() + if !strings.Contains(out, "TEMPORAL SUSPENSION") || !strings.Contains(out, "p resume") { + t.Fatal("overlay content missing") + } + if !strings.ContainsAny(out, "╭╮╰╯│─") { + t.Fatal("overlay has no border") + } + rows := strings.Split(out, "\n") + for i, r := range rows { + if len([]rune(r)) != 100 { + t.Fatalf("overlay changed row %d width to %d", i, len([]rune(r))) + } + } +} + +func TestTooSmallNoticeReportsBothSizes(t *testing.T) { + out := TooSmallNotice(34, 19) + for _, want := range []string{"THIS UNIVERSE IS TOO SMALL", "resize terminal to continue", "34", "19", "40", "24"} { + if !strings.Contains(out, want) { + t.Errorf("notice missing %q:\n%s", want, out) + } + } +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `go test ./internal/render/ -run 'TestWideHud|TestControls|TestTitle|TestSmallHud|TestNextQueue|TestHeld|TestMission|TestOverlay|TestTooSmall' -v` +Expected: FAIL — undefined: `DrawHUD`. + +- [ ] **Step 3: Implement `hud.go`** + +Each panel paints inside its `Rect` and clips at the rect edges — never past them. Mini-piece previews for HOLD and NEXT draw `Shape(kind, 0)` into a 4×2-row cell block, using the piece's own color. Zero-width or empty Rects are skipped. + +- [ ] **Step 4: Run it to verify it passes** + +Run: `go test ./internal/render/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . && git add internal/render/hud.go internal/render/hud_test.go +git commit -m "feat(render): HUD panels, mission line, overlays, too-small notice" +``` + +--- + +### Task 15: The frame pipeline + +**Files:** +- Create: `internal/render/render.go` +- Test: `internal/render/render_test.go` + +**Interfaces:** +- Consumes: Tasks 12–14. +- Produces: + +```go +type Scene struct { + Game *game.Game + Mode Mode + Width int + Height int + Paused bool + Overlay []string // non-nil draws a centered panel over everything + Mission string +} +func Frame(s Scene) string +``` + +`Frame` is §37's pipeline minus the FX steps, which Plan 2 inserts: compute layout → too-small short-circuit → board → HUD → mission → overlay. Plan 2 will extend `Scene` with an `*fx.World` and add the composite steps; keeping the pipeline in one function is what makes that a small change. + +- [ ] **Step 1: Write the failing test** + +```go +package render + +import ( + "strings" + "testing" + + "cosmic-tetris/internal/game" +) + +func TestFrameOutputHasExactTerminalDimensions(t *testing.T) { + for _, c := range [][2]int{{40, 24}, {50, 25}, {80, 30}, {200, 60}} { + out := Frame(Scene{Game: game.New(1), Mode: ModeFull, Width: c[0], Height: c[1]}) + rows := strings.Split(stripANSI(out), "\n") + if len(rows) != c[1] { + t.Errorf("at %dx%d got %d rows, want %d", c[0], c[1], len(rows), c[1]) + } + for i, r := range rows { + if len([]rune(r)) != c[0] { + t.Errorf("at %dx%d row %d is %d runes, want %d", c[0], c[1], i, len([]rune(r)), c[0]) + } + } + } +} + +func TestFrameShowsTheTooSmallNoticeBelowMinimum(t *testing.T) { + out := stripANSI(Frame(Scene{Game: game.New(1), Mode: ModeFull, Width: 34, Height: 19})) + if !strings.Contains(out, "THIS UNIVERSE IS TOO SMALL") { + t.Fatalf("expected the too-small notice, got:\n%s", out) + } + if strings.Contains(out, "╔") { + t.Error("the board should not render below the minimum size") + } +} + +func TestFrameNeverPanicsAcrossASweepOfSizes(t *testing.T) { + g := game.New(3) + for w := 0; w <= 90; w++ { + for h := 0; h <= 40; h++ { + func() { + defer func() { + if r := recover(); r != nil { + t.Fatalf("Frame panicked at %dx%d: %v", w, h, r) + } + }() + Frame(Scene{Game: g, Mode: ModeFull, Width: w, Height: h}) + }() + } + } +} + +func TestFrameDoesNotMutateTheGame(t *testing.T) { + g := game.New(5) + before := *g + Frame(Scene{Game: g, Mode: ModeFull, Width: 80, Height: 30, Mission: "NOMINALISH"}) + if g.Board != before.Board || g.Active != before.Active || g.Score != before.Score || g.Level != before.Level { + t.Fatal("Frame mutated the game") + } +} + +func TestOverlayDrawsOverTheBoard(t *testing.T) { + out := stripANSI(Frame(Scene{ + Game: game.New(1), Mode: ModeFull, Width: 80, Height: 30, + Overlay: []string{"TEMPORAL SUSPENSION", "SPACE IS PAUSED", "p resume"}, + })) + if !strings.Contains(out, "TEMPORAL SUSPENSION") { + t.Fatal("overlay not rendered") + } +} + +func TestAsciiModeFrameIsEntirelyAscii(t *testing.T) { + out := stripANSI(Frame(Scene{Game: game.New(1), Mode: ModeASCII, Width: 80, Height: 30, Mission: "NOMINALISH"})) + for _, r := range out { + if r > 127 && r != '\n' { + t.Fatalf("non-ASCII rune %q in ASCII-mode frame", r) + } + } +} +``` + +- [ ] **Step 2: Add the ANSI stripper the golden tests need** + +Create `internal/render/strip_test.go` with `func stripANSI(s string) string` that removes CSI sequences (`\x1b[` … final byte in `@`–`~`). Keep it in a `_test.go` file: it is test infrastructure, not product code. + +- [ ] **Step 3: Run the test to verify it fails** + +Run: `go test ./internal/render/ -run 'TestFrame|TestOverlayDraws|TestAsciiModeFrame' -v` +Expected: FAIL — undefined: `Frame`, `Scene`. + +- [ ] **Step 4: Implement `Frame` in `render.go`** + +When `Compute` returns `LayoutTooSmall`, paint `TooSmallNotice(s.Width, s.Height)` centered on a blank canvas and return that. Otherwise run the pipeline in order and return `c.String()`. Every returned string has exactly `s.Height` rows of exactly `s.Width` columns (0-sized terminals return `""`). + +- [ ] **Step 5: Run it to verify it passes** + +Run: `go test ./internal/render/ -v` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +gofmt -l . && git add internal/render/render.go internal/render/render_test.go internal/render/strip_test.go +git commit -m "feat(render): frame pipeline with too-small and overlay handling" +``` + +--- + +### Task 16: Bubble Tea app, keys, and CLI + +**Files:** +- Create: `internal/app/keys.go`, `internal/app/messages.go`, `internal/app/model.go`, `internal/app/update.go`, `cmd/cosmic-tetris/main.go` +- Test: `internal/app/update_test.go` + +**Interfaces:** +- Consumes: `game` and `render`. +- Produces: + +```go +// messages.go +type FrameMsg struct{ Now time.Time } +const FrameInterval = 16 * time.Millisecond // ~60 Hz (§36) +const MaxFrameDt = 100 * time.Millisecond // stall clamp (Review Focus 1) +func Tick() tea.Cmd + +// keys.go +type KeyMap struct { + Left, Right, SoftDrop, RotateCW, RotateCCW, HardDrop, Hold, Pause, Restart, Help, Quit key.Binding +} +func DefaultKeyMap() KeyMap +func (k KeyMap) ShortHelp() []key.Binding +func (k KeyMap) FullHelp() [][]key.Binding + +// model.go +type AppState int +const (StatePlaying AppState = iota; StatePaused; StateGameOver) +type Options struct { + Seed int64 + Mode render.Mode + NoFX bool + ReducedMotion bool +} +type Model struct { + Game *game.Game + Width int + Height int + State AppState + ShowHelp bool + LastFrame time.Time + Keys KeyMap + Opts Options +} +func New(opts Options) Model +func (m Model) Init() tea.Cmd +func (m Model) View() string + +// update.go +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) +``` + +`Model.Game` is a pointer, not §34's value field: a `Game` holds a `*rand.Rand`, so copying the struct on every `Update` would share RNG state across copies and make ownership ambiguous. `GravityMsg` from §36 is deliberately absent — §36 also says to prefer one animation clock, so gravity advances from `FrameMsg`'s `dt` (§49.2). + +Key repeat for held left/right comes from terminal auto-repeat: each repeat arrives as its own `tea.KeyMsg` and is applied the moment it arrives, so input never waits for a tick (§8, §36). + +- [ ] **Step 1: Write the failing test** + +```go +package app + +import ( + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "cosmic-tetris/internal/game" + "cosmic-tetris/internal/render" +) + +func keyMsg(s string) tea.KeyMsg { return tea.KeyPressMsg{Code: rune(s[0]), Text: s} } + +func newModel() Model { return New(Options{Seed: 1, Mode: render.ModeFull}) } + +func TestArrowAndViKeysMovePieceImmediately(t *testing.T) { + m := newModel() + m.Width, m.Height = 80, 30 + startX := m.Game.Active.X + next, _ := m.Update(keyMsg("h")) + m = next.(Model) + if m.Game.Active.X != startX-1 { + t.Fatalf("X = %d after 'h', want %d", m.Game.Active.X, startX-1) + } + next, _ = m.Update(keyMsg("l")) + m = next.(Model) + if m.Game.Active.X != startX { + t.Fatalf("X = %d after 'l', want %d", m.Game.Active.X, startX) + } +} + +func TestSpaceHardDropsAndKeysWorkWithoutAnyTick(t *testing.T) { + m := newModel() + m.Width, m.Height = 80, 30 + kind := m.Game.Active.Kind + next, _ := m.Update(tea.KeyPressMsg{Code: ' ', Text: " "}) + m = next.(Model) + if m.Game.Score == 0 && m.Game.Active.Kind == kind { + t.Fatal("space did not hard drop") + } +} + +func TestPauseTogglesStateAndFreezesGravity(t *testing.T) { + m := newModel() + m.Width, m.Height = 80, 30 + next, _ := m.Update(keyMsg("p")) + m = next.(Model) + if m.State != StatePaused { + t.Fatalf("State = %v, want StatePaused", m.State) + } + beforeY := m.Game.Active.Y + m.LastFrame = time.Now().Add(-2 * time.Second) + next, _ = m.Update(FrameMsg{Now: time.Now()}) + m = next.(Model) + if m.Game.Active.Y != beforeY { + t.Fatal("gravity ran while paused") + } + next, _ = m.Update(keyMsg("p")) + if next.(Model).State != StatePlaying { + t.Fatal("p did not resume") + } +} + +func TestMovementKeysAreIgnoredWhilePaused(t *testing.T) { + m := newModel() + m.Width, m.Height = 80, 30 + next, _ := m.Update(keyMsg("p")) + m = next.(Model) + before := *m.Game + for _, k := range []string{"h", "l", "j", "x", "z", "c", " "} { + next, _ = m.Update(keyMsg(k)) + m = next.(Model) + } + if m.Game.Active != before.Active || m.Game.Score != before.Score || m.Game.Board != before.Board { + t.Fatal("a key mutated the game while paused") + } +} + +func TestKeysAreIgnoredAfterGameOver(t *testing.T) { + m := newModel() + m.Width, m.Height = 80, 30 + m.Game.Over = true + m.State = StateGameOver + before := *m.Game + for _, k := range []string{"h", "l", "j", "x", "z", "c", " "} { + next, _ := m.Update(keyMsg(k)) + m = next.(Model) + } + if m.Game.Active != before.Active || m.Game.Score != before.Score { + t.Fatal("a key mutated a finished game") + } +} + +func TestRestartWorksFromPlayingAndGameOver(t *testing.T) { + m := newModel() + m.Width, m.Height = 80, 30 + m.Update(keyMsg(" ")) + m.State = StateGameOver + m.Game.Over = true + next, _ := m.Update(keyMsg("r")) + m = next.(Model) + if m.State != StatePlaying || m.Game.Over || m.Game.Score != 0 { + t.Fatalf("restart left State=%v Over=%v Score=%d", m.State, m.Game.Over, m.Game.Score) + } +} + +func TestGameOverTransitionsStateOnItsOwn(t *testing.T) { + m := newModel() + m.Width, m.Height = 80, 30 + for y := 0; y < game.Height; y++ { + for x := 0; x < game.Width; x++ { + if x != 0 { + m.Game.Board.Set(x, y, game.Cell(1)) + } + } + } + m.Game.Active = game.Piece{Kind: game.I, Rotation: 1, X: -2, Y: game.Height - 4} + next, _ := m.Update(tea.KeyPressMsg{Code: ' ', Text: " "}) + m = next.(Model) + if m.State != StateGameOver { + t.Fatalf("State = %v after a fatal placement, want StateGameOver", m.State) + } +} + +func TestFrameDtIsClampedSoAStallDoesNotAvalanche(t *testing.T) { + m := newModel() + m.Width, m.Height = 80, 30 + m.Game.Level = 15 + startY := m.Game.Active.Y + m.LastFrame = time.Now().Add(-30 * time.Second) // a long suspend + next, _ := m.Update(FrameMsg{Now: time.Now()}) + m = next.(Model) + maxRows := int(MaxFrameDt/game.DropInterval(15)) + 1 + if m.Game.Active.Y-startY > maxRows && !m.Game.Over { + t.Fatalf("piece fell %d rows from one stalled frame; clamp allows at most %d", + m.Game.Active.Y-startY, maxRows) + } +} + +func TestFrameAdvancesGravityOverTime(t *testing.T) { + m := newModel() + m.Width, m.Height = 80, 30 + startY := m.Game.Active.Y + now := time.Now() + for i := 0; i < 80; i++ { // 80 x 16ms ~ 1.28s: at least one drop at level 1 + m.LastFrame = now + now = now.Add(16 * time.Millisecond) + next, _ := m.Update(FrameMsg{Now: now}) + m = next.(Model) + } + if m.Game.Active.Y <= startY { + t.Fatalf("Y = %d after ~1.3s, want more than %d", m.Game.Active.Y, startY) + } +} + +func TestResizeIsStoredAndDegenerateSizesDoNotPanic(t *testing.T) { + m := newModel() + for _, s := range []tea.WindowSizeMsg{{Width: 80, Height: 30}, {Width: 0, Height: 0}, {Width: 1, Height: 1}, {Width: 400, Height: 120}} { + next, _ := m.Update(s) + m = next.(Model) + if m.Width != s.Width || m.Height != s.Height { + t.Fatalf("size not stored: got %dx%d want %dx%d", m.Width, m.Height, s.Width, s.Height) + } + if got := m.View(); got == "" && s.Width > 0 { + t.Fatalf("View() empty at %dx%d", s.Width, s.Height) + } + } +} + +func TestHelpTogglesAndQuitReturnsQuitCmd(t *testing.T) { + m := newModel() + m.Width, m.Height = 80, 30 + next, _ := m.Update(keyMsg("?")) + m = next.(Model) + if !m.ShowHelp { + t.Error("? did not open help") + } + next, _ = m.Update(keyMsg("?")) + if next.(Model).ShowHelp { + t.Error("? did not close help") + } + _, cmd := m.Update(keyMsg("q")) + if cmd == nil { + t.Fatal("q must return a command (tea.Quit)") + } +} + +func TestPausedViewShowsTheSuspensionOverlay(t *testing.T) { + m := newModel() + m.Width, m.Height = 80, 30 + next, _ := m.Update(keyMsg("p")) + m = next.(Model) + if got := m.View(); !contains(got, "TEMPORAL SUSPENSION") { + t.Fatalf("paused view lacks the overlay:\n%s", got) + } +} + +func TestGameOverViewShowsScoreAndOptions(t *testing.T) { + m := newModel() + m.Width, m.Height = 80, 30 + m.Game.Score, m.Game.Lines, m.Game.Level = 483200, 127, 13 + m.State = StateGameOver + out := m.View() + for _, want := range []string{"UNIVERSE EXPIRED", "483", "127", "13", "REBOOT UNIVERSE"} { + if !contains(out, want) { + t.Errorf("game-over view missing %q", want) + } + } +} +``` + +Add a small `contains` helper in the test file that strips ANSI before calling `strings.Contains` (reuse the same CSI-stripping logic as `render`'s test helper). + +- [ ] **Step 2: Run it to verify it fails** + +Run: `go test ./internal/app/ -v` +Expected: FAIL — undefined: `New`, `Model`, `FrameMsg`. + +- [ ] **Step 3: Implement `keys.go` and `messages.go`** + +Bindings per §8, including the WASD aliases: Left `left`/`h`/`a`, Right `right`/`l`/`d`, SoftDrop `down`/`j`/`s`, RotateCW `up`/`k`/`x`/`w`, RotateCCW `z`, HardDrop `space`, Hold `c`, Pause `p`, Restart `r`, Help `?`, Quit `q`/`esc`. Help text per §39's flight-manual wording. `Tick()` returns `tea.Tick(FrameInterval, func(t time.Time) tea.Msg { return FrameMsg{Now: t} })`. + +- [ ] **Step 4: Implement `model.go` and `update.go`** + +`Init` returns `Tick()`. `Update` handles: `tea.WindowSizeMsg` (store dimensions); `FrameMsg` (compute `dt = msg.Now.Sub(m.LastFrame)`, clamp to `MaxFrameDt`, clamp negatives to 0, set `LastFrame`, and — only when `State == StatePlaying` — call `m.Game.Advance(dt)`; if the game reports `Over`, set `StateGameOver`; always return `Tick()`); `tea.KeyMsg` (Quit always; Restart always; Help always; Pause when playing or paused; everything else only when `State == StatePlaying`, and after each call check `m.Game.Over`). + +`View` builds a `render.Scene`: `Overlay` is §30's pause panel when paused, §39's flight manual when `ShowHelp`, and §28's final panel when `StateGameOver` (`UNIVERSE EXPIRED`, comma-grouped score, lines, level, `r REBOOT UNIVERSE`, `q ACCEPT COSMIC DEATH`). Help wins over pause when both apply. + +- [ ] **Step 5: Run it to verify it passes** + +Run: `go test ./internal/app/ -v` +Expected: PASS. + +- [ ] **Step 6: Implement `cmd/cosmic-tetris/main.go`** + +Parse with `flag`: `--seed int64` (default: a time-derived value, the one place a clock is legitimate), `--ascii`, `--no-fx`, `--reduced-motion`, and `flag.Usage` copy naming all five (§49.5). Build `app.Options`, then `tea.NewProgram(app.New(opts), tea.WithAltScreen())` and `Run()`. Exit non-zero with the error on failure. + +- [ ] **Step 7: Verify the binary builds, the flags parse, and the game runs** + +Run: `go build ./... && go vet ./... && go run ./cmd/cosmic-tetris --help` +Expected: build and vet clean; usage text lists `--seed`, `--ascii`, `--no-fx`, `--reduced-motion`, `--help`. + +- [ ] **Step 8: Commit** + +```bash +gofmt -l . && git add internal/app/ cmd/ +git commit -m "feat(app): bubble tea model, key handling, CLI flags" +``` + +--- + +### Task 17: Golden layout snapshots + +**Files:** +- Test: `internal/render/golden_test.go` +- Create: `internal/render/testdata/*.txt` (generated) + +**Interfaces:** +- Consumes: `render.Frame`. +- Produces: the binding layout contract of §41 and §49.7. + +- [ ] **Step 1: Write the failing golden test** + +```go +package render + +import ( + "flag" + "os" + "path/filepath" + "strings" + "testing" + + "cosmic-tetris/internal/game" +) + +var update = flag.Bool("update", false, "rewrite golden files") + +// fixture builds a deterministic mid-game position: no clocks, no RNG surprises. +func fixture() *game.Game { + g := game.New(0x7F3A) + g.Score, g.Lines, g.Level, g.Combo = 129340, 42, 7, 3 + for x := 0; x < game.Width-3; x++ { + g.Board.Set(x, game.Height-1, game.Cell(int(game.T)+1)) + g.Board.Set(x, game.Height-2, game.Cell(int(game.S)+1)) + } + g.Board.Set(0, game.Height-3, game.Cell(int(game.Z)+1)) + g.Active = game.Piece{Kind: game.T, Rotation: 0, X: 3, Y: 8} + held := game.O + g.Hold = &held + return g +} + +func assertGolden(t *testing.T, name string, out string) { + t.Helper() + path := filepath.Join("testdata", name+".txt") + plain := stripANSI(out) + if *update { + if err := os.MkdirAll("testdata", 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(plain), 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(path) + if err != nil { + t.Fatalf("missing golden %s (run: go test ./internal/render -update): %v", path, err) + } + if string(want) != plain { + t.Errorf("golden %s mismatch\n--- want ---\n%s\n--- got ---\n%s", name, want, plain) + } +} + +func TestGoldenLayouts(t *testing.T) { + cases := []struct { + name string + scene Scene + }{ + {"wide", Scene{Game: fixture(), Mode: ModeFull, Width: 80, Height: 30, Mission: "GRAVITY TAX INCREASED"}}, + {"medium", Scene{Game: fixture(), Mode: ModeFull, Width: 54, Height: 26, Mission: "NOMINALISH"}}, + {"small", Scene{Game: fixture(), Mode: ModeFull, Width: 40, Height: 24}}, + {"toosmall", Scene{Game: fixture(), Mode: ModeFull, Width: 34, Height: 19}}, + {"ascii", Scene{Game: fixture(), Mode: ModeASCII, Width: 80, Height: 30, Mission: "NOMINALISH"}}, + {"paused", Scene{Game: fixture(), Mode: ModeFull, Width: 80, Height: 30, Paused: true, + Overlay: []string{"TEMPORAL SUSPENSION", "", "SPACE IS PAUSED", "", "p resume"}}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { assertGolden(t, c.name, Frame(c.scene)) }) + } +} + +func TestGoldensSatisfyTheLayoutInvariants(t *testing.T) { + entries, err := filepath.Glob(filepath.Join("testdata", "*.txt")) + if err != nil || len(entries) == 0 { + t.Fatalf("no golden files found: %v", err) + } + for _, path := range entries { + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + rows := strings.Split(string(b), "\n") + width := len([]rune(rows[0])) + for i, r := range rows { + if len([]rune(r)) != width { + t.Errorf("%s: row %d is %d runes, want %d (ragged output means overlap)", path, i, len([]rune(r)), width) + } + } + if filepath.Base(path) == "toosmall.txt" { + continue + } + // Every board row must have exactly 20 interior columns between its walls. + for _, r := range rows { + if !strings.ContainsAny(r, "║|") { + continue + } + left := strings.IndexAny(r, "║|") + right := strings.LastIndexAny(r, "║|") + if left == right { + continue + } + interior := len([]rune(string([]rune(r)[left+1 : right]))) + if interior != 20 { + t.Errorf("%s: a board row has %d interior columns, want 20: %q", path, interior, r) + } + } + } +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `go test ./internal/render/ -run TestGolden -v` +Expected: FAIL — missing golden files in `testdata/`. + +- [ ] **Step 3: Generate the goldens and read every one** + +Run: `go test ./internal/render/ -run TestGoldenLayouts -update` +Then read each file in `internal/render/testdata/`. Confirm by eye: nothing overlaps, the board is 20 interior columns by 20 rows, the HUD does not run into the board, `small.txt` has no title row and no stat labels, `ascii.txt` contains no non-ASCII bytes. If any of those are wrong, the bug is in Tasks 12–15 — fix the code and regenerate, never hand-edit a golden. + +- [ ] **Step 4: Run the full suite to verify it passes** + +Run: `go test ./... -v` +Expected: PASS across `internal/game`, `internal/render`, `internal/app`. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . && git add internal/render/golden_test.go internal/render/testdata/ +git commit -m "test(render): golden layout snapshots for wide, medium, small, ascii, paused" +``` + +--- + +### Task 18: README, LICENSE, and Plan 1 acceptance + +**Files:** +- Create: `README.md`, `LICENSE` + +**Interfaces:** +- Consumes: the whole build. +- Produces: nothing code-facing. + +- [ ] **Step 1: Write `README.md`** + +Sections: what it is (one paragraph, in the spec's voice), `go run ./cmd/cosmic-tetris`, the five CLI flags from §49.5, the controls table from §8, and a short architecture note naming the four `internal` packages and the rule that FX never modify game state. + +- [ ] **Step 2: Add `LICENSE`** + +MIT, copyright the repository owner. + +- [ ] **Step 3: Verify the acceptance criteria this plan owns** + +Run: `go build ./... && go vet ./... && go test ./... -race` +Then play it: `go run ./cmd/cosmic-tetris --seed 1234`. Confirm by hand, and note each in the commit body: controls feel immediate; resizing the window mid-play never crashes and switches layouts; hold, ghost, and the next queue work; gravity speeds up as the level rises; pause and restart work; `--ascii` renders a playable board; game over shows the panel and `r` reboots. + +- [ ] **Step 4: Commit** + +```bash +gofmt -l . && git add README.md LICENSE +git commit -m "docs: README and LICENSE; Plan 1 acceptance verified" +``` + +--- + +## What Plan 1 deliberately leaves out + +These belong to Plans 2 and 3 and are already accounted for there: starfield, animated border, piece trails, mission-control message generation (Plan 1 renders whatever string it is handed), particles, hard-drop impact, line-clear supernova, screen shake, shockwaves, hyperdrive, the four-line sequence, combo escalation, level-up notification, the boot sequence, the game-over black hole, the help overlay's final styling, and `--no-fx` / `--reduced-motion` actually suppressing anything (Plan 1 only parses and stores them). diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/Q2-reader-recipe-cosmic-31/2026-09-17-cosmic-tetris-2-cosmic-effects.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/Q2-reader-recipe-cosmic-31/2026-09-17-cosmic-tetris-2-cosmic-effects.md new file mode 100644 index 000000000..b8b98b845 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/Q2-reader-recipe-cosmic-31/2026-09-17-cosmic-tetris-2-cosmic-effects.md @@ -0,0 +1,2779 @@ +# Cosmic Tetris — Plan 2: Cosmic Effects Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the independent effects simulation — starfield, particles, trails, animated border, impacts, screen shake, supernova line clears, shockwaves, hyperdrive, the four-line sequence, combo escalation, and mission-control commentary — so the terminal visibly loses its composure without gameplay ever waiting for it. + +**Architecture:** `internal/fx` is a simulation that observes `[]game.Event` and advances on `Update(dt)`. It never holds a `*game.Game` beyond a read and never writes to one. It emits nothing but data: `World.Marks()` appends glyph/brightness/color-role marks in terminal cell coordinates, tagged with the layer they belong to, and `internal/render` decides how those become ANSI. `internal/flavor` is a pure string table. Effect intensity is a single scalar that `--no-fx` zeroes and `--reduced-motion` partly suppresses, which is why both flags cost almost nothing. + +**Tech Stack:** Go 1.24, `charm.land/lipgloss/v2` (render side only), `math/rand` (v1) with an RNG that is not the game's. + +**Spec:** `design.md` (this plan implements §14–§27, §37 steps 2/6/9/10/11, §38, §42 phases 3–4, §44) + +**Depends on:** Plan 1 (`plans/2026-09-17-cosmic-tetris-1-engine-and-playable-terminal.md`) complete and its tests green. + +## Global Constraints + +- `internal/fx` must not import `internal/render` or `internal/app` — `render` imports `fx`, and a cycle is a compile error. `fx` imports `internal/game` for the `Event` type only. +- **`fx` may never modify `GameState`** (§14). Every `fx` function that receives a `*game.Game` takes it read-only; prefer passing only the event slice plus scalars. +- `fx.World` holds its own `*rand.Rand`, independent of the game's (§35, §49.6). Crossing them makes piece order depend on particle counts. +- Effects never delay gameplay: no `Update` path blocks, sleeps, or gates an input (§44). +- Screen shake never exceeds one terminal cell in any direction (§44). +- Particles never permanently alter the rendered board — they are composited per frame onto a fresh canvas (§44). +- Board readability is sacred: nothing in this plan may draw over the active piece, and board-layer marks are clipped to the board interior (§21, §44). +- No goroutine per particle, no goroutine per frame, no filesystem access during gameplay, no synchronous per-frame logging (§38). Reuse slices. +- `--no-fx` zeroes all effects and the game must still be good (§32). `--reduced-motion` suppresses screen shake, hyperdrive acceleration, and shockwaves while leaving color, trails, and particles alone (§49.5). +- New files this plan adds beyond §33's tree: none. It fills in `internal/fx/{world,particle,starfield,events}.go` and `internal/flavor/messages.go` exactly as §33 lists them. +- Every task ends with a commit. `gofmt -l .` must print nothing. + +## Review Focus + +1. **Sustained four-line clears with hyperdrive stacking**: a skilled player triggers a tetris every few seconds, each emitting dozens of particles plus a star-density bump. Without a hard cap, the particle slice grows until frame time collapses and the terminal — the actual bottleneck (§38) — stops keeping up. Cap and evict. — Task 2. +2. **A particle drifting far off-screen**: physics puts `X` at `-40` or `1e6` (or a NaN if a drag/dt combination ever divides), and a mark at that coordinate must be culled by the simulation and clipped by the canvas, never indexed. — Tasks 2 and 4. +3. **`dt == 0` and enormous `dt`**: the app clamps gameplay `dt`, but FX see the same value, and a zero `dt` must not divide, while a 100 ms `dt` must not let a 140 ms trail skip its entire fade in a single step. — Task 1. +4. **A resize mid-effect**: the terminal shrinks from 100×40 to 40×24 while a shockwave is expanding and a banner is up. Marks computed for the old geometry must be clipped or re-based, not written past the new canvas, and the board layer must follow the board's new rect. — Task 4. +5. **`--no-fx` with events still flowing**: the world is constructed but disabled, so `Observe` and `Update` must remain safe no-ops that allocate nothing and return zero marks — a disabled world must not be a nil-pointer path. — Task 1. + +--- + +## File Structure + +```text +internal/fx/ +├── events.go Config, intensity, event ingestion, trigger classification +├── particle.go Particle, integration step, emitters, the population cap +├── starfield.go three depth layers, drift, hyperdrive speed +└── world.go World: state, Update, Observe, Marks, shake, border, banner +internal/flavor/ +└── messages.go every fixed string the spec pins, plus contextual selection +internal/render/ +├── fx.go NEW: Marks -> Canvas compositing, star drawing, shake offset +├── render.go MODIFIED: Scene gains *fx.World; pipeline gains steps 2/6/9/10 +├── board.go MODIFIED: honors the shake offset +├── hud.go MODIFIED: HUD pulse styling +└── palette.go MODIFIED: ColorRole -> style, border gradient colors +internal/app/ +├── model.go MODIFIED: owns the World, session best score +└── update.go MODIFIED: feeds events to the World, advances it +``` + +--- + +### Task 1: World skeleton, config, and intensity + +**Files:** +- Create: `internal/fx/events.go`, `internal/fx/world.go` +- Test: `internal/fx/world_test.go` + +**Interfaces:** +- Consumes: `game.Event`, `game.EventKind` (Plan 1 Task 7). +- Produces: + +```go +// events.go +type Config struct { + Enabled bool // false when --no-fx + ReducedMotion bool // true when --reduced-motion + ASCII bool // true when --ascii: restrict glyphs to ASCII +} +type Layer uint8 +const (LayerBoard Layer = iota; LayerScreen) // board layer is clipped to the board interior +type ColorRole uint8 +const ( + RoleStarFar ColorRole = iota + RoleStarMid + RoleStarNear + RoleDebris + RoleImpact + RolePiece // use Mark.Kind + RoleBanner + RoleShock +) +type Mark struct { + X, Y int + Glyph rune + Bright float64 // 0..1 + Layer Layer + Role ColorRole + Kind game.PieceKind // meaningful when Role == RolePiece +} + +// world.go +type Rect struct { X, Y, W, H int } +type World struct { + Cfg Config + Level int + Combo int + Elapsed time.Duration + Screen Rect + Board Rect // board interior, in screen cell coordinates + // unexported: rng, particles, stars, trails, clears, shocks, hyper, shake, banner, border, mission +} +func NewWorld(seed int64, cfg Config) *World +func (w *World) SetGeometry(screen, board Rect) +func (w *World) Update(dt time.Duration) +func (w *World) Observe(events []game.Event) +func (w *World) Marks(dst []Mark) []Mark // appends; returns dst for reuse (§38) +func (w *World) ParticleCount() int +``` + +`Observe` takes only the event slice — that is the narrowest read of game state that works, and it makes "FX cannot modify GameState" true by construction rather than by discipline. + +- [ ] **Step 1: Write the failing test** + +```go +package fx + +import ( + "testing" + "time" + + "cosmic-tetris/internal/game" +) + +func enabled() Config { return Config{Enabled: true} } + +func TestNewWorldStartsQuietAndTracksElapsed(t *testing.T) { + w := NewWorld(1, enabled()) + w.SetGeometry(Rect{0, 0, 80, 30}, Rect{10, 3, 20, 20}) + if w.Level != 1 { + t.Errorf("Level = %d, want 1", w.Level) + } + w.Update(50 * time.Millisecond) + if w.Elapsed != 50*time.Millisecond { + t.Errorf("Elapsed = %v, want 50ms", w.Elapsed) + } + w.Update(50 * time.Millisecond) + if w.Elapsed != 100*time.Millisecond { + t.Errorf("Elapsed = %v, want 100ms", w.Elapsed) + } +} + +func TestZeroAndHugeDtAreSafe(t *testing.T) { + w := NewWorld(1, enabled()) + w.SetGeometry(Rect{0, 0, 80, 30}, Rect{10, 3, 20, 20}) + w.Observe([]game.Event{{Kind: game.EventPieceHardDropped, Distance: 12, Piece: game.Piece{Kind: game.I, X: 3, Y: 8}}}) + w.Update(0) + w.Update(0) + before := w.ParticleCount() + w.Update(10 * time.Second) // everything transient must have expired, not exploded + if w.ParticleCount() > before { + t.Errorf("particles grew across a 10s step: %d -> %d", before, w.ParticleCount()) + } + marks := w.Marks(nil) + for _, m := range marks { + if m.Bright < 0 || m.Bright > 1 { + t.Fatalf("mark brightness %v out of range after a huge dt", m.Bright) + } + } +} + +func TestDisabledWorldProducesNothingAndAllocatesNothing(t *testing.T) { + w := NewWorld(1, Config{Enabled: false}) + w.SetGeometry(Rect{0, 0, 80, 30}, Rect{10, 3, 20, 20}) + w.Observe([]game.Event{ + {Kind: game.EventPieceHardDropped, Distance: 20}, + {Kind: game.EventLinesCleared, Rows: []int{18, 19, 20, 21}}, + {Kind: game.EventComboChanged, Value: 7}, + {Kind: game.EventLevelChanged, Value: 9}, + {Kind: game.EventGameOver}, + }) + for i := 0; i < 100; i++ { + w.Update(16 * time.Millisecond) + } + if got := w.ParticleCount(); got != 0 { + t.Errorf("disabled world holds %d particles, want 0", got) + } + if got := w.Marks(nil); len(got) != 0 { + t.Errorf("disabled world produced %d marks, want 0", len(got)) + } + if dx, dy := w.ShakeOffset(); dx != 0 || dy != 0 { + t.Errorf("disabled world shakes: %d,%d", dx, dy) + } +} + +func TestObserveNeverPanicsOnAnyEventKind(t *testing.T) { + kinds := []game.EventKind{ + game.EventPieceMoved, game.EventPieceRotated, game.EventPieceHardDropped, + game.EventPieceLocked, game.EventHoldUsed, game.EventLinesCleared, + game.EventComboChanged, game.EventLevelChanged, game.EventGameOver, + } + for _, k := range kinds { + w := NewWorld(1, enabled()) + w.SetGeometry(Rect{0, 0, 80, 30}, Rect{10, 3, 20, 20}) + w.Observe([]game.Event{{Kind: k, Rows: []int{21}, Distance: 3, Value: 4, Piece: game.Piece{Kind: game.T, X: 4, Y: 10}}}) + w.Update(16 * time.Millisecond) + _ = w.Marks(nil) + } + // Also: no geometry set at all. + w := NewWorld(1, enabled()) + w.Observe([]game.Event{{Kind: game.EventLinesCleared, Rows: []int{21}}}) + w.Update(16 * time.Millisecond) + _ = w.Marks(nil) +} + +func TestObserveHandlesNilAndEmptySlices(t *testing.T) { + w := NewWorld(1, enabled()) + w.SetGeometry(Rect{0, 0, 80, 30}, Rect{10, 3, 20, 20}) + w.Observe(nil) + w.Observe([]game.Event{}) + w.Update(16 * time.Millisecond) +} + +func TestFXRngIsIndependentOfTheGameRng(t *testing.T) { + // Running FX against one game must not change the piece order of another + // game with the same seed. This is §35's isolation requirement. + plain := game.New(4242) + withFX := game.New(4242) + w := NewWorld(999, enabled()) + w.SetGeometry(Rect{0, 0, 80, 30}, Rect{10, 3, 20, 20}) + for i := 0; i < 200; i++ { + w.Observe(withFX.HardDrop()) + w.Update(16 * time.Millisecond) + plain.HardDrop() + if withFX.Over || plain.Over { + break + } + } + if plain.Board != withFX.Board || plain.Score != withFX.Score || plain.Active != withFX.Active { + t.Fatal("running FX changed the game: the RNGs are crossed") + } +} + +func TestObserveDoesNotMutateTheEventsItIsGiven(t *testing.T) { + rows := []int{18, 19, 20, 21} + evs := []game.Event{{Kind: game.EventLinesCleared, Rows: rows}} + w := NewWorld(1, enabled()) + w.SetGeometry(Rect{0, 0, 80, 30}, Rect{10, 3, 20, 20}) + w.Observe(evs) + if evs[0].Rows[0] != 18 || len(evs[0].Rows) != 4 { + t.Fatalf("Observe mutated the event slice: %v", evs[0].Rows) + } +} + +func TestLevelAndComboAreTrackedFromEvents(t *testing.T) { + w := NewWorld(1, enabled()) + w.SetGeometry(Rect{0, 0, 80, 30}, Rect{10, 3, 20, 20}) + w.Observe([]game.Event{{Kind: game.EventLevelChanged, Value: 9}, {Kind: game.EventComboChanged, Value: 5}}) + if w.Level != 9 { + t.Errorf("Level = %d, want 9", w.Level) + } + if w.Combo != 5 { + t.Errorf("Combo = %d, want 5", w.Combo) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/fx/ -v` +Expected: FAIL — undefined: `NewWorld`, `Config`, `Mark`. + +- [ ] **Step 3: Implement `events.go` and the `world.go` skeleton** + +`NewWorld` builds `rng: rand.New(rand.NewSource(seed))` and `Level: 1`. Every public method returns immediately when `!w.Cfg.Enabled`. `Update` adds `dt` to `Elapsed` and then advances each subsystem; subsystem advance functions are stubs for now (`updateParticles`, `updateStars`, `updateTrails`, `updateClears`, `updateShocks`, `updateHyper`, `updateShake`, `updateBanner`, `updateBorder`) so later tasks fill one each. `Observe` switches on `EventKind`, updating `Level`/`Combo` and calling per-effect emitters (also stubs). Add `func (w *World) ShakeOffset() (int, int)` returning `0, 0` for now — Task 8 implements it. + +Guard against a large `dt` starving short animations: `Update` splits `dt` into steps of at most `MaxStep = 16 * time.Millisecond` and loops, capping the number of substeps at 8 so an enormous `dt` costs bounded work. + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `go test ./internal/fx/ -v` +Expected: PASS (8 tests). + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . && git add internal/fx/ +git commit -m "feat(fx): world skeleton, config, event ingestion, mark interface" +``` + +--- + +### Task 2: Particle simulation + +**Files:** +- Create: `internal/fx/particle.go` +- Modify: `internal/fx/world.go` +- Test: `internal/fx/particle_test.go` + +**Interfaces:** +- Consumes: Task 1's `World`, `Mark`, `Layer`, `ColorRole`. +- Produces: + +```go +type Particle struct { + X, Y float64 + VX, VY float64 + Life float64 // seconds remaining + MaxLife float64 + Glyph rune + Brightness float64 + Layer Layer + Role ColorRole +} +const MaxParticles = 600 +var Gravity = 14.0 // cells/s^2, downward +var Drag = 0.90 // per-step multiplier +func (w *World) Emit(p Particle) +func (w *World) EmitBurst(x, y float64, count int, speed float64, layer Layer, role ColorRole) +func (w *World) ParticleCount() int +``` + +Integration per §23: `position += velocity × dt; velocity += acceleration × dt; velocity *= drag; life -= dt`. Particles die at `Life <= 0` or when outside the screen rect. No collision detection. + +- [ ] **Step 1: Write the failing test** + +```go +package fx + +import ( + "math" + "testing" + "time" +) + +func worldAt(w, h int) *World { + world := NewWorld(7, Config{Enabled: true}) + world.SetGeometry(Rect{0, 0, w, h}, Rect{10, 3, 20, 20}) + return world +} + +func TestParticleMovesByVelocityAndFalls(t *testing.T) { + w := worldAt(80, 30) + w.Emit(Particle{X: 10, Y: 10, VX: 4, VY: 0, Life: 5, MaxLife: 5, Glyph: '*', Brightness: 1}) + w.Update(100 * time.Millisecond) + m := w.Marks(nil) + if len(m) == 0 { + t.Fatal("no marks after emitting a particle") + } + var found bool + for _, k := range m { + if k.Glyph == '*' { + found = true + if k.X <= 10 { + t.Errorf("particle X = %d, want it to have moved right of 10", k.X) + } + if k.Y < 10 { + t.Errorf("particle Y = %d, want gravity to pull it down or hold it", k.Y) + } + } + } + if !found { + t.Fatal("particle produced no mark") + } +} + +func TestParticleDiesWhenLifeRunsOut(t *testing.T) { + w := worldAt(80, 30) + w.Emit(Particle{X: 10, Y: 10, Life: 0.05, MaxLife: 0.05, Glyph: '*', Brightness: 1}) + if w.ParticleCount() != 1 { + t.Fatalf("ParticleCount = %d, want 1", w.ParticleCount()) + } + w.Update(100 * time.Millisecond) + if w.ParticleCount() != 0 { + t.Fatalf("ParticleCount = %d after its life expired, want 0", w.ParticleCount()) + } +} + +func TestParticleBrightnessFadesWithLife(t *testing.T) { + w := worldAt(80, 30) + w.Emit(Particle{X: 10, Y: 10, Life: 1, MaxLife: 1, Glyph: '*', Brightness: 1}) + first := w.Marks(nil)[0].Bright + w.Update(500 * time.Millisecond) + second := w.Marks(nil)[0].Bright + if !(second < first) { + t.Fatalf("brightness did not fade: %v -> %v", first, second) + } + if second < 0 { + t.Fatalf("brightness went negative: %v", second) + } +} + +func TestParticlesLeavingTheViewportAreCulled(t *testing.T) { + w := worldAt(80, 30) + for _, p := range []Particle{ + {X: -50, Y: 10, Life: 9, MaxLife: 9, Glyph: 'a', Brightness: 1}, + {X: 500, Y: 10, Life: 9, MaxLife: 9, Glyph: 'b', Brightness: 1}, + {X: 10, Y: -30, Life: 9, MaxLife: 9, Glyph: 'c', Brightness: 1}, + {X: 10, Y: 900, Life: 9, MaxLife: 9, Glyph: 'd', Brightness: 1}, + } { + w.Emit(p) + } + w.Update(16 * time.Millisecond) + if got := w.ParticleCount(); got != 0 { + t.Fatalf("ParticleCount = %d, want all off-screen particles culled", got) + } +} + +func TestNonFiniteParticlesAreRejectedOrCulled(t *testing.T) { + w := worldAt(80, 30) + w.Emit(Particle{X: math.NaN(), Y: 5, Life: 9, MaxLife: 9, Glyph: 'n', Brightness: 1}) + w.Emit(Particle{X: 5, Y: math.Inf(1), Life: 9, MaxLife: 9, Glyph: 'i', Brightness: 1}) + w.Update(16 * time.Millisecond) + for _, m := range w.Marks(nil) { + if m.Glyph == 'n' || m.Glyph == 'i' { + t.Fatalf("a non-finite particle produced a mark at %d,%d", m.X, m.Y) + } + } +} + +func TestParticlePopulationIsCapped(t *testing.T) { + w := worldAt(80, 30) + for i := 0; i < MaxParticles*4; i++ { + w.EmitBurst(40, 15, 20, 12, LayerScreen, RoleDebris) + } + if got := w.ParticleCount(); got > MaxParticles { + t.Fatalf("ParticleCount = %d, want at most %d", got, MaxParticles) + } + // And the cap must not stop new effects from appearing at all. + if w.ParticleCount() == 0 { + t.Fatal("the cap dropped everything") + } +} + +func TestEmitBurstSpreadsInAllDirections(t *testing.T) { + w := worldAt(80, 30) + w.EmitBurst(40, 15, 40, 10, LayerScreen, RoleDebris) + if w.ParticleCount() < 30 { + t.Fatalf("burst produced %d particles, want ~40", w.ParticleCount()) + } + w.Update(120 * time.Millisecond) + var left, right, up bool + for _, m := range w.Marks(nil) { + if m.Role != RoleDebris { + continue + } + if m.X < 40 { + left = true + } + if m.X > 40 { + right = true + } + if m.Y < 15 { + up = true + } + } + if !left || !right || !up { + t.Fatalf("burst is not radial: left=%v right=%v up=%v", left, right, up) + } +} + +func TestBurstIsReproducibleForAGivenFxSeed(t *testing.T) { + snap := func() []Mark { + w := worldAt(80, 30) + w.EmitBurst(40, 15, 30, 10, LayerScreen, RoleDebris) + w.Update(80 * time.Millisecond) + return w.Marks(nil) + } + a, b := snap(), snap() + if len(a) != len(b) { + t.Fatalf("same fx seed produced %d vs %d marks", len(a), len(b)) + } + for i := range a { + if a[i] != b[i] { + t.Fatalf("mark %d diverged: %+v vs %+v", i, a[i], b[i]) + } + } +} + +func TestMarksAppendsToTheProvidedSliceForReuse(t *testing.T) { + w := worldAt(80, 30) + w.EmitBurst(40, 15, 10, 8, LayerScreen, RoleDebris) + buf := make([]Mark, 0, 512) + buf = w.Marks(buf[:0]) + first := len(buf) + buf = w.Marks(buf[:0]) + if len(buf) != first { + t.Fatalf("Marks into a reused buffer gave %d then %d", first, len(buf)) + } + if cap(buf) < 512 { + t.Error("Marks reallocated instead of appending into the given slice") + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/fx/ -run TestParticle -run 'TestParticle|TestNonFinite|TestEmitBurst|TestBurstIs|TestMarksAppends' -v` +Expected: FAIL — undefined: `Particle`, `Emit`, `MaxParticles`. + +- [ ] **Step 3: Implement `particle.go` and wire `updateParticles` into `Update`** + +`Emit` drops the particle when `X`/`Y`/`VX`/`VY` are not finite (`math.IsNaN` or `math.IsInf`). When the slice is at `MaxParticles`, overwrite the oldest entry rather than appending — a bounded ring keeps a tetris storm from unbounded growth. Culling compacts in place (`particles = particles[:n]`) so the backing array is reused. Mark brightness is `p.Brightness × (p.Life / p.MaxLife)`, clamped to `0..1`. `Marks` rounds float positions with `int(math.Round(...))`. + +`EmitBurst` picks `count` angles as `2π × i / count` plus a `rng`-drawn jitter, and speed as `speed × (0.6 + 0.8 × rng.Float64())` — the "radial explosion force + random angular variation" of §23. + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `go test ./internal/fx/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . && git add internal/fx/particle.go internal/fx/world.go internal/fx/particle_test.go +git commit -m "feat(fx): particle physics with a bounded population" +``` + +--- + +### Task 3: Starfield + +**Files:** +- Create: `internal/fx/starfield.go` +- Test: `internal/fx/starfield_test.go` + +**Interfaces:** +- Produces: + +```go +type Star struct { + X, Y float64 + Speed float64 + Glyph rune + Depth int // 0 far, 1 mid, 2 near +} +const (StarsFar = 40; StarsMid = 24; StarsNear = 10) +func (w *World) seedStars() // called by SetGeometry +func (w *World) StarCount() int +func (w *World) SpeedMultiplier() float64 // level drift + hyperdrive, 1.0 at rest +``` + +Glyph sets (§15): far `.`, mid `·` and `˚`, near `✦` and `✧`; in ASCII mode far `.`, mid `:`, near `*`. Base downward speeds: far 0.6, mid 1.6, near 3.4 cells/s. Level scaling is subtle: `1 + 0.04 × (level - 1)`, capped at 2.0. Stars that pass the bottom wrap to the top with a fresh random column. + +- [ ] **Step 1: Write the failing test** + +```go +package fx + +import ( + "strings" + "testing" + "time" +) + +func TestStarsExistInThreeDepthsAfterGeometryIsSet(t *testing.T) { + w := worldAt(80, 30) + depths := map[int]int{} + for _, m := range w.Marks(nil) { + switch m.Role { + case RoleStarFar: + depths[0]++ + case RoleStarMid: + depths[1]++ + case RoleStarNear: + depths[2]++ + } + } + for d := 0; d < 3; d++ { + if depths[d] == 0 { + t.Errorf("depth %d has no stars", d) + } + } + if depths[0] <= depths[2] { + t.Errorf("far stars (%d) should outnumber near stars (%d)", depths[0], depths[2]) + } +} + +func TestStarsDriftDownwardAndNearFasterThanFar(t *testing.T) { + w := worldAt(80, 30) + before := map[rune]float64{} + sum := func() (far, near float64) { + for _, m := range w.Marks(nil) { + switch m.Role { + case RoleStarFar: + far += float64(m.Y) + case RoleStarNear: + near += float64(m.Y) + } + } + return + } + far0, near0 := sum() + w.Update(500 * time.Millisecond) + far1, near1 := sum() + if far1 <= far0 { + t.Errorf("far stars did not drift down: %v -> %v", far0, far1) + } + if (near1-near0)/StarsNear <= (far1-far0)/StarsFar { + t.Error("near stars must move faster than far stars") + } + _ = before +} + +func TestStarCountIsStableAcrossLongRuns(t *testing.T) { + w := worldAt(80, 30) + start := w.StarCount() + for i := 0; i < 600; i++ { + w.Update(16 * time.Millisecond) + } + if w.StarCount() != start { + t.Fatalf("StarCount drifted from %d to %d; stars must wrap, not accumulate", start, w.StarCount()) + } +} + +func TestStarsStayInsideTheScreen(t *testing.T) { + w := worldAt(80, 30) + for i := 0; i < 400; i++ { + w.Update(16 * time.Millisecond) + for _, m := range w.Marks(nil) { + if m.Role != RoleStarFar && m.Role != RoleStarMid && m.Role != RoleStarNear { + continue + } + if m.X < 0 || m.X >= 80 || m.Y < 0 || m.Y >= 30 { + t.Fatalf("star escaped the screen at %d,%d", m.X, m.Y) + } + } + } +} + +func TestHigherLevelSpeedsStarsUpSubtly(t *testing.T) { + slow := worldAt(80, 30) + fast := worldAt(80, 30) + fast.Level = 12 + if !(fast.SpeedMultiplier() > slow.SpeedMultiplier()) { + t.Fatalf("level 12 multiplier %v not greater than level 1 %v", fast.SpeedMultiplier(), slow.SpeedMultiplier()) + } + if fast.SpeedMultiplier() > 2.0 { + t.Fatalf("multiplier %v exceeds the 2.0 cap; §15 says subtle", fast.SpeedMultiplier()) + } +} + +func TestAsciiModeUsesOnlyAsciiStarGlyphs(t *testing.T) { + w := NewWorld(3, Config{Enabled: true, ASCII: true}) + w.SetGeometry(Rect{0, 0, 80, 30}, Rect{10, 3, 20, 20}) + for _, m := range w.Marks(nil) { + if m.Glyph > 127 { + t.Fatalf("non-ASCII star glyph %q in ASCII mode", m.Glyph) + } + } +} + +func TestStarGlyphsMatchTheSpecSets(t *testing.T) { + w := worldAt(80, 30) + allowed := map[ColorRole]string{ + RoleStarFar: ".", + RoleStarMid: "·˚", + RoleStarNear: "✦✧*", + } + for _, m := range w.Marks(nil) { + set, ok := allowed[m.Role] + if !ok { + continue + } + if !strings.ContainsRune(set, m.Glyph) { + t.Errorf("role %v used glyph %q, want one of %q", m.Role, m.Glyph, set) + } + } +} + +func TestResizeRebasesStarsIntoTheNewScreen(t *testing.T) { + w := worldAt(200, 60) + w.Update(time.Second) + w.SetGeometry(Rect{0, 0, 40, 24}, Rect{10, 2, 20, 20}) + w.Update(16 * time.Millisecond) + for _, m := range w.Marks(nil) { + if m.X < 0 || m.X >= 40 || m.Y < 0 || m.Y >= 24 { + t.Fatalf("after shrinking, a mark sits at %d,%d outside 40x24", m.X, m.Y) + } + } +} + +func TestZeroSizedScreenHasNoStarsAndNoPanic(t *testing.T) { + w := NewWorld(1, Config{Enabled: true}) + w.SetGeometry(Rect{0, 0, 0, 0}, Rect{0, 0, 0, 0}) + w.Update(time.Second) + if got := len(w.Marks(nil)); got != 0 { + t.Fatalf("zero-sized screen produced %d marks", got) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/fx/ -run 'TestStar|TestHigherLevel|TestAsciiMode|TestResizeRebases|TestZeroSized' -v` +Expected: FAIL — undefined: `Star`, `StarCount`, `SpeedMultiplier`. + +- [ ] **Step 3: Implement `starfield.go` and wire `updateStars`** + +`seedStars` fills the three layers with random positions across the screen rect and returns early when the rect has zero area. `SetGeometry` reseeds when the dimensions change (that also handles Review Focus 4 for stars). `updateStars` moves each star down by `Speed × SpeedMultiplier() × dt` and wraps at the bottom. `SpeedMultiplier` is `min(1 + 0.04*(Level-1), 2.0)` multiplied by the hyperdrive factor (Task 11 supplies that; return the level part alone for now). + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `go test ./internal/fx/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . && git add internal/fx/starfield.go internal/fx/starfield_test.go internal/fx/world.go +git commit -m "feat(fx): three-layer starfield with level-scaled drift" +``` + +--- + +### Task 4: Compositing marks into the frame + +**Files:** +- Create: `internal/render/fx.go` +- Modify: `internal/render/render.go`, `internal/render/palette.go` +- Test: `internal/render/fx_test.go` + +**Interfaces:** +- Consumes: `fx.World`, `fx.Mark`; `Canvas`, `Layout`, `Palette`. +- Produces: + +```go +func DrawMarks(c *Canvas, w *fx.World, l Layout, p Palette, layer fx.Layer, occupied func(x, y int) bool) +func (p Palette) MarkStyle(role fx.ColorRole, kind game.PieceKind, bright float64) lipgloss.Style +// Scene gains: +// FX *fx.World // nil is legal and draws nothing +``` + +`Frame` becomes §37 in full: layout → background stars (`DrawMarks` with the star roles, screen layer) → board (locked, ghost, active) → board-layer FX → border → HUD → screen-layer FX → banners → mission → controls. `occupied` is how §44's "never obscure the active piece" becomes mechanical: the board pass passes a predicate that reports the active piece's cells, and `DrawMarks` skips them. + +- [ ] **Step 1: Write the failing test** + +```go +package render + +import ( + "strings" + "testing" + "time" + + "cosmic-tetris/internal/fx" + "cosmic-tetris/internal/game" +) + +func liveWorld(w, h int, l Layout) *fx.World { + world := fx.NewWorld(11, fx.Config{Enabled: true}) + world.SetGeometry(fx.Rect{X: 0, Y: 0, W: w, H: h}, fx.Rect{X: l.Board.X, Y: l.Board.Y, W: l.Board.W, H: l.Board.H}) + return world +} + +func TestFramePaintsStarsOutsideTheBoard(t *testing.T) { + l := Compute(80, 30) + w := liveWorld(80, 30, l) + out := stripANSI(Frame(Scene{Game: game.New(1), Mode: ModeFull, Width: 80, Height: 30, FX: w})) + if !strings.ContainsAny(out, ".·˚✦✧") { + t.Fatalf("no star glyphs in the frame:\n%s", out) + } +} + +func TestFrameWithNilFXIsIdenticalToPlan1Output(t *testing.T) { + g := game.New(1) + withNil := Frame(Scene{Game: g, Mode: ModeFull, Width: 80, Height: 30}) + disabled := fx.NewWorld(1, fx.Config{Enabled: false}) + disabled.SetGeometry(fx.Rect{X: 0, Y: 0, W: 80, H: 30}, fx.Rect{}) + withDisabled := Frame(Scene{Game: g, Mode: ModeFull, Width: 80, Height: 30, FX: disabled}) + if withNil != withDisabled { + t.Fatal("a disabled world changed the frame; --no-fx must be pixel-identical to no FX") + } +} + +func TestMarksNeverOverwriteTheActivePiece(t *testing.T) { + g := game.New(1) + g.Active = game.Piece{Kind: game.T, Rotation: 0, X: 4, Y: 10} + l := Compute(80, 30) + w := liveWorld(80, 30, l) + // Flood the board layer with debris right where the active piece is. + for i := 0; i < 200; i++ { + w.Emit(fx.Particle{ + X: float64(l.Board.X + 9), Y: float64(l.Board.Y + 8), + Life: 9, MaxLife: 9, Glyph: '#', Brightness: 1, + Layer: fx.LayerBoard, Role: fx.RoleDebris, + }) + } + out := stripANSI(Frame(Scene{Game: g, Mode: ModeFull, Width: 80, Height: 30, FX: w})) + rows := strings.Split(out, "\n") + for _, c := range g.Active.Cells() { + if c[1] < game.HiddenRows { + continue + } + y := l.Board.Y + c[1] - game.HiddenRows + x := l.Board.X + c[0]*2 + cell := string([]rune(rows[y])[x : x+2]) + if strings.Contains(cell, "#") { + t.Fatalf("debris drew over the active piece at %d,%d: %q", x, y, cell) + } + } +} + +func TestBoardLayerMarksAreClippedToTheBoardInterior(t *testing.T) { + g := game.New(1) + l := Compute(80, 30) + w := liveWorld(80, 30, l) + for _, pos := range [][2]int{ + {l.Board.X - 3, l.Board.Y + 5}, + {l.Board.X + l.Board.W + 3, l.Board.Y + 5}, + {l.Board.X + 4, l.Board.Y - 3}, + {l.Board.X + 4, l.Board.Y + l.Board.H + 3}, + } { + w.Emit(fx.Particle{X: float64(pos[0]), Y: float64(pos[1]), Life: 9, MaxLife: 9, + Glyph: '@', Brightness: 1, Layer: fx.LayerBoard, Role: fx.RoleDebris}) + } + out := stripANSI(Frame(Scene{Game: g, Mode: ModeFull, Width: 80, Height: 30, FX: w})) + if strings.Contains(out, "@") { + t.Fatalf("board-layer marks escaped the board interior:\n%s", out) + } +} + +func TestFrameDimensionsSurviveHeavyFX(t *testing.T) { + g := game.New(1) + for _, dims := range [][2]int{{40, 24}, {50, 26}, {80, 30}, {200, 60}} { + l := Compute(dims[0], dims[1]) + w := liveWorld(dims[0], dims[1], l) + w.Observe([]game.Event{ + {Kind: game.EventLinesCleared, Rows: []int{18, 19, 20, 21}}, + {Kind: game.EventPieceHardDropped, Distance: 18, Piece: game.Piece{Kind: game.I, X: 3, Y: 3}}, + {Kind: game.EventComboChanged, Value: 7}, + }) + for i := 0; i < 30; i++ { + w.Update(16 * time.Millisecond) + } + rows := strings.Split(stripANSI(Frame(Scene{Game: g, Mode: ModeFull, Width: dims[0], Height: dims[1], FX: w})), "\n") + if len(rows) != dims[1] { + t.Fatalf("at %dx%d got %d rows, want %d", dims[0], dims[1], len(rows), dims[1]) + } + for i, r := range rows { + if len([]rune(r)) != dims[0] { + t.Fatalf("at %dx%d row %d is %d runes, want %d", dims[0], dims[1], i, len([]rune(r)), dims[0]) + } + } + } +} + +func TestFrameSurvivesAResizeWhileEffectsAreLive(t *testing.T) { + g := game.New(1) + big := Compute(200, 60) + w := liveWorld(200, 60, big) + w.Observe([]game.Event{{Kind: game.EventLinesCleared, Rows: []int{18, 19, 20, 21}}}) + w.Update(50 * time.Millisecond) + // Shrink without telling the world first: Frame must still be well-formed. + rows := strings.Split(stripANSI(Frame(Scene{Game: g, Mode: ModeFull, Width: 40, Height: 24, FX: w})), "\n") + if len(rows) != 24 { + t.Fatalf("got %d rows after shrinking, want 24", len(rows)) + } + for i, r := range rows { + if len([]rune(r)) != 40 { + t.Fatalf("row %d is %d runes after shrinking, want 40", i, len([]rune(r))) + } + } +} + +func TestFXNeverMutatesTheGameAcrossAWholeFrame(t *testing.T) { + g := game.New(9) + before := *g + l := Compute(80, 30) + w := liveWorld(80, 30, l) + w.Observe([]game.Event{{Kind: game.EventLinesCleared, Rows: []int{20, 21}}}) + for i := 0; i < 20; i++ { + w.Update(16 * time.Millisecond) + Frame(Scene{Game: g, Mode: ModeFull, Width: 80, Height: 30, FX: w}) + } + if g.Board != before.Board || g.Active != before.Active || g.Score != before.Score || + g.Level != before.Level || g.Combo != before.Combo || g.Lines != before.Lines { + t.Fatal("effects modified game state") + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/render/ -run 'TestFrame|TestMarks|TestBoardLayer|TestFX' -v` +Expected: FAIL — `Scene` has no field `FX`; undefined `DrawMarks`. + +- [ ] **Step 3: Implement `render/fx.go`, extend `Scene`, and rewrite `Frame`'s pipeline** + +`DrawMarks` reuses a package-level `[]fx.Mark` buffer through `w.Marks(buf[:0])`. For `LayerBoard`, skip any mark outside the board interior rect and any mark for which `occupied(x, y)` is true. `MarkStyle` maps the role to a color and scales it by brightness (in `ModeFull` interpolate toward the background; in `ModeReduced`/`ModeASCII` pick from two or three fixed steps). + +`Frame` runs the twelve §37 steps in order. Stars draw first so everything else covers them; board-layer FX draw after the active piece but before the border; screen-layer FX draw after the HUD. + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `go test ./internal/render/ -v` +Expected: PASS — including Plan 1's goldens, which must be untouched because their scenes have no FX. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . && git add internal/render/ +git commit -m "feat(render): composite fx marks with active-piece and board clipping" +``` + +--- + +### Task 5: Piece trails + +**Files:** +- Modify: `internal/fx/world.go` +- Test: `internal/fx/trail_test.go` + +**Interfaces:** +- Produces: `func (w *World) emitTrail(p game.Piece, strong bool)`, called from `Observe` on `EventPieceMoved`, `EventPieceRotated`, and `EventPieceHardDropped`. Trail glyph ramp per §17: `▓▓` at 1 step old, `▒▒` at 2, `░░` at 3 (ASCII mode: `%`, `:`, `.`). Lifetime 140 ms (§17's 100–160 ms range). Trails are `LayerBoard`, `RolePiece` with the piece's `Kind`. + +- [ ] **Step 1: Write the failing test** + +```go +package fx + +import ( + "testing" + "time" + + "cosmic-tetris/internal/game" +) + +func TestMovingAPieceLeavesATrail(t *testing.T) { + w := worldAt(80, 30) + before := len(w.Marks(nil)) + w.Observe([]game.Event{{Kind: game.EventPieceMoved, Piece: game.Piece{Kind: game.T, X: 4, Y: 10}}}) + after := len(w.Marks(nil)) + if after <= before { + t.Fatalf("PieceMoved added no marks (%d -> %d)", before, after) + } +} + +func TestTrailsCarryThePieceColorRole(t *testing.T) { + w := worldAt(80, 30) + w.Observe([]game.Event{{Kind: game.EventPieceMoved, Piece: game.Piece{Kind: game.Z, X: 4, Y: 10}}}) + var found bool + for _, m := range w.Marks(nil) { + if m.Role == RolePiece { + found = true + if m.Kind != game.Z { + t.Errorf("trail Kind = %v, want Z", m.Kind) + } + if m.Layer != LayerBoard { + t.Errorf("trail Layer = %v, want LayerBoard", m.Layer) + } + } + } + if !found { + t.Fatal("no RolePiece marks after a move") + } +} + +func TestTrailsExpireWithinOneSixthOfASecond(t *testing.T) { + w := worldAt(80, 30) + w.Observe([]game.Event{{Kind: game.EventPieceMoved, Piece: game.Piece{Kind: game.T, X: 4, Y: 10}}}) + w.Update(160 * time.Millisecond) + for _, m := range w.Marks(nil) { + if m.Role == RolePiece { + t.Fatalf("a trail mark survived 160ms: %+v", m) + } + } +} + +func TestTrailBrightnessDecaysThroughTheGlyphRamp(t *testing.T) { + w := worldAt(80, 30) + w.Observe([]game.Event{{Kind: game.EventPieceMoved, Piece: game.Piece{Kind: game.T, X: 4, Y: 10}}}) + bright := func() float64 { + max := 0.0 + for _, m := range w.Marks(nil) { + if m.Role == RolePiece && m.Bright > max { + max = m.Bright + } + } + return max + } + first := bright() + w.Update(60 * time.Millisecond) + if second := bright(); !(second < first) { + t.Fatalf("trail did not fade: %v -> %v", first, second) + } +} + +func TestHardDropTrailIsStrongerThanAMoveTrail(t *testing.T) { + move := worldAt(80, 30) + move.Observe([]game.Event{{Kind: game.EventPieceMoved, Piece: game.Piece{Kind: game.I, X: 3, Y: 18}}}) + drop := worldAt(80, 30) + drop.Observe([]game.Event{{Kind: game.EventPieceHardDropped, Distance: 14, Piece: game.Piece{Kind: game.I, X: 3, Y: 4}}}) + countRole := func(w *World, role ColorRole) int { + n := 0 + for _, m := range w.Marks(nil) { + if m.Role == role { + n++ + } + } + return n + } + if countRole(drop, RolePiece) <= countRole(move, RolePiece) { + t.Fatalf("hard-drop trail (%d marks) is not stronger than a move trail (%d)", + countRole(drop, RolePiece), countRole(move, RolePiece)) + } +} + +func TestHardDropTrailCoversTheCellsThePieceCrossed(t *testing.T) { + w := worldAt(80, 30) + // board rect starts at y=3; the piece fell from board row 4 to 18 + w.Observe([]game.Event{{Kind: game.EventPieceHardDropped, Distance: 14, Piece: game.Piece{Kind: game.I, Rotation: 1, X: 3, Y: 4}}}) + rows := map[int]bool{} + for _, m := range w.Marks(nil) { + if m.Role == RolePiece { + rows[m.Y] = true + } + } + if len(rows) < 8 { + t.Fatalf("hard-drop trail touched only %d rows, want a vertical streak", len(rows)) + } +} + +func TestTrailsAreSuppressedWhenDisabledButNotByReducedMotion(t *testing.T) { + off := NewWorld(1, Config{Enabled: false}) + off.SetGeometry(Rect{0, 0, 80, 30}, Rect{10, 3, 20, 20}) + off.Observe([]game.Event{{Kind: game.EventPieceMoved, Piece: game.Piece{Kind: game.T, X: 4, Y: 10}}}) + if len(off.Marks(nil)) != 0 { + t.Error("--no-fx still produced trail marks") + } + reduced := NewWorld(1, Config{Enabled: true, ReducedMotion: true}) + reduced.SetGeometry(Rect{0, 0, 80, 30}, Rect{10, 3, 20, 20}) + reduced.Observe([]game.Event{{Kind: game.EventPieceMoved, Piece: game.Piece{Kind: game.T, X: 4, Y: 10}}}) + var sawTrail bool + for _, m := range reduced.Marks(nil) { + if m.Role == RolePiece { + sawTrail = true + } + } + if !sawTrail { + t.Error("--reduced-motion suppressed trails; §49.5 keeps them") + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/fx/ -run TestTrail -run 'TestMovingAPiece|TestTrail|TestHardDropTrail' -v` +Expected: FAIL — no trail marks are produced. + +- [ ] **Step 3: Implement trails** + +Store trails as short-lived particles with zero velocity (`Layer: LayerBoard`, `Role: RolePiece`, `MaxLife: 0.14`), one per board cell the piece occupied, converted to screen coordinates through `w.Board`. For a hard drop, emit one row of trail cells for each row crossed, with life scaled down the further up the row is, so the streak fades upward. + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `go test ./internal/fx/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . && git add internal/fx/ && git commit -m "feat(fx): ion trails behind moving and hard-dropped pieces" +``` + +--- + +### Task 6: Animated board border + +**Files:** +- Modify: `internal/fx/world.go`, `internal/render/board.go`, `internal/render/palette.go` +- Test: `internal/fx/border_test.go`, `internal/render/border_test.go` + +**Interfaces:** +- Produces: + +```go +// fx +type BorderState struct { + Phase float64 // 0..1, cycles slowly + Energy float64 // 0..1, spikes on events and decays +} +func (w *World) BorderState() BorderState +func (w *World) FlashBorder(amount float64) +// render +func (p Palette) BorderStyleAt(pos int, total int, b fx.BorderState) lipgloss.Style +``` + +The border is the game's energy-state indicator (§25). Base cycle: `Phase` advances at `0.05` per second — one full sweep every 20 s, which is the "subtle" §25 asks for. `Energy` decays at `2.5` per second. `LinesCleared`, `PieceHardDropped`, `LevelChanged`, and combos ≥ 2 each add energy. At high energy the gradient moves rapidly around the border. Palette per §25: deep violet, electric cyan, magenta, stellar blue, hot white. + +- [ ] **Step 1: Write the failing test** + +```go +// internal/fx/border_test.go +package fx + +import ( + "testing" + "time" + + "cosmic-tetris/internal/game" +) + +func TestBorderPhaseAdvancesSlowlyAndWraps(t *testing.T) { + w := worldAt(80, 30) + start := w.BorderState().Phase + w.Update(time.Second) + after := w.BorderState().Phase + if after == start { + t.Fatal("border phase did not advance") + } + if d := after - start; d > 0.2 { + t.Fatalf("phase moved %v in one second; §25 says subtle", d) + } + for i := 0; i < 100; i++ { + w.Update(time.Second) + if p := w.BorderState().Phase; p < 0 || p >= 1 { + t.Fatalf("phase left 0..1: %v", p) + } + } +} + +func TestEventsRaiseBorderEnergyAndItDecays(t *testing.T) { + w := worldAt(80, 30) + if e := w.BorderState().Energy; e != 0 { + t.Fatalf("resting energy = %v, want 0", e) + } + w.Observe([]game.Event{{Kind: game.EventLinesCleared, Rows: []int{21}}}) + peak := w.BorderState().Energy + if peak <= 0 { + t.Fatal("a line clear did not energize the border") + } + w.Update(200 * time.Millisecond) + mid := w.BorderState().Energy + if !(mid < peak) { + t.Fatalf("energy did not decay: %v -> %v", peak, mid) + } + w.Update(3 * time.Second) + if e := w.BorderState().Energy; e != 0 { + t.Fatalf("energy = %v after 3s, want it fully decayed to 0", e) + } +} + +func TestEnergyIsClampedToOne(t *testing.T) { + w := worldAt(80, 30) + for i := 0; i < 50; i++ { + w.FlashBorder(1.0) + } + if e := w.BorderState().Energy; e > 1 { + t.Fatalf("Energy = %v, want at most 1", e) + } +} + +func TestFourLineClearEnergizesMoreThanASingle(t *testing.T) { + single := worldAt(80, 30) + single.Observe([]game.Event{{Kind: game.EventLinesCleared, Rows: []int{21}}}) + quad := worldAt(80, 30) + quad.Observe([]game.Event{{Kind: game.EventLinesCleared, Rows: []int{18, 19, 20, 21}}}) + if !(quad.BorderState().Energy > single.BorderState().Energy) { + t.Fatalf("quad energy %v not above single %v", quad.BorderState().Energy, single.BorderState().Energy) + } +} +``` + +```go +// internal/render/border_test.go +package render + +import ( + "strings" + "testing" + + "cosmic-tetris/internal/fx" + "cosmic-tetris/internal/game" +) + +func TestBorderStyleVariesAroundThePerimeter(t *testing.T) { + p := NewPalette(ModeFull) + b := fx.BorderState{Phase: 0.3, Energy: 0.5} + a := p.BorderStyleAt(0, 80, b).Render("═") + z := p.BorderStyleAt(40, 80, b).Render("═") + if a == z { + t.Fatal("border style is uniform; §25 wants a gradient") + } +} + +func TestBorderStillRendersWithoutFX(t *testing.T) { + out := stripANSI(Frame(Scene{Game: game.New(1), Mode: ModeFull, Width: 80, Height: 30})) + if !strings.Contains(out, "╔") || !strings.Contains(out, "╝") { + t.Fatal("the border vanished when FX are absent") + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -run 'TestBorder|TestEvents|TestEnergy|TestFourLine' -v && go test ./internal/render/ -run TestBorder -v` +Expected: FAIL — undefined: `BorderState`, `BorderStyleAt`. + +- [ ] **Step 3: Implement the border state and its styling** + +`updateBorder` advances `Phase` by `0.05 × dt` (wrapping with `math.Mod`) plus `0.35 × Energy × dt` so high energy visibly speeds the sweep, and decays `Energy` toward 0. `DrawBoard` takes the palette's `BorderStyleAt(index, perimeter, state)` for each frame cell, where `index` walks the perimeter clockwise from the top-left. With no FX world, `DrawBoard` uses `fx.BorderState{}` and gets the resting style — so Plan 1's goldens do not move. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/fx/ ./internal/render/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . && git add internal/fx/ internal/render/ +git commit -m "feat(fx): animated board border as an energy-state indicator" +``` + +--- + +### Task 7: Mission control + +**Files:** +- Create: `internal/flavor/messages.go` +- Modify: `internal/fx/world.go`, `internal/render/render.go` +- Test: `internal/flavor/messages_test.go`, `internal/fx/mission_test.go` + +**Interfaces:** +- Produces: + +```go +// flavor +type Trigger uint8 +const ( + TriggerIdle Trigger = iota + TriggerLock + TriggerClear + TriggerQuad + TriggerCombo + TriggerLevel + TriggerHold + TriggerHoldO + TriggerKineticRod // a vertically hard-dropped I (§45) + TriggerScoreRoll // score crossed a power of ten (§45) + TriggerLongIdle + TriggerRare +) +func Mission(t Trigger, level, combo int, rng *rand.Rand) string +func LevelUpSubtitle(rng *rand.Rand) string +func QuadBanner(rng *rand.Rand) string +func ComboLine(combo int) string +func GameOverSubtitle() string + +// fx +const MissionCooldown = 2500 * time.Millisecond +const MissionHold = 4000 * time.Millisecond +func (w *World) Mission() string +``` + +Strings are the spec's, verbatim: §27's nine mission lines, §20's four quad banners, §21's three combo lines, §22's three level subtitles, §45's five rarities, §28's `CAUSE: EXCESSIVE GEOMETRY`. `ComboLine(5)` returns `COMBO 5 // UNAUTHORIZED ORBITAL MANEUVER`, `(6)` `COMBO 6 // STRUCTURAL REALITY FAILURE`, `(7)` and above `COMBO 7 // NASA DENIES EVERYTHING` with the actual combo number substituted. + +- [ ] **Step 1: Write the failing flavor test** + +```go +package flavor + +import ( + "math/rand" + "strings" + "testing" +) + +func TestMissionReturnsNonEmptyForEveryTrigger(t *testing.T) { + rng := rand.New(rand.NewSource(1)) + for tr := TriggerIdle; tr <= TriggerRare; tr++ { + got := Mission(tr, 5, 3, rng) + if strings.TrimSpace(got) == "" { + t.Errorf("Mission(%d) returned empty", tr) + } + if got != strings.ToUpper(got) { + t.Errorf("Mission(%d) = %q; mission control shouts", tr, got) + } + } +} + +func TestSpecPinnedStringsArePresent(t *testing.T) { + rng := rand.New(rand.NewSource(1)) + seen := map[string]bool{} + for i := 0; i < 2000; i++ { + seen[Mission(TriggerIdle, 3, 0, rng)] = true + seen[Mission(TriggerLock, 3, 0, rng)] = true + seen[Mission(TriggerClear, 3, 1, rng)] = true + } + for _, want := range []string{ + "GRAVITY REMAINS MOSTLY LEGAL", + "TETROMINO INJECTION SUCCESSFUL", + "STRUCTURAL VIBES: QUESTIONABLE", + "LOCAL UNIVERSE STABLE*", + "MOON NOTIFIED", + "ORBITAL OSHA HAS ENTERED THE CHAT", + "WE HAVE EXCEEDED THE RECOMMENDED NUMBER OF BLOCKS", + "PHYSICS TEAM SAYS KEEP GOING", + } { + if !seen[want] { + t.Errorf("never produced the pinned line %q", want) + } + } +} + +func TestOptionalDetailTriggersUseTheirPinnedLines(t *testing.T) { + rng := rand.New(rand.NewSource(1)) + cases := map[Trigger]string{ + TriggerKineticRod: "KINETIC ROD DEPLOYED", + TriggerHoldO: "CUBE ADJACENT OBJECT SECURED", + TriggerScoreRoll: "NUMBER BECAME BIGGER", + TriggerLongIdle: "CAPTAIN?", + } + for tr, want := range cases { + if got := Mission(tr, 1, 0, rng); !strings.Contains(got, want) { + t.Errorf("Mission(%d) = %q, want it to contain %q", tr, got, want) + } + } + rare := Mission(TriggerRare, 1, 0, rng) + if !strings.Contains(rare, "TERMINAL") { + t.Errorf("rare line = %q, want the terminal joke", rare) + } +} + +func TestQuadBannerDrawsFromAllFourSpecBanners(t *testing.T) { + rng := rand.New(rand.NewSource(2)) + seen := map[string]bool{} + for i := 0; i < 500; i++ { + seen[QuadBanner(rng)] = true + } + for _, want := range []string{ + "✦ EVENT HORIZON ✦", + "QUADRUPLE COSMIC INCIDENT", + "FOUR ROWS HAVE LEFT THE CHAT", + "SPACE-TIME HAS FILED A COMPLAINT", + } { + if !seen[want] { + t.Errorf("QuadBanner never returned %q", want) + } + } +} + +func TestLevelUpSubtitlesAreTheSpecThree(t *testing.T) { + rng := rand.New(rand.NewSource(3)) + seen := map[string]bool{} + for i := 0; i < 500; i++ { + seen[LevelUpSubtitle(rng)] = true + } + for _, want := range []string{ + "GRAVITY TAX INCREASED", + "LOCAL PHYSICS UPDATED WITHOUT CONSENT", + "PLEASE SECURE ALL LOOSE TETROMINOES", + } { + if !seen[want] { + t.Errorf("LevelUpSubtitle never returned %q", want) + } + } +} + +func TestComboLineEscalatesWithTheNumber(t *testing.T) { + cases := map[int]string{ + 5: "COMBO 5 // UNAUTHORIZED ORBITAL MANEUVER", + 6: "COMBO 6 // STRUCTURAL REALITY FAILURE", + 7: "COMBO 7 // NASA DENIES EVERYTHING", + } + for combo, want := range cases { + if got := ComboLine(combo); got != want { + t.Errorf("ComboLine(%d) = %q, want %q", combo, got, want) + } + } + if got := ComboLine(9); !strings.HasPrefix(got, "COMBO 9 //") { + t.Errorf("ComboLine(9) = %q, want a COMBO 9 prefix", got) + } + if got := ComboLine(1); got != "" { + t.Errorf("ComboLine(1) = %q, want empty: a lone clear is not a combo", got) + } +} + +func TestGameOverSubtitleIsPinned(t *testing.T) { + if got := GameOverSubtitle(); got != "CAUSE: EXCESSIVE GEOMETRY" { + t.Fatalf("GameOverSubtitle() = %q", got) + } +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `go test ./internal/flavor/ -v` +Expected: FAIL — package does not exist. + +- [ ] **Step 3: Implement `flavor/messages.go`** + +Tables of strings per trigger; `Mission` picks with `rng.Intn`. `TriggerKineticRod`, `TriggerHoldO`, `TriggerScoreRoll`, `TriggerLongIdle`, and `TriggerRare` each have exactly the one pinned line from §45. + +- [ ] **Step 4: Run it to verify it passes** + +Run: `go test ./internal/flavor/ -v` +Expected: PASS (7 tests). + +- [ ] **Step 5: Write the failing mission-channel test** + +```go +package fx + +import ( + "testing" + "time" + + "cosmic-tetris/internal/game" +) + +func TestMissionStartsWithAMessageAndKeepsIt(t *testing.T) { + w := worldAt(80, 30) + first := w.Mission() + if first == "" { + t.Fatal("mission channel is empty at startup") + } + w.Update(100 * time.Millisecond) + if w.Mission() != first { + t.Fatal("the message changed after 100ms; §27 says give them time to breathe") + } +} + +func TestMessagesDoNotChangeMoreOftenThanTheCooldown(t *testing.T) { + w := worldAt(80, 30) + changes := 0 + last := w.Mission() + for i := 0; i < 625; i++ { // 625 x 16ms = 10s + w.Observe([]game.Event{{Kind: game.EventPieceLocked, Piece: game.Piece{Kind: game.T}}}) + w.Update(16 * time.Millisecond) + if m := w.Mission(); m != last { + changes++ + last = m + } + } + maxChanges := int(10*time.Second/MissionCooldown) + 1 + if changes > maxChanges { + t.Fatalf("the channel changed %d times in 10s; the %v cooldown allows at most %d", + changes, MissionCooldown, maxChanges) + } + if changes == 0 { + t.Fatal("the channel never changed across 10s of locks") + } +} + +func TestABigEventPreemptsTheCooldown(t *testing.T) { + w := worldAt(80, 30) + w.Observe([]game.Event{{Kind: game.EventPieceLocked}}) + w.Update(50 * time.Millisecond) + before := w.Mission() + w.Observe([]game.Event{{Kind: game.EventLinesCleared, Rows: []int{18, 19, 20, 21}}}) + if w.Mission() == before { + t.Fatal("a four-line clear did not preempt the current message") + } +} + +func TestMissionIsEmptyWhenFXAreDisabled(t *testing.T) { + w := NewWorld(1, Config{Enabled: false}) + w.SetGeometry(Rect{0, 0, 80, 30}, Rect{10, 3, 20, 20}) + w.Observe([]game.Event{{Kind: game.EventLinesCleared, Rows: []int{21}}}) + if got := w.Mission(); got != "" { + t.Fatalf("Mission() = %q with FX off, want empty", got) + } +} +``` + +- [ ] **Step 6: Run it to verify it fails** + +Run: `go test ./internal/fx/ -run TestMission -run 'TestMission|TestMessages|TestABigEvent' -v` +Expected: FAIL — undefined: `Mission`, `MissionCooldown`. + +- [ ] **Step 7: Implement the mission channel and render the line** + +`World` holds `mission string`, `missionAge time.Duration`, and `missionPriority int`. A new message is accepted when its priority exceeds the current one, or when `missionAge >= MissionCooldown`. Priorities, low to high: idle, lock, clear, combo, level, quad. `Frame` uses `s.FX.Mission()` when `s.Mission` is empty, and prefixes the line with `☄ MISSION CONTROL: ` (§27; ASCII mode uses `>`). + +- [ ] **Step 8: Run the suite to verify it passes** + +Run: `go test ./... -v` +Expected: PASS. + +- [ ] **Step 9: Commit** + +```bash +gofmt -l . && git add internal/flavor/ internal/fx/ internal/render/ +git commit -m "feat(flavor): mission control channel with cooldown and priorities" +``` + +--- + +### Task 8: Hard-drop impact + +**Files:** +- Modify: `internal/fx/world.go`, `internal/render/board.go` +- Test: `internal/fx/impact_test.go` + +**Interfaces:** +- Produces: + +```go +const ShakeDuration = 80 * time.Millisecond +var ShakePattern = [5][2]int{{0, 1}, {-1, 0}, {1, 0}, {0, -1}, {0, 0}} // §18, deterministic +func (w *World) ShakeOffset() (dx, dy int) +func (w *World) Shake(strength float64) // strength scales duration, never amplitude +``` + +§18's four parts: the vertical ion trail (Task 5 already emits it), impact particles from the contact area with glyphs `· * ✦ +`, ~80 ms of one-cell screen shake stepping through `ShakePattern`, and a border flash (Task 6's `FlashBorder`). Shake is suppressed entirely under `--reduced-motion` (§49.5). + +- [ ] **Step 1: Write the failing test** + +```go +package fx + +import ( + "strings" + "testing" + "time" + + "cosmic-tetris/internal/game" +) + +func hardDrop(w *World, dist int) { + w.Observe([]game.Event{{Kind: game.EventPieceHardDropped, Distance: dist, + Piece: game.Piece{Kind: game.I, Rotation: 1, X: 4, Y: 6}}}) +} + +func TestHardDropEmitsImpactDebris(t *testing.T) { + w := worldAt(80, 30) + before := w.ParticleCount() + hardDrop(w, 12) + if w.ParticleCount() <= before { + t.Fatal("a hard drop emitted no particles") + } + glyphs := "" + for _, m := range w.Marks(nil) { + if m.Role == RoleImpact { + glyphs += string(m.Glyph) + } + } + if glyphs == "" { + t.Fatal("no RoleImpact marks") + } + for _, g := range glyphs { + if !strings.ContainsRune("·*✦+", g) { + t.Errorf("impact glyph %q is not one of §18's four", g) + } + } +} + +func TestHardDropShakesForAboutEightyMilliseconds(t *testing.T) { + w := worldAt(80, 30) + hardDrop(w, 12) + if dx, dy := w.ShakeOffset(); dx == 0 && dy == 0 { + t.Fatal("no shake right after impact") + } + w.Update(ShakeDuration + 20*time.Millisecond) + if dx, dy := w.ShakeOffset(); dx != 0 || dy != 0 { + t.Fatalf("still shaking after %v: %d,%d", ShakeDuration, dx, dy) + } +} + +func TestShakeNeverExceedsOneCell(t *testing.T) { + w := worldAt(80, 30) + for i := 0; i < 20; i++ { + hardDrop(w, 20) + w.Observe([]game.Event{{Kind: game.EventLinesCleared, Rows: []int{18, 19, 20, 21}}}) + for j := 0; j < 12; j++ { + w.Update(8 * time.Millisecond) + dx, dy := w.ShakeOffset() + if dx < -1 || dx > 1 || dy < -1 || dy > 1 { + t.Fatalf("shake offset %d,%d exceeds one cell (§44)", dx, dy) + } + } + } +} + +func TestShakeWalksTheDeterministicPattern(t *testing.T) { + run := func() [][2]int { + w := worldAt(80, 30) + hardDrop(w, 12) + var seq [][2]int + for i := 0; i < 10; i++ { + dx, dy := w.ShakeOffset() + seq = append(seq, [2]int{dx, dy}) + w.Update(10 * time.Millisecond) + } + return seq + } + a, b := run(), run() + for i := range a { + if a[i] != b[i] { + t.Fatalf("shake is not deterministic: step %d %v vs %v", i, a[i], b[i]) + } + } + // The offsets must come from the pinned pattern. + for _, off := range a { + var ok bool + for _, p := range ShakePattern { + if off == p { + ok = true + } + } + if !ok { + t.Fatalf("offset %v is not in ShakePattern", off) + } + } +} + +func TestReducedMotionSuppressesShakeButKeepsDebris(t *testing.T) { + w := NewWorld(5, Config{Enabled: true, ReducedMotion: true}) + w.SetGeometry(Rect{0, 0, 80, 30}, Rect{10, 3, 20, 20}) + hardDrop(w, 14) + if dx, dy := w.ShakeOffset(); dx != 0 || dy != 0 { + t.Fatalf("reduced motion still shook: %d,%d", dx, dy) + } + var sawImpact bool + for _, m := range w.Marks(nil) { + if m.Role == RoleImpact { + sawImpact = true + } + } + if !sawImpact { + t.Error("reduced motion removed impact particles; §49.5 keeps them") + } +} + +func TestHardDropFlashesTheBorder(t *testing.T) { + w := worldAt(80, 30) + hardDrop(w, 14) + if w.BorderState().Energy <= 0 { + t.Fatal("a hard drop did not flash the border") + } +} + +func TestZeroDistanceHardDropStillReacts(t *testing.T) { + w := worldAt(80, 30) + hardDrop(w, 0) + if w.ParticleCount() == 0 { + t.Fatal("a zero-distance hard drop produced nothing; the piece still hit something") + } +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `go test ./internal/fx/ -run 'TestHardDrop|TestShake|TestReducedMotion|TestZeroDistance' -v` +Expected: FAIL — `ShakeOffset` always returns 0,0; no impact particles. + +- [ ] **Step 3: Implement impact, shake, and the render offset** + +On `EventPieceHardDropped`: `EmitBurst` from the bottom edge of the landed piece with an upward bias (`VY` negative for most of the burst) and glyphs cycled from `· * ✦ +`; `FlashBorder(0.5 + 0.4×min(distance/20, 1))`; `Shake(0.6 + 0.4×min(distance/20, 1))`. + +`Shake` sets a remaining duration of `ShakeDuration × strength`, and returns immediately under `ReducedMotion`. `ShakeOffset` maps elapsed shake time to a `ShakePattern` index (`index = int(elapsed / (ShakeDuration/5))`, clamped) — deterministic and never more than one cell. + +`DrawBoard` adds the offset to the board rect before painting, and clamps the shifted rect so it stays inside the canvas (a board at the terminal edge cannot shift off it). + +- [ ] **Step 4: Run the suite to verify it passes** + +Run: `go test ./internal/fx/ ./internal/render/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . && git add internal/fx/ internal/render/ +git commit -m "feat(fx): hard-drop impact with debris, one-cell shake, border flash" +``` + +--- + +### Task 9: Line-clear supernova + +**Files:** +- Modify: `internal/fx/world.go` +- Test: `internal/fx/clear_test.go` + +**Interfaces:** +- Produces: + +```go +const ClearDuration = 220 * time.Millisecond // §19 +type clearAnim struct { rows []int; age time.Duration } +func (w *World) ClearAnimActive() bool +``` + +Three phases across 220 ms (§19): A critical mass (0–70 ms, `▓` speckle across the row), B supernova (70–150 ms, a bright front moving from the row center outward with `✦` at the front), C collapse (150–220 ms, the row becomes debris particles whose horizontal velocity is proportional to distance from center). + +Because gameplay already collapsed the board (§19: "gameplay state may already know the result"), the animation is an overlay at the cleared rows' screen positions — the cells beneath have already moved. That is the intended behavior, not a bug to fix. + +- [ ] **Step 1: Write the failing test** + +```go +package fx + +import ( + "testing" + "time" + + "cosmic-tetris/internal/game" +) + +func clear(w *World, rows ...int) { + w.Observe([]game.Event{{Kind: game.EventLinesCleared, Rows: rows}}) +} + +func TestClearAnimationRunsForAboutTwoHundredTwentyMilliseconds(t *testing.T) { + w := worldAt(80, 30) + clear(w, 21) + if !w.ClearAnimActive() { + t.Fatal("no clear animation started") + } + w.Update(200 * time.Millisecond) + if !w.ClearAnimActive() { + t.Fatal("the animation ended before 220ms") + } + w.Update(40 * time.Millisecond) + if w.ClearAnimActive() { + t.Fatal("the animation outlived 240ms") + } +} + +func TestClearAnimationPassesThroughThreeVisualPhases(t *testing.T) { + w := worldAt(80, 30) + clear(w, 21) + snapshot := func() string { + s := "" + for _, m := range w.Marks(nil) { + if m.Layer == LayerBoard && m.Role != RolePiece { + s += string(m.Glyph) + } + } + return s + } + a := snapshot() + w.Update(90 * time.Millisecond) + b := snapshot() + w.Update(80 * time.Millisecond) + c := snapshot() + if a == b || b == c || a == c { + t.Fatalf("phases are not distinct:\nA %q\nB %q\nC %q", a, b, c) + } +} + +func TestSupernovaSpreadsOutwardFromTheRowCenter(t *testing.T) { + w := worldAt(80, 30) + clear(w, 21) + spread := func() int { + minX, maxX := 1<<30, -(1 << 30) + for _, m := range w.Marks(nil) { + if m.Layer != LayerBoard || m.Role == RolePiece { + continue + } + if m.X < minX { + minX = m.X + } + if m.X > maxX { + maxX = m.X + } + } + if maxX < minX { + return 0 + } + return maxX - minX + } + w.Update(80 * time.Millisecond) + early := spread() + w.Update(50 * time.Millisecond) + if late := spread(); late < early { + t.Fatalf("the front contracted: %d -> %d", early, late) + } +} + +func TestCollapseDebrisInheritsHorizontalVelocityFromItsPosition(t *testing.T) { + w := worldAt(80, 30) + clear(w, 21) + w.Update(200 * time.Millisecond) // into phase C + center := float64(w.Board.X + w.Board.W/2) + var leftGoesLeft, rightGoesRight bool + positions := map[int]bool{} + for _, m := range w.Marks(nil) { + if m.Role == RoleDebris { + positions[m.X] = true + } + } + w.Update(60 * time.Millisecond) + for _, m := range w.Marks(nil) { + if m.Role != RoleDebris { + continue + } + if float64(m.X) < center-3 { + leftGoesLeft = true + } + if float64(m.X) > center+3 { + rightGoesRight = true + } + } + if !leftGoesLeft || !rightGoesRight { + t.Fatalf("debris did not fan outward: left=%v right=%v", leftGoesLeft, rightGoesRight) + } + _ = positions +} + +func TestFourRowClearAnimatesAllFourRows(t *testing.T) { + w := worldAt(80, 30) + clear(w, 18, 19, 20, 21) + w.Update(40 * time.Millisecond) + rows := map[int]bool{} + for _, m := range w.Marks(nil) { + if m.Layer == LayerBoard && m.Role != RolePiece { + rows[m.Y] = true + } + } + if len(rows) < 4 { + t.Fatalf("only %d rows animating, want 4", len(rows)) + } +} + +func TestClearMarksStayInsideTheBoardRect(t *testing.T) { + w := worldAt(80, 30) + clear(w, 18, 19, 20, 21) + for i := 0; i < 20; i++ { + w.Update(16 * time.Millisecond) + for _, m := range w.Marks(nil) { + if m.Layer != LayerBoard { + continue + } + if m.X < w.Board.X || m.X >= w.Board.X+w.Board.W || m.Y < w.Board.Y || m.Y >= w.Board.Y+w.Board.H { + t.Fatalf("board-layer clear mark at %d,%d escaped %+v", m.X, m.Y, w.Board) + } + } + } +} + +func TestClearRowsOutsideTheVisibleRangeAreIgnored(t *testing.T) { + w := worldAt(80, 30) + clear(w, 0, 1) // hidden spawn rows: not visible, must not produce marks above the board + w.Update(16 * time.Millisecond) + for _, m := range w.Marks(nil) { + if m.Layer == LayerBoard && m.Y < w.Board.Y { + t.Fatalf("a mark for a hidden row landed at y=%d, above the board at %d", m.Y, w.Board.Y) + } + } +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `go test ./internal/fx/ -run 'TestClear|TestSupernova|TestCollapse|TestFourRow' -v` +Expected: FAIL — undefined: `ClearAnimActive`, `ClearDuration`. + +- [ ] **Step 3: Implement the clear animation and wire `updateClears`** + +Convert each cleared board row to its screen row through `w.Board`, skipping rows above `game.HiddenRows`. Phase glyph ramps: A uses `▓` and `█` alternating by column parity and age; B draws a bright front at `center ± progress × halfWidth` with `✦` at the front and `██`/`▓▓`/`░░` trailing inward; C converts each surviving column into a debris particle with `VX = k × (x - center)` and a small upward `VY`. ASCII mode uses `# = - .` for the ramp and `* + .` for debris. + +- [ ] **Step 4: Run the suite to verify it passes** + +Run: `go test ./internal/fx/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . && git add internal/fx/ && git commit -m "feat(fx): three-phase supernova line-clear animation" +``` + +--- + +### Task 10: Shockwaves + +**Files:** +- Modify: `internal/fx/world.go` +- Test: `internal/fx/shock_test.go` + +**Interfaces:** +- Produces: `const ShockDuration = 300 * time.Millisecond`, `func (w *World) Shockwave(cx, cy float64)`, `func (w *World) ShockCount() int`. + +Rings are faked with the glyph groups from §24 (`· ○ ◌ ◯`; ASCII `. o O 0`) arranged around an approximate ellipse — terminal cells are about twice as tall as wide, so the horizontal radius is twice the vertical. Used sparingly: only four-line clears and combo ≥ 5 trigger one, at most one at a time. Suppressed under `--reduced-motion` (§49.5). + +- [ ] **Step 1: Write the failing test** + +```go +package fx + +import ( + "testing" + "time" + + "cosmic-tetris/internal/game" +) + +func TestShockwaveExpandsThenExpires(t *testing.T) { + w := worldAt(80, 30) + w.Shockwave(40, 15) + radius := func() int { + max := 0 + for _, m := range w.Marks(nil) { + if m.Role != RoleShock { + continue + } + if d := abs(m.X - 40); d > max { + max = d + } + } + return max + } + early := radius() + w.Update(120 * time.Millisecond) + if late := radius(); late <= early { + t.Fatalf("ring did not expand: %d -> %d", early, late) + } + w.Update(ShockDuration) + if w.ShockCount() != 0 { + t.Fatalf("ShockCount = %d after %v, want 0", w.ShockCount(), ShockDuration) + } +} + +func TestShockwaveIsWiderThanTallForSquareLookingCells(t *testing.T) { + w := worldAt(80, 30) + w.Shockwave(40, 15) + w.Update(150 * time.Millisecond) + var maxDX, maxDY int + for _, m := range w.Marks(nil) { + if m.Role != RoleShock { + continue + } + if d := abs(m.X - 40); d > maxDX { + maxDX = d + } + if d := abs(m.Y - 15); d > maxDY { + maxDY = d + } + } + if maxDY == 0 || maxDX <= maxDY { + t.Fatalf("ring is %dx%d; terminal cells need a wider-than-tall ellipse", maxDX, maxDY) + } +} + +func TestShockwaveGlyphsComeFromTheSpecSet(t *testing.T) { + w := worldAt(80, 30) + w.Shockwave(40, 15) + for i := 0; i < 15; i++ { + for _, m := range w.Marks(nil) { + if m.Role == RoleShock && !containsRune("·○◌◯", m.Glyph) { + t.Fatalf("shock glyph %q is not one of §24's four", m.Glyph) + } + } + w.Update(20 * time.Millisecond) + } +} + +func TestOnlyBigEventsTriggerShockwaves(t *testing.T) { + single := worldAt(80, 30) + single.Observe([]game.Event{{Kind: game.EventLinesCleared, Rows: []int{21}}}) + if single.ShockCount() != 0 { + t.Error("a single-line clear triggered a shockwave; §24 says use sparingly") + } + quad := worldAt(80, 30) + quad.Observe([]game.Event{{Kind: game.EventLinesCleared, Rows: []int{18, 19, 20, 21}}}) + if quad.ShockCount() == 0 { + t.Error("a four-line clear did not trigger a shockwave") + } + combo := worldAt(80, 30) + combo.Observe([]game.Event{{Kind: game.EventComboChanged, Value: 5}}) + if combo.ShockCount() == 0 { + t.Error("combo 5 did not trigger a shockwave") + } +} + +func TestShockwavesDoNotStack(t *testing.T) { + w := worldAt(80, 30) + for i := 0; i < 10; i++ { + w.Shockwave(40, 15) + } + if w.ShockCount() > 1 { + t.Fatalf("ShockCount = %d, want at most 1", w.ShockCount()) + } +} + +func TestReducedMotionSuppressesShockwaves(t *testing.T) { + w := NewWorld(1, Config{Enabled: true, ReducedMotion: true}) + w.SetGeometry(Rect{0, 0, 80, 30}, Rect{10, 3, 20, 20}) + w.Shockwave(40, 15) + w.Observe([]game.Event{{Kind: game.EventLinesCleared, Rows: []int{18, 19, 20, 21}}}) + if w.ShockCount() != 0 { + t.Fatalf("ShockCount = %d under reduced motion, want 0", w.ShockCount()) + } +} +``` + +Add small `abs(int) int` and `containsRune(string, rune) bool` helpers to the fx test files. + +- [ ] **Step 2: Run it to verify it fails** + +Run: `go test ./internal/fx/ -run TestShock -run 'TestShock|TestOnlyBig|TestReducedMotionSuppressesShock' -v` +Expected: FAIL — undefined: `Shockwave`, `ShockCount`. + +- [ ] **Step 3: Implement shockwaves** + +Store at most one ring: `{cx, cy, age}`. `Marks` walks 24 angles, computing `x = cx + 2·r·cos θ` and `y = cy + r·sin θ` with `r = maxR × age/ShockDuration`, and picks the glyph by radius bucket. Brightness fades with age. `Shockwave` returns immediately under `ReducedMotion` or when a ring is already live. + +- [ ] **Step 4: Run the suite to verify it passes** + +Run: `go test ./internal/fx/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . && git add internal/fx/ && git commit -m "feat(fx): radial shockwave rings for major events" +``` + +--- + +### Task 11: Hyperdrive + +**Files:** +- Modify: `internal/fx/world.go`, `internal/fx/starfield.go` +- Test: `internal/fx/hyper_test.go` + +**Interfaces:** +- Produces: + +```go +type HyperCause uint8 +const (HyperQuad HyperCause = iota; HyperCombo; HyperHighScore) +func (w *World) Hyperdrive(cause HyperCause) +func (w *World) HyperFactor() float64 // 1.0 at rest; 0 during the pause beat +func (w *World) HyperActive() bool +func (w *World) NoteScore(score int) // app calls this; crossing the session best triggers HyperHighScore +``` + +§16's timeline, measured from the trigger: 0 ms stars pause (factor ~0), 50 ms stretch, 100 ms accelerate violently, 500 ms peak, 800 ms decay begins, 1100 ms back to normal. `SpeedMultiplier` becomes `levelPart × HyperFactor()`. Under `--reduced-motion` the acceleration is suppressed: `HyperFactor` stays 1.0 (§49.5). + +Since §2 forbids persistence, "new high score" means the best score seen in this process since launch. The app owns that number and calls `NoteScore`. + +- [ ] **Step 1: Write the failing test** + +```go +package fx + +import ( + "testing" + "time" + + "cosmic-tetris/internal/game" +) + +func TestHyperdriveFollowsTheSpecTimeline(t *testing.T) { + w := worldAt(80, 30) + rest := w.HyperFactor() + if rest != 1.0 { + t.Fatalf("resting HyperFactor = %v, want 1.0", rest) + } + w.Hyperdrive(HyperQuad) + if f := w.HyperFactor(); f >= 1.0 { + t.Errorf("at 0ms stars must pause: factor = %v, want below 1", f) + } + w.Update(120 * time.Millisecond) + accel := w.HyperFactor() + if accel <= 1.0 { + t.Errorf("at 120ms stars must be accelerating: factor = %v", accel) + } + w.Update(380 * time.Millisecond) // ~500ms: peak + peak := w.HyperFactor() + if peak < accel { + t.Errorf("peak factor %v is below the 120ms factor %v", peak, accel) + } + w.Update(400 * time.Millisecond) // ~900ms: decaying + if decay := w.HyperFactor(); decay >= peak { + t.Logf("decay %v below peak %v: good", decay, peak) + } else { + t.Errorf("factor did not decay: %v vs peak %v", decay, peak) + } + w.Update(400 * time.Millisecond) // ~1300ms: over + if w.HyperActive() { + t.Error("hyperdrive still active past 1100ms") + } + if f := w.HyperFactor(); f != 1.0 { + t.Errorf("post-hyperdrive factor = %v, want exactly 1.0", f) + } +} + +func TestHyperdriveActuallyMovesTheStarsFaster(t *testing.T) { + measure := func(hyper bool) float64 { + w := worldAt(80, 30) + if hyper { + w.Hyperdrive(HyperQuad) + w.Update(150 * time.Millisecond) // past the pause, into acceleration + } + sum := func() float64 { + total := 0.0 + for _, m := range w.Marks(nil) { + if m.Role == RoleStarNear { + total += float64(m.Y) + } + } + return total + } + before := sum() + w.Update(100 * time.Millisecond) + return sum() - before + } + if measure(true) <= measure(false) { + t.Fatal("hyperdrive did not speed up the starfield") + } +} + +func TestQuadClearAndBigComboTriggerHyperdrive(t *testing.T) { + quad := worldAt(80, 30) + quad.Observe([]game.Event{{Kind: game.EventLinesCleared, Rows: []int{18, 19, 20, 21}}}) + if !quad.HyperActive() { + t.Error("a four-line clear did not trigger hyperdrive") + } + combo := worldAt(80, 30) + combo.Observe([]game.Event{{Kind: game.EventComboChanged, Value: 5}}) + if !combo.HyperActive() { + t.Error("combo 5 did not trigger hyperdrive") + } + small := worldAt(80, 30) + small.Observe([]game.Event{{Kind: game.EventLinesCleared, Rows: []int{21}}, {Kind: game.EventComboChanged, Value: 2}}) + if small.HyperActive() { + t.Error("a single clear at combo 2 triggered hyperdrive; §16 reserves it for big events") + } +} + +func TestNoteScoreTriggersHyperdriveOnlyOnANewSessionBest(t *testing.T) { + w := worldAt(80, 30) + // The first game of a session only establishes the baseline. Firing + // hyperdrive on the first points anyone ever scores would fire it on + // every player's first hard drop, which is not what §16 means by + // "new high score". + w.NoteScore(1000) + if w.HyperActive() { + t.Fatal("the first score of the session triggered hyperdrive") + } + w.NoteScore(500) // below the best + if w.HyperActive() { + t.Fatal("a lower score triggered hyperdrive") + } + w.NoteScore(1001) // beats the established best + if !w.HyperActive() { + t.Fatal("beating the session best did not trigger hyperdrive") + } +} + +func TestRetriggeringHyperdriveRestartsRatherThanStacking(t *testing.T) { + w := worldAt(80, 30) + w.Hyperdrive(HyperQuad) + w.Update(600 * time.Millisecond) + w.Hyperdrive(HyperQuad) + if f := w.HyperFactor(); f >= 1.0 { + t.Fatalf("retrigger did not restart the timeline: factor = %v, want the pause beat", f) + } + w.Update(2 * time.Second) + if w.HyperActive() { + t.Fatal("hyperdrive outlived a restarted timeline") + } +} + +func TestReducedMotionKeepsTheFactorAtOne(t *testing.T) { + w := NewWorld(1, Config{Enabled: true, ReducedMotion: true}) + w.SetGeometry(Rect{0, 0, 80, 30}, Rect{10, 3, 20, 20}) + w.Hyperdrive(HyperQuad) + for i := 0; i < 100; i++ { + if f := w.HyperFactor(); f != 1.0 { + t.Fatalf("reduced motion factor = %v at step %d, want 1.0", f, i) + } + w.Update(16 * time.Millisecond) + } +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `go test ./internal/fx/ -run 'TestHyper|TestQuadClear|TestNoteScore|TestRetriggering|TestReducedMotionKeeps' -v` +Expected: FAIL — undefined: `Hyperdrive`, `HyperFactor`. + +- [ ] **Step 3: Implement hyperdrive** + +Piecewise-linear factor over the §16 keyframes: `{0ms: 0.05, 50ms: 0.4, 100ms: 4.0, 500ms: 9.0, 800ms: 6.0, 1100ms: 1.0}`, interpolated. `HyperActive` is true while age < 1100 ms. `Hyperdrive` resets age to 0 (restart, never stack) and also bumps star density per §20 by temporarily adding near stars — cap that so `StarCount` still returns to its baseline when the burst ends. + +`NoteScore` keeps a `best int` on the world. When `score > best`, it records the new best; it triggers `Hyperdrive(HyperHighScore)` only when the old best was already above zero. That one condition is the difference between a celebration and a strobe light on every player's first drop. + +- [ ] **Step 4: Run the suite to verify it passes** + +Run: `go test ./internal/fx/ -v` +Expected: PASS. `TestStarCountIsStableAcrossLongRuns` from Task 3 must still pass — the density bump has to be temporary. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . && git add internal/fx/ && git commit -m "feat(fx): hyperdrive timeline with star density bump" +``` + +--- + +### Task 12: Four-line sequence and banners + +**Files:** +- Modify: `internal/fx/world.go`, `internal/render/render.go`, `internal/render/hud.go` +- Test: `internal/fx/banner_test.go`, `internal/render/banner_test.go` + +**Interfaces:** +- Produces: + +```go +// fx +const BannerDuration = 700 * time.Millisecond // §20 +func (w *World) Banner() (text string, bright float64, ok bool) +func (w *World) HUDPulse() float64 // 0..1, drives the HUD flash and combo pulse +// render +func DrawBanner(c *Canvas, text string, bright float64, l Layout, p Palette) +``` + +A four-line clear triggers everything at once (§20): hyperdrive, a bigger shake, the border pulse, a particle eruption, the HUD flash, the temporary star density increase, and a 700 ms banner from `flavor.QuadBanner`. The banner must not block input — it is drawn, nothing more. + +- [ ] **Step 1: Write the failing test** + +```go +// internal/fx/banner_test.go +package fx + +import ( + "testing" + "time" + + "cosmic-tetris/internal/game" +) + +func TestQuadClearTriggersEverythingAtOnce(t *testing.T) { + w := worldAt(80, 30) + w.Observe([]game.Event{{Kind: game.EventLinesCleared, Rows: []int{18, 19, 20, 21}}}) + if !w.HyperActive() { + t.Error("no hyperdrive") + } + if dx, dy := w.ShakeOffset(); dx == 0 && dy == 0 { + t.Error("no shake") + } + if w.BorderState().Energy <= 0 { + t.Error("no border pulse") + } + if w.ParticleCount() == 0 { + t.Error("no particle eruption") + } + if w.HUDPulse() <= 0 { + t.Error("no HUD flash") + } + if w.ShockCount() == 0 { + t.Error("no shockwave") + } + if _, _, ok := w.Banner(); !ok { + t.Error("no banner") + } +} + +func TestQuadShakeIsLongerThanAPlainHardDropButStillOneCell(t *testing.T) { + drop := worldAt(80, 30) + drop.Observe([]game.Event{{Kind: game.EventPieceHardDropped, Distance: 4, Piece: game.Piece{Kind: game.O, X: 4, Y: 18}}}) + quad := worldAt(80, 30) + quad.Observe([]game.Event{{Kind: game.EventLinesCleared, Rows: []int{18, 19, 20, 21}}}) + shakeFor := func(w *World) time.Duration { + var total time.Duration + for i := 0; i < 60; i++ { + if dx, dy := w.ShakeOffset(); dx != 0 || dy != 0 { + total += 8 * time.Millisecond + } + w.Update(8 * time.Millisecond) + } + return total + } + if shakeFor(quad) <= shakeFor(drop) { + t.Error("§20 asks for a larger shake on a four-line clear") + } +} + +func TestBannerLastsSevenHundredMilliseconds(t *testing.T) { + w := worldAt(80, 30) + w.Observe([]game.Event{{Kind: game.EventLinesCleared, Rows: []int{18, 19, 20, 21}}}) + text, _, ok := w.Banner() + if !ok || text == "" { + t.Fatal("banner missing") + } + w.Update(650 * time.Millisecond) + if _, _, ok := w.Banner(); !ok { + t.Fatal("banner vanished before 700ms") + } + w.Update(100 * time.Millisecond) + if _, _, ok := w.Banner(); ok { + t.Fatal("banner outlived 750ms") + } +} + +func TestBannerTextIsOneOfTheSpecFour(t *testing.T) { + allowed := map[string]bool{ + "✦ EVENT HORIZON ✦": true, + "QUADRUPLE COSMIC INCIDENT": true, + "FOUR ROWS HAVE LEFT THE CHAT": true, + "SPACE-TIME HAS FILED A COMPLAINT": true, + } + for seed := int64(0); seed < 30; seed++ { + w := NewWorld(seed, Config{Enabled: true}) + w.SetGeometry(Rect{0, 0, 80, 30}, Rect{10, 3, 20, 20}) + w.Observe([]game.Event{{Kind: game.EventLinesCleared, Rows: []int{18, 19, 20, 21}}}) + text, _, _ := w.Banner() + if !allowed[text] { + t.Fatalf("banner %q is not one of §20's four", text) + } + } +} + +func TestOneTwoAndThreeLineClearsDoNotBanner(t *testing.T) { + for _, rows := range [][]int{{21}, {20, 21}, {19, 20, 21}} { + w := worldAt(80, 30) + w.Observe([]game.Event{{Kind: game.EventLinesCleared, Rows: rows}}) + if _, _, ok := w.Banner(); ok { + t.Errorf("clearing %d rows produced a banner", len(rows)) + } + } +} + +func TestHUDPulseDecaysToZero(t *testing.T) { + w := worldAt(80, 30) + w.Observe([]game.Event{{Kind: game.EventLinesCleared, Rows: []int{18, 19, 20, 21}}}) + peak := w.HUDPulse() + w.Update(300 * time.Millisecond) + mid := w.HUDPulse() + if !(mid < peak) { + t.Fatalf("HUD pulse did not decay: %v -> %v", peak, mid) + } + w.Update(3 * time.Second) + if got := w.HUDPulse(); got != 0 { + t.Fatalf("HUDPulse = %v after 3s, want 0", got) + } +} +``` + +```go +// internal/render/banner_test.go +package render + +import ( + "strings" + "testing" + "time" + + "cosmic-tetris/internal/fx" + "cosmic-tetris/internal/game" +) + +func TestFrameShowsTheBannerWithoutBreakingLayout(t *testing.T) { + g := game.New(1) + l := Compute(80, 30) + w := liveWorld(80, 30, l) + w.Observe([]game.Event{{Kind: game.EventLinesCleared, Rows: []int{18, 19, 20, 21}}}) + w.Update(50 * time.Millisecond) + out := stripANSI(Frame(Scene{Game: g, Mode: ModeFull, Width: 80, Height: 30, FX: w})) + text, _, _ := w.Banner() + if !strings.Contains(out, text) { + t.Fatalf("banner %q missing from the frame:\n%s", text, out) + } + rows := strings.Split(out, "\n") + if len(rows) != 30 { + t.Fatalf("banner changed the row count to %d", len(rows)) + } + for i, r := range rows { + if len([]rune(r)) != 80 { + t.Fatalf("banner made row %d %d runes wide", i, len([]rune(r))) + } + } +} + +func TestBannerFitsInASmallTerminal(t *testing.T) { + g := game.New(1) + l := Compute(40, 24) + w := liveWorld(40, 24, l) + w.Observe([]game.Event{{Kind: game.EventLinesCleared, Rows: []int{18, 19, 20, 21}}}) + w.Update(50 * time.Millisecond) + rows := strings.Split(stripANSI(Frame(Scene{Game: g, Mode: ModeFull, Width: 40, Height: 24, FX: w})), "\n") + for i, r := range rows { + if len([]rune(r)) != 40 { + t.Fatalf("row %d is %d runes at 40 columns; the banner must clip", i, len([]rune(r))) + } + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -run 'TestQuad|TestBanner|TestOneTwo|TestHUDPulse' -v && go test ./internal/render/ -run TestBanner -v && go test ./internal/render/ -run TestFrameShowsTheBanner -v` +Expected: FAIL — undefined: `Banner`, `HUDPulse`, `DrawBanner`. + +- [ ] **Step 3: Implement the quad sequence and banner rendering** + +In `Observe`, a `LinesCleared` with `len(Rows) == 4` fires: `Hyperdrive(HyperQuad)`, `Shake(2.0)` (longer, still one cell), `FlashBorder(1.0)`, a large `EmitBurst` at the board center, `Shockwave` at the board center, `HUDPulse = 1.0`, a temporary star density bump, and `banner = flavor.QuadBanner(w.rng)` with age 0. + +`DrawBanner` centers the text over the board, clipping to the layout width, and uses `Palette.MarkStyle(fx.RoleBanner, 0, bright)`. `Frame` draws it at §37 step 10 — after HUD and screen FX, before mission control. + +- [ ] **Step 4: Run the suite to verify it passes** + +Run: `go test ./... -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . && git add internal/fx/ internal/render/ +git commit -m "feat(fx): four-line clear sequence with banner and HUD flash" +``` + +--- + +### Task 13: Combo escalation and the level-up notification + +**Files:** +- Modify: `internal/fx/world.go`, `internal/render/hud.go` +- Test: `internal/fx/combo_test.go` + +**Interfaces:** +- Produces: `func (w *World) Notice() (title, subtitle string, bright float64, ok bool)` for §22's level-up panel; combo escalation folded into `Observe`. + +§21's ladder: combo 2 small sparks, combo 3 meteor particles, combo 4 the HUD begins pulsing, combo 5+ shockwave, hyperdrive, and a `flavor.ComboLine` on the mission channel. Effects intensify but readability is preserved — the escalation adds particles outside the board and pulse on the HUD, never marks over the board's occupied cells. + +§22's notification slides away without pausing the game: title `GRAVITY ANOMALY DETECTED`, subtitle `LEVEL 08`-style, plus one of §22's three subtitles on the mission channel. Lifetime 1200 ms. + +- [ ] **Step 1: Write the failing test** + +```go +package fx + +import ( + "strings" + "testing" + "time" + + "cosmic-tetris/internal/game" +) + +func comboAt(v int) *World { + w := worldAt(80, 30) + w.Observe([]game.Event{{Kind: game.EventComboChanged, Value: v}}) + return w +} + +func TestComboEscalatesParticleActivity(t *testing.T) { + two := comboAt(2).ParticleCount() + three := comboAt(3).ParticleCount() + five := comboAt(5).ParticleCount() + if two == 0 { + t.Error("combo 2 produced no sparks") + } + if three <= two { + t.Errorf("combo 3 (%d) is not more active than combo 2 (%d)", three, two) + } + if five <= three { + t.Errorf("combo 5 (%d) is not more active than combo 3 (%d)", five, three) + } +} + +func TestComboOneAndZeroAreQuiet(t *testing.T) { + for _, v := range []int{0, 1} { + w := comboAt(v) + if w.ParticleCount() != 0 { + t.Errorf("combo %d emitted %d particles, want 0", v, w.ParticleCount()) + } + if w.HUDPulse() != 0 { + t.Errorf("combo %d pulsed the HUD", v) + } + } +} + +func TestHUDPulsesFromComboFour(t *testing.T) { + if comboAt(3).HUDPulse() != 0 { + t.Error("combo 3 pulsed the HUD; §21 starts that at combo 4") + } + if comboAt(4).HUDPulse() <= 0 { + t.Error("combo 4 did not pulse the HUD") + } +} + +func TestComboFivePutsItsLineOnTheMissionChannel(t *testing.T) { + w := comboAt(5) + if got := w.Mission(); !strings.Contains(got, "COMBO 5") { + t.Fatalf("Mission() = %q, want the combo 5 line", got) + } +} + +func TestComboEscalationKeepsSomeMarksOutsideTheBoard(t *testing.T) { + w := comboAt(7) + for i := 0; i < 20; i++ { + w.Update(16 * time.Millisecond) + boardMarks := 0 + total := 0 + for _, m := range w.Marks(nil) { + total++ + if m.Layer == LayerBoard { + boardMarks++ + } + } + if total > 0 && boardMarks == total { + t.Fatal("all combo marks are inside the board; readability is sacred (§21)") + } + } +} + +func TestLevelUpShowsANoticeThatExpires(t *testing.T) { + w := worldAt(80, 30) + w.Observe([]game.Event{{Kind: game.EventLevelChanged, Value: 8}}) + title, subtitle, _, ok := w.Notice() + if !ok { + t.Fatal("no level-up notice") + } + if title != "GRAVITY ANOMALY DETECTED" { + t.Errorf("title = %q, want §22's wording", title) + } + if !strings.Contains(subtitle, "LEVEL") || !strings.Contains(subtitle, "08") { + t.Errorf("subtitle = %q, want a zero-padded LEVEL 08", subtitle) + } + w.Update(1300 * time.Millisecond) + if _, _, _, ok := w.Notice(); ok { + t.Fatal("the notice outlived 1.3s") + } +} + +func TestLevelUpAlsoTalksToMissionControl(t *testing.T) { + w := worldAt(80, 30) + w.Observe([]game.Event{{Kind: game.EventLevelChanged, Value: 8}}) + m := w.Mission() + ok := false + for _, want := range []string{"GRAVITY TAX INCREASED", "LOCAL PHYSICS UPDATED WITHOUT CONSENT", "PLEASE SECURE ALL LOOSE TETROMINOES"} { + if strings.Contains(m, want) { + ok = true + } + } + if !ok { + t.Fatalf("Mission() = %q, want one of §22's subtitles", m) + } +} + +func TestNoticeDoesNotPauseAnything(t *testing.T) { + w := worldAt(80, 30) + w.Observe([]game.Event{{Kind: game.EventLevelChanged, Value: 8}}) + starY := func() float64 { + total := 0.0 + for _, m := range w.Marks(nil) { + if m.Role == RoleStarMid { + total += float64(m.Y) + } + } + return total + } + before := starY() + w.Update(300 * time.Millisecond) + if starY() == before { + t.Fatal("the starfield froze while a notice was up") + } +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `go test ./internal/fx/ -run 'TestCombo|TestHUDPulses|TestLevelUp|TestNotice' -v` +Expected: FAIL — undefined: `Notice`; combo escalation absent. + +- [ ] **Step 3: Implement combo escalation and the notice** + +`Observe` on `EventComboChanged`: `Value >= 2` emits sparks at the board edges (screen layer) with count scaling as `4 × (Value - 1)`; `>= 3` switches glyphs to meteor `✦`/`*` with a horizontal drift; `>= 4` sets `HUDPulse = min(1, 0.3 + 0.15×Value)`; `>= 5` calls `Shockwave` and `Hyperdrive(HyperCombo)` and pushes `flavor.ComboLine(Value)`. + +`Notice` holds `{title, subtitle, age}` for 1200 ms with brightness fading over the last 400 ms (§22's slide/fade). `DrawHUD` scales stat styling by `HUDPulse` when a world is present, and `Frame` draws the notice near the top of the board area. + +- [ ] **Step 4: Run the suite to verify it passes** + +Run: `go test ./... -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . && git add internal/fx/ internal/render/ +git commit -m "feat(fx): combo escalation ladder and level-up notification" +``` + +--- + +### Task 14: Wire effects into the app + +**Files:** +- Modify: `internal/app/model.go`, `internal/app/update.go`, `cmd/cosmic-tetris/main.go` +- Test: `internal/app/fx_test.go` + +**Interfaces:** +- Produces: `Model` gains `FX *fx.World` and `SessionBest int`; `New(opts)` constructs the world with an FX seed **derived independently of the game seed** (`opts.Seed ^ 0x5DEECE66` is fine, since the two generators must merely be separate, not unrelated) and `fx.Config{Enabled: !opts.NoFX, ReducedMotion: opts.ReducedMotion, ASCII: opts.Mode == render.ModeASCII}`. + +- [ ] **Step 1: Write the failing test** + +```go +package app + +import ( + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "cosmic-tetris/internal/render" +) + +func TestEveryGameEventReachesTheWorld(t *testing.T) { + m := newModel() + m.Width, m.Height = 80, 30 + next, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 30}) + m = next.(Model) + next, _ = m.Update(tea.KeyPressMsg{Code: ' ', Text: " "}) // hard drop + m = next.(Model) + if m.FX.ParticleCount() == 0 { + t.Fatal("a hard drop produced no particles: events are not reaching the world") + } +} + +func TestTheWorldAdvancesOnFrameMessages(t *testing.T) { + m := newModel() + m.Width, m.Height = 80, 30 + next, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 30}) + m = next.(Model) + before := m.FX.Elapsed + now := time.Now() + m.LastFrame = now + next, _ = m.Update(FrameMsg{Now: now.Add(16 * time.Millisecond)}) + m = next.(Model) + if m.FX.Elapsed <= before { + t.Fatal("the world did not advance on a frame") + } +} + +func TestEffectsKeepRunningWhileGameplayIsPaused(t *testing.T) { + m := newModel() + m.Width, m.Height = 80, 30 + next, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 30}) + m = next.(Model) + next, _ = m.Update(keyMsg("p")) + m = next.(Model) + before := m.FX.Elapsed + now := time.Now() + m.LastFrame = now + next, _ = m.Update(FrameMsg{Now: now.Add(16 * time.Millisecond)}) + m = next.(Model) + if m.FX.Elapsed <= before { + t.Fatal("background stars must keep drifting while paused (§30)") + } +} + +func TestPausedGameplayParticlesDoNotAdvance(t *testing.T) { + m := newModel() + m.Width, m.Height = 80, 30 + next, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 30}) + m = next.(Model) + next, _ = m.Update(tea.KeyPressMsg{Code: ' ', Text: " "}) + m = next.(Model) + next, _ = m.Update(keyMsg("p")) + m = next.(Model) + before := m.FX.ParticleCount() + now := time.Now() + for i := 0; i < 30; i++ { + m.LastFrame = now + now = now.Add(16 * time.Millisecond) + next, _ = m.Update(FrameMsg{Now: now}) + m = next.(Model) + } + if m.FX.ParticleCount() != before { + t.Fatalf("gameplay particles advanced while paused: %d -> %d (§30)", before, m.FX.ParticleCount()) + } +} + +func TestNoFXOptionYieldsADisabledWorld(t *testing.T) { + m := New(Options{Seed: 1, Mode: render.ModeFull, NoFX: true}) + m.Width, m.Height = 80, 30 + next, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 30}) + m = next.(Model) + next, _ = m.Update(tea.KeyPressMsg{Code: ' ', Text: " "}) + m = next.(Model) + if m.FX.ParticleCount() != 0 { + t.Fatal("--no-fx produced particles") + } + if got := m.View(); got == "" { + t.Fatal("--no-fx produced no view at all") + } +} + +func TestResizeUpdatesTheWorldGeometry(t *testing.T) { + m := newModel() + for _, s := range []tea.WindowSizeMsg{{Width: 80, Height: 30}, {Width: 40, Height: 24}, {Width: 0, Height: 0}, {Width: 200, Height: 60}} { + next, _ := m.Update(s) + m = next.(Model) + if m.FX.Screen.W != s.Width || m.FX.Screen.H != s.Height { + t.Fatalf("world screen = %dx%d after a %dx%d resize", m.FX.Screen.W, m.FX.Screen.H, s.Width, s.Height) + } + _ = m.View() + } +} + +func TestSessionBestDrivesTheHighScoreHyperdrive(t *testing.T) { + m := newModel() + m.Width, m.Height = 80, 30 + next, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 30}) + m = next.(Model) + for i := 0; i < 6; i++ { // a few hard drops build a score + next, _ = m.Update(tea.KeyPressMsg{Code: ' ', Text: " "}) + m = next.(Model) + } + if m.SessionBest < m.Game.Score { + t.Fatalf("SessionBest = %d, want at least the current score %d", m.SessionBest, m.Game.Score) + } + best := m.SessionBest + next, _ = m.Update(keyMsg("r")) + m = next.(Model) + if m.SessionBest != best { + t.Fatalf("restart reset SessionBest from %d to %d; it is per-session (§16)", best, m.SessionBest) + } +} + +func TestRestartClearsLiveEffects() {} + +func TestRestartResetsTheWorld(t *testing.T) { + m := newModel() + m.Width, m.Height = 80, 30 + next, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 30}) + m = next.(Model) + next, _ = m.Update(tea.KeyPressMsg{Code: ' ', Text: " "}) + m = next.(Model) + if m.FX.ParticleCount() == 0 { + t.Fatal("precondition: expected live particles") + } + next, _ = m.Update(keyMsg("r")) + m = next.(Model) + if m.FX.ParticleCount() != 0 { + t.Fatalf("restart left %d particles alive", m.FX.ParticleCount()) + } + if _, _, ok := m.FX.Banner(); ok { + t.Fatal("restart left a banner up") + } +} +``` + +Delete the empty `TestRestartClearsLiveEffects` stub before running — it is a placeholder to remove, not a test. + +- [ ] **Step 2: Run it to verify it fails** + +Run: `go test ./internal/app/ -v` +Expected: FAIL — `Model` has no field `FX`. + +- [ ] **Step 3: Wire the world into the model** + +`New` builds the world. `Update` on `tea.WindowSizeMsg` calls `m.FX.SetGeometry` with the screen rect and the board rect from `render.Compute`. On `FrameMsg`: advance the game only when playing, then `m.FX.Observe(events)`, then `m.FX.Update(dt)` **always** (§30: background stars keep drifting while paused) — but pass the paused world a flag so gameplay particles hold still. Add `func (w *World) UpdateBackgroundOnly(dt time.Duration)` in `fx` for that, tested by `TestPausedGameplayParticlesDoNotAdvance`. Call `m.FX.NoteScore(m.Game.Score)` after each input that can score, and keep `SessionBest = max(SessionBest, Game.Score)` across restarts. Restart calls a new `func (w *World) Reset()` that clears particles, trails, clears, shocks, banner, notice, and shake but keeps the starfield and the session best. + +`View` passes `FX: m.FX` in the `render.Scene`. + +- [ ] **Step 4: Run the whole suite to verify it passes** + +Run: `go test ./... -race -v` +Expected: PASS. + +- [ ] **Step 5: Add FX golden snapshots at a fixed simulated time** + +Append to `internal/render/golden_test.go` two cases that build a world with a fixed FX seed, feed a fixed event set, advance it by an exact number of 16 ms steps, and snapshot: `fx_midclear` (a four-line clear 6 frames in, 80×30) and `fx_ascii` (the same in ASCII mode). Generate with `-update`, then read both files and confirm the board is still legible and no row is ragged. + +- [ ] **Step 6: Play it and check §43's coolness acceptance test** + +Run: `go run ./cmd/cosmic-tetris --seed 1234` +Within the first 30 seconds confirm all six: moving starfield, animated board border, piece trails, hard-drop impact, particles, mission-control commentary. On the first line clear confirm the supernova, debris, and border reaction. Trigger a four-line clear and confirm it is gloriously excessive. Then run `--no-fx` and confirm the game is still good, and `--reduced-motion` and confirm no shake, no hyperdrive acceleration, and no shockwaves while color, trails, and particles remain. + +- [ ] **Step 7: Commit** + +```bash +gofmt -l . && git add internal/app/ internal/fx/ internal/render/ cmd/ +git commit -m "feat(app): wire the fx world into the frame loop, resize, and restart" +``` + +--- + +## What Plan 2 deliberately leaves out + +Plan 3 owns: the boot sequence, the game-over freeze/collapse/black-hole sequence, the help overlay's final styling, ASCII-mode auditing across every effect, terminal-capability detection for full vs reduced color, FX intensity reduction on small terminals, the §45 optional details' trigger plumbing, and the remaining §41 golden snapshots (game over, help). diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/Q2-reader-recipe-cosmic-31/2026-09-17-cosmic-tetris-3-polish-and-fallbacks.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/Q2-reader-recipe-cosmic-31/2026-09-17-cosmic-tetris-3-polish-and-fallbacks.md new file mode 100644 index 000000000..ccb663da5 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/Q2-reader-recipe-cosmic-31/2026-09-17-cosmic-tetris-3-polish-and-fallbacks.md @@ -0,0 +1,1615 @@ +# Cosmic Tetris — Plan 3: Polish and Fallbacks Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship it — the boot sequence, the game-over black hole, the help overlay, terminal-capability degradation, FX that scale down on small terminals, the last flavor details, the remaining golden snapshots, and the acceptance checklist walked end to end. + +**Architecture:** The two new full-screen moments (boot, game over) are app states with an elapsed-time counter, drawn by pure functions of that elapsed time — which makes them snapshot-testable and keeps them working when `--no-fx` has silenced the effect world. Capability detection happens once at startup and resolves to the same `render.Mode` the flags select, so there is exactly one degradation path. FX intensity becomes a single scalar the layout multiplies down on small terminals. + +**Tech Stack:** Go 1.24, `charm.land/bubbletea/v2`, `charm.land/bubbles/v2` (key + help), `charm.land/lipgloss/v2`. + +**Spec:** `design.md` (this plan implements §28–§32, §39, §41, §43, §45, §47, §48, §42 phase 5) + +**Depends on:** Plans 1 and 2 complete, all tests green. + +## Global Constraints + +- Everything from Plan 1 and Plan 2's Global Constraints still holds, in particular: `internal/game` stays clock-free, `internal/fx` never modifies game state, shake never exceeds one cell, `gofmt -l .` prints nothing before each commit. +- The three render modes are the only degradation axis (§32): Full (truecolor + Unicode), Reduced (16 colors, simpler glyphs), ASCII (no Unicode at all). ASCII mode output must be pure ASCII — every byte < 128 (§32). +- The boot sequence is skippable by any key and never exceeds ~1 second (§29). +- Game over must not need a keystroke to advance its animation, and `R` restarts from the panel (§28, §11). +- No persistence: no score file, no config file, no network (§2, §48). Session best lives in memory only. +- Help is a toggle, not a mode that can trap the player (§39). +- The §4 mockup is intent, not geometry; the golden files are the layout contract (§49.7). Never hand-edit a golden — regenerate with `-update` and read the diff. +- No new dependencies. `go.mod` still lists exactly the three Charm modules. + +## Review Focus + +1. **A key pressed during the game-over collapse, before the panel appears**: `R` must not restart into a half-collapsed effect world, and `Q`/`Ctrl+C` must quit immediately rather than waiting out the animation. — Task 2. +2. **A terminal that reports no color** (`NO_COLOR=1`, `TERM=dumb`): mode detection must degrade instead of emitting escapes into a pipe, and `--ascii` on a truecolor terminal must still produce pure ASCII — the flag wins over detection. — Task 4. +3. **Boot and game-over panels in a terminal below the 40×24 minimum**: a 30×10 window must show the TooSmall notice rather than a panel drawn past the canvas edge, and the boot sequence must still be skippable there. — Tasks 1 and 2. +4. **A resize mid-sequence**: shrinking during boot, during the collapse, or with help open must re-center the panel on the next frame with no ragged rows and no lost state. — Tasks 1, 2, 3. +5. **Help opened while a banner is up and while paused**: the overlay must win the screen, the game must stay paused underneath, `?` must close it, and gameplay keys must not leak through to the board while it is open. — Task 3. + +--- + +## File Structure + +```text +internal/render/ +├── boot.go NEW: DrawBoot — pure function of elapsed time +├── gameover.go NEW: DrawCollapse + DrawFinalPanel +├── help.go NEW: DrawHelp overlay panel +├── render.go MODIFIED: Scene gains BootElapsed, OverElapsed, ShowHelp +├── palette.go MODIFIED: DetectMode — capability detection +└── testdata/ MODIFIED: boot.txt, gameover.txt, help.txt added +internal/fx/ +├── world.go MODIFIED: Collapse, Intensity, ShootingStar +└── starfield.go MODIFIED: star counts scale with Intensity +internal/app/ +├── model.go MODIFIED: StateBoot, BootElapsed, OverElapsed, idle tracking +├── update.go MODIFIED: state machine for boot/over/help, flavor triggers +└── keys.go MODIFIED: help bindings wired to bubbles/help +cmd/cosmic-tetris/ +└── main.go MODIFIED: DetectMode, --help text +README.md MODIFIED: final feature list, flags, screenshots section +``` + +--- + +### Task 1: Boot sequence + +**Files:** +- Create: `internal/render/boot.go` +- Modify: `internal/render/render.go`, `internal/app/model.go`, `internal/app/update.go` +- Test: `internal/render/boot_test.go`, `internal/app/boot_test.go` + +**Interfaces:** +- Consumes: `Canvas`, `Layout`, `Palette`, `Mode` (Plan 1 Task 12); `AppState`, `FrameMsg` (Plan 1 Task 16). +- Produces: + +```go +// render +const BootDuration = 1000 * time.Millisecond // §29: "approximately one second" +func BootLines(m Mode) []string +// app +const (StateBoot AppState = ...) // a new first member of Plan 1's AppState set +``` + +`BootLines` returns §29's copy verbatim, with only the leading star swapped for ASCII mode: + +```go +[]string{ + "✦", // "*" in ModeASCII + "", + "C O S M I C", + "", + "T E T R I S", + "", + "INITIALIZING LOCAL UNIVERSE...", + "", + "gravity ........ OK", + "spacetime ...... OK", + "tetrominoes .... QUESTIONABLE", + "", + "UNIVERSE ONLINE", +} +``` + +Also: `func DrawBoot(c *Canvas, elapsed time.Duration, l Layout, p Palette, m Mode)`, and `Scene` gains `Booting bool` and `BootElapsed time.Duration`. + +Add `StateBoot` to Plan 1's existing `AppState` `const` group as its first member, so `StateBoot`, `StatePlaying`, `StatePaused`, `StateGameOver` stay one contiguous set. + +Lines appear progressively: line `i` is visible once `elapsed >= BootDuration × (i+1) / len(lines)`. `UNIVERSE ONLINE` is therefore the last thing to appear, immediately before the game starts (§29). The starfield draws behind the block, which happens for free because `Frame` draws stars at step 2. + +- [ ] **Step 1: Write the failing render test** + +```go +package render + +import ( + "strings" + "testing" + "time" +) + +func TestBootRevealsLinesProgressively(t *testing.T) { + l := Compute(80, 30) + p := NewPalette(ModeFull) + count := func(elapsed time.Duration) int { + c := NewCanvas(80, 30) + DrawBoot(c, elapsed, l, p, ModeFull) + out := c.Plain() + n := 0 + for _, line := range BootLines(ModeFull) { + if line != "" && strings.Contains(out, line) { + n++ + } + } + return n + } + early := count(50 * time.Millisecond) + mid := count(500 * time.Millisecond) + late := count(BootDuration) + if !(early < mid && mid < late) { + t.Fatalf("lines did not appear progressively: %d, %d, %d", early, mid, late) + } + var nonBlank int + for _, line := range BootLines(ModeFull) { + if line != "" { + nonBlank++ + } + } + if late != nonBlank { + t.Errorf("at the end %d lines are visible, want all %d", late, nonBlank) + } +} + +func TestBootCopyIsTheSpecCopy(t *testing.T) { + lines := BootLines(ModeFull) + joined := strings.Join(lines, "\n") + for _, want := range []string{ + "C O S M I C", + "T E T R I S", + "INITIALIZING LOCAL UNIVERSE...", + "gravity ........ OK", + "spacetime ...... OK", + "tetrominoes .... QUESTIONABLE", + "UNIVERSE ONLINE", + } { + if !strings.Contains(joined, want) { + t.Errorf("BootLines is missing §29's line %q", want) + } + } +} + +func TestUniverseOnlineComesLast(t *testing.T) { + c := NewCanvas(80, 30) + DrawBoot(c, 30*time.Millisecond, Compute(80, 30), NewPalette(ModeFull), ModeFull) + if strings.Contains(c.Plain(), "UNIVERSE ONLINE") { + t.Error("UNIVERSE ONLINE appeared immediately; §29 puts it at the end") + } + full := NewCanvas(80, 30) + DrawBoot(full, BootDuration, Compute(80, 30), NewPalette(ModeFull), ModeFull) + if !strings.Contains(full.Plain(), "UNIVERSE ONLINE") { + t.Error("UNIVERSE ONLINE never appeared") + } +} + +func TestBootFitsEveryTerminalSizeItIsGiven(t *testing.T) { + for _, dims := range [][2]int{{40, 24}, {50, 26}, {80, 30}, {200, 60}} { + c := NewCanvas(dims[0], dims[1]) + DrawBoot(c, BootDuration, Compute(dims[0], dims[1]), NewPalette(ModeFull), ModeFull) + rows := strings.Split(c.Plain(), "\n") + if len(rows) != dims[1] { + t.Fatalf("at %dx%d got %d rows", dims[0], dims[1], len(rows)) + } + for i, r := range rows { + if len([]rune(r)) != dims[0] { + t.Fatalf("at %dx%d row %d is %d runes", dims[0], dims[1], i, len([]rune(r))) + } + } + } +} + +func TestBootInAnUndersizedTerminalDoesNotPanicOrOverflow(t *testing.T) { + for _, dims := range [][2]int{{30, 10}, {10, 3}, {1, 1}, {0, 0}} { + c := NewCanvas(dims[0], dims[1]) + DrawBoot(c, 500*time.Millisecond, Compute(dims[0], dims[1]), NewPalette(ModeFull), ModeFull) + if dims[0] > 0 { + for i, r := range strings.Split(c.Plain(), "\n") { + if len([]rune(r)) != dims[0] { + t.Fatalf("at %dx%d row %d is %d runes", dims[0], dims[1], i, len([]rune(r))) + } + } + } + } +} + +func TestBootIsAsciiInAsciiMode(t *testing.T) { + for _, line := range BootLines(ModeASCII) { + for _, r := range line { + if r > 127 { + t.Errorf("ASCII-mode boot line %q contains non-ASCII %q", line, r) + } + } + } + // And full mode is allowed its star. + if !strings.Contains(strings.Join(BootLines(ModeFull), ""), "✦") { + t.Error("full mode dropped §29's star") + } +} + +func TestFrameDrawsBootInsteadOfTheBoard(t *testing.T) { + out := StripANSI(Frame(Scene{ + Mode: ModeFull, Width: 80, Height: 30, + Booting: true, BootElapsed: 400 * time.Millisecond, + })) + if !strings.Contains(out, "INITIALIZING LOCAL UNIVERSE") { + t.Fatal("Frame did not draw the boot screen") + } + if strings.Contains(out, "SCORE") { + t.Error("the HUD is visible during boot") + } +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `go test ./internal/render/ -run TestBoot -v && go test ./internal/render/ -run TestFrameDrawsBoot -v` +Expected: FAIL — undefined: `DrawBoot`, `BootLines`; `Scene` has no field `Booting`. + +- [ ] **Step 3: Implement `boot.go` and the `Frame` branch** + +Center the block on the canvas, left-aligning the diagnostic lines with each other so the `.... OK` columns line up, and centering the three title lines. Clip any line longer than the canvas. When `Booting` is true, `Frame` draws stars, then the boot block, and nothing else — `Frame` must tolerate `Scene.Game == nil` on this path. + +Also in this step, promote Plan 1's test-only `stripANSI` to product code: move it from `internal/render/strip_test.go` into `internal/render/render.go` as `func StripANSI(s string) string`, delete the test-only copy, and update every call site in the render tests from Plans 1 and 2. `internal/app`'s tests need it too from Task 2 onward, and a `_test.go` helper cannot cross packages. It is small, it is exactly the operation the goldens are defined in terms of, and exporting it is cheaper than maintaining two copies. + +- [ ] **Step 4: Run it to verify it passes** + +Run: `go test ./internal/render/ -v` +Expected: PASS. + +- [ ] **Step 5: Write the failing app test** + +```go +package app + +import ( + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "cosmic-tetris/internal/render" +) + +func TestTheAppStartsInBoot(t *testing.T) { + m := New(Options{Seed: 1, Mode: render.ModeFull}) + if m.State != StateBoot { + t.Fatalf("State = %v at launch, want StateBoot", m.State) + } +} + +func TestBootAdvancesOnFramesAndEndsOnItsOwn(t *testing.T) { + m := New(Options{Seed: 1, Mode: render.ModeFull}) + next, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 30}) + m = next.(Model) + now := time.Now() + for i := 0; i < 80; i++ { // 80 x 16ms = 1.28s, past BootDuration + m.LastFrame = now + now = now.Add(16 * time.Millisecond) + next, _ = m.Update(FrameMsg{Now: now}) + m = next.(Model) + } + if m.State != StatePlaying { + t.Fatalf("State = %v after %v, want StatePlaying", m.State, render.BootDuration) + } +} + +func TestAnyKeySkipsBoot(t *testing.T) { + for _, k := range []tea.KeyPressMsg{keyMsg("x"), keyMsg("j"), {Code: tea.KeyEnter}, {Code: ' ', Text: " "}} { + m := New(Options{Seed: 1, Mode: render.ModeFull}) + next, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 30}) + m = next.(Model) + next, _ = m.Update(k) + m = next.(Model) + if m.State != StatePlaying { + t.Fatalf("key %v did not skip boot: State = %v", k, m.State) + } + } +} + +func TestQuitDuringBootStillQuits(t *testing.T) { + m := New(Options{Seed: 1, Mode: render.ModeFull}) + next, cmd := m.Update(keyMsg("q")) + m = next.(Model) + if cmd == nil { + t.Fatal("q during boot returned no command; it must quit") + } + if msg := cmd(); msg == nil { + t.Fatal("the command produced no message") + } +} + +func TestSkippingBootDoesNotDropTheGravityClock(t *testing.T) { + // The piece must not instantly fall a second's worth after boot ends. + m := New(Options{Seed: 1, Mode: render.ModeFull}) + next, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 30}) + m = next.(Model) + startY := m.Game.Active.Y + next, _ = m.Update(keyMsg("x")) + m = next.(Model) + now := time.Now() + m.LastFrame = now + next, _ = m.Update(FrameMsg{Now: now.Add(16 * time.Millisecond)}) + m = next.(Model) + if m.Game.Active.Y > startY+1 { + t.Fatalf("the piece fell from %d to %d on the first frame after boot", startY, m.Game.Active.Y) + } +} + +func TestNoFXSkipsBootEntirely(t *testing.T) { + m := New(Options{Seed: 1, Mode: render.ModeFull, NoFX: true}) + if m.State != StatePlaying { + t.Fatalf("State = %v with --no-fx, want StatePlaying: boot is an effect", m.State) + } +} + +func TestBootSurvivesAResize(t *testing.T) { + m := New(Options{Seed: 1, Mode: render.ModeFull}) + for _, s := range []tea.WindowSizeMsg{{Width: 80, Height: 30}, {Width: 30, Height: 10}, {Width: 100, Height: 40}} { + next, _ := m.Update(s) + m = next.(Model) + if m.State != StateBoot { + t.Fatalf("a resize ended boot early: State = %v", m.State) + } + _ = m.View() + } +} +``` + +- [ ] **Step 6: Run it to verify it fails** + +Run: `go test ./internal/app/ -run 'TestTheAppStarts|TestBoot|TestAnyKey|TestQuitDuring|TestSkipping|TestNoFXSkips' -v` +Expected: FAIL — undefined: `StateBoot`. + +- [ ] **Step 7: Implement the boot state** + +`New` sets `State: StateBoot` unless `NoFX`. `Update` on `FrameMsg` in `StateBoot` adds the clamped `dt` to `BootElapsed`, advances the FX world (background only, via Plan 2's `UpdateBackgroundOnly` — the stars must drift), and transitions to `StatePlaying` at `render.BootDuration`. Any `tea.KeyPressMsg` other than quit transitions immediately. The transition sets `LastFrame` to the message's `Now` so the first playing frame has a small `dt` — that is what `TestSkippingBootDoesNotDropTheGravityClock` checks. `View` passes `Booting: m.State == StateBoot` and `BootElapsed`. + +Every existing app test from Plans 1 and 2 calls `newModel()` and then acts immediately, so those tests now start in `StateBoot` and fail. Change the helper in `internal/app/helpers_test.go` to skip boot, which keeps every earlier test testing what it meant to test: + +```go +func newModel() Model { + m := New(Options{Seed: 1, Mode: render.ModeFull}) + m.State = StatePlaying + m.BootElapsed = render.BootDuration + return m +} +``` + +The tests in this task construct `New(...)` directly rather than through `newModel`, so they still see the boot state. + +- [ ] **Step 8: Run the suite to verify it passes** + +Run: `go test ./... -v` +Expected: PASS. + +- [ ] **Step 9: Commit** + +```bash +gofmt -l . && git add internal/render/boot.go internal/render/boot_test.go internal/render/render.go internal/app/ +git commit -m "feat: boot sequence with progressive diagnostics and any-key skip" +``` + +--- + +### Task 2: Game over — freeze, collapse, black hole, panel + +**Files:** +- Create: `internal/render/gameover.go` +- Modify: `internal/fx/world.go`, `internal/render/render.go`, `internal/app/model.go`, `internal/app/update.go` +- Test: `internal/render/gameover_test.go`, `internal/fx/collapse_test.go`, `internal/app/gameover_test.go` + +**Interfaces:** +- Produces: + +```go +// render +const ( + OverFreeze = 300 * time.Millisecond // §28: everything freezes for a beat + OverCollapse = 900 * time.Millisecond // the board falls into the black hole + OverPanel = OverFreeze + OverCollapse +) +func DrawCollapse(c *Canvas, g *game.Game, elapsed time.Duration, l Layout, p Palette) +func DrawFinalPanel(c *Canvas, g *game.Game, best int, l Layout, p Palette) +// Scene gains: OverElapsed time.Duration; Best int +// fx +func (w *World) Collapse(center Rect) // particles spiral inward toward the center +func (w *World) Collapsing() bool +``` + +§28's sequence: freeze → the board's rows collapse toward a point at the board center, row by row from the outside in → particles spiral inward → the final panel. + +Panel contents are §28's copy verbatim — Plan 1 Task 16 already pinned these when it drew the placeholder panel through `Scene.Overlay`, and they must not drift: `UNIVERSE EXPIRED`, `CAUSE: EXCESSIVE GEOMETRY` from `flavor.GameOverSubtitle()`, then `SCORE 483,200`-style comma-grouped stats for score, lines, and level, then `r REBOOT UNIVERSE` and `q ACCEPT COSMIC DEATH`. + +One documented addition: a `BEST ` line below `LEVEL`. §28's mockup does not show it, but §16 makes a session high score a real concept the player can trigger hyperdrive with, and a high score the player can never see is not a high score. `render` imports `internal/flavor` for the subtitle; that introduces no cycle, since `flavor` imports nothing of ours. + +- [ ] **Step 1: Write the failing fx test** + +```go +package fx + +import ( + "math" + "testing" + "time" +) + +func TestCollapseSpiralsParticlesInward(t *testing.T) { + w := worldAt(80, 30) + center := Rect{X: 30, Y: 5, W: 20, H: 20} + w.Collapse(center) + if !w.Collapsing() { + t.Fatal("Collapsing() is false right after Collapse()") + } + cx, cy := float64(center.X+center.W/2), float64(center.Y+center.H/2) + dist := func() float64 { + total, n := 0.0, 0 + for _, m := range w.Marks(nil) { + if m.Role != RoleDebris { + continue + } + dx, dy := float64(m.X)-cx, float64(m.Y)-cy + total += math.Hypot(dx, dy) + n++ + } + if n == 0 { + return 0 + } + return total / float64(n) + } + before := dist() + if before == 0 { + t.Fatal("Collapse emitted no debris") + } + w.Update(200 * time.Millisecond) + if after := dist(); after >= before { + t.Fatalf("particles did not move inward: mean radius %v -> %v", before, after) + } +} + +func TestCollapseEndsAndClearsItself(t *testing.T) { + w := worldAt(80, 30) + w.Collapse(Rect{X: 30, Y: 5, W: 20, H: 20}) + w.Update(2 * time.Second) + if w.Collapsing() { + t.Fatal("still collapsing after 2s") + } + if w.ParticleCount() != 0 { + t.Fatalf("%d particles survived the collapse", w.ParticleCount()) + } +} + +func TestCollapseIsInertWhenFXAreDisabled(t *testing.T) { + w := NewWorld(1, Config{Enabled: false}) + w.SetGeometry(Rect{0, 0, 80, 30}, Rect{10, 3, 20, 20}) + w.Collapse(Rect{X: 10, Y: 3, W: 20, H: 20}) + if w.Collapsing() || w.ParticleCount() != 0 { + t.Fatal("--no-fx still ran the collapse") + } +} + +func TestGameOverEventTriggersTheCollapse(t *testing.T) { + w := worldAt(80, 30) + w.Observe([]game.Event{{Kind: game.EventGameOver}}) + if !w.Collapsing() { + t.Fatal("EventGameOver did not start the collapse") + } +} +``` + +Add the `"cosmic-tetris/internal/game"` import to this file. + +- [ ] **Step 2: Run it to verify it fails** + +Run: `go test ./internal/fx/ -run 'TestCollapse|TestGameOverEvent' -v` +Expected: FAIL — undefined: `Collapse`. + +- [ ] **Step 3: Implement the collapse in `fx`** + +`Collapse` emits one particle per board cell position around the rect with velocity aimed at the center plus a tangential component (that is the spiral), and sets `collapseAge`. `updateParticles` gains an inward acceleration for `RoleDebris` particles while `Collapsing()` — accelerate toward the center and kill any particle within one cell of it, so the population drains to zero rather than orbiting forever. `Collapsing()` is true for 1.2 s (`render.OverPanel`'s duration, expressed as a local `collapseDuration` constant in `fx` so the packages stay independent). + +- [ ] **Step 4: Run it to verify it passes** + +Run: `go test ./internal/fx/ -v` +Expected: PASS — including Task 2 of Plan 2's cap test, which the inward acceleration must not break. + +- [ ] **Step 5: Write the failing render test** + +```go +package render + +import ( + "strings" + "testing" + "time" + + "cosmic-tetris/internal/game" +) + +func filledGame() *game.Game { + g := game.New(1) + for x := 0; x < game.Width; x++ { + g.Board.Set(x, game.Height-1, game.Cell(1)) + g.Board.Set(x, game.Height-2, game.Cell(2)) + } + g.Score, g.Lines, g.Level = 12345, 42, 5 + g.Over = true + return g +} + +func TestCollapsePullsRowsTowardTheCenterOverTime(t *testing.T) { + l := Compute(80, 30) + p := NewPalette(ModeFull) + blocks := func(elapsed time.Duration) int { + c := NewCanvas(80, 30) + DrawCollapse(c, filledGame(), elapsed, l, p) + return strings.Count(c.Plain(), "█") + } + start := blocks(OverFreeze) + mid := blocks(OverFreeze + OverCollapse/2) + end := blocks(OverPanel) + if start == 0 { + t.Fatal("nothing on the board at the start of the collapse") + } + if !(mid < start) { + t.Errorf("the board did not shrink: %d -> %d", start, mid) + } + if end != 0 { + t.Errorf("%d blocks survived the collapse", end) + } +} + +func TestFreezeBeatShowsTheBoardUnchanged(t *testing.T) { + l := Compute(80, 30) + p := NewPalette(ModeFull) + g := filledGame() + frozen := NewCanvas(80, 30) + DrawCollapse(frozen, g, 10*time.Millisecond, l, p) + still := NewCanvas(80, 30) + DrawCollapse(still, g, OverFreeze-10*time.Millisecond, l, p) + if frozen.Plain() != still.Plain() { + t.Fatal("the board moved during the freeze beat; §28 wants a held frame") + } +} + +func TestFinalPanelShowsEveryStatAndTheSpecCopy(t *testing.T) { + c := NewCanvas(80, 30) + DrawFinalPanel(c, filledGame(), 99999, Compute(80, 30), NewPalette(ModeFull)) + out := c.Plain() + for _, want := range []string{ + "UNIVERSE EXPIRED", + "CAUSE: EXCESSIVE GEOMETRY", + "12,345", // score, comma-grouped as §28 shows + "42", // lines + "5", // level + "99,999", // session best + "REBOOT UNIVERSE", + "ACCEPT COSMIC DEATH", + } { + if !strings.Contains(out, want) { + t.Errorf("the final panel is missing %q:\n%s", want, out) + } + } +} + +func TestFinalPanelFitsTheMinimumTerminal(t *testing.T) { + for _, dims := range [][2]int{{40, 24}, {50, 26}, {80, 30}, {200, 60}} { + c := NewCanvas(dims[0], dims[1]) + DrawFinalPanel(c, filledGame(), 500, Compute(dims[0], dims[1]), NewPalette(ModeFull)) + rows := strings.Split(c.Plain(), "\n") + if len(rows) != dims[1] { + t.Fatalf("at %dx%d got %d rows", dims[0], dims[1], len(rows)) + } + for i, r := range rows { + if len([]rune(r)) != dims[0] { + t.Fatalf("at %dx%d row %d is %d runes", dims[0], dims[1], i, len([]rune(r))) + } + } + } +} + +func TestUndersizedTerminalShowsTheNoticeNotThePanel(t *testing.T) { + out := StripANSI(Frame(Scene{ + Game: filledGame(), Mode: ModeFull, Width: 30, Height: 10, + OverElapsed: OverPanel + time.Second, Best: 500, + })) + if strings.Contains(out, "UNIVERSE EXPIRED") { + t.Fatal("the panel drew in a 30x10 terminal instead of the too-small notice") + } + for i, r := range strings.Split(out, "\n") { + if len([]rune(r)) > 30 { + t.Fatalf("row %d is %d runes wide in a 30-column terminal", i, len([]rune(r))) + } + } +} + +func TestFrameSwitchesFromCollapseToPanel(t *testing.T) { + g := filledGame() + during := StripANSI(Frame(Scene{Game: g, Mode: ModeFull, Width: 80, Height: 30, OverElapsed: OverFreeze + 100*time.Millisecond})) + if strings.Contains(during, "UNIVERSE EXPIRED") { + t.Error("the panel appeared during the collapse") + } + after := StripANSI(Frame(Scene{Game: g, Mode: ModeFull, Width: 80, Height: 30, OverElapsed: OverPanel + 50*time.Millisecond})) + if !strings.Contains(after, "UNIVERSE EXPIRED") { + t.Error("the panel did not appear after the collapse") + } +} +``` + +- [ ] **Step 6: Run it to verify it fails** + +Run: `go test ./internal/render/ -run 'TestCollapse|TestFreeze|TestFinalPanel|TestUndersized|TestFrameSwitches' -v` +Expected: FAIL — undefined: `DrawCollapse`, `DrawFinalPanel`, `OverFreeze`. + +- [ ] **Step 7: Implement the collapse rendering and the panel** + +`DrawCollapse` draws the board normally while `elapsed < OverFreeze`. Past that, `progress = (elapsed - OverFreeze) / OverCollapse`, and each row is drawn only if its distance from the board's vertical center exceeds `progress × halfHeight` — outer rows vanish first, so the stack visibly falls into a point. Also compress each surviving row horizontally toward the center by `progress`. + +`DrawFinalPanel` draws a bordered box centered over the board with the copy above, and returns without drawing when the layout mode is `LayoutTooSmall`. `Frame` picks: `OverElapsed > 0 && OverElapsed < OverPanel` → `DrawCollapse` plus screen FX; `>= OverPanel` → `DrawCollapse` (which now draws an empty board) plus `DrawFinalPanel`. + +- [ ] **Step 8: Write the failing app test** + +```go +package app + +import ( + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "cosmic-tetris/internal/render" +) + +// fillToTop hard-drops until the game ends. +func fillToTop(t *testing.T, m Model) Model { + t.Helper() + for i := 0; i < 500 && !m.Game.Over; i++ { + next, _ := m.Update(tea.KeyPressMsg{Code: ' ', Text: " "}) + m = next.(Model) + } + if !m.Game.Over { + t.Fatal("could not reach game over with 500 hard drops") + } + return m +} + +func TestGameOverEntersTheOverStateAndRunsTheSequence(t *testing.T) { + m := playingModel(t) + m = fillToTop(t, m) + if m.State != StateGameOver { + t.Fatalf("State = %v after the game ended", m.State) + } + if m.OverElapsed != 0 { + t.Fatalf("OverElapsed = %v at the moment of death, want 0", m.OverElapsed) + } + now := time.Now() + for i := 0; i < 100; i++ { + m.LastFrame = now + now = now.Add(16 * time.Millisecond) + next, _ := m.Update(FrameMsg{Now: now}) + m = next.(Model) + } + if m.OverElapsed < render.OverPanel { + t.Fatalf("OverElapsed = %v after 1.6s of frames, want at least %v", m.OverElapsed, render.OverPanel) + } +} + +func TestGameplayKeysAreIgnoredAfterGameOver(t *testing.T) { + m := playingModel(t) + m = fillToTop(t, m) + before := m.Game.Board + score := m.Game.Score + for _, k := range []tea.KeyPressMsg{keyMsg("h"), keyMsg("l"), keyMsg("j"), keyMsg("z"), {Code: ' ', Text: " "}, keyMsg("c"), keyMsg("p")} { + next, _ := m.Update(k) + m = next.(Model) + } + if m.Game.Board != before || m.Game.Score != score { + t.Fatal("a gameplay key changed the game after it ended") + } + if m.State != StateGameOver { + t.Fatalf("State = %v after gameplay keys, want StateGameOver", m.State) + } +} + +func TestRestartDuringTheCollapseIsCleanNotHalfway(t *testing.T) { + m := playingModel(t) + m = fillToTop(t, m) + now := time.Now() + m.LastFrame = now + next, _ := m.Update(FrameMsg{Now: now.Add(200 * time.Millisecond)}) // mid-freeze + m = next.(Model) + next, _ = m.Update(keyMsg("r")) + m = next.(Model) + if m.State != StatePlaying { + t.Fatalf("State = %v after R, want StatePlaying", m.State) + } + if m.OverElapsed != 0 { + t.Fatalf("OverElapsed = %v after a restart, want 0", m.OverElapsed) + } + if m.Game.Over || m.Game.Score != 0 { + t.Fatalf("the game was not reset: Over=%v Score=%d", m.Game.Over, m.Game.Score) + } + if m.FX.Collapsing() { + t.Fatal("the effect world is still collapsing after a restart") + } + if m.FX.ParticleCount() != 0 { + t.Fatalf("%d particles carried over into the new game", m.FX.ParticleCount()) + } +} + +func TestQuitDuringTheCollapseQuitsImmediately(t *testing.T) { + m := playingModel(t) + m = fillToTop(t, m) + next, cmd := m.Update(keyMsg("q")) + _ = next + if cmd == nil { + t.Fatal("q during the collapse returned no command") + } +} + +func TestSessionBestSurvivesTheDeath(t *testing.T) { + m := playingModel(t) + m = fillToTop(t, m) + best := m.SessionBest + if best < m.Game.Score { + t.Fatalf("SessionBest = %d, want at least the final score %d", best, m.Game.Score) + } + next, _ := m.Update(keyMsg("r")) + m = next.(Model) + if m.SessionBest != best { + t.Fatalf("SessionBest changed from %d to %d across a restart", best, m.SessionBest) + } +} + +func TestGameOverViewIsWellFormedThroughoutTheSequence(t *testing.T) { + m := playingModel(t) + m = fillToTop(t, m) + now := time.Now() + for i := 0; i < 120; i++ { + m.LastFrame = now + now = now.Add(16 * time.Millisecond) + next, _ := m.Update(FrameMsg{Now: now}) + m = next.(Model) + checkRect(t, m.View(), 80, 30) + } +} +``` + +Add helpers `playingModel(t)` (a `New` plus an 80×30 resize plus a boot skip) and `checkRect(t, view, w, h)` (splits on newline, asserts the row count and rune width after `render.StripANSI`, which Task 1 promoted to product code) to `internal/app/helpers_test.go`. + +- [ ] **Step 9: Run it to verify it fails** + +Run: `go test ./internal/app/ -run 'TestGameOver|TestGameplayKeys|TestRestartDuring|TestQuitDuring|TestSessionBest' -v` +Expected: FAIL — undefined: `OverElapsed`. + +- [ ] **Step 10: Implement the game-over state machine** + +When `Advance` or an input returns `EventGameOver`, set `State = StateGameOver`, `OverElapsed = 0`, update `SessionBest`, and let `FX.Observe` start the collapse. In `StateGameOver`, `FrameMsg` adds the clamped `dt` to `OverElapsed` and advances the FX world but never the game. `R` restarts at any point in the sequence: `Game.Restart()`, `OverElapsed = 0`, `FX.Reset()`, `State = StatePlaying`. `Q` and `Ctrl+C` quit at any point. Every other key is ignored. + +Plan 1 Task 16 filled `Scene.Overlay` with a plain game-over panel as a placeholder. Remove that: `View` now passes `OverElapsed` and `Best` instead, and `Overlay` keeps serving only the pause panel. Update Plan 1's app test that asserted the game-over overlay text to assert on the new panel copy instead. + +- [ ] **Step 11: Run the suite to verify it passes** + +Run: `go test ./... -race -v` +Expected: PASS. + +- [ ] **Step 12: Commit** + +```bash +gofmt -l . && git add internal/render/ internal/fx/ internal/app/ +git commit -m "feat: game-over freeze, black-hole collapse, and final panel" +``` + +--- + +### Task 3: Help overlay + +**Files:** +- Create: `internal/render/help.go` +- Modify: `internal/app/keys.go`, `internal/app/update.go`, `internal/render/render.go` +- Test: `internal/render/help_test.go`, `internal/app/help_test.go` + +**Interfaces:** +- Produces: + +```go +// render +func DrawHelp(c *Canvas, rows [][2]string, l Layout, p Palette) // {key, description} pairs +// Scene gains: ShowHelp bool; HelpRows [][2]string +// The rows arrive through the Scene rather than being built in render, because +// they come from app's KeyMap and render must not import app. +// app +func (k KeyMap) HelpRows() [][2]string // ordered key/description pairs for the overlay +func (k KeyMap) ShortHelp() []key.Binding // bubbles/help interface +func (k KeyMap) FullHelp() [][]key.Binding +``` + +§39: bindings live in `bubbles/key` so the help text is generated from them rather than duplicated. The overlay is a centered panel listing every binding with its description; `?` opens and closes it; `Esc` also closes it. + +- [ ] **Step 1: Write the failing test** + +```go +// internal/render/help_test.go +package render + +import ( + "strings" + "testing" +) + +func TestHelpPanelListsEveryRowItIsGiven(t *testing.T) { + rows := [][2]string{ + {"h/l or ←/→", "move"}, + {"j or ↓", "soft drop"}, + {"space", "hard drop"}, + {"z/x", "rotate"}, + {"c", "hold"}, + {"p", "pause"}, + {"r", "restart"}, + {"?", "help"}, + {"q", "quit"}, + } + c := NewCanvas(80, 30) + DrawHelp(c, rows, Compute(80, 30), NewPalette(ModeFull)) + out := c.Plain() + for _, r := range rows { + if !strings.Contains(out, r[1]) { + t.Errorf("the help panel is missing the description %q:\n%s", r[1], out) + } + } +} + +func TestHelpPanelFitsEverySupportedSize(t *testing.T) { + rows := make([][2]string, 9) + for i := range rows { + rows[i] = [2]string{"key", "a reasonably long description here"} + } + for _, dims := range [][2]int{{40, 24}, {50, 26}, {80, 30}, {200, 60}} { + c := NewCanvas(dims[0], dims[1]) + DrawHelp(c, rows, Compute(dims[0], dims[1]), NewPalette(ModeFull)) + lines := strings.Split(c.Plain(), "\n") + if len(lines) != dims[1] { + t.Fatalf("at %dx%d got %d rows", dims[0], dims[1], len(lines)) + } + for i, r := range lines { + if len([]rune(r)) != dims[0] { + t.Fatalf("at %dx%d row %d is %d runes", dims[0], dims[1], i, len([]rune(r))) + } + } + } +} + +func TestHelpPanelIsAsciiCleanInAsciiMode(t *testing.T) { + rows := [][2]string{{"space", "hard drop"}} + c := NewCanvas(80, 30) + DrawHelp(c, rows, Compute(80, 30), NewPalette(ModeASCII)) + for _, r := range c.Plain() { + if r > 127 && r != '\n' { + t.Fatalf("non-ASCII %q in the ASCII-mode help panel", r) + } + } +} +``` + +```go +// internal/app/help_test.go +package app + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "cosmic-tetris/internal/render" +) + +func TestHelpRowsCoverEveryBinding(t *testing.T) { + rows := DefaultKeyMap().HelpRows() + joined := "" + for _, r := range rows { + joined += r[0] + " " + r[1] + "\n" + } + for _, want := range []string{"move", "soft drop", "hard drop", "rotate", "hold", "pause", "restart", "quit", "help"} { + if !strings.Contains(joined, want) { + t.Errorf("HelpRows is missing %q:\n%s", want, joined) + } + } +} + +func TestQuestionMarkTogglesHelp(t *testing.T) { + m := playingModel(t) + if m.ShowHelp { + t.Fatal("help starts open") + } + next, _ := m.Update(keyMsg("?")) + m = next.(Model) + if !m.ShowHelp { + t.Fatal("? did not open help") + } + if !strings.Contains(render.StripANSI(m.View()), "hard drop") { + t.Fatal("the help panel is not in the view") + } + next, _ = m.Update(keyMsg("?")) + m = next.(Model) + if m.ShowHelp { + t.Fatal("? did not close help") + } +} + +func TestEscapeClosesHelp(t *testing.T) { + m := playingModel(t) + next, _ := m.Update(keyMsg("?")) + m = next.(Model) + next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + m = next.(Model) + if m.ShowHelp { + t.Fatal("escape did not close help") + } +} + +func TestGameplayKeysDoNotLeakThroughHelp(t *testing.T) { + m := playingModel(t) + next, _ := m.Update(keyMsg("?")) + m = next.(Model) + before := m.Game.Active + score := m.Game.Score + for _, k := range []tea.KeyPressMsg{keyMsg("h"), keyMsg("l"), keyMsg("j"), keyMsg("z"), {Code: ' ', Text: " "}, keyMsg("c")} { + next, _ = m.Update(k) + m = next.(Model) + } + if m.Game.Active != before || m.Game.Score != score { + t.Fatal("a gameplay key acted while help was open") + } + if !m.ShowHelp { + t.Fatal("a gameplay key closed help") + } +} + +func TestHelpPausesGameplayButNotTheStarfield(t *testing.T) { + m := playingModel(t) + next, _ := m.Update(keyMsg("?")) + m = next.(Model) + y := m.Game.Active.Y + elapsed := m.FX.Elapsed + now := m.LastFrame + for i := 0; i < 80; i++ { // 1.28s: well past one gravity step at level 1 + m.LastFrame = now + now = now.Add(16 * time.Millisecond) + next, _ = m.Update(FrameMsg{Now: now}) + m = next.(Model) + } + if m.Game.Active.Y != y { + t.Fatalf("the piece fell from %d to %d while help was open", y, m.Game.Active.Y) + } + if m.FX.Elapsed <= elapsed { + t.Fatal("the starfield froze while help was open") + } +} + +func TestHelpOverAPausedGameStaysPausedWhenClosed(t *testing.T) { + m := playingModel(t) + next, _ := m.Update(keyMsg("p")) + m = next.(Model) + next, _ = m.Update(keyMsg("?")) + m = next.(Model) + next, _ = m.Update(keyMsg("?")) + m = next.(Model) + if m.State != StatePaused { + t.Fatalf("State = %v after help closed over a paused game, want StatePaused", m.State) + } +} + +func TestHelpWithABannerUpStillDrawsCleanly(t *testing.T) { + m := playingModel(t) + m.FX.Observe([]gameEventQuad()) + next, _ := m.Update(keyMsg("?")) + m = next.(Model) + checkRect(t, m.View(), 80, 30) + if !strings.Contains(render.StripANSI(m.View()), "hard drop") { + t.Fatal("the banner hid the help panel; help wins the screen") + } +} + +func TestHelpSurvivesAResize(t *testing.T) { + m := playingModel(t) + next, _ := m.Update(keyMsg("?")) + m = next.(Model) + for _, s := range []tea.WindowSizeMsg{{Width: 40, Height: 24}, {Width: 200, Height: 60}, {Width: 30, Height: 10}} { + next, _ = m.Update(s) + m = next.(Model) + if !m.ShowHelp { + t.Fatal("a resize closed help") + } + checkRect(t, m.View(), s.Width, s.Height) + } +} +``` + +Replace `[]gameEventQuad()` with a small helper in `helpers_test.go`: `func gameEventQuad() []game.Event { return []game.Event{{Kind: game.EventLinesCleared, Rows: []int{18, 19, 20, 21}}} }`, and call it as `m.FX.Observe(gameEventQuad())`. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/render/ -run TestHelp -v && go test ./internal/app/ -run 'TestHelp|TestQuestionMark|TestEscape|TestGameplayKeysDoNot' -v` +Expected: FAIL — undefined: `DrawHelp`, `HelpRows`. + +- [ ] **Step 3: Implement the help overlay** + +`HelpRows` reads `key.Binding.Help()` from each binding so the strings live in one place (§39). `DrawHelp` renders a bordered panel sized to the longest row, centered, clipped to the layout; in `LayoutTooSmall` it draws nothing. `Frame` draws it last of all, when `s.ShowHelp`, so it wins the screen — including over a banner. `View` passes `ShowHelp: m.ShowHelp` and `HelpRows: m.Keys.HelpRows()`. `Update` handles `ShowHelp` before the state switch: while help is open, only the help, escape, and quit bindings are live, and `FrameMsg` advances FX but not the game. Plan 1 Task 16 already gave `Model` a `ShowHelp` field and toggled it on `?`; this task adds the escape binding, the gameplay lockout, and the actual panel. + +- [ ] **Step 4: Run the suite to verify it passes** + +Run: `go test ./... -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . && git add internal/render/help.go internal/render/help_test.go internal/app/ +git commit -m "feat: help overlay generated from the key bindings" +``` + +--- + +### Task 4: Terminal capability detection and ASCII completeness + +**Files:** +- Modify: `internal/render/palette.go`, `cmd/cosmic-tetris/main.go` +- Test: `internal/render/mode_test.go`, `internal/render/ascii_test.go` + +**Interfaces:** +- Produces: + +```go +type ColorSupport int +const ( + ColorNone ColorSupport = iota // not a TTY, or no color at all + Color16 + Color256 + ColorTrue +) +func DetectColorSupport(out *os.File) ColorSupport // asks lipgloss/v2 once +func DetectMode(c ColorSupport, termEnv, noColor string) Mode +// ModeASCII when TERM is "dumb" or empty; ModeReduced when NO_COLOR is +// non-empty or support is below Color256; ModeFull otherwise. +``` + +The split matters: `DetectMode` is pure and takes its inputs as parameters, so it is testable without a terminal, and `DetectColorSupport` is the single place that touches lipgloss v2's color-profile API. If that API's exact shape differs from what you expect, only that one function changes. `main.go` calls `render.DetectMode(render.DetectColorSupport(os.Stdout), os.Getenv("TERM"), os.Getenv("NO_COLOR"))`. `--ascii` overrides the result (§32: the flag is explicit intent). + +- [ ] **Step 1: Write the failing test** + +```go +// internal/render/mode_test.go +package render + +import "testing" + +func TestDetectModeDegradesForLimitedTerminals(t *testing.T) { + cases := []struct { + name string + support ColorSupport + term string + noColor string + want Mode + }{ + {"truecolor xterm", ColorTrue, "xterm-256color", "", ModeFull}, + {"256 color", Color256, "xterm-256color", "", ModeFull}, + {"16 color", Color16, "xterm", "", ModeReduced}, + {"no color at all", ColorNone, "xterm-256color", "", ModeReduced}, + {"NO_COLOR set", ColorTrue, "xterm-256color", "1", ModeReduced}, + {"NO_COLOR empty string is unset", ColorTrue, "xterm-256color", "", ModeFull}, + {"dumb terminal", ColorTrue, "dumb", "", ModeASCII}, + {"no TERM at all", ColorTrue, "", "", ModeASCII}, + {"dumb terminal with no color either", ColorNone, "dumb", "1", ModeASCII}, + } + for _, c := range cases { + if got := DetectMode(c.support, c.term, c.noColor); got != c.want { + t.Errorf("%s: DetectMode = %v, want %v", c.name, got, c.want) + } + } +} +``` + +```go +// internal/render/ascii_test.go +package render + +import ( + "strings" + "testing" + "time" + + "cosmic-tetris/internal/fx" + "cosmic-tetris/internal/game" +) + +// asciiScene exercises as much of the renderer as one frame can reach. +func asciiScene(t *testing.T, w, h int) string { + t.Helper() + g := game.New(7) + for x := 0; x < game.Width-1; x++ { + g.Board.Set(x, game.Height-1, game.Cell(x%7+1)) + } + g.Score, g.Lines, g.Level, g.Combo = 98765, 37, 4, 6 + hold := game.O + g.Hold = &hold + l := Compute(w, h) + world := fx.NewWorld(3, fx.Config{Enabled: true, ASCII: true}) + world.SetGeometry(fx.Rect{X: 0, Y: 0, W: w, H: h}, fx.Rect{X: l.Board.X, Y: l.Board.Y, W: l.Board.W, H: l.Board.H}) + world.Observe([]game.Event{ + {Kind: game.EventPieceHardDropped, Distance: 15, Piece: game.Piece{Kind: game.I, X: 3, Y: 6}}, + {Kind: game.EventLinesCleared, Rows: []int{18, 19, 20, 21}}, + {Kind: game.EventComboChanged, Value: 6}, + {Kind: game.EventLevelChanged, Value: 4}, + }) + world.Update(80 * time.Millisecond) + return Frame(Scene{Game: g, Mode: ModeASCII, Width: w, Height: h, FX: world, Mission: "TESTING"}) +} + +func TestAsciiModeEmitsOnlyAsciiBytes(t *testing.T) { + for _, dims := range [][2]int{{40, 24}, {50, 26}, {80, 30}, {200, 60}} { + out := StripANSI(asciiScene(t, dims[0], dims[1])) + for i, r := range out { + if r > 127 && r != '\n' { + t.Fatalf("at %dx%d byte %d is non-ASCII: %q", dims[0], dims[1], i, r) + } + } + } +} + +func TestAsciiModeIsAsciiForEverySpecialScreen(t *testing.T) { + g := game.New(1) + g.Over = true + screens := map[string]string{ + "boot": Frame(Scene{Mode: ModeASCII, Width: 80, Height: 30, Booting: true, BootElapsed: 600 * time.Millisecond}), + "gameover": Frame(Scene{Game: g, Mode: ModeASCII, Width: 80, Height: 30, OverElapsed: OverPanel + time.Second, Best: 4242}), + "paused": Frame(Scene{Game: g, Mode: ModeASCII, Width: 80, Height: 30, Paused: true}), + "toosmall": Frame(Scene{Game: g, Mode: ModeASCII, Width: 30, Height: 10}), + } + for name, out := range screens { + for _, r := range StripANSI(out) { + if r > 127 && r != '\n' { + t.Errorf("the %s screen contains non-ASCII %q in ASCII mode", name, r) + } + } + } +} + +func TestReducedModeStillUsesUnicodeButFewerColors(t *testing.T) { + out := StripANSI(Frame(Scene{Game: game.New(1), Mode: ModeReduced, Width: 80, Height: 30})) + if !strings.Contains(out, "█") && !strings.Contains(out, "╔") { + t.Fatal("reduced mode dropped Unicode; that is ASCII mode's job (§32)") + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/render/ -run 'TestDetectMode|TestAscii|TestReducedMode' -v` +Expected: FAIL — undefined: `DetectMode`; and the ASCII tests will very likely fail on real non-ASCII leaks from Plan 2's effects. + +- [ ] **Step 3: Implement `DetectMode` and fix every ASCII leak the test finds** + +Order the checks so the harshest wins: `dumb`/empty `TERM` → ASCII; `NO_COLOR` non-empty or support below `Color256` → Reduced; otherwise Full. Then walk the failures from `TestAsciiModeEmitsOnlyAsciiBytes` and route each non-ASCII glyph through the mode: shock rings (`. o O 0`), mission-control prefix (`>`), clear-animation ramp (`# = - .`), impact glyphs (`* + . '`), banner decoration (drop the `✦`), notice, and any box-drawing characters (`+ - | =`). + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./... -v` +Expected: PASS. + +- [ ] **Step 5: Wire detection into `main.go` and verify the flag precedence by hand** + +`main` computes `mode := render.DetectMode(render.DetectColorSupport(os.Stdout), os.Getenv("TERM"), os.Getenv("NO_COLOR"))` and overrides it with `render.ModeASCII` when `--ascii` is set. Verify: + +```bash +go run ./cmd/cosmic-tetris --help +TERM=dumb go run ./cmd/cosmic-tetris --seed 1 | head -30 +NO_COLOR=1 go run ./cmd/cosmic-tetris --seed 1 | head -30 +go run ./cmd/cosmic-tetris --ascii --seed 1 | head -30 +``` + +Each must render something legible rather than a wall of escapes. + +- [ ] **Step 6: Commit** + +```bash +gofmt -l . && git add internal/render/ cmd/ +git commit -m "feat(render): terminal capability detection and full ASCII-mode coverage" +``` + +--- + +### Task 5: FX intensity scales with terminal size + +**Files:** +- Modify: `internal/fx/world.go`, `internal/fx/starfield.go`, `internal/app/update.go` +- Test: `internal/fx/intensity_test.go` + +**Interfaces:** +- Produces: + +```go +func (w *World) SetIntensity(scale float64) // 0..1, clamped +func (w *World) Intensity() float64 +``` + +§31: at small sizes, reduce particle counts and effect scale rather than dropping the whole system. The app derives the scale from the layout mode: `LayoutWide` 1.0, `LayoutMedium` 0.7, `LayoutSmall` 0.45, `LayoutTooSmall` 0.0. + +- [ ] **Step 1: Write the failing test** + +```go +package fx + +import ( + "testing" + "time" + + "cosmic-tetris/internal/game" +) + +func TestIntensityScalesParticleCounts(t *testing.T) { + count := func(scale float64) int { + w := worldAt(80, 30) + w.SetIntensity(scale) + w.Observe([]game.Event{{Kind: game.EventLinesCleared, Rows: []int{18, 19, 20, 21}}}) + return w.ParticleCount() + } + full, small := count(1.0), count(0.45) + if small >= full { + t.Fatalf("intensity 0.45 emitted %d particles, full emitted %d", small, full) + } + if small == 0 { + t.Fatal("intensity 0.45 emitted nothing; §31 reduces rather than removes") + } +} + +func TestIntensityScalesStarCounts(t *testing.T) { + big := worldAt(200, 60) + big.SetIntensity(1.0) + small := worldAt(40, 24) + small.SetIntensity(0.45) + if small.StarCount() >= big.StarCount() { + t.Fatalf("small terminal has %d stars, big has %d", small.StarCount(), big.StarCount()) + } + if small.StarCount() == 0 { + t.Fatal("the small terminal has no stars at all") + } +} + +func TestZeroIntensitySilencesEverythingWithoutDisablingTheWorld(t *testing.T) { + w := worldAt(80, 30) + w.SetIntensity(0) + w.Observe([]game.Event{ + {Kind: game.EventLinesCleared, Rows: []int{18, 19, 20, 21}}, + {Kind: game.EventPieceHardDropped, Distance: 18}, + }) + w.Update(50 * time.Millisecond) + if got := len(w.Marks(nil)); got != 0 { + t.Fatalf("intensity 0 produced %d marks", got) + } + // Raising it again must bring the effects back. + w.SetIntensity(1) + w.Observe([]game.Event{{Kind: game.EventPieceHardDropped, Distance: 18}}) + if w.ParticleCount() == 0 { + t.Fatal("raising the intensity did not restore effects") + } +} + +func TestIntensityIsClampedToTheUnitRange(t *testing.T) { + w := worldAt(80, 30) + w.SetIntensity(-3) + if got := w.Intensity(); got != 0 { + t.Errorf("Intensity = %v after SetIntensity(-3), want 0", got) + } + w.SetIntensity(9) + if got := w.Intensity(); got != 1 { + t.Errorf("Intensity = %v after SetIntensity(9), want 1", got) + } +} + +func TestShakeAndShockSurviveReducedIntensity(t *testing.T) { + // §31 reduces counts and scale; it does not remove whole effects while any + // intensity remains. + w := worldAt(50, 26) + w.SetIntensity(0.45) + w.Observe([]game.Event{{Kind: game.EventLinesCleared, Rows: []int{18, 19, 20, 21}}}) + if dx, dy := w.ShakeOffset(); dx == 0 && dy == 0 { + t.Error("no shake at 0.45 intensity") + } + if w.ShockCount() == 0 { + t.Error("no shockwave at 0.45 intensity") + } +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `go test ./internal/fx/ -run 'TestIntensity|TestZeroIntensity|TestShakeAndShock' -v` +Expected: FAIL — undefined: `SetIntensity`. + +- [ ] **Step 3: Implement intensity** + +Store `intensity float64` defaulting to 1. Every emitter multiplies its count by it and rounds up to at least 1 when the intensity is non-zero — that is what keeps small terminals expressive rather than dead. At exactly 0, emitters return immediately, `seedStars` seeds nothing, and `ShakeOffset`/`Shockwave`/`Banner` are inert. `seedStars` scales `StarsFar/Mid/Near` by intensity, and `SetIntensity` reseeds when the scale changes. + +- [ ] **Step 4: Set the intensity from the layout in the app** + +In `Update`'s `tea.WindowSizeMsg` branch, map the computed `render.Layout.Mode` to the scale above and call `m.FX.SetIntensity`. Add to `internal/app/fx_test.go`: + +```go +func TestLayoutModeDrivesFXIntensity(t *testing.T) { + m := New(Options{Seed: 1, Mode: render.ModeFull}) + cases := []struct { + w, h int + want float64 + }{{200, 60, 1.0}, {56, 25, 0.7}, {42, 24, 0.45}, {30, 10, 0.0}} + for _, c := range cases { + next, _ := m.Update(tea.WindowSizeMsg{Width: c.w, Height: c.h}) + m = next.(Model) + if got := m.FX.Intensity(); got != c.want { + t.Errorf("at %dx%d Intensity = %v, want %v", c.w, c.h, got, c.want) + } + } +} +``` + +Pick the dimensions in that table from the thresholds Plan 1 Task 12 pinned; if a size lands in a different mode than intended, fix the test's dimensions, not the thresholds — the goldens depend on them. + +- [ ] **Step 5: Run the suite to verify it passes** + +Run: `go test ./... -v` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +gofmt -l . && git add internal/fx/ internal/app/ +git commit -m "feat(fx): scale effect intensity with the available terminal size" +``` + +--- + +### Task 6: The remaining flavor details + +**Files:** +- Modify: `internal/fx/world.go`, `internal/fx/starfield.go`, `internal/app/update.go` +- Test: `internal/fx/flavor_test.go` + +**Interfaces:** +- Produces: + +```go +func (w *World) NoteIdle(dt time.Duration) // drives TriggerLongIdle after 12s +func (w *World) shootingStar() // §45: rare, crosses the background +const ShootingStarChance = 0.0015 // per 16ms step: roughly one per 11s +``` + +§45's optional details, all wired to the `flavor.Trigger` values Plan 2 Task 7 already defined: a vertically hard-dropped I emits `TriggerKineticRod`; holding an O emits `TriggerHoldO`; a score crossing a power of ten emits `TriggerScoreRoll`; 12 s without an input emits `TriggerLongIdle`; and a rare shooting star crosses the starfield. + +- [ ] **Step 1: Write the failing test** + +```go +package fx + +import ( + "strings" + "testing" + "time" + + "cosmic-tetris/internal/game" +) + +func TestVerticalIHardDropIsAKineticRod(t *testing.T) { + w := worldAt(80, 30) + w.Observe([]game.Event{{Kind: game.EventPieceHardDropped, Distance: 16, + Piece: game.Piece{Kind: game.I, Rotation: 1, X: 4, Y: 4}}}) + if got := w.Mission(); !strings.Contains(got, "KINETIC ROD DEPLOYED") { + t.Fatalf("Mission() = %q, want the kinetic rod line", got) + } +} + +func TestHorizontalIHardDropIsNotAKineticRod(t *testing.T) { + w := worldAt(80, 30) + w.Observe([]game.Event{{Kind: game.EventPieceHardDropped, Distance: 16, + Piece: game.Piece{Kind: game.I, Rotation: 0, X: 3, Y: 4}}}) + if got := w.Mission(); strings.Contains(got, "KINETIC ROD") { + t.Fatalf("a flat I was called a kinetic rod: %q", got) + } +} + +func TestHoldingAnOSecuresTheCubeAdjacentObject(t *testing.T) { + w := worldAt(80, 30) + w.Observe([]game.Event{{Kind: game.EventHoldUsed, Piece: game.Piece{Kind: game.O}}}) + if got := w.Mission(); !strings.Contains(got, "CUBE ADJACENT OBJECT SECURED") { + t.Fatalf("Mission() = %q, want the cube line", got) + } + other := worldAt(80, 30) + other.Observe([]game.Event{{Kind: game.EventHoldUsed, Piece: game.Piece{Kind: game.S}}}) + if got := other.Mission(); strings.Contains(got, "CUBE ADJACENT") { + t.Fatalf("holding an S produced the cube line: %q", got) + } +} + +func TestCrossingAPowerOfTenAnnouncesThatNumberBecameBigger(t *testing.T) { + w := worldAt(80, 30) + w.NoteScore(400) + w.Update(3 * time.Second) // clear the mission cooldown + w.NoteScore(1200) // 3 digits -> 4 digits + if got := w.Mission(); !strings.Contains(got, "NUMBER BECAME BIGGER") { + t.Fatalf("Mission() = %q, want the score-roll line", got) + } +} + +func TestNotCrossingAPowerOfTenSaysNothing(t *testing.T) { + w := worldAt(80, 30) + w.NoteScore(1200) + w.Update(3 * time.Second) + // Put a known, non-roll message on the channel so "unchanged" is provable. + w.Observe([]game.Event{{Kind: game.EventLevelChanged, Value: 4}}) + before := w.Mission() + if strings.Contains(before, "NUMBER BECAME BIGGER") { + t.Fatalf("precondition failed: the channel already reads %q", before) + } + w.Update(3 * time.Second) + w.NoteScore(1300) // still 4 digits + if got := w.Mission(); got != before { + t.Fatalf("1200 -> 1300 changed the channel to %q, want it to stay %q", got, before) + } +} + +func TestTwelveSecondsOfIdleAsksForTheCaptain(t *testing.T) { + w := worldAt(80, 30) + for i := 0; i < 800; i++ { // 12.8s + w.NoteIdle(16 * time.Millisecond) + w.Update(16 * time.Millisecond) + } + if got := w.Mission(); !strings.Contains(got, "CAPTAIN?") { + t.Fatalf("Mission() = %q after 12.8s idle, want the captain line", got) + } +} + +func TestAnyEventResetsTheIdleTimer(t *testing.T) { + w := worldAt(80, 30) + for i := 0; i < 600; i++ { // 9.6s + w.NoteIdle(16 * time.Millisecond) + w.Update(16 * time.Millisecond) + } + w.Observe([]game.Event{{Kind: game.EventPieceMoved, Piece: game.Piece{Kind: game.T, X: 4, Y: 10}}}) + for i := 0; i < 300; i++ { // 4.8s more: under 12s since the move + w.NoteIdle(16 * time.Millisecond) + w.Update(16 * time.Millisecond) + } + if got := w.Mission(); strings.Contains(got, "CAPTAIN?") { + t.Fatalf("the idle timer did not reset on a move: %q", got) + } +} + +func TestAShootingStarEventuallyCrossesTheBackground(t *testing.T) { + w := worldAt(80, 30) + var seen bool + for i := 0; i < 4000 && !seen; i++ { // ~64s of frames + w.Update(16 * time.Millisecond) + for _, m := range w.Marks(nil) { + if m.Role == RoleStarNear && m.Glyph == '─' { + seen = true + } + } + } + if !seen { + t.Fatalf("no shooting star in ~64s at a %v per-step chance", ShootingStarChance) + } +} + +func TestShootingStarsAreRareNotConstant(t *testing.T) { + w := worldAt(80, 30) + frames, hits := 0, 0 + for i := 0; i < 2000; i++ { + w.Update(16 * time.Millisecond) + frames++ + for _, m := range w.Marks(nil) { + if m.Glyph == '─' { + hits++ + break + } + } + } + if float64(hits)/float64(frames) > 0.25 { + t.Fatalf("shooting stars are on screen %.0f%% of frames; §45 says rare", 100*float64(hits)/float64(frames)) + } +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `go test ./internal/fx/ -run 'TestVertical|TestHorizontal|TestHolding|TestCrossing|TestNotCrossing|TestTwelve|TestAnyEvent|TestAShooting|TestShooting' -v` +Expected: FAIL — undefined: `NoteIdle`, `ShootingStarChance`; the flavor triggers are not wired. + +- [ ] **Step 3: Implement the detail triggers** + +A vertical I is `Kind == game.I && Rotation%2 == 1`. Score rolls compare `powerOfTenBucket(old)` with `powerOfTenBucket(new)` (`len(strconv.Itoa(score))` is enough). `NoteIdle` accumulates when the app sees no input and resets on any `Observe` with a non-empty slice; crossing 12 s pushes `TriggerLongIdle` once, then rearms after another 12 s. `TriggerRare` is drawn with probability `0.02` in place of any idle message, giving §45's terminal joke. + +Give the shooting star its **own single slot** on `World` — `shooting struct { x, y, vx, vy float64; age time.Duration; live bool }` — the same shape as Plan 2's shockwave, at most one at a time, ~600 ms, `Role: RoleStarNear`, `Glyph: '─'` (ASCII `-`), full brightness fading over its last 200 ms, drawn on `LayerScreen`. Do not make it a particle and do not make it a `Star`: a particle would perturb `ParticleCount` and make Plan 2's exact-count particle tests flaky, and a `Star` would perturb `StarCount` and break `TestStarCountIsStableAcrossLongRuns`. A private slot touches neither. Roll for it once per substep in `updateStars`, and have `Marks` skip it when its position is outside the screen rect so `TestStarsStayInsideTheScreen` keeps holding. + +One Plan 2 test needs widening, and the widening is correct rather than a concession: `TestStarGlyphsMatchTheSpecSets` must add `─` and `-` to the allowed `RoleStarNear` glyph set. `TestAsciiModeUsesOnlyAsciiStarGlyphs` needs no change and is the assertion that catches a missed mode check on the new glyph. + +In `internal/app/update.go`, call `m.FX.NoteIdle(dt)` on each `FrameMsg` where no key arrived since the last frame, and `m.FX.NoteScore(m.Game.Score)` after every scoring input. + +- [ ] **Step 4: Run the suite to verify it passes** + +Run: `go test ./... -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . && git add internal/fx/ internal/app/ +git commit -m "feat(flavor): kinetic rods, cube-adjacent objects, score rolls, idle chatter, shooting stars" +``` + +--- + +### Task 7: Remaining goldens, acceptance walk, and README + +**Files:** +- Modify: `internal/render/golden_test.go`, `README.md` +- Create: `internal/render/testdata/{boot,gameover,help}.txt` + +**Interfaces:** +- Consumes: everything above. + +- [ ] **Step 1: Add the three remaining golden cases** + +Extend Plan 1 Task 17's table with: + +| name | scene | +|---|---| +| `boot` | `Scene{Mode: ModeFull, Width: 80, Height: 30, Booting: true, BootElapsed: 600ms}` with a fixed-seed FX world advanced 10 frames | +| `gameover` | a game with a known board, `Score: 12345`, `Lines: 42`, `Level: 5`, `Over: true`, `OverElapsed: OverPanel + 100ms`, `Best: 99999` | +| `help` | `Scene{Game: game.New(1), Mode: ModeFull, Width: 80, Height: 30, ShowHelp: true, HelpRows: }` | + +The `help` golden spells its rows out literally rather than calling `app.DefaultKeyMap().HelpRows()` — `render` must not import `app`. If the two drift, Task 3's `TestHelpRowsCoverEveryBinding` is what catches it. + +- [ ] **Step 2: Generate and read the goldens** + +Run: `go test ./internal/render -update && go test ./internal/render` +Then open all nine `testdata/*.txt` files and read them as a player would. Check each: the board is 20 cells wide and 20 rows tall, the border closes, nothing overlaps, the panels are centered, no row is ragged, and the ASCII files contain no bytes above 127. Fix the renderer and regenerate for anything wrong — never hand-edit a golden. + +- [ ] **Step 3: Walk §43's acceptance criteria** + +Run `go run ./cmd/cosmic-tetris` and confirm each, noting the result: + +- [ ] Bare `go run ./cmd/cosmic-tetris` launches into a playable game +- [ ] Every documented key does what the help says +- [ ] Hold, ghost piece, next-5 queue, wall kicks, and lock delay all behave +- [ ] Score, lines, and level update correctly; the level raises gravity noticeably +- [ ] `--seed 1234` twice produces the same piece sequence +- [ ] Resizing between 40×24 and full screen never corrupts the display +- [ ] A terminal below 40×24 shows the notice and recovers when grown +- [ ] `--no-fx` is still a good game +- [ ] `--reduced-motion` has no shake, no hyperdrive acceleration, no shockwaves +- [ ] `--ascii` is legible with no mojibake +- [ ] The boot sequence runs in about a second and any key skips it +- [ ] Game over freezes, collapses, and shows the panel; `R` restarts, `Q` quits +- [ ] `Ctrl+C` quits cleanly from every state and leaves the terminal usable +- [ ] Within 30 seconds: starfield, animated border, trails, impacts, particles, and commentary are all visible +- [ ] A four-line clear is gloriously excessive +- [ ] Nothing ever obscures the active piece + +- [ ] **Step 4: Confirm §47's non-negotiables and §48's exclusions** + +- [ ] Gameplay never waits on an animation — verified by playing during a four-line sequence +- [ ] `go build ./...` on a clean checkout with only the three Charm deps in `go.mod` +- [ ] `go test ./... -race` passes +- [ ] `go vet ./...` is clean +- [ ] `gofmt -l .` prints nothing +- [ ] No sound, no mouse, no network, no config file, no score file, no multiplayer, no AI player (§48) +- [ ] `internal/game` contains no `time.Now()` call and no import of `render`, `app`, `fx`, or any Charm package — check with `go list -deps ./internal/game` and `grep -rn 'time.Now' internal/game/` + +- [ ] **Step 5: Write the README** + +Cover: what it is (one paragraph with the §1 pitch), a `go run` quickstart, the full flag list (`--seed`, `--ascii`, `--no-fx`, `--reduced-motion`, `--help`), the key bindings table, the 40×24 minimum and what happens below it, terminal recommendations (truecolor + a font with box drawing for the full experience), a short architecture note (engine / effects / render / app, and the determinism promise), and how to run the tests including `-update` for the goldens. + +- [ ] **Step 6: Commit** + +```bash +gofmt -l . && git add internal/render/ README.md +git commit -m "test: complete golden coverage; docs: README and acceptance walk" +``` + +--- + +## Notes for the executor + +- **Anything §43 fails is a bug in this plan's scope, not a follow-up.** The acceptance walk in Task 7 is a gate: if the four-line clear is not excessive or a resize corrupts the display, go back and fix it before the final commit. +- **The goldens will churn.** Tasks 1–6 each change what `Frame` draws, so regenerate with `-update` and read every diff. A diff you cannot explain is a bug you just found. +- **When a spec detail and a golden disagree, the spec wins and the golden gets regenerated** — except for the §4 mockup, which §49.7 explicitly demotes to intent. diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/Q2-reader-recipe-cosmic-32/2026-09-17-cosmic-tetris-1-engine.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/Q2-reader-recipe-cosmic-32/2026-09-17-cosmic-tetris-1-engine.md new file mode 100644 index 000000000..178367f19 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/Q2-reader-recipe-cosmic-32/2026-09-17-cosmic-tetris-1-engine.md @@ -0,0 +1,1719 @@ +# Cosmic Tetris — Plan 1: Deterministic Game Engine + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the headless, deterministic falling-block engine under `internal/game` — pieces, board, 7-bag, movement, rotation kicks, gravity, lock delay, line clears, scoring, hold, hard drop, game over — with comprehensive unit tests and a seeded replay test. + +**Architecture:** A single `internal/game` package with no dependencies outside the standard library. `Game` is a plain struct advanced by `Advance(dt time.Duration)` and mutated by explicit input methods (`MoveLeft`, `Rotate`, `HardDrop`, `Hold`, …). Every mutating method returns `[]Event` describing what happened; the engine never reads a clock, never renders, and never knows about Bubble Tea. Board coordinates are `y = 0` at the top and increase downward; gravity moves `+Y`. + +**Tech Stack:** Go 1.26, standard library only (`math/rand/v2`, `time`, `testing`). + +**Spec:** `design.md` (this plan implements §5–§13, §34, §35, §40 engine items, and pinned decisions §49.1, §49.2, §49.6) + +**Follow-on plans:** Plan 2 (`plans/2026-09-17-cosmic-tetris-2-playable-terminal.md`) renders this engine; Plan 3 (`plans/2026-09-17-cosmic-tetris-3-cosmic-fx.md`) consumes its events. Neither may modify engine state. + +## Global Constraints + +- Module path: `cosmic-tetris`. Go directive: `go 1.26`. No remote; commit directly on `main`. +- `internal/game` imports **only** the standard library. No Bubble Tea, no Lip Gloss, no rendering concerns. +- Nothing under `internal/game` calls `time.Now()`, `time.Since`, or starts a goroutine or timer (§49.2). Elapsed time enters exclusively as the `dt` argument to `Advance`. +- Game RNG is `math/rand/v2`: `rand.New(rand.NewPCG(uint64(seed), 0x9E3779B97F4A7C15))`, owned by `Game`, and used **only** by the 7-bag (§49.6). No other code path may draw from it. +- Board geometry (§5): width 10, total height 22, visible rows 20, hidden spawn rows 2. Hidden rows are `y = 0..1`; visible rows are `y = 2..21`. +- Scoring (§13, §49.1): 1/2/3/4 lines = 100/300/500/800 × level; combo bonus = `50 × (combo - 1) × level`; soft drop +1/cell; hard drop +2/cell. +- Gravity (§11): `interval = 800ms × 0.86^(level-1)`, clamped to a floor of 60ms. Level = `1 + lines/10`. +- Lock (§12): lock delay 500ms; a successful move or rotation while grounded resets it; maximum 15 resets. +- Wall-kick offsets, in this exact order (§7): `(0,0) (-1,0) (1,0) (-2,0) (2,0) (0,-1) (-1,-1) (1,-1)`. First valid wins; otherwise the rotation fails and no event is emitted. +- Every test file is `package game` (internal tests); the engine has no exported-only test seam. +- Run `gofmt -l .` before every commit; it must print nothing. + +## Review Focus + +Five failure modes the spec implies but no task's own tests would otherwise exercise. Each has a test added to the task that owns the code. + +1. **A rotation kick that would push the piece above the board** (`y < 0`) must fail cleanly rather than panic on a negative row index — `Board.Fits` is the only guard, and every kick candidate goes through it. (Task 6) +2. **`Advance` with a `dt` far larger than one gravity interval** (a suspended process, a slow terminal) must apply each gravity step through collision checks in order and terminate — never skip a collision or spin forever. (Task 8) +3. **`Advance` with `dt <= 0`** must be a no-op returning no events, so a paused or clock-skewed caller cannot corrupt state. (Task 8) +4. **Hold when the incoming piece cannot spawn** must end the game rather than leave a piece overlapping locked cells. (Task 9) +5. **Gravity interval at extreme level** must stay at the 60ms floor and never reach zero or negative, which would make `Advance` loop without bound. (Task 4) + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `go.mod` | Module `cosmic-tetris`, `go 1.26`. | +| `internal/game/piece.go` | `PieceKind`, `Piece`, `Point`, base shapes, the four rotations per kind, spawn positions. | +| `internal/game/board.go` | `Cell`, `Board`, bounds/collision, complete-row detection, clear + collapse. | +| `internal/game/bag.go` | 7-bag generator over an injected `*rand.Rand`. | +| `internal/game/scoring.go` | Pure scoring/level/gravity functions. | +| `internal/game/events.go` | `Event` interface and the concrete event types. | +| `internal/game/game.go` | `Game` struct, construction, spawn, input methods, `Advance`, lock pipeline. | +| `internal/game/rules.go` | Kick table and the grounded/lock-reset rules used by `game.go`. | +| `internal/game/*_test.go` | One test file per source file, plus `replay_test.go`. | + +--- + +### Task 1: Module skeleton and piece geometry + +**Files:** +- Create: `go.mod` +- Create: `internal/game/piece.go` +- Test: `internal/game/piece_test.go` + +**Interfaces:** +- Consumes: nothing. +- Produces: + ```go + type PieceKind int + const (KindI PieceKind = iota; KindJ; KindL; KindO; KindS; KindT; KindZ) + func (k PieceKind) String() string // "I","J","L","O","S","T","Z" + var AllKinds = [7]PieceKind{KindI, KindJ, KindL, KindO, KindS, KindT, KindZ} + + type Point struct{ X, Y int } + type Piece struct { + Kind PieceKind + Rotation int // 0..3, clockwise + X, Y int // top-left of the piece's rotation box, in board coords + } + func (p Piece) Cells() [4]Point // absolute board coords, row-major order + func (p Piece) Rotated(delta int) Piece // Rotation wrapped into 0..3; X,Y unchanged + func SpawnPiece(k PieceKind) Piece // Rotation 0 at the spawn position + ``` + +- [ ] **Step 1: Write the failing test** + +```go +package game + +import "testing" + +func TestTRotationCells(t *testing.T) { + // Base T is ".#." / "###" / "..." in a 3x3 box at X=0, Y=0. + p := Piece{Kind: KindT, Rotation: 0, X: 0, Y: 0} + want := [4]Point{{1, 0}, {0, 1}, {1, 1}, {2, 1}} + if got := p.Cells(); got != want { + t.Fatalf("rotation 0 = %v, want %v", got, want) + } + // One clockwise turn points the stem right: ".#." / ".##" / ".#." + p.Rotation = 1 + want = [4]Point{{1, 0}, {1, 1}, {2, 1}, {1, 2}} + if got := p.Cells(); got != want { + t.Fatalf("rotation 1 = %v, want %v", got, want) + } +} + +func TestEveryRotationHasFourCells(t *testing.T) { + for _, k := range AllKinds { + for r := 0; r < 4; r++ { + p := Piece{Kind: k, Rotation: r} + seen := map[Point]bool{} + for _, c := range p.Cells() { + if seen[c] { + t.Fatalf("%v rotation %d has duplicate cell %v", k, r, c) + } + seen[c] = true + } + if len(seen) != 4 { + t.Fatalf("%v rotation %d has %d cells, want 4", k, r, len(seen)) + } + } + } +} + +func TestFourRotationsReturnToBase(t *testing.T) { + for _, k := range AllKinds { + p := Piece{Kind: k, Rotation: 0} + if got := p.Rotated(4); got.Cells() != p.Cells() { + t.Fatalf("%v: four turns changed the shape", k) + } + } +} + +func TestORotationIsIdentical(t *testing.T) { + base := Piece{Kind: KindO, Rotation: 0}.Cells() + for r := 1; r < 4; r++ { + if got := (Piece{Kind: KindO, Rotation: r}).Cells(); got != base { + t.Fatalf("O rotation %d = %v, want %v", r, got, base) + } + } +} + +func TestSpawnPieceSitsInHiddenRows(t *testing.T) { + for _, k := range AllKinds { + for _, c := range SpawnPiece(k).Cells() { + if c.Y < 0 || c.Y > 1 { + t.Fatalf("%v spawn cell %v is outside hidden rows 0..1", k, c) + } + if c.X < 0 || c.X >= BoardWidth { + t.Fatalf("%v spawn cell %v is outside the board width", k, c) + } + } + } + if got := SpawnPiece(KindO).X; got != 4 { + t.Fatalf("O spawn X = %d, want 4", got) + } + if got := SpawnPiece(KindI).X; got != 3 { + t.Fatalf("I spawn X = %d, want 3", got) + } +} + +func TestRotatedWrapsNegative(t *testing.T) { + p := Piece{Kind: KindT, Rotation: 0} + if got := p.Rotated(-1).Rotation; got != 3 { + t.Fatalf("Rotated(-1) = %d, want 3", got) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/game/ -run 'TestT|TestEvery|TestFour|TestO|TestSpawn|TestRotated' -v` +Expected: build failure — `undefined: PieceKind`, `undefined: BoardWidth`. (`BoardWidth` arrives in Task 2; declare it in `board.go` then. To keep this task green on its own, declare `const BoardWidth = 10` in `piece.go` now and move it to `board.go` in Task 2, or declare it in `board.go` as part of this commit — either is fine, but `go test` must pass before the commit.) + +- [ ] **Step 3: Implement `internal/game/piece.go`** + +Create `go.mod` with `module cosmic-tetris` and `go 1.26` first (`go mod init cosmic-tetris`). + +Store one base shape per kind and derive rotations 1–3 by rotating the square box clockwise. Base shapes, exactly: + +```text +I size 4: .... #### .... .... +J size 3: #.. ### ... +L size 3: ..# ### ... +O size 2: ## ## +S size 3: .## ##. ... +T size 3: .#. ### ... +Z size 3: ##. .## ... +``` + +Clockwise rotation of a `size × size` box: `rotated[r][c] = base[size-1-c][r]`. + +Precompute all 28 cell sets into a package-level `[7][4][4]Point` in an `init` (or a `var` built by a helper), scanning each rotated box row-major (top row first, left to right) so `Cells()` ordering is deterministic. `Cells()` adds `p.X`/`p.Y` to the stored offsets. + +Spawn position: `X = (BoardWidth - size) / 2`, `Y = 0`. With these base shapes every occupied row is 0 or 1, so a spawned piece lies entirely in the hidden rows. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS (6 tests). + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . +git add go.mod internal/game/piece.go internal/game/piece_test.go +git commit -m "feat(game): tetromino kinds, rotations, and spawn positions" +``` + +--- + +### Task 2: Board — bounds, collision, row completion, collapse + +**Files:** +- Create: `internal/game/board.go` +- Test: `internal/game/board_test.go` + +**Interfaces:** +- Consumes: `Piece`, `Point`, `PieceKind` (Task 1). +- Produces: + ```go + const ( + BoardWidth = 10 + BoardHeight = 22 + VisibleRows = 20 + HiddenRows = 2 // BoardHeight - VisibleRows; hidden rows are y = 0..1 + ) + + type Cell struct { + Filled bool + Kind PieceKind + } + + type Board struct { + Cells [BoardHeight][BoardWidth]Cell + } + + func (b *Board) InBounds(x, y int) bool // 0<=x<10 && 0<=y<22 + func (b *Board) At(x, y int) Cell // zero Cell when out of bounds + func (b *Board) Blocked(x, y int) bool // out of bounds OR filled + func (b *Board) Fits(p Piece) bool // no cell of p is Blocked + func (b *Board) Lock(p Piece) // writes p's cells as Filled with p.Kind + func (b *Board) CompleteRows() []int // ascending row indices, all 10 filled + func (b *Board) ClearRows(rows []int) // removes rows, collapses everything above down + func (b *Board) RowKinds(y int) [BoardWidth]PieceKind // kinds in a row, for FX snapshots + func (b *Board) Clear() // empties every cell + ``` + +- [ ] **Step 1: Write the failing test** + +```go +package game + +import ( + "reflect" + "testing" +) + +func fillRow(b *Board, y int, holes ...int) { + hole := map[int]bool{} + for _, x := range holes { + hole[x] = true + } + for x := 0; x < BoardWidth; x++ { + if !hole[x] { + b.Cells[y][x] = Cell{Filled: true, Kind: KindI} + } + } +} + +func TestBoundsAndBlocked(t *testing.T) { + var b Board + if b.InBounds(-1, 0) || b.InBounds(BoardWidth, 0) || b.InBounds(0, -1) || b.InBounds(0, BoardHeight) { + t.Fatal("InBounds accepted an out-of-range coordinate") + } + if !b.Blocked(-1, 5) || !b.Blocked(0, BoardHeight) { + t.Fatal("out-of-bounds coordinates must be Blocked") + } + if b.Blocked(0, 0) { + t.Fatal("an empty in-bounds cell must not be Blocked") + } + b.Cells[5][5] = Cell{Filled: true, Kind: KindZ} + if !b.Blocked(5, 5) { + t.Fatal("a filled cell must be Blocked") + } +} + +func TestFitsRejectsOverlapAndWalls(t *testing.T) { + var b Board + p := Piece{Kind: KindO, Rotation: 0, X: 4, Y: 0} + if !b.Fits(p) { + t.Fatal("O should fit on an empty board") + } + p.X = -1 + if b.Fits(p) { + t.Fatal("O overhanging the left wall must not fit") + } + p.X = BoardWidth - 1 + if b.Fits(p) { + t.Fatal("O overhanging the right wall must not fit") + } + p = Piece{Kind: KindO, Rotation: 0, X: 4, Y: BoardHeight - 2} + b.Cells[BoardHeight-1][4] = Cell{Filled: true, Kind: KindT} + if b.Fits(p) { + t.Fatal("O overlapping a locked cell must not fit") + } +} + +func TestLockWritesKinds(t *testing.T) { + var b Board + b.Lock(Piece{Kind: KindT, Rotation: 0, X: 0, Y: 0}) + for _, c := range (Piece{Kind: KindT, Rotation: 0, X: 0, Y: 0}).Cells() { + if got := b.At(c.X, c.Y); !got.Filled || got.Kind != KindT { + t.Fatalf("cell %v = %+v, want filled T", c, got) + } + } +} + +func TestCompleteRowsAscending(t *testing.T) { + var b Board + fillRow(&b, 21) + fillRow(&b, 19) + fillRow(&b, 20, 3) // one hole + if got, want := b.CompleteRows(), []int{19, 21}; !reflect.DeepEqual(got, want) { + t.Fatalf("CompleteRows = %v, want %v", got, want) + } +} + +func TestClearRowsCollapses(t *testing.T) { + var b Board + b.Cells[18][0] = Cell{Filled: true, Kind: KindS} // marker above the cleared rows + fillRow(&b, 19) + fillRow(&b, 21) + b.ClearRows([]int{19, 21}) + if len(b.CompleteRows()) != 0 { + t.Fatal("cleared rows should be gone") + } + if got := b.At(0, 20); !got.Filled || got.Kind != KindS { + t.Fatalf("marker should have fallen two rows to y=20, got %+v", got) + } + if b.At(0, 18).Filled { + t.Fatal("the marker's old row should be empty") + } + for x := 0; x < BoardWidth; x++ { + if b.At(x, 21).Filled && x != 0 { + t.Fatalf("row 21 should hold only the collapsed remainder, found fill at x=%d", x) + } + } +} + +func TestClearRowsFillsTopWithEmpty(t *testing.T) { + var b Board + for y := 0; y < BoardHeight; y++ { + fillRow(&b, y) + } + b.ClearRows([]int{21}) + if b.At(0, 0).Filled { + t.Fatal("the top row must be empty after a collapse") + } +} + +func TestRowKinds(t *testing.T) { + var b Board + b.Cells[10][2] = Cell{Filled: true, Kind: KindZ} + if got := b.RowKinds(10)[2]; got != KindZ { + t.Fatalf("RowKinds[2] = %v, want Z", got) + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/game/ -run TestBoard -v; go test ./internal/game/ -v` +Expected: build failure — `undefined: Board`. + +- [ ] **Step 3: Implement `internal/game/board.go`** + +`ClearRows` collapses by copying rows downward: walk from the bottom up with a write cursor, skipping rows in the cleared set, then zero the remaining rows at the top. Treat the `rows` argument as unsorted (sort a copy or use a `[BoardHeight]bool` mask) so callers cannot break it with ordering. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . +git add internal/game/board.go internal/game/board_test.go +git commit -m "feat(game): board collision, row completion, and collapse" +``` + +--- + +### Task 3: 7-bag piece generator + +**Files:** +- Create: `internal/game/bag.go` +- Test: `internal/game/bag_test.go` + +**Interfaces:** +- Consumes: `PieceKind`, `AllKinds` (Task 1). +- Produces: + ```go + type Bag struct { /* rng *rand.Rand; queue []PieceKind */ } + func NewBag(rng *rand.Rand) *Bag // math/rand/v2 + func (b *Bag) Next() PieceKind // refills and reshuffles when empty + ``` + +- [ ] **Step 1: Write the failing test** + +```go +package game + +import ( + "math/rand/v2" + "testing" +) + +func newTestRNG(seed int64) *rand.Rand { + return rand.New(rand.NewPCG(uint64(seed), 0x9E3779B97F4A7C15)) +} + +func TestEveryBagContainsAllSevenExactlyOnce(t *testing.T) { + b := NewBag(newTestRNG(1)) + for bagIndex := 0; bagIndex < 20; bagIndex++ { + counts := map[PieceKind]int{} + for i := 0; i < 7; i++ { + counts[b.Next()]++ + } + for _, k := range AllKinds { + if counts[k] != 1 { + t.Fatalf("bag %d contains %d of %v, want exactly 1", bagIndex, counts[k], k) + } + } + } +} + +func TestSeededBagIsReproducible(t *testing.T) { + a, b := NewBag(newTestRNG(8675309)), NewBag(newTestRNG(8675309)) + for i := 0; i < 50; i++ { + x, y := a.Next(), b.Next() + if x != y { + t.Fatalf("draw %d: %v != %v for the same seed", i, x, y) + } + } +} + +func TestDifferentSeedsDiverge(t *testing.T) { + a, b := NewBag(newTestRNG(1)), NewBag(newTestRNG(2)) + var seqA, seqB [14]PieceKind + for i := range seqA { + seqA[i], seqB[i] = a.Next(), b.Next() + } + if seqA == seqB { + t.Fatal("two different seeds produced identical two-bag sequences") + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/game/ -run Bag -v` +Expected: FAIL — `undefined: NewBag`. + +- [ ] **Step 3: Implement `internal/game/bag.go`** + +`Next` refills the queue with `AllKinds` in declaration order and shuffles it with `rng.Shuffle` whenever the queue is empty, then pops the front. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . +git add internal/game/bag.go internal/game/bag_test.go +git commit -m "feat(game): seeded 7-bag piece generator" +``` + +--- + +### Task 4: Scoring, level, and gravity as pure functions + +**Files:** +- Create: `internal/game/scoring.go` +- Test: `internal/game/scoring_test.go` + +**Interfaces:** +- Consumes: nothing. +- Produces: + ```go + const ( + LockDelay = 500 * time.Millisecond + MaxLockResets = 15 + BaseGravity = 800 * time.Millisecond + MinGravity = 60 * time.Millisecond + GravityFactor = 0.86 + LinesPerLevel = 10 + ) + + func ClearScore(lines, level int) int // 0 for lines<=0; 100/300/500/800 x level; 800 x level for lines>4 + func ComboBonus(combo, level int) int // 50 * (combo-1) * level, never negative + func SoftDropScore(cells int) int // 1 per cell + func HardDropScore(cells int) int // 2 per cell + func LevelForLines(lines int) int // 1 + lines/10 + func GravityInterval(level int) time.Duration + ``` + +- [ ] **Step 1: Write the failing test** + +```go +package game + +import ( + "testing" + "time" +) + +func TestClearScoreTable(t *testing.T) { + cases := []struct{ lines, level, want int }{ + {1, 1, 100}, {2, 1, 300}, {3, 1, 500}, {4, 1, 800}, + {1, 7, 700}, {4, 7, 5600}, + {0, 5, 0}, {-1, 5, 0}, + } + for _, c := range cases { + if got := ClearScore(c.lines, c.level); got != c.want { + t.Errorf("ClearScore(%d,%d) = %d, want %d", c.lines, c.level, got, c.want) + } + } +} + +func TestComboBonusStartsAtComboTwo(t *testing.T) { + cases := []struct{ combo, level, want int }{ + {0, 3, 0}, {1, 3, 0}, {2, 3, 150}, {5, 2, 400}, + } + for _, c := range cases { + if got := ComboBonus(c.combo, c.level); got != c.want { + t.Errorf("ComboBonus(%d,%d) = %d, want %d", c.combo, c.level, got, c.want) + } + } +} + +func TestDropScores(t *testing.T) { + if got := SoftDropScore(3); got != 3 { + t.Errorf("SoftDropScore(3) = %d, want 3", got) + } + if got := HardDropScore(7); got != 14 { + t.Errorf("HardDropScore(7) = %d, want 14", got) + } + if got := HardDropScore(0); got != 0 { + t.Errorf("HardDropScore(0) = %d, want 0", got) + } +} + +func TestLevelForLines(t *testing.T) { + cases := []struct{ lines, want int }{{0, 1}, {9, 1}, {10, 2}, {19, 2}, {127, 13}} + for _, c := range cases { + if got := LevelForLines(c.lines); got != c.want { + t.Errorf("LevelForLines(%d) = %d, want %d", c.lines, got, c.want) + } + } +} + +func TestGravityIntervalCurve(t *testing.T) { + if got := GravityInterval(1); got != BaseGravity { + t.Errorf("level 1 = %v, want %v", got, BaseGravity) + } + l2 := GravityInterval(2) + if l2 <= 680*time.Millisecond || l2 >= 690*time.Millisecond { + t.Errorf("level 2 = %v, want ~688ms", l2) + } + if GravityInterval(3) >= GravityInterval(2) { + t.Error("gravity interval must decrease with level") + } +} + +// Review Focus 5: an extreme level must clamp, never reach zero. +func TestGravityIntervalClampsAtFloor(t *testing.T) { + for _, level := range []int{20, 100, 10000} { + got := GravityInterval(level) + if got < MinGravity { + t.Errorf("level %d = %v, below the %v floor", level, got, MinGravity) + } + if got <= 0 { + t.Fatalf("level %d = %v, must stay positive", level, got) + } + } + if got := GravityInterval(0); got != BaseGravity { + t.Errorf("level 0 = %v, want the level-1 interval %v", got, BaseGravity) + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/game/ -run 'Score|Combo|Level|Gravity' -v` +Expected: FAIL — `undefined: ClearScore`. + +- [ ] **Step 3: Implement `internal/game/scoring.go`** + +`GravityInterval` computes `float64(BaseGravity) * math.Pow(GravityFactor, float64(level-1))` with `level` treated as at least 1, then clamps to `MinGravity`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . +git add internal/game/scoring.go internal/game/scoring_test.go +git commit -m "feat(game): scoring, level, and gravity curve" +``` + +--- + +### Task 5: Events, `Game` construction, next queue, and spawning + +**Files:** +- Create: `internal/game/events.go` +- Create: `internal/game/game.go` +- Test: `internal/game/game_test.go` + +**Interfaces:** +- Consumes: everything from Tasks 1–4. +- Produces: + ```go + // events.go — the closed set from §14. Every mutating method returns []Event. + type Event interface{ event() } + + type PieceSpawned struct{ Piece Piece } + type PieceMoved struct{ Piece Piece; DX, DY int } + type PieceRotated struct{ Piece Piece; Delta int } + type PieceHardDropped struct{ Piece Piece; Distance int } + type PieceLocked struct{ Piece Piece } + type HoldUsed struct{ Stored PieceKind; Incoming PieceKind } + type LinesCleared struct { + Rows []int // ascending board rows that were cleared + Kinds [][BoardWidth]PieceKind // contents of each cleared row, same order as Rows + Count int + Score int // clear value + combo bonus awarded for this placement + } + type ComboChanged struct{ Combo int } + type LevelChanged struct{ Level int } + type GameOver struct{ Score, Lines, Level int } + + // game.go + type State int + const (StatePlaying State = iota; StateOver) + + const NextQueueLen = 5 + + type Game struct { + Board Board + Active Piece + Hold *PieceKind + CanHold bool + + Next []PieceKind + Bag *Bag + + Score int + Lines int + Level int + Combo int + + GravityAccumulator time.Duration + LockAccumulator time.Duration + LockResets int + + State State + Seed int64 + // rng *rand.Rand — unexported; drives Bag only (§49.6) + } + + func New(seed int64) *Game + func (g *Game) Over() bool + ``` + +- [ ] **Step 1: Write the failing test** + +```go +package game + +import "testing" + +func TestNewGameInitialState(t *testing.T) { + g := New(8675309) + if g.Level != 1 || g.Score != 0 || g.Lines != 0 || g.Combo != 0 { + t.Fatalf("unexpected initial counters: %+v", g) + } + if g.Seed != 8675309 { + t.Fatalf("Seed = %d, want 8675309", g.Seed) + } + if len(g.Next) != NextQueueLen { + t.Fatalf("len(Next) = %d, want %d", len(g.Next), NextQueueLen) + } + if g.Hold != nil { + t.Fatal("Hold must start empty") + } + if !g.CanHold { + t.Fatal("CanHold must start true") + } + if g.State != StatePlaying || g.Over() { + t.Fatal("a new game must be playing") + } + if !g.Board.Fits(g.Active) { + t.Fatal("the first active piece must fit on the empty board") + } + for _, c := range g.Active.Cells() { + if c.Y > 1 { + t.Fatalf("the first piece spawned outside the hidden rows: %v", c) + } + } +} + +func TestNewGameIsSeedReproducible(t *testing.T) { + a, b := New(42), New(42) + if a.Active != b.Active { + t.Fatalf("active pieces differ: %+v vs %+v", a.Active, b.Active) + } + for i := range a.Next { + if a.Next[i] != b.Next[i] { + t.Fatalf("Next[%d] differs: %v vs %v", i, a.Next[i], b.Next[i]) + } + } +} + +func TestSpawnRefillsTheQueue(t *testing.T) { + g := New(7) + head := g.Next[0] + evs := g.spawnNext() + if g.Active.Kind != head { + t.Fatalf("spawned %v, want the queue head %v", g.Active.Kind, head) + } + if g.Active.Rotation != 0 { + t.Fatalf("spawned at rotation %d, want 0", g.Active.Rotation) + } + if len(g.Next) != NextQueueLen { + t.Fatalf("len(Next) = %d after spawn, want %d", len(g.Next), NextQueueLen) + } + if len(evs) != 1 { + t.Fatalf("spawn emitted %d events, want 1 PieceSpawned", len(evs)) + } + if _, ok := evs[0].(PieceSpawned); !ok { + t.Fatalf("spawn emitted %T, want PieceSpawned", evs[0]) + } + if !g.CanHold { + t.Fatal("spawning must re-arm CanHold") + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/game/ -run 'TestNewGame|TestSpawn' -v` +Expected: FAIL — `undefined: New`, `g.spawnNext undefined`. + +- [ ] **Step 3: Implement `internal/game/events.go` and `internal/game/game.go`** + +Each event type gets a one-line `func (X) event() {}`. In `game.go`, `New` builds the RNG per the Global Constraints, creates the bag, fills `Next` to `NextQueueLen`, sets `Level = 1`, `CanHold = true`, and spawns the first piece (discarding its events; `New` returns only `*Game`). + +`spawnNext()` (unexported) pops `Next[0]`, refills from the bag, sets `Active = SpawnPiece(kind)`, resets `LockAccumulator`, `LockResets`, `GravityAccumulator`, sets `CanHold = true`, and returns `[]Event{PieceSpawned{...}}`. Task 7 adds the blocked-spawn game-over branch to this same method. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . +git add internal/game/events.go internal/game/game.go internal/game/game_test.go +git commit -m "feat(game): game state, event types, spawn and next queue" +``` + +--- + +### Task 6: Movement, rotation with wall kicks, and the ghost position + +**Files:** +- Create: `internal/game/rules.go` +- Modify: `internal/game/game.go` +- Test: `internal/game/movement_test.go` + +**Interfaces:** +- Consumes: `Game`, `Board.Fits`, events (Task 5). +- Produces: + ```go + // rules.go + var KickOffsets = [8]Point{{0, 0}, {-1, 0}, {1, 0}, {-2, 0}, {2, 0}, {0, -1}, {-1, -1}, {1, -1}} + + // game.go + func (g *Game) MoveLeft() []Event // PieceMoved{DX:-1} on success, nil on failure + func (g *Game) MoveRight() []Event // PieceMoved{DX:+1} + func (g *Game) Rotate(delta int) []Event // delta +1 CW, -1 CCW; PieceRotated or nil + func (g *Game) Grounded() bool // the active piece cannot move down + func (g *Game) GhostY() int // Y the active piece would rest at (§10) + ``` + Movement and rotation also feed the lock-reset rule: a successful move or rotation while `Grounded()` zeroes `LockAccumulator` and increments `LockResets`, but only while `LockResets < MaxLockResets` (Task 8 consumes this). + +- [ ] **Step 1: Write the failing test** + +```go +package game + +import "testing" + +// place puts a known piece under player control on an empty board. +func place(t *testing.T, g *Game, p Piece) { + t.Helper() + g.Active = p + if !g.Board.Fits(p) { + t.Fatalf("test setup: %+v does not fit", p) + } +} + +func TestMoveLeftRightAndWalls(t *testing.T) { + g := New(1) + place(t, g, Piece{Kind: KindO, Rotation: 0, X: 4, Y: 10}) + if evs := g.MoveLeft(); len(evs) != 1 { + t.Fatalf("MoveLeft emitted %d events, want 1", len(evs)) + } else if m, ok := evs[0].(PieceMoved); !ok || m.DX != -1 { + t.Fatalf("MoveLeft emitted %#v, want PieceMoved{DX:-1}", evs[0]) + } + if g.Active.X != 3 { + t.Fatalf("X = %d, want 3", g.Active.X) + } + for i := 0; i < 10; i++ { + g.MoveLeft() + } + if g.Active.X != 0 { + t.Fatalf("X = %d after hitting the left wall, want 0", g.Active.X) + } + if evs := g.MoveLeft(); evs != nil { + t.Fatalf("a blocked move must emit no events, got %v", evs) + } + for i := 0; i < 20; i++ { + g.MoveRight() + } + if g.Active.X != BoardWidth-2 { + t.Fatalf("X = %d after hitting the right wall, want %d", g.Active.X, BoardWidth-2) + } +} + +func TestRotateEmitsEventAndChangesRotation(t *testing.T) { + g := New(1) + place(t, g, Piece{Kind: KindT, Rotation: 0, X: 4, Y: 10}) + evs := g.Rotate(1) + if len(evs) != 1 { + t.Fatalf("Rotate emitted %d events, want 1", len(evs)) + } + if r, ok := evs[0].(PieceRotated); !ok || r.Delta != 1 { + t.Fatalf("Rotate emitted %#v, want PieceRotated{Delta:1}", evs[0]) + } + if g.Active.Rotation != 1 { + t.Fatalf("Rotation = %d, want 1", g.Active.Rotation) + } + g.Rotate(-1) + if g.Active.Rotation != 0 { + t.Fatalf("Rotation = %d after CCW, want 0", g.Active.Rotation) + } +} + +func TestRotateKicksOffTheWall(t *testing.T) { + g := New(1) + // Vertical I flush against the left wall: rotating to horizontal only fits after a kick. + place(t, g, Piece{Kind: KindI, Rotation: 1, X: -1, Y: 10}) + before := g.Active + if evs := g.Rotate(1); len(evs) == 0 { + t.Fatal("rotation against the wall should have succeeded via a kick") + } + if g.Active.X == before.X { + t.Fatalf("X = %d, expected a kick to shift the piece", g.Active.X) + } + if !g.Board.Fits(g.Active) { + t.Fatalf("kicked piece %+v does not fit", g.Active) + } +} + +func TestRotateFailsWhenNoKickFits(t *testing.T) { + g := New(1) + place(t, g, Piece{Kind: KindT, Rotation: 0, X: 4, Y: 10}) + // Wall in every direction the kick table can reach. + for y := 8; y <= 13; y++ { + for x := 0; x < BoardWidth; x++ { + if x >= 4 && x <= 6 && y >= 10 && y <= 11 { + continue + } + g.Board.Cells[y][x] = Cell{Filled: true, Kind: KindZ} + } + } + before := g.Active + if evs := g.Rotate(1); evs != nil { + t.Fatalf("boxed-in rotation must fail, got %v", evs) + } + if g.Active != before { + t.Fatalf("a failed rotation must not change the piece: %+v", g.Active) + } +} + +// Review Focus 1: a kick offset of (0,-1) at the ceiling must fail, not panic. +func TestRotateAtCeilingDoesNotPanic(t *testing.T) { + g := New(1) + place(t, g, Piece{Kind: KindI, Rotation: 0, X: 3, Y: 0}) + for delta := -1; delta <= 1; delta += 2 { + for i := 0; i < 4; i++ { + g.Rotate(delta) // must never panic, whatever it decides + if !g.Board.Fits(g.Active) { + t.Fatalf("piece left in an invalid position: %+v", g.Active) + } + for _, c := range g.Active.Cells() { + if c.Y < 0 { + t.Fatalf("piece kicked above the board: %v", c) + } + } + } + } +} + +func TestGroundedAndGhostY(t *testing.T) { + g := New(1) + place(t, g, Piece{Kind: KindO, Rotation: 0, X: 4, Y: 0}) + if g.Grounded() { + t.Fatal("a piece with room below must not be Grounded") + } + if got, want := g.GhostY(), BoardHeight-2; got != want { + t.Fatalf("GhostY = %d, want %d", got, want) + } + fillRow(&g.Board, BoardHeight-1) + if got, want := g.GhostY(), BoardHeight-3; got != want { + t.Fatalf("GhostY over a filled floor = %d, want %d", got, want) + } + place(t, g, Piece{Kind: KindO, Rotation: 0, X: 4, Y: BoardHeight - 3}) + if !g.Grounded() { + t.Fatal("a piece resting on the floor must be Grounded") + } + if got := g.GhostY(); got != g.Active.Y { + t.Fatalf("GhostY = %d for a grounded piece, want %d", got, g.Active.Y) + } +} + +func TestMoveWhileGroundedResetsLockTimer(t *testing.T) { + g := New(1) + place(t, g, Piece{Kind: KindO, Rotation: 0, X: 4, Y: BoardHeight - 2}) + g.LockAccumulator = 300 * time.Millisecond + g.MoveLeft() + if g.LockAccumulator != 0 { + t.Fatalf("LockAccumulator = %v, want 0 after a grounded move", g.LockAccumulator) + } + if g.LockResets != 1 { + t.Fatalf("LockResets = %d, want 1", g.LockResets) + } + g.LockResets = MaxLockResets + g.LockAccumulator = 300 * time.Millisecond + g.MoveRight() + if g.LockAccumulator != 300*time.Millisecond { + t.Fatalf("past %d resets the timer must not reset, got %v", MaxLockResets, g.LockAccumulator) + } +} +``` + +(add `"time"` to the imports) + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/game/ -run 'TestMove|TestRotate|TestGrounded' -v` +Expected: FAIL — `g.MoveLeft undefined`. + +- [ ] **Step 3: Implement `internal/game/rules.go` and the movement methods in `game.go`** + +Return `nil` from every input method when `g.State != StatePlaying`. `Rotate` builds the candidate with `Active.Rotated(delta)` and then walks `KickOffsets` in order, accepting the first candidate that `Fits`. `GhostY` steps a copy of the active piece down until it no longer fits. Factor the grounded lock-reset behavior into one unexported helper (`g.touchLockTimer()`) called by both move methods and `Rotate` on success. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . +git add internal/game/rules.go internal/game/game.go internal/game/movement_test.go +git commit -m "feat(game): movement, wall-kick rotation, ghost position" +``` + +--- + +### Task 7: Hard drop and the lock pipeline (clear, score, combo, level, game over) + +**Files:** +- Modify: `internal/game/game.go` +- Test: `internal/game/lock_test.go` + +**Interfaces:** +- Consumes: `ClearScore`, `ComboBonus`, `HardDropScore`, `LevelForLines` (Task 4); `Board.CompleteRows`, `ClearRows`, `RowKinds` (Task 2); `spawnNext` (Task 5). +- Produces: + ```go + func (g *Game) HardDrop() []Event // PieceHardDropped, then the lock pipeline's events + func (g *Game) lockActive() []Event + ``` + Lock pipeline order (§12): commit the piece → `PieceLocked` → detect and clear complete rows → update `Score`/`Lines`/`Combo`/`Level` → `LinesCleared`, `ComboChanged` (only when the value changed), `LevelChanged` (only when the value changed) → `spawnNext()`, or `GameOver` and `State = StateOver` when the new piece does not fit. + +- [ ] **Step 1: Write the failing test** + +```go +package game + +import "testing" + +func eventsOfType[T Event](evs []Event) []T { + var out []T + for _, e := range evs { + if t, ok := e.(T); ok { + out = append(out, t) + } + } + return out +} + +func TestHardDropLandsLocksAndScores(t *testing.T) { + g := New(1) + place(t, g, Piece{Kind: KindO, Rotation: 0, X: 4, Y: 0}) + evs := g.HardDrop() + drops := eventsOfType[PieceHardDropped](evs) + if len(drops) != 1 { + t.Fatalf("got %d PieceHardDropped events, want 1", len(drops)) + } + if want := BoardHeight - 2; drops[0].Distance != want { + t.Fatalf("Distance = %d, want %d", drops[0].Distance, want) + } + if got, want := g.Score, HardDropScore(BoardHeight-2); got != want { + t.Fatalf("Score = %d, want %d", got, want) + } + if len(eventsOfType[PieceLocked](evs)) != 1 { + t.Fatal("hard drop must lock the piece") + } + if len(eventsOfType[PieceSpawned](evs)) != 1 { + t.Fatal("hard drop must spawn the next piece") + } + if !g.Board.At(4, BoardHeight-1).Filled { + t.Fatal("the dropped piece must be committed to the board") + } +} + +// Review Focus: a hard drop with nowhere to fall scores nothing and still locks. +func TestHardDropOnTheFloorScoresZero(t *testing.T) { + g := New(1) + place(t, g, Piece{Kind: KindO, Rotation: 0, X: 4, Y: BoardHeight - 2}) + evs := g.HardDrop() + if d := eventsOfType[PieceHardDropped](evs)[0].Distance; d != 0 { + t.Fatalf("Distance = %d, want 0", d) + } + if g.Score != 0 { + t.Fatalf("Score = %d, want 0", g.Score) + } + if len(eventsOfType[PieceLocked](evs)) != 1 { + t.Fatal("the piece must still lock") + } +} + +func TestSingleLineClearScoresAndCollapses(t *testing.T) { + g := New(1) + g.Level = 1 + fillRow(&g.Board, BoardHeight-1, 4, 5) // two-wide gap for an O + place(t, g, Piece{Kind: KindO, Rotation: 0, X: 4, Y: BoardHeight - 3}) + evs := g.HardDrop() + cleared := eventsOfType[LinesCleared](evs) + if len(cleared) != 1 { + t.Fatalf("got %d LinesCleared events, want 1", len(cleared)) + } + c := cleared[0] + if c.Count != 1 || len(c.Rows) != 1 || c.Rows[0] != BoardHeight-1 { + t.Fatalf("LinesCleared = %+v, want one row %d", c, BoardHeight-1) + } + if len(c.Kinds) != 1 || c.Kinds[0][4] != KindO { + t.Fatalf("Kinds snapshot missing the dropped piece: %+v", c.Kinds) + } + if c.Score != 100 { + t.Fatalf("clear Score = %d, want 100", c.Score) + } + if g.Lines != 1 { + t.Fatalf("Lines = %d, want 1", g.Lines) + } + if g.Combo != 1 { + t.Fatalf("Combo = %d, want 1 after the first clearing placement", g.Combo) + } + if len(g.Board.CompleteRows()) != 0 { + t.Fatal("the cleared row must be gone from the board") + } +} + +func TestFourLineClearScoresEightHundredTimesLevel(t *testing.T) { + g := New(1) + g.Level = 3 + for y := BoardHeight - 4; y < BoardHeight; y++ { + fillRow(&g.Board, y, 0) + } + place(t, g, Piece{Kind: KindI, Rotation: 1, X: -1, Y: BoardHeight - 4}) + if !g.Board.Fits(g.Active) { + t.Skip("adjust the vertical-I column so it lands in the x=0 well") + } + evs := g.HardDrop() + c := eventsOfType[LinesCleared](evs)[0] + if c.Count != 4 { + t.Fatalf("Count = %d, want 4", c.Count) + } + if c.Score != 800*3 { + t.Fatalf("Score = %d, want %d", c.Score, 800*3) + } +} + +func TestComboIncrementsAndResets(t *testing.T) { + g := New(1) + clearOneRow := func() []Event { + fillRow(&g.Board, BoardHeight-1, 4, 5) + place(t, g, Piece{Kind: KindO, Rotation: 0, X: 4, Y: BoardHeight - 3}) + return g.HardDrop() + } + clearOneRow() + if g.Combo != 1 { + t.Fatalf("Combo = %d, want 1", g.Combo) + } + evs := clearOneRow() + if g.Combo != 2 { + t.Fatalf("Combo = %d, want 2", g.Combo) + } + if got := eventsOfType[LinesCleared](evs)[0].Score; got != 100*g.Level+ComboBonus(2, g.Level) { + t.Fatalf("second clear Score = %d, want clear+combo bonus", got) + } + if changes := eventsOfType[ComboChanged](evs); len(changes) != 1 || changes[0].Combo != 2 { + t.Fatalf("ComboChanged = %+v, want Combo 2", changes) + } + // A placement that clears nothing resets the combo to 0. + place(t, g, Piece{Kind: KindO, Rotation: 0, X: 0, Y: 0}) + evs = g.HardDrop() + if g.Combo != 0 { + t.Fatalf("Combo = %d after a non-clearing placement, want 0", g.Combo) + } + if changes := eventsOfType[ComboChanged](evs); len(changes) != 1 || changes[0].Combo != 0 { + t.Fatalf("ComboChanged = %+v, want Combo 0", changes) + } +} + +func TestLevelChangesEveryTenLines(t *testing.T) { + g := New(1) + g.Lines = 9 + fillRow(&g.Board, BoardHeight-1, 4, 5) + place(t, g, Piece{Kind: KindO, Rotation: 0, X: 4, Y: BoardHeight - 3}) + evs := g.HardDrop() + if g.Lines != 10 || g.Level != 2 { + t.Fatalf("Lines=%d Level=%d, want 10 and 2", g.Lines, g.Level) + } + if ups := eventsOfType[LevelChanged](evs); len(ups) != 1 || ups[0].Level != 2 { + t.Fatalf("LevelChanged = %+v, want Level 2", ups) + } +} + +func TestBlockedSpawnEndsTheGame(t *testing.T) { + g := New(1) + for y := 0; y < BoardHeight-1; y++ { + fillRow(&g.Board, y) + } + place(t, g, Piece{Kind: KindO, Rotation: 0, X: 4, Y: BoardHeight - 2}) + // setup leaves no room, so clear the two cells the piece occupies + g.Board.Cells[BoardHeight-2][4] = Cell{} + g.Board.Cells[BoardHeight-2][5] = Cell{} + evs := g.HardDrop() + overs := eventsOfType[GameOver](evs) + if len(overs) != 1 { + t.Fatalf("got %d GameOver events, want 1", len(overs)) + } + if overs[0].Score != g.Score || overs[0].Lines != g.Lines || overs[0].Level != g.Level { + t.Fatalf("GameOver = %+v, want the final counters", overs[0]) + } + if g.State != StateOver || !g.Over() { + t.Fatal("State must be StateOver") + } + if evs := g.HardDrop(); evs != nil { + t.Fatal("input after game over must be ignored") + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/game/ -run 'TestHardDrop|TestSingleLine|TestFourLine|TestCombo|TestLevelChanges|TestBlockedSpawn' -v` +Expected: FAIL — `g.HardDrop undefined`. + +- [ ] **Step 3: Implement `HardDrop` and `lockActive` in `internal/game/game.go`** + +`HardDrop` computes the distance to `GhostY()`, moves the piece there, awards `HardDropScore(distance)`, emits `PieceHardDropped`, then appends `lockActive()`'s events. + +`lockActive` snapshots `RowKinds` for each complete row **before** calling `ClearRows`, so `LinesCleared.Kinds` carries the pre-collapse contents. Combo per §49.1: a clearing placement does `Combo++`; a non-clearing placement sets `Combo = 0`. Emit `ComboChanged` only when the value actually changed, `LevelChanged` only when `LevelForLines(g.Lines)` differs from `g.Level`. + +Extend `spawnNext` from Task 5: if the newly spawned piece does not `Fit`, set `State = StateOver` and return `PieceSpawned` plus `GameOver{Score, Lines, Level}` — or return only `GameOver`, as long as the tests above pass; pick one and keep it consistent (Plan 3 keys the collapse animation off `GameOver`). + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . +git add internal/game/game.go internal/game/lock_test.go +git commit -m "feat(game): hard drop, lock pipeline, line clears, combo, level, game over" +``` + +--- + +### Task 8: `Advance(dt)` — gravity, soft drop, and lock delay + +**Files:** +- Modify: `internal/game/game.go` +- Test: `internal/game/advance_test.go` + +**Interfaces:** +- Consumes: `GravityInterval`, `LockDelay`, `MaxLockResets` (Task 4); `lockActive` (Task 7). +- Produces: + ```go + func (g *Game) Advance(dt time.Duration) []Event // the only time input into the engine (§49.2) + func (g *Game) SoftDrop() []Event // one cell down, +1 point, PieceMoved{DY:1}; locks nothing + ``` + +- [ ] **Step 1: Write the failing test** + +```go +package game + +import ( + "testing" + "time" +) + +func TestAdvanceDropsOnePerInterval(t *testing.T) { + g := New(1) + place(t, g, Piece{Kind: KindO, Rotation: 0, X: 4, Y: 0}) + if evs := g.Advance(GravityInterval(1) - time.Millisecond); len(evs) != 0 { + t.Fatalf("a short tick emitted %v, want nothing", evs) + } + if g.Active.Y != 0 { + t.Fatalf("Y = %d before a full interval, want 0", g.Active.Y) + } + evs := g.Advance(2 * time.Millisecond) + if g.Active.Y != 1 { + t.Fatalf("Y = %d after one interval, want 1", g.Active.Y) + } + if len(eventsOfType[PieceMoved](evs)) != 1 { + t.Fatalf("expected one PieceMoved, got %v", evs) + } +} + +// Review Focus 2: a huge dt applies every step in order and terminates. +func TestAdvanceWithHugeDTStepsThroughCollision(t *testing.T) { + g := New(1) + place(t, g, Piece{Kind: KindO, Rotation: 0, X: 4, Y: 0}) + done := make(chan []Event, 1) + go func() { done <- g.Advance(30 * time.Second) }() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("Advance did not terminate on a 30s dt") + } + if !g.Board.At(4, BoardHeight-1).Filled { + t.Fatal("the piece should have fallen, grounded, and locked") + } + if g.State != StatePlaying { + t.Fatalf("State = %v, want StatePlaying", g.State) + } +} + +// Review Focus 3: a non-positive dt changes nothing. +func TestAdvanceIgnoresNonPositiveDT(t *testing.T) { + g := New(1) + before := *g + for _, dt := range []time.Duration{0, -time.Second} { + if evs := g.Advance(dt); evs != nil { + t.Fatalf("Advance(%v) emitted %v, want nil", dt, evs) + } + } + if g.Active != before.Active || g.GravityAccumulator != before.GravityAccumulator { + t.Fatal("Advance with a non-positive dt must not change state") + } +} + +func TestAdvanceIgnoredAfterGameOver(t *testing.T) { + g := New(1) + g.State = StateOver + before := g.Active + if evs := g.Advance(time.Second); evs != nil { + t.Fatalf("Advance after game over emitted %v, want nil", evs) + } + if g.Active != before { + t.Fatal("Advance after game over must not move the piece") + } +} + +func TestLockDelayAndReset(t *testing.T) { + g := New(1) + place(t, g, Piece{Kind: KindO, Rotation: 0, X: 4, Y: BoardHeight - 2}) + if evs := g.Advance(LockDelay - time.Millisecond); len(eventsOfType[PieceLocked](evs)) != 0 { + t.Fatal("the piece must not lock before the lock delay elapses") + } + g.MoveLeft() // resets the timer + if evs := g.Advance(LockDelay - time.Millisecond); len(eventsOfType[PieceLocked](evs)) != 0 { + t.Fatal("a grounded move must have reset the lock timer") + } + evs := g.Advance(2 * time.Millisecond) + if len(eventsOfType[PieceLocked](evs)) != 1 { + t.Fatalf("expected a lock once the delay elapsed, got %v", evs) + } +} + +func TestLockResetsAreCapped(t *testing.T) { + g := New(1) + place(t, g, Piece{Kind: KindO, Rotation: 0, X: 4, Y: BoardHeight - 2}) + for i := 0; i < MaxLockResets+5; i++ { + g.Advance(10 * time.Millisecond) + if i%2 == 0 { + g.MoveLeft() + } else { + g.MoveRight() + } + } + if g.LockResets < MaxLockResets { + t.Fatalf("LockResets = %d, want it to reach %d", g.LockResets, MaxLockResets) + } + evs := g.Advance(LockDelay) + if len(eventsOfType[PieceLocked](evs)) != 1 { + t.Fatalf("stalling past %d resets must still lock, got %v", MaxLockResets, evs) + } +} + +func TestSoftDropScoresOnePerCell(t *testing.T) { + g := New(1) + place(t, g, Piece{Kind: KindO, Rotation: 0, X: 4, Y: 0}) + evs := g.SoftDrop() + if g.Active.Y != 1 { + t.Fatalf("Y = %d, want 1", g.Active.Y) + } + if g.Score != 1 { + t.Fatalf("Score = %d, want 1", g.Score) + } + if m := eventsOfType[PieceMoved](evs); len(m) != 1 || m[0].DY != 1 { + t.Fatalf("SoftDrop emitted %#v, want PieceMoved{DY:1}", evs) + } + place(t, g, Piece{Kind: KindO, Rotation: 0, X: 4, Y: BoardHeight - 2}) + scoreBefore := g.Score + if evs := g.SoftDrop(); evs != nil { + t.Fatalf("a blocked soft drop emitted %v, want nil", evs) + } + if g.Score != scoreBefore { + t.Fatal("a blocked soft drop must not score") + } +} + +func TestGravityFollowsLevel(t *testing.T) { + g := New(1) + g.Level = 5 + place(t, g, Piece{Kind: KindO, Rotation: 0, X: 4, Y: 0}) + g.Advance(GravityInterval(5)) + if g.Active.Y != 1 { + t.Fatalf("Y = %d after one level-5 interval, want 1", g.Active.Y) + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/game/ -run 'TestAdvance|TestLock|TestSoftDrop|TestGravityFollows' -v` +Expected: FAIL — `g.Advance undefined`. + +- [ ] **Step 3: Implement `Advance` and `SoftDrop` in `internal/game/game.go`** + +`Advance` returns `nil` immediately when `dt <= 0` or `g.State != StatePlaying`. Then: add `dt` to `GravityAccumulator` and, while it is at least `GravityInterval(g.Level)`, subtract one interval and try to step down — a successful step emits `PieceMoved{DY:1}` and zeroes `LockAccumulator`; a blocked step leaves the piece grounded. Separately, when `Grounded()`, add `dt` to `LockAccumulator` and call `lockActive()` once it reaches `LockDelay`; when not grounded, zero it. Stop the gravity loop as soon as the piece locks so a huge `dt` cannot drive two placements in one call — drain or reset `GravityAccumulator` at lock time. + +`SoftDrop` moves down one cell if it fits, awards `SoftDropScore(1)`, resets the gravity accumulator, and emits `PieceMoved{DY:1}`; otherwise returns `nil`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . +git add internal/game/game.go internal/game/advance_test.go +git commit -m "feat(game): Advance(dt) gravity, soft drop, lock delay" +``` + +--- + +### Task 9: Hold + +**Files:** +- Modify: `internal/game/game.go` +- Test: `internal/game/hold_test.go` + +**Interfaces:** +- Consumes: `spawnNext`, `CanHold`, `Hold` (Tasks 5, 7). +- Produces: + ```go + func (g *Game) HoldPiece() []Event // HoldUsed + PieceSpawned; nil when !CanHold or not playing + ``` + Named `HoldPiece` because the `Hold *PieceKind` field already owns the name `Hold`. + +- [ ] **Step 1: Write the failing test** + +```go +package game + +import "testing" + +func TestFirstHoldStoresAndSpawnsNext(t *testing.T) { + g := New(1) + active := g.Active.Kind + queueHead := g.Next[0] + evs := g.HoldPiece() + if g.Hold == nil || *g.Hold != active { + t.Fatalf("Hold = %v, want %v", g.Hold, active) + } + if g.Active.Kind != queueHead { + t.Fatalf("Active = %v, want the queue head %v", g.Active.Kind, queueHead) + } + h := eventsOfType[HoldUsed](evs) + if len(h) != 1 || h[0].Stored != active || h[0].Incoming != queueHead { + t.Fatalf("HoldUsed = %+v, want stored %v incoming %v", h, active, queueHead) + } + if g.CanHold { + t.Fatal("CanHold must be false until the active piece locks") + } +} + +func TestSecondHoldBlockedUntilLock(t *testing.T) { + g := New(1) + g.HoldPiece() + held, active := *g.Hold, g.Active + if evs := g.HoldPiece(); evs != nil { + t.Fatalf("the second hold emitted %v, want nil", evs) + } + if *g.Hold != held || g.Active != active { + t.Fatal("a blocked hold must change nothing") + } +} + +func TestHoldSwapsAndResetsRotation(t *testing.T) { + g := New(1) + g.HoldPiece() + held := *g.Hold + g.Active.Rotation = 2 + g.Active.X = 0 + g.HardDrop() // locking re-arms CanHold + if !g.CanHold { + t.Fatal("locking must re-arm CanHold") + } + swappedIn := g.Active.Kind + g.Active.Rotation = 3 + g.HoldPiece() + if g.Active.Kind != held { + t.Fatalf("Active = %v, want the previously held %v", g.Active.Kind, held) + } + if g.Active.Rotation != 0 { + t.Fatalf("Rotation = %d, want the spawn rotation 0", g.Active.Rotation) + } + if *g.Hold != swappedIn { + t.Fatalf("Hold = %v, want %v", *g.Hold, swappedIn) + } + if got := SpawnPiece(held); g.Active.X != got.X || g.Active.Y != got.Y { + t.Fatalf("held piece returned at (%d,%d), want the spawn position (%d,%d)", g.Active.X, g.Active.Y, got.X, got.Y) + } +} + +// Review Focus 4: a hold whose incoming piece cannot spawn ends the game. +func TestHoldIntoABlockedSpawnEndsTheGame(t *testing.T) { + g := New(1) + for y := 0; y < BoardHeight; y++ { + fillRow(&g.Board, y) + } + evs := g.HoldPiece() + if len(eventsOfType[GameOver](evs)) != 1 { + t.Fatalf("expected GameOver, got %v", evs) + } + if g.State != StateOver { + t.Fatal("State must be StateOver") + } + for _, c := range g.Active.Cells() { + if !g.Board.At(c.X, c.Y).Filled { + continue + } + return // an overlapping piece is fine only because the game is over + } +} + +func TestHoldIgnoredAfterGameOver(t *testing.T) { + g := New(1) + g.State = StateOver + if evs := g.HoldPiece(); evs != nil { + t.Fatalf("hold after game over emitted %v, want nil", evs) + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/game/ -run Hold -v` +Expected: FAIL — `g.HoldPiece undefined`. + +- [ ] **Step 3: Implement `HoldPiece` in `internal/game/game.go`** + +Empty hold: store `Active.Kind`, then `spawnNext()`. Occupied hold: swap `*g.Hold` with `Active.Kind` and reset the incoming piece to `SpawnPiece(kind)`, reusing the blocked-spawn check from Task 7 so a swap into a full board ends the game. Set `CanHold = false` on success. `spawnNext` sets `CanHold = true`, so re-arm order matters: clear the flag after the spawn call. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . +git add internal/game/game.go internal/game/hold_test.go +git commit -m "feat(game): hold with once-per-piece rule" +``` + +--- + +### Task 10: Restart and the seeded replay determinism test + +**Files:** +- Modify: `internal/game/game.go` +- Test: `internal/game/replay_test.go` +- Create: `README.md` + +**Interfaces:** +- Consumes: everything above. +- Produces: + ```go + func (g *Game) Restart() // same seed, fresh universe + func (g *Game) RestartWithSeed(seed int64) + ``` + +- [ ] **Step 1: Write the failing test** + +```go +package game + +import ( + "testing" + "time" +) + +type replayStep struct { + input string // "L","R","CW","CCW","SOFT","HARD","HOLD","" + dt time.Duration +} + +func runReplay(t *testing.T, seed int64, steps []replayStep) *Game { + t.Helper() + g := New(seed) + for _, s := range steps { + switch s.input { + case "L": + g.MoveLeft() + case "R": + g.MoveRight() + case "CW": + g.Rotate(1) + case "CCW": + g.Rotate(-1) + case "SOFT": + g.SoftDrop() + case "HARD": + g.HardDrop() + case "HOLD": + g.HoldPiece() + } + g.Advance(s.dt) + } + return g +} + +func cannedSteps() []replayStep { + inputs := []string{"L", "CW", "HARD", "R", "R", "SOFT", "HOLD", "CCW", "HARD", "", "L", "HARD"} + steps := make([]replayStep, 0, 400) + for i := 0; i < 30; i++ { + for j, in := range inputs { + steps = append(steps, replayStep{input: in, dt: time.Duration(7+((i+j)%13)) * time.Millisecond}) + } + } + return steps +} + +func TestReplayIsDeterministic(t *testing.T) { + steps := cannedSteps() + a := runReplay(t, 8675309, steps) + b := runReplay(t, 8675309, steps) + if a.Score != b.Score || a.Lines != b.Lines || a.Level != b.Level || a.Combo != b.Combo { + t.Fatalf("counters diverged: %+v vs %+v", a, b) + } + if a.Board != b.Board { + t.Fatal("boards diverged for the same seed, inputs, and timings") + } + if a.Active != b.Active || a.State != b.State { + t.Fatalf("active piece or state diverged: %+v / %+v", a, b) + } + for i := range a.Next { + if a.Next[i] != b.Next[i] { + t.Fatalf("Next[%d] diverged", i) + } + } +} + +func TestReplayDoesSomething(t *testing.T) { + g := runReplay(t, 8675309, cannedSteps()) + if g.Score == 0 { + t.Fatal("the canned replay should have scored; it is not exercising the engine") + } +} + +func TestRestartResetsEverythingButTheSeed(t *testing.T) { + g := runReplay(t, 4242, cannedSteps()) + seed := g.Seed + g.Restart() + fresh := New(seed) + if g.Seed != seed { + t.Fatalf("Seed = %d, want %d preserved", g.Seed, seed) + } + if g.Score != 0 || g.Lines != 0 || g.Combo != 0 || g.Level != 1 { + t.Fatalf("counters not reset: %+v", g) + } + if g.Hold != nil || !g.CanHold { + t.Fatal("hold state not reset") + } + if g.State != StatePlaying { + t.Fatal("State not reset to StatePlaying") + } + for y := 0; y < BoardHeight; y++ { + for x := 0; x < BoardWidth; x++ { + if g.Board.At(x, y).Filled { + t.Fatalf("board cell (%d,%d) survived the restart", x, y) + } + } + } + if g.Active != fresh.Active { + t.Fatalf("restart gave %+v, want the same first piece as a fresh game %+v", g.Active, fresh.Active) + } +} + +func TestRestartWithSeedChangesTheSequence(t *testing.T) { + g := New(1) + g.RestartWithSeed(2) + if g.Seed != 2 { + t.Fatalf("Seed = %d, want 2", g.Seed) + } + if g.Active != New(2).Active { + t.Fatal("RestartWithSeed must reproduce a fresh game on the new seed") + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/game/ -run 'Replay|Restart' -v` +Expected: FAIL — `g.Restart undefined`. + +- [ ] **Step 3: Implement `Restart` / `RestartWithSeed` and write `README.md`** + +`RestartWithSeed` rebuilds the game in place: `*g = *New(seed)`. `Restart` calls `RestartWithSeed(g.Seed)`. + +`README.md`: name, one-paragraph description, `go test ./...`, and a short "engine is deterministic and clock-free" note pointing at §49.2. The playable-binary sections arrive in Plan 2. + +- [ ] **Step 4: Run the full suite** + +Run: `go test ./... -count=1` +Expected: PASS, all engine tests. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . +git add internal/game/game.go internal/game/replay_test.go README.md +git commit -m "feat(game): restart and seeded replay determinism test" +``` + +--- + +## Engine API summary (for Plans 2 and 3) + +```go +package game // cosmic-tetris/internal/game + +const (BoardWidth = 10; BoardHeight = 22; VisibleRows = 20; HiddenRows = 2; NextQueueLen = 5) + +func New(seed int64) *Game +func (g *Game) Advance(dt time.Duration) []Event +func (g *Game) MoveLeft() []Event +func (g *Game) MoveRight() []Event +func (g *Game) Rotate(delta int) []Event // +1 CW, -1 CCW +func (g *Game) SoftDrop() []Event +func (g *Game) HardDrop() []Event +func (g *Game) HoldPiece() []Event +func (g *Game) Restart() +func (g *Game) RestartWithSeed(seed int64) +func (g *Game) GhostY() int +func (g *Game) Grounded() bool +func (g *Game) Over() bool + +// read-only for renderers and FX: Board, Active, Hold, CanHold, Next, +// Score, Lines, Level, Combo, State, Seed +``` + +Events: `PieceSpawned`, `PieceMoved`, `PieceRotated`, `PieceHardDropped`, `PieceLocked`, `HoldUsed`, `LinesCleared`, `ComboChanged`, `LevelChanged`, `GameOver`. diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/Q2-reader-recipe-cosmic-32/2026-09-17-cosmic-tetris-2-playable-terminal.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/Q2-reader-recipe-cosmic-32/2026-09-17-cosmic-tetris-2-playable-terminal.md new file mode 100644 index 000000000..3bd090f31 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/Q2-reader-recipe-cosmic-32/2026-09-17-cosmic-tetris-2-playable-terminal.md @@ -0,0 +1,1844 @@ +# Cosmic Tetris — Plan 2: Playable Terminal + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Turn the Plan 1 engine into a genuinely good playable terminal game: Bubble Tea event loop, keyboard, board/ghost/hold/next/stats rendering, adaptive layout with a too-small notice, pause / help / game-over screens, the `cosmic-tetris` binary with its full CLI, and golden layout tests. + +**Architecture:** `internal/render` owns a tiny character grid (`Grid`) that every drawing function writes into; a frame is one `Grid` built from a pure `render.Input` snapshot and flattened to a styled string. `internal/app` is the Bubble Tea layer: it owns the clock, converts a single 60 Hz `FrameMsg` into `game.Advance(dt)`, maps keys to engine methods, and hands the renderer a snapshot. Rendering never mutates game state; the engine never learns about the terminal. FX hooks (`Sprites`, `Shake`, `Banner`, `Boot`) exist as empty fields on `render.Input` for Plan 3 to fill. + +**Tech Stack:** Go 1.26, `charm.land/bubbletea/v2` v2.0.9, `charm.land/lipgloss/v2` v2.0.6, `charm.land/bubbles/v2` v2.2.1, `github.com/charmbracelet/x/ansi` (test-only, for one strip assertion). + +**Spec:** `design.md` (this plan implements §4, §5 rendering, §8, §10 rendering, §25, §26, §27 plumbing, §28 final card, §30, §31, §32, §33, §34, §36, §37, §39, §41, §46, and pinned decisions §49.3, §49.4, §49.5 flag surface, §49.7) + +**Depends on:** `plans/2026-09-17-cosmic-tetris-1-engine.md` (the whole `internal/game` API, summarized at the end of that plan). + +## Global Constraints + +- Imports use the `charm.land` paths (§3): `charm.land/bubbletea/v2`, `charm.land/lipgloss/v2`, `charm.land/bubbles/v2`. Add them with `go get charm.land/bubbletea/v2@latest charm.land/lipgloss/v2@latest charm.land/bubbles/v2@latest`. +- Do not wrap Bubble Tea in a homegrown framework (§3). `app.Model` implements `tea.Model` directly: `Init() tea.Cmd`, `Update(tea.Msg) (tea.Model, tea.Cmd)`, `View() tea.View`. +- Bubble Tea v2 specifics: key events arrive as `tea.KeyPressMsg` (a `tea.Key` with `Code rune` and `Text string`; `String()` gives `"left"`, `"h"`, `"space"`); `View()` returns a `tea.View` — build it with `tea.NewView(s)` and set `AltScreen = true`; leave `Cursor` nil so no cursor blinks over the board. +- `internal/render` must not import `internal/app` (that would be a cycle). The display-phase enum therefore lives in `render` as `render.Phase`, and `app.Model.State` is of that type — this is how §34's `AppState` is spelled. +- One animation clock only (§36): a single `tea.Tick` at 16ms producing `FrameMsg`. No second timer, no goroutine per frame or particle (§38). +- Rendering is pure: `render.Render(in Input) string` and every `Draw*` helper take the game by pointer but must not call any mutating engine method. +- Cell geometry (§5, §49.4): one logical cell is **2 terminal columns × 1 terminal row**. Blocks are `██` (`[]` in ASCII), ghosts `░░` (`··` in ASCII). Pieces use a bright foreground on filled glyphs — never a background pair (§49.4). The active piece renders one step brighter than locked cells. + - Note: §49.4's ASCII ghost `··` is U+00B7, not strictly ASCII, and §32 asks ASCII mode to avoid special Unicode. §49 wins, so ship `··`; it is single-width and safe in any UTF-8 terminal. `Glyphs.Ghost` is one string constant, so `..` is a one-line change if a terminal ever objects. +- Only the visible rows render: board rows `y = 2..21`. Cells of the active piece in the hidden rows are clipped, never drawn above the board. +- Minimum usable size is 40 × 24 (§31). Below that, the too-small notice, live on resize, never a crash. +- Golden files live in `internal/render/testdata/*.golden` and are regenerated with `go test ./internal/render/ -update`. +- Run `gofmt -l .` before every commit; it must print nothing. + +## Review Focus + +Five failure modes the spec implies but no task's own tests would otherwise exercise. Each has a test added to the task that owns the code. + +1. **A terminal reporting 0×0 or 1×1** (some emulators do this mid-resize, and Bubble Tea sends the first `WindowSizeMsg` before anything is drawn) must render the too-small notice, not divide by zero or index a negative-width grid. (Task 4) +2. **A very large terminal** (300 × 120) must centre the same fixed-size chrome without stretching the board or writing outside the grid. (Task 4) +3. **A score that outgrows its HUD field** (8+ digits, reachable per §45's "NUMBER BECAME BIGGER") must not push the side column wider and shift the board. (Task 6) +4. **A frame delivered after a long stall** (laptop suspend: `dt` of tens of seconds) must be clamped before reaching `Advance`, so the player does not lose four pieces at once on wake. (Task 7) +5. **Every supported terminal size renders without overlap or stray writes** — one property test sweeps widths 40..120 × heights 24..60, asserting each row's rendered width never exceeds the terminal width and the board interior contains only board glyphs. (Task 9) + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `internal/render/grid.go` | `Grid` character buffer, `Rect`, clipped writes, plain and styled flattening. | +| `internal/render/palette.go` | `Mode`, `Glyphs`, `Palette` — all colors and glyph choices in one place. | +| `internal/render/layout.go` | `Phase`, size tiers, chrome ladder (§49.3), rects for board/left/right/mission/controls, too-small notice. | +| `internal/render/board.go` | Board border, locked cells, ghost, active piece. | +| `internal/render/hud.go` | HOLD, NEXT, stats, mission-control line, controls line, mini-piece drawing. | +| `internal/render/overlay.go` | Centred panels: pause, help, game-over card. | +| `internal/render/render.go` | `Input`, `Sprite`, `Render`, `RenderGrid` — the §37 pipeline order. | +| `internal/app/keys.go` | `KeyMap` (also a `help.KeyMap`). | +| `internal/app/messages.go` | `FrameMsg`, `frameCmd`. | +| `internal/app/model.go` | `Model`, `Options`, `New`, `Init`, `View`. | +| `internal/app/update.go` | `Update`: keys, resize, frame/dt, pause, restart, quit. | +| `cmd/cosmic-tetris/main.go` | Flag parsing and program start. | + +--- + +### Task 1: The `Grid` character buffer + +**Files:** +- Create: `internal/render/grid.go` +- Test: `internal/render/grid_test.go` + +**Interfaces:** +- Consumes: `charm.land/lipgloss/v2`. +- Produces: + ```go + type Rect struct{ X, Y, W, H int } + func (r Rect) Contains(x, y int) bool + func (r Rect) Inset(d int) Rect + + type Cell struct { + Rune rune + Style *lipgloss.Style // nil means unstyled + } + + type Grid struct{ W, H int /* cells []Cell */ } + func NewGrid(w, h int) *Grid // w,h clamped to >= 0 + func (g *Grid) Set(x, y int, r rune, st *lipgloss.Style) // out of bounds: silent no-op + func (g *Grid) SetString(x, y int, s string, st *lipgloss.Style) int // returns columns advanced + func (g *Grid) At(x, y int) Cell + func (g *Grid) Fill(r Rect, ch rune, st *lipgloss.Style) + func (g *Grid) PlainString() string // runes only, per-row right-trimmed, rows joined with \n + func (g *Grid) String() string // styled: runs of identical *Style rendered together + ``` + +- [ ] **Step 1: Write the failing test** + +```go +package render + +import ( + "strings" + "testing" + + "charm.land/lipgloss/v2" +) + +func TestGridSetAndPlainString(t *testing.T) { + g := NewGrid(4, 2) + g.Set(0, 0, 'a', nil) + g.Set(3, 1, 'z', nil) + if got, want := g.PlainString(), "a\n z"; got != want { + t.Fatalf("PlainString = %q, want %q", got, want) + } + if got := g.At(0, 0).Rune; got != 'a' { + t.Fatalf("At(0,0) = %q, want 'a'", got) + } +} + +func TestGridClipsOutOfBoundsWrites(t *testing.T) { + g := NewGrid(3, 2) + g.Set(-1, 0, 'x', nil) + g.Set(0, -1, 'x', nil) + g.Set(3, 0, 'x', nil) + g.Set(0, 2, 'x', nil) + if strings.ContainsRune(g.PlainString(), 'x') { + t.Fatalf("out-of-bounds writes leaked into the grid: %q", g.PlainString()) + } + if n := g.SetString(2, 0, "abc", nil); n != 1 { + t.Fatalf("SetString past the right edge advanced %d, want 1", n) + } + if got, want := g.PlainString(), " a"; got != want { + t.Fatalf("PlainString = %q, want %q", got, want) + } +} + +func TestGridZeroAndNegativeSize(t *testing.T) { + for _, d := range [][2]int{{0, 0}, {-5, 3}, {3, -5}} { + g := NewGrid(d[0], d[1]) + g.Set(0, 0, 'x', nil) // must not panic + if g.PlainString() != "" { + t.Fatalf("NewGrid(%d,%d) produced %q, want empty", d[0], d[1], g.PlainString()) + } + } +} + +func TestGridFillRect(t *testing.T) { + g := NewGrid(4, 3) + g.Fill(Rect{X: 1, Y: 1, W: 2, H: 2}, '#', nil) + if got, want := g.PlainString(), "\n ##\n ##"; got != want { + t.Fatalf("PlainString = %q, want %q", got, want) + } +} + +func TestGridStringCarriesStyles(t *testing.T) { + st := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF3D71")) + g := NewGrid(3, 1) + g.SetString(0, 0, "hey", &st) + out := g.String() + if !strings.Contains(out, "\x1b[") { + t.Fatalf("String() emitted no escape sequences: %q", out) + } + if !strings.Contains(out, "hey") { + t.Fatalf("String() lost the text: %q", out) + } +} + +func TestRectHelpers(t *testing.T) { + r := Rect{X: 2, Y: 3, W: 4, H: 5} + if !r.Contains(2, 3) || !r.Contains(5, 7) || r.Contains(6, 7) || r.Contains(1, 3) { + t.Fatal("Contains is wrong at the edges") + } + if got, want := r.Inset(1), (Rect{X: 3, Y: 4, W: 2, H: 3}); got != want { + t.Fatalf("Inset(1) = %+v, want %+v", got, want) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go get charm.land/lipgloss/v2@latest && go test ./internal/render/ -v` +Expected: FAIL — `undefined: NewGrid`. + +- [ ] **Step 3: Implement `internal/render/grid.go`** + +Back the grid with a flat `[]Cell` of `W*H`, spaces by default. `SetString` iterates runes (not bytes) and stops at the right edge. `String()` walks each row accumulating a run of cells sharing the same `*Style` pointer, rendering each run with one `Style.Render` call (nil style: emit the raw runes) — this keeps escape sequences per frame proportional to style changes, not cells (§38). + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . +git add go.mod go.sum internal/render/grid.go internal/render/grid_test.go +git commit -m "feat(render): clipped character grid with styled flattening" +``` + +--- + +### Task 2: Modes, glyphs, and palette + +**Files:** +- Create: `internal/render/palette.go` +- Test: `internal/render/palette_test.go` + +**Interfaces:** +- Consumes: `game.PieceKind`, `game.AllKinds`. +- Produces: + ```go + type Mode int + const (ModeFull Mode = iota; ModeReduced; ModeASCII) + + type Glyphs struct { + Block string // "██" / "[]" + Ghost string // "░░" / "··" + Empty string // " " + Border [6]string // TL,TR,BL,BR,H,V — double-line box, ASCII "+ + + + - |" + Comet string // "☄" / ">" + Star [3]string // far, mid, near default glyphs (Plan 3 uses these too) + } + func GlyphsFor(mode Mode) Glyphs + + type Palette struct { + Active [7]lipgloss.Style // bright, for the falling piece + Locked [7]lipgloss.Style // one step dimmer, for committed cells + Ghost lipgloss.Style + Border lipgloss.Style + Title lipgloss.Style + Label lipgloss.Style + Value lipgloss.Style + Mission lipgloss.Style + Banner lipgloss.Style + Dim lipgloss.Style + } + func NewPalette(mode Mode) *Palette + func (p *Palette) PieceStyle(k game.PieceKind, active bool) *lipgloss.Style + ``` + +- [ ] **Step 1: Write the failing test** + +```go +package render + +import ( + "testing" + + "cosmic-tetris/internal/game" +) + +func TestGlyphsPerMode(t *testing.T) { + full := GlyphsFor(ModeFull) + if full.Block != "██" || full.Ghost != "░░" { + t.Fatalf("full glyphs = %q/%q, want ██/░░", full.Block, full.Ghost) + } + if GlyphsFor(ModeReduced).Block != full.Block { + t.Fatal("reduced mode keeps the Unicode block glyph") + } + ascii := GlyphsFor(ModeASCII) + if ascii.Block != "[]" || ascii.Ghost != "··" { + t.Fatalf("ascii glyphs = %q/%q, want []/·· (§49.4)", ascii.Block, ascii.Ghost) + } + for _, g := range []Glyphs{full, GlyphsFor(ModeReduced), ascii} { + if len([]rune(g.Block)) != 2 || len([]rune(g.Ghost)) != 2 || len([]rune(g.Empty)) != 2 { + t.Fatalf("every cell glyph must be exactly 2 columns: %+v", g) + } + } +} + +func TestPaletteHasADistinctStyleForEveryKind(t *testing.T) { + p := NewPalette(ModeFull) + seen := map[string]game.PieceKind{} + for _, k := range game.AllKinds { + fg := p.PieceStyle(k, true).GetForeground() + if fg == nil { + t.Fatalf("%v has no foreground color", k) + } + key := colorKey(fg) + if prev, dup := seen[key]; dup { + t.Fatalf("%v shares its color with %v", k, prev) + } + seen[key] = k + } +} + +func TestActiveIsBrighterThanLocked(t *testing.T) { + p := NewPalette(ModeFull) + for _, k := range game.AllKinds { + if colorKey(p.PieceStyle(k, true).GetForeground()) == colorKey(p.PieceStyle(k, false).GetForeground()) { + t.Fatalf("%v: active and locked styles are identical (§49.4 wants active brighter)", k) + } + } +} + +func TestASCIIPaletteAvoidsTruecolor(t *testing.T) { + p := NewPalette(ModeASCII) + for _, k := range game.AllKinds { + if got := colorKey(p.PieceStyle(k, true).GetForeground()); len(got) > 0 && got[0] == '#' { + t.Fatalf("%v uses a hex color in ASCII mode: %s", k, got) + } + } +} +``` + +Add a small test helper in the same file: + +```go +func colorKey(c any) string { + if c == nil { + return "" + } + return lipgloss.NewStyle().Foreground(c.(color.Color)).Render("x") +} +``` + +(imports: `image/color`, `charm.land/lipgloss/v2`. If `GetForeground` already returns `color.Color`, drop the assertion.) + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/render/ -run 'Glyph|Palette|Active|ASCII' -v` +Expected: FAIL — `undefined: GlyphsFor`. + +- [ ] **Step 3: Implement `internal/render/palette.go`** + +Neon space palette (§26), pinned. Full and Reduced use these hex values — Bubble Tea's color profile downsamples for 256-color terminals, so Reduced differs from Full only in Plan 3's gradient step count: + +```text +kind active locked +I #22E4F7 #0E7C89 +J #3B6BFF #1E3A99 +L #FF8A2B #99511A +O #FFD34D #99802E +S #57F287 #2E8A4F +T #A855F7 #62309A +Z #FF3D71 #99244A +``` + +ASCII mode maps to ANSI indices instead: I `"14"`, J `"12"`, L `"3"`, O `"11"`, S `"10"`, T `"13"`, Z `"9"`; locked variants use the non-bright pair (`"6" "4" "3" "3" "2" "5" "1"`). Chrome styles: border `#8B5CF6`, title `#E8E8FF` bold, label `#6B7280`, value `#E8E8FF` bold, mission `#22E4F7`, banner `#FFD34D` bold, ghost `#3F4A63`, dim `#4B5563`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . +git add internal/render/palette.go internal/render/palette_test.go +git commit -m "feat(render): neon space palette and per-mode glyph sets" +``` + +--- + +### Task 3: Board panel — border, locked cells, ghost, active piece + +**Files:** +- Create: `internal/render/board.go` +- Test: `internal/render/board_test.go` + +**Interfaces:** +- Consumes: `Grid`, `Rect`, `Palette`, `Glyphs` (Tasks 1–2); `game.Game`, `game.GhostY`. +- Produces: + ```go + const ( + BoardInnerW = game.BoardWidth * 2 // 20 terminal columns + BoardInnerH = game.VisibleRows // 20 terminal rows + BoardOuterW = BoardInnerW + 2 // 22 + BoardOuterH = BoardInnerH + 2 // 22 + ) + + func DrawBoardFrame(g *Grid, r Rect, p *Palette, gl Glyphs, border *lipgloss.Style) + func DrawLockedCells(g *Grid, inner Rect, b *game.Board, p *Palette, gl Glyphs) + func DrawGhost(g *Grid, inner Rect, gm *game.Game, p *Palette, gl Glyphs) + func DrawActive(g *Grid, inner Rect, gm *game.Game, p *Palette, gl Glyphs) + // cellRect maps a board coordinate to the grid: x = inner.X + bx*2, y = inner.Y + by - game.HiddenRows + ``` + `border` is a style override so Plan 3 can flash the border without touching this code; nil means `p.Border`. + +- [ ] **Step 1: Write the failing test** + +```go +package render + +import ( + "strings" + "testing" + + "cosmic-tetris/internal/game" +) + +func boardTestGrid() (*Grid, Rect, Rect, *Palette, Glyphs) { + g := NewGrid(BoardOuterW, BoardOuterH) + outer := Rect{X: 0, Y: 0, W: BoardOuterW, H: BoardOuterH} + return g, outer, outer.Inset(1), NewPalette(ModeFull), GlyphsFor(ModeFull) +} + +func TestBoardFrameGeometry(t *testing.T) { + g, outer, _, p, gl := boardTestGrid() + DrawBoardFrame(g, outer, p, gl, nil) + rows := strings.Split(g.PlainString(), "\n") + if len(rows) != BoardOuterH { + t.Fatalf("frame is %d rows, want %d", len(rows), BoardOuterH) + } + top := []rune(rows[0]) + if len(top) != BoardOuterW { + t.Fatalf("top border is %d columns, want %d", len(top), BoardOuterW) + } + if string(top[0]) != gl.Border[0] || string(top[len(top)-1]) != gl.Border[1] { + t.Fatalf("top corners = %q..%q", top[0], top[len(top)-1]) + } + for _, r := range rows[1 : len(rows)-1] { + runes := []rune(r) + if string(runes[0]) != gl.Border[5] { + t.Fatalf("row %q does not start with the vertical border glyph", r) + } + } +} + +func TestLockedCellsRenderTwoColumnsWide(t *testing.T) { + g, outer, inner, p, gl := boardTestGrid() + DrawBoardFrame(g, outer, p, gl, nil) + var b game.Board + b.Cells[game.BoardHeight-1][0] = game.Cell{Filled: true, Kind: game.KindZ} + b.Cells[game.HiddenRows][game.BoardWidth-1] = game.Cell{Filled: true, Kind: game.KindI} + DrawLockedCells(g, inner, &b, p, gl) + rows := strings.Split(g.PlainString(), "\n") + bottom := []rune(rows[BoardOuterH-2]) + if string(bottom[1:3]) != gl.Block { + t.Fatalf("bottom-left cell = %q, want %q", string(bottom[1:3]), gl.Block) + } + top := []rune(rows[1]) + if string(top[1+(game.BoardWidth-1)*2:1+game.BoardWidth*2]) != gl.Block { + t.Fatalf("top-right visible cell missing: %q", string(top)) + } +} + +func TestHiddenRowsAreNotDrawn(t *testing.T) { + g, outer, inner, p, gl := boardTestGrid() + DrawBoardFrame(g, outer, p, gl, nil) + var b game.Board + for x := 0; x < game.BoardWidth; x++ { + b.Cells[0][x] = game.Cell{Filled: true, Kind: game.KindO} + b.Cells[1][x] = game.Cell{Filled: true, Kind: game.KindO} + } + DrawLockedCells(g, inner, &b, p, gl) + if strings.Contains(g.PlainString(), gl.Block) { + t.Fatal("cells in the hidden rows must not render") + } +} + +func TestGhostSitsAtTheLandingPositionAndActiveWinsOnTop(t *testing.T) { + g, outer, inner, p, gl := boardTestGrid() + gm := game.New(1) + gm.Active = game.Piece{Kind: game.KindO, Rotation: 0, X: 4, Y: game.HiddenRows} + DrawBoardFrame(g, outer, p, gl, nil) + DrawGhost(g, inner, gm, p, gl) + DrawActive(g, inner, gm, p, gl) + rows := strings.Split(g.PlainString(), "\n") + ghostRow := []rune(rows[BoardOuterH-2]) + if got := string(ghostRow[1+4*2 : 1+5*2]); got != gl.Ghost { + t.Fatalf("ghost bottom row = %q, want %q", got, gl.Ghost) + } + activeRow := []rune(rows[1]) + if got := string(activeRow[1+4*2 : 1+5*2]); got != gl.Block { + t.Fatalf("active piece row = %q, want %q", got, gl.Block) + } +} + +func TestGhostNeverOverwritesLockedCells(t *testing.T) { + g, outer, inner, p, gl := boardTestGrid() + gm := game.New(1) + gm.Active = game.Piece{Kind: game.KindI, Rotation: 0, X: 3, Y: game.HiddenRows} + for x := 0; x < game.BoardWidth; x++ { + gm.Board.Cells[game.BoardHeight-1][x] = game.Cell{Filled: true, Kind: game.KindT} + } + DrawBoardFrame(g, outer, p, gl, nil) + DrawLockedCells(g, inner, &gm.Board, p, gl) + DrawGhost(g, inner, gm, p, gl) + bottom := []rune(strings.Split(g.PlainString(), "\n")[BoardOuterH-2]) + if got := string(bottom[1:3]); got != gl.Block { + t.Fatalf("locked row was overwritten by the ghost: %q", got) + } +} + +func TestActivePieceCellsAboveTheBoardAreClipped(t *testing.T) { + g, outer, inner, p, gl := boardTestGrid() + gm := game.New(1) + gm.Active = game.Piece{Kind: game.KindI, Rotation: 1, X: 4, Y: 0} // spans hidden and visible rows + DrawBoardFrame(g, outer, p, gl, nil) + DrawActive(g, inner, gm, p, gl) + rows := strings.Split(g.PlainString(), "\n") + if got := string([]rune(rows[0])[0]); got != gl.Border[0] { + t.Fatalf("the piece drew over the top border: %q", got) + } + if len(strings.Split(g.PlainString(), "\n")) != BoardOuterH { + t.Fatal("the piece added rows to the grid") + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/render/ -run Board -v` +Expected: FAIL — `undefined: DrawBoardFrame`. + +- [ ] **Step 3: Implement `internal/render/board.go`** + +Draw order inside a frame is the caller's business (§37 fixes it: locked → ghost → active). `DrawGhost` computes `dy = gm.GhostY() - gm.Active.Y`, offsets the active piece's cells by it, and skips any target cell whose board coordinate is already filled or is also occupied by the active piece. Every draw runs through `Grid.Set`, so clipping is automatic; also skip board rows `< game.HiddenRows` explicitly so nothing lands on the border. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . +git add internal/render/board.go internal/render/board_test.go +git commit -m "feat(render): board frame, locked cells, ghost, and active piece" +``` + +--- + +### Task 4: Adaptive layout, chrome ladder, and the too-small notice + +**Files:** +- Create: `internal/render/layout.go` +- Test: `internal/render/layout_test.go` + +**Interfaces:** +- Consumes: `Rect`, board constants (Task 3). +- Produces: + ```go + type Phase int + const (PhaseBoot Phase = iota; PhasePlaying; PhasePaused; PhaseHelp; PhaseGameOver) + + type Tier int + const (TierTooSmall Tier = iota; TierSmall; TierMedium; TierWide) + + const (MinCols = 40; MinRows = 24; SideColW = 12; GapW = 2) + + type Layout struct { + Tier Tier + Width int + Height int + Board Rect // outer, 22x22 + Inner Rect // Board.Inset(1) + Left Rect // zero W when the tier has no left column + Right Rect + Title Rect // zero H when dropped + Mission Rect // zero H when dropped + Controls Rect + Frame bool // draw the outer rounded frame + ShowHold bool + ShowLabels bool + NextCount int // 5 wide, 3 medium and small + } + + func Compute(width, height int) Layout + func DrawTooSmall(g *Grid, width, height int, p *Palette) // §31 notice + ``` + +- [ ] **Step 1: Write the failing test** + +```go +package render + +import ( + "strings" + "testing" +) + +func TestTierByWidth(t *testing.T) { + cases := []struct { + w, h int + tier Tier + }{ + {39, 30, TierTooSmall}, + {50, 23, TierTooSmall}, + {40, 24, TierSmall}, + {43, 24, TierSmall}, + {44, 24, TierMedium}, + {51, 40, TierMedium}, + {52, 40, TierWide}, + {200, 60, TierWide}, + } + for _, c := range cases { + if got := Compute(c.w, c.h).Tier; got != c.tier { + t.Errorf("Compute(%d,%d).Tier = %v, want %v", c.w, c.h, got, c.tier) + } + } +} + +// Review Focus 1: degenerate sizes must not panic or produce negative rects. +func TestComputeSurvivesDegenerateSizes(t *testing.T) { + for _, c := range [][2]int{{0, 0}, {1, 1}, {-4, 10}, {80, 0}} { + l := Compute(c[0], c[1]) + if l.Tier != TierTooSmall { + t.Fatalf("Compute(%d,%d) tier = %v, want TierTooSmall", c[0], c[1], l.Tier) + } + for _, r := range []Rect{l.Board, l.Inner, l.Left, l.Right, l.Title, l.Mission, l.Controls} { + if r.W < 0 || r.H < 0 { + t.Fatalf("Compute(%d,%d) produced a negative rect %+v", c[0], c[1], r) + } + } + } + g := NewGrid(0, 0) + DrawTooSmall(g, 0, 0, NewPalette(ModeFull)) // must not panic +} + +// Review Focus 2: a huge terminal centres fixed-size chrome. +func TestHugeTerminalCentresFixedChrome(t *testing.T) { + l := Compute(300, 120) + if l.Board.W != BoardOuterW || l.Board.H != BoardOuterH { + t.Fatalf("board = %dx%d, want %dx%d — the board never stretches", l.Board.W, l.Board.H, BoardOuterW, BoardOuterH) + } + content := SideColW + GapW + BoardOuterW + GapW + SideColW + if want := (300 - content) / 2; l.Left.X != want { + t.Fatalf("left column X = %d, want %d (centred)", l.Left.X, want) + } + if l.Board.X+l.Board.W > 300 || l.Controls.Y+l.Controls.H > 120 { + t.Fatal("chrome extends past the terminal") + } +} + +func TestChromeLadderByHeight(t *testing.T) { + // §49.3: title border goes first, then mission control, then stats labels. + l27 := Compute(80, 27) + if !l27.Frame || l27.Title.H == 0 || l27.Mission.H == 0 { + t.Fatalf("27 rows should keep frame, title, and mission: %+v", l27) + } + l25 := Compute(80, 25) + if l25.Frame { + t.Fatal("25 rows must drop the outer frame") + } + if l25.Mission.H == 0 || l25.Controls.H == 0 { + t.Fatal("25 rows must keep mission control and controls") + } + l24 := Compute(80, 24) + if l24.Title.H != 0 { + t.Fatal("24 rows must have no title row") + } + if l24.Mission.H == 0 { + t.Fatal("mission control outranks the title (§49.3)") + } + // The ladder is defined below the supported minimum too, so an odd terminal + // size degrades instead of dividing by zero. The app still shows the notice. + l23 := chromeFor(23) + if l23.Mission.H != 0 { + t.Fatal("at 23 rows mission control is the next thing to drop") + } + if l23.Controls.H == 0 { + t.Fatal("controls are one of the last two things standing (§49.3)") + } +} + +func TestSmallTierDropsLabelsAndHoldButKeepsNext(t *testing.T) { + l := Compute(40, 24) + if l.ShowLabels { + t.Fatal("small tier shows stat values without labels (§49.3)") + } + if l.ShowHold { + t.Fatal("small tier drops the HOLD panel (§31 priority order)") + } + if l.NextCount != 3 { + t.Fatalf("NextCount = %d, want 3 at small sizes (§49.3)", l.NextCount) + } + if l.Right.X < l.Board.X+l.Board.W { + t.Fatal("NEXT must sit beside the board, never above or below it (§49.3)") + } + if l.Right.X+l.Right.W > 40 { + t.Fatalf("right column overflows: X=%d W=%d", l.Right.X, l.Right.W) + } +} + +func TestWideTierHasBothColumnsAndFiveNext(t *testing.T) { + l := Compute(80, 30) + if !l.ShowHold || !l.ShowLabels || l.NextCount != 5 { + t.Fatalf("wide tier = %+v, want hold, labels, and 5 next pieces", l) + } + if l.Left.X+l.Left.W > l.Board.X { + t.Fatal("left column overlaps the board") + } + if l.Board.X+l.Board.W > l.Right.X { + t.Fatal("board overlaps the right column") + } +} + +func TestNoRectsOverlapAcrossSizes(t *testing.T) { + for w := MinCols; w <= 120; w += 7 { + for h := MinRows; h <= 60; h += 5 { + l := Compute(w, h) + rects := map[string]Rect{"board": l.Board, "left": l.Left, "right": l.Right, + "title": l.Title, "mission": l.Mission, "controls": l.Controls} + for an, a := range rects { + if a.W == 0 || a.H == 0 { + continue + } + if a.X < 0 || a.Y < 0 || a.X+a.W > w || a.Y+a.H > h { + t.Fatalf("%dx%d: %s %+v is outside the terminal", w, h, an, a) + } + for bn, b := range rects { + if an >= bn || b.W == 0 || b.H == 0 { + continue + } + if a.X < b.X+b.W && b.X < a.X+a.W && a.Y < b.Y+b.H && b.Y < a.Y+a.H { + t.Fatalf("%dx%d: %s %+v overlaps %s %+v", w, h, an, a, bn, b) + } + } + } + } + } +} + +func TestTooSmallNoticeReportsBothSizes(t *testing.T) { + g := NewGrid(34, 19) + DrawTooSmall(g, 34, 19, NewPalette(ModeFull)) + out := g.PlainString() + for _, want := range []string{"THIS UNIVERSE IS TOO SMALL", "resize terminal to continue", "34", "19", "40", "24"} { + if !strings.Contains(out, want) { + t.Errorf("notice is missing %q:\n%s", want, out) + } + } + for _, line := range strings.Split(out, "\n") { + if len([]rune(line)) > 34 { + t.Errorf("notice line overflows 34 columns: %q", line) + } + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/render/ -run 'Tier|Compute|Chrome|Small|Wide|NoRects|TooSmall' -v` +Expected: FAIL — `undefined: Compute`. + +- [ ] **Step 3: Implement `internal/render/layout.go`** + +Pinned tiers (content widths in terminal columns; leftover width is absorbed by centring): + +```text +TierWide cols >= 52 : left(12) gap(2) board(22) gap(2) right(12) = 50, hold + labels + NEXT x5 +TierMedium cols >= 44 : board(22) gap(2) right(12) = 36, hold + labels + NEXT x3 +TierSmall cols >= 40 : board(22) gap(1) right(12) = 35, no hold, values only, NEXT x3 +TierTooSmall : cols < 40 or rows < 24 +``` + +Height ladder — factor it into `chromeFor(rows int) Layout` (unexported, tested directly) so §49.3's order is one readable function: + +```text +rows >= 27 : outer frame (2) + title (1) + board (22) + mission (1) + controls (1) +rows 25-26 : title (1) + board (22) + mission (1) + controls (1) -- frame dropped +rows 24 : board (22) + mission (1) + controls (1) -- title dropped +rows 23 : board (22) + controls (1) -- mission dropped +rows < 23 : board + controls, clipped by the grid -- app shows the notice below 24 +``` + +Vertically centre the block within the available rows. `Compute` returns `Layout{Tier: TierTooSmall}` with all-zero rects when either dimension is below the minimum, and clamps negative inputs to 0 first. + +`DrawTooSmall` centres the four lines from §31 (`THIS UNIVERSE IS TOO SMALL`, blank, `resize terminal to continue`, blank, `current: W × H`, `needed: approximately 40 × 24`) and relies on `Grid.Set` clipping for terminals too narrow even for that. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . +git add internal/render/layout.go internal/render/layout_test.go +git commit -m "feat(render): adaptive layout tiers, chrome ladder, too-small notice" +``` + +--- + +### Task 5: Overlay panels — pause, help, game over + +**Files:** +- Create: `internal/render/overlay.go` +- Test: `internal/render/overlay_test.go` + +**Interfaces:** +- Consumes: `Grid`, `Rect`, `Palette`. +- Produces: + ```go + func DrawPanel(g *Grid, area Rect, title string, lines []string, p *Palette, gl Glyphs) + func DrawPause(g *Grid, area Rect, p *Palette, gl Glyphs) + func DrawHelp(g *Grid, area Rect, body string, p *Palette, gl Glyphs) // body from bubbles help + func DrawGameOver(g *Grid, area Rect, score, lines, level int, p *Palette, gl Glyphs) + ``` + `DrawPanel` centres a rounded box inside `area` and is the single implementation the other three call. + +- [ ] **Step 1: Write the failing test** + +```go +package render + +import ( + "strings" + "testing" +) + +func TestPausePanelText(t *testing.T) { + g := NewGrid(60, 24) + DrawPause(g, Rect{X: 0, Y: 0, W: 60, H: 24}, NewPalette(ModeFull), GlyphsFor(ModeFull)) + out := g.PlainString() + for _, want := range []string{"TEMPORAL SUSPENSION", "SPACE IS PAUSED", "p resume"} { + if !strings.Contains(out, want) { + t.Errorf("pause overlay is missing %q:\n%s", want, out) + } + } +} + +func TestGameOverPanelShowsFinalNumbers(t *testing.T) { + g := NewGrid(60, 24) + DrawGameOver(g, Rect{X: 0, Y: 0, W: 60, H: 24}, 483200, 127, 13, NewPalette(ModeFull), GlyphsFor(ModeFull)) + out := g.PlainString() + for _, want := range []string{"UNIVERSE EXPIRED", "483,200", "127", "13", "REBOOT UNIVERSE", "ACCEPT COSMIC DEATH"} { + if !strings.Contains(out, want) { + t.Errorf("game over panel is missing %q:\n%s", want, out) + } + } +} + +func TestHelpPanelWrapsTheBody(t *testing.T) { + g := NewGrid(60, 24) + DrawHelp(g, Rect{X: 0, Y: 0, W: 60, H: 24}, "← → move\nSPACE YEET", NewPalette(ModeFull), GlyphsFor(ModeFull)) + out := g.PlainString() + if !strings.Contains(out, "FLIGHT MANUAL") || !strings.Contains(out, "SPACE YEET") { + t.Fatalf("help overlay is wrong:\n%s", out) + } +} + +func TestPanelNeverDrawsOutsideItsArea(t *testing.T) { + g := NewGrid(40, 24) + area := Rect{X: 5, Y: 3, W: 20, H: 8} + long := []string{strings.Repeat("X", 200), strings.Repeat("Y", 200)} + DrawPanel(g, area, "A VERY LONG PANEL TITLE THAT DOES NOT FIT", long, NewPalette(ModeFull), GlyphsFor(ModeFull)) + rows := strings.Split(g.PlainString(), "\n") + for y, row := range rows { + for x, r := range []rune(row) { + if r == ' ' { + continue + } + if !area.Contains(x, y) { + t.Fatalf("panel wrote %q at (%d,%d), outside %+v", r, x, y, area) + } + } + } +} + +func TestPanelInATinyAreaDoesNotPanic(t *testing.T) { + g := NewGrid(10, 3) + DrawPanel(g, Rect{X: 0, Y: 0, W: 2, H: 1}, "T", []string{"x"}, NewPalette(ModeFull), GlyphsFor(ModeFull)) + DrawPanel(g, Rect{}, "T", []string{"x"}, NewPalette(ModeFull), GlyphsFor(ModeFull)) +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/render/ -run 'Pause|GameOver|Help|Panel' -v` +Expected: FAIL — `undefined: DrawPause`. + +- [ ] **Step 3: Implement `internal/render/overlay.go`** + +`DrawPanel` sizes the box to the widest line (capped at `area.W`), centres it in `area`, truncates lines that still do not fit, and writes every rune through `Grid.Set` with an explicit `area.Contains` guard so a box larger than its area is clipped rather than leaking. Game-over score formatting uses thousands separators (`483,200`) — a small local `formatThousands(int) string` helper, reused by the HUD in Task 6. Panel copy comes verbatim from §28, §30, §39. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . +git add internal/render/overlay.go internal/render/overlay_test.go +git commit -m "feat(render): pause, help, and game-over overlay panels" +``` + +--- + +### Task 6: HUD — hold, next, stats, mission line, controls + +**Files:** +- Create: `internal/render/hud.go` +- Test: `internal/render/hud_test.go` + +**Interfaces:** +- Consumes: `Grid`, `Layout`, `Palette`, `Glyphs`, `formatThousands` (Task 5); `game.PieceKind`, `game.SpawnPiece`. +- Produces: + ```go + func DrawMiniPiece(g *Grid, at Rect, k game.PieceKind, p *Palette, gl Glyphs) // rotation 0, left-aligned, max 8x2 + func DrawHold(g *Grid, r Rect, held *game.PieceKind, canHold bool, p *Palette, gl Glyphs) + func DrawNext(g *Grid, r Rect, next []game.PieceKind, count int, p *Palette, gl Glyphs) + func DrawStats(g *Grid, r Rect, score, lines, level int, showLabels bool, p *Palette, gl Glyphs) + func DrawTitle(g *Grid, r Rect, seed int64, p *Palette, gl Glyphs) // "✦ COSMIC TETRIS" + "LOCAL UNIVERSE %04X" + func DrawMission(g *Grid, r Rect, msg string, p *Palette, gl Glyphs) // "☄ MISSION CONTROL: msg", truncated with "…" + func DrawControls(g *Grid, r Rect, p *Palette, gl Glyphs) // longest variant that fits + ``` + +- [ ] **Step 1: Write the failing test** + +```go +package render + +import ( + "strings" + "testing" + + "cosmic-tetris/internal/game" +) + +func hudGrid() (*Grid, *Palette, Glyphs) { + return NewGrid(60, 24), NewPalette(ModeFull), GlyphsFor(ModeFull) +} + +func TestMiniPieceFitsItsBox(t *testing.T) { + g, p, gl := hudGrid() + box := Rect{X: 1, Y: 1, W: 8, H: 2} + for _, k := range game.AllKinds { + g2 := NewGrid(60, 24) + DrawMiniPiece(g2, box, k, p, gl) + for y, row := range strings.Split(g2.PlainString(), "\n") { + for x, r := range []rune(row) { + if r != ' ' && !box.Contains(x, y) { + t.Fatalf("%v drew %q at (%d,%d), outside %+v", k, r, x, y, box) + } + } + } + } + DrawMiniPiece(g, box, game.KindO, p, gl) + if !strings.Contains(g.PlainString(), gl.Block) { + t.Fatal("mini piece drew nothing") + } +} + +func TestHoldPanelEmptyAndFilled(t *testing.T) { + g, p, gl := hudGrid() + DrawHold(g, Rect{X: 0, Y: 0, W: SideColW, H: 4}, nil, true, p, gl) + if !strings.Contains(g.PlainString(), "HOLD") { + t.Fatal("HOLD label missing") + } + if strings.Contains(g.PlainString(), gl.Block) { + t.Fatal("an empty hold must not draw a piece") + } + g2, _, _ := hudGrid() + k := game.KindT + DrawHold(g2, Rect{X: 0, Y: 0, W: SideColW, H: 4}, &k, false, p, gl) + if !strings.Contains(g2.PlainString(), gl.Block) { + t.Fatal("a filled hold must draw the piece") + } +} + +func TestNextDrawsExactlyCountPieces(t *testing.T) { + g, p, gl := hudGrid() + next := []game.PieceKind{game.KindI, game.KindO, game.KindT, game.KindS, game.KindZ} + DrawNext(g, Rect{X: 0, Y: 0, W: SideColW, H: 16}, next, 3, p, gl) + out := g.PlainString() + if !strings.Contains(out, "NEXT") { + t.Fatal("NEXT label missing") + } + rowsWithBlocks := 0 + for _, row := range strings.Split(out, "\n") { + if strings.Contains(row, gl.Block) { + rowsWithBlocks++ + } + } + if rowsWithBlocks == 0 || rowsWithBlocks > 3*2 { + t.Fatalf("drew block rows for %d pieces, want at most 3", rowsWithBlocks/2) + } +} + +func TestNextToleratesAShortQueue(t *testing.T) { + g, p, gl := hudGrid() + DrawNext(g, Rect{X: 0, Y: 0, W: SideColW, H: 16}, []game.PieceKind{game.KindI}, 5, p, gl) + if !strings.Contains(g.PlainString(), "NEXT") { + t.Fatal("a short queue must still render the panel") + } +} + +func TestStatsLabelsCanBeDropped(t *testing.T) { + g, p, gl := hudGrid() + DrawStats(g, Rect{X: 0, Y: 0, W: SideColW, H: 9}, 129340, 42, 7, true, p, gl) + out := g.PlainString() + for _, want := range []string{"SCORE", "LINES", "LEVEL", "129,340", "042", "07"} { + if !strings.Contains(out, want) { + t.Errorf("labeled stats missing %q:\n%s", want, out) + } + } + g2, _, _ := hudGrid() + DrawStats(g2, Rect{X: 0, Y: 0, W: SideColW, H: 9}, 129340, 42, 7, false, p, gl) + out2 := g2.PlainString() + if strings.Contains(out2, "LINES") { + t.Error("labels must be gone when showLabels is false (§49.3)") + } + if !strings.Contains(out2, "042") { + t.Errorf("values must remain:\n%s", out2) + } +} + +// Review Focus 3: an eight-digit score must not widen the column. +func TestHugeScoreStaysInsideTheColumn(t *testing.T) { + g, p, gl := hudGrid() + r := Rect{X: 0, Y: 0, W: SideColW, H: 9} + DrawStats(g, r, 999999999, 9999, 99, true, p, gl) + for y, row := range strings.Split(g.PlainString(), "\n") { + for x, ch := range []rune(row) { + if ch != ' ' && !r.Contains(x, y) { + t.Fatalf("stats wrote %q at (%d,%d), outside %+v", ch, x, y, r) + } + } + } +} + +func TestMissionLineTruncatesWithEllipsis(t *testing.T) { + g, p, gl := hudGrid() + r := Rect{X: 0, Y: 0, W: 30, H: 1} + DrawMission(g, r, "WE HAVE EXCEEDED THE RECOMMENDED NUMBER OF BLOCKS", p, gl) + line := strings.Split(g.PlainString(), "\n")[0] + if len([]rune(line)) > 30 { + t.Fatalf("mission line is %d columns, want <= 30: %q", len([]rune(line)), line) + } + if !strings.Contains(line, "MISSION CONTROL") { + t.Fatalf("mission line lost its prefix: %q", line) + } + if !strings.HasSuffix(strings.TrimRight(line, " "), "…") { + t.Fatalf("a truncated mission line must end with an ellipsis: %q", line) + } +} + +func TestControlsFitEveryWidth(t *testing.T) { + p, gl := NewPalette(ModeFull), GlyphsFor(ModeFull) + for w := 20; w <= 80; w++ { + g := NewGrid(w, 1) + DrawControls(g, Rect{X: 0, Y: 0, W: w, H: 1}, p, gl) + line := strings.Split(g.PlainString(), "\n")[0] + if len([]rune(line)) > w { + t.Fatalf("width %d: controls line is %d columns: %q", w, len([]rune(line)), line) + } + if strings.TrimSpace(line) == "" { + t.Fatalf("width %d: controls line is empty", w) + } + } +} + +func TestTitleShowsSeedAsHexUniverse(t *testing.T) { + g, p, gl := hudGrid() + DrawTitle(g, Rect{X: 0, Y: 0, W: 60, H: 1}, 0x7F3A, p, gl) + out := strings.Split(g.PlainString(), "\n")[0] + if !strings.Contains(out, "COSMIC TETRIS") || !strings.Contains(out, "7F3A") { + t.Fatalf("title = %q, want the game name and LOCAL UNIVERSE 7F3A", out) + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/render/ -run 'Mini|Hold|Next|Stats|Mission|Controls|Title' -v` +Expected: FAIL — `undefined: DrawMiniPiece`. + +- [ ] **Step 3: Implement `internal/render/hud.go`** + +Formats, pinned: score `formatThousands` (`129,340`), lines `%03d`, level `%02d`, universe `%04X` of `uint16(seed)`. Controls has three variants, longest-first, and `DrawControls` picks the widest that fits: + +```text +"←→ move ↑ rotate ↓ descend SPACE YEET C hold ? help" +"←→ ↑ ↓ SPACE yeet C hold ? help" +"? help" +``` + +ASCII mode replaces the arrows with `hjkl`; take the arrow glyphs from `Glyphs` rather than hardcoding them. Mini pieces draw the rotation-0 cells of `game.SpawnPiece(k)` normalised to the box's top-left. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . +git add internal/render/hud.go internal/render/hud_test.go +git commit -m "feat(render): hold, next, stats, mission, controls, title" +``` + +--- + +### Task 7: `render.Input` and the frame pipeline + +**Files:** +- Create: `internal/render/render.go` +- Test: `internal/render/render_test.go` + +**Interfaces:** +- Consumes: everything in `internal/render` so far. +- Produces: + ```go + type Sprite struct { + X, Y int // absolute grid coordinates + Glyph rune + Style *lipgloss.Style + } + + type Input struct { + Game *game.Game + Width int + Height int + Mode Mode + Phase Phase + Mission string + HelpBody string + + // Filled by Plan 3; zero values here mean "no effects". + Stars []Sprite // drawn before the board (§37 step 2) + BoardFX []Sprite // composited over the board interior (step 6) + GlobalFX []Sprite // composited over everything (step 9) + Shake Rect // X,Y used as the board offset; W,H ignored + Banner string // step 10 + BorderStyle *lipgloss.Style + BootScreen string // when non-empty and Phase == PhaseBoot, replaces the frame + } + + func RenderGrid(in Input) *Grid // tests read PlainString() + func Render(in Input) string // RenderGrid(in).String() + ``` + The pipeline follows §37 exactly: layout → stars → locked board → ghost → active → board FX → border → hold/next/stats → global FX → banner → mission → controls, with overlays (pause/help/game over) last. + +- [ ] **Step 1: Write the failing test** + +```go +package render + +import ( + "strings" + "testing" + + "cosmic-tetris/internal/game" +) + +func baseInput(w, h int) Input { + return Input{Game: game.New(8675309), Width: w, Height: h, Mode: ModeFull, + Phase: PhasePlaying, Mission: "NOMINALISH"} +} + +func TestRenderGridMatchesTerminalSize(t *testing.T) { + g := RenderGrid(baseInput(80, 30)) + if g.W != 80 || g.H != 30 { + t.Fatalf("grid is %dx%d, want 80x30", g.W, g.H) + } + for _, row := range strings.Split(g.PlainString(), "\n") { + if len([]rune(row)) > 80 { + t.Fatalf("row exceeds the terminal width: %q", row) + } + } +} + +func TestRenderTooSmallShowsTheNotice(t *testing.T) { + out := RenderGrid(baseInput(30, 15)).PlainString() + if !strings.Contains(out, "THIS UNIVERSE IS TOO SMALL") { + t.Fatalf("expected the too-small notice:\n%s", out) + } + if strings.Contains(out, "MISSION CONTROL") { + t.Fatal("the notice replaces the whole frame") + } +} + +func TestRenderIncludesEveryPipelineElement(t *testing.T) { + in := baseInput(80, 30) + out := RenderGrid(in).PlainString() + for _, want := range []string{"COSMIC TETRIS", "HOLD", "NEXT", "SCORE", "LINES", "LEVEL", "MISSION CONTROL", "NOMINALISH", "help"} { + if !strings.Contains(out, want) { + t.Errorf("frame is missing %q:\n%s", want, out) + } + } +} + +func TestRenderDoesNotMutateGameState(t *testing.T) { + in := baseInput(80, 30) + before := *in.Game + Render(in) + after := *in.Game + if before.Board != after.Board || before.Active != after.Active || + before.Score != after.Score || before.GravityAccumulator != after.GravityAccumulator { + t.Fatal("rendering mutated game state (§37)") + } +} + +func TestSpritesAreComposited(t *testing.T) { + in := baseInput(80, 30) + in.GlobalFX = []Sprite{{X: 0, Y: 0, Glyph: '✦'}} + if !strings.Contains(RenderGrid(in).PlainString(), "✦") { + t.Fatal("global FX sprites must be drawn") + } + in.GlobalFX = []Sprite{{X: -5, Y: -5, Glyph: 'Q'}, {X: 5000, Y: 5000, Glyph: 'Q'}} + if strings.Contains(RenderGrid(in).PlainString(), "Q") { + t.Fatal("off-grid sprites must be clipped") + } +} + +func TestBannerAndOverlaysByPhase(t *testing.T) { + in := baseInput(80, 30) + in.Banner = "✦ EVENT HORIZON ✦" + if !strings.Contains(RenderGrid(in).PlainString(), "EVENT HORIZON") { + t.Fatal("banner missing") + } + in.Phase = PhasePaused + if !strings.Contains(RenderGrid(in).PlainString(), "TEMPORAL SUSPENSION") { + t.Fatal("pause overlay missing") + } + in.Phase = PhaseHelp + in.HelpBody = "SPACE YEET" + if !strings.Contains(RenderGrid(in).PlainString(), "FLIGHT MANUAL") { + t.Fatal("help overlay missing") + } + in.Phase = PhaseGameOver + if !strings.Contains(RenderGrid(in).PlainString(), "UNIVERSE EXPIRED") { + t.Fatal("game over panel missing") + } + in.Phase = PhaseBoot + in.BootScreen = "UNIVERSE ONLINE" + if got := RenderGrid(in).PlainString(); !strings.Contains(got, "UNIVERSE ONLINE") { + t.Fatalf("boot screen missing:\n%s", got) + } +} + +func TestShakeOffsetsTheBoardWithoutLeaking(t *testing.T) { + in := baseInput(80, 30) + plain := RenderGrid(in).PlainString() + in.Shake = Rect{X: 1, Y: -1} + shaken := RenderGrid(in) + if shaken.PlainString() == plain { + t.Fatal("a shake offset must change the frame") + } + if shaken.W != 80 || shaken.H != 30 { + t.Fatal("a shake must not resize the grid") + } + for _, row := range strings.Split(shaken.PlainString(), "\n") { + if len([]rune(row)) > 80 { + t.Fatalf("shake pushed a row past the terminal width: %q", row) + } + } +} + +func TestASCIIModeUsesNoBlockGlyphs(t *testing.T) { + in := baseInput(80, 30) + in.Mode = ModeASCII + out := RenderGrid(in).PlainString() + if strings.Contains(out, "██") || strings.Contains(out, "╔") { + t.Fatalf("ASCII mode leaked Unicode drawing glyphs:\n%s", out) + } + if !strings.Contains(out, "[]") { + t.Fatalf("ASCII mode should draw pieces with []:\n%s", out) + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/render/ -run Render -v` +Expected: FAIL — `undefined: RenderGrid`. + +- [ ] **Step 3: Implement `internal/render/render.go`** + +Board-local drawing goes through an offset board rect (`layout.Board` shifted by `in.Shake.X/Y`, then clamped so the shifted rect stays inside the terminal). `BoardFX` sprites shift with the board; `GlobalFX` and `Stars` do not. `PhaseBoot` with a non-empty `BootScreen` renders only the centred boot text. Overlays draw last so nothing covers them. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . +git add internal/render/render.go internal/render/render_test.go +git commit -m "feat(render): frame pipeline over a pure Input snapshot" +``` + +--- + +### Task 8: Bubble Tea app — keys, clock, update, view + +**Files:** +- Create: `internal/app/keys.go` +- Create: `internal/app/messages.go` +- Create: `internal/app/model.go` +- Create: `internal/app/update.go` +- Test: `internal/app/update_test.go` + +**Interfaces:** +- Consumes: the whole engine API and `render.Input` / `render.Render` / `render.Phase`. +- Produces: + ```go + const FrameInterval = 16 * time.Millisecond + const MaxFrameDT = 100 * time.Millisecond // §36 clamp: a stalled process must not lose pieces + + type FrameMsg struct{ Now time.Time } + func frameCmd() tea.Cmd // tea.Tick(FrameInterval, ...) + + type KeyMap struct { + Left, Right, SoftDrop, RotateCW, RotateCCW, HardDrop, Hold, Pause, Restart, Help, Quit key.Binding + } + func DefaultKeyMap() KeyMap + func (k KeyMap) ShortHelp() []key.Binding + func (k KeyMap) FullHelp() [][]key.Binding + + type Options struct { + Seed int64 + Mode render.Mode + FXEnabled bool + ReducedMotion bool + } + + type Model struct { + Game *game.Game + Width, Height int + State render.Phase + LastFrame time.Time + Keys KeyMap + Help help.Model + Opts Options + Mission string + // Plan 3 adds: FX *fx.World + } + + func New(opts Options) Model + func (m Model) Init() tea.Cmd + func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) + func (m Model) View() tea.View + func (m Model) renderInput() render.Input // Plan 3 extends this + ``` + Key bindings (§8): left `left h a`; right `right l d`; soft drop `down j s`; rotate CW `up k x w`; rotate CCW `z`; hard drop `space`; hold `c`; pause `p`; restart `r`; help `?`; quit `q esc ctrl+c`. + +- [ ] **Step 1: Write the failing test** + +```go +package app + +import ( + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "cosmic-tetris/internal/game" + "cosmic-tetris/internal/render" +) + +func testModel() Model { + m := New(Options{Seed: 8675309, Mode: render.ModeFull, FXEnabled: false}) + next, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 30}) + return next.(Model) +} + +func press(t *testing.T, m Model, code rune, text string) Model { + t.Helper() + next, _ := m.Update(tea.KeyPressMsg{Code: code, Text: text}) + return next.(Model) +} + +func TestArrowKeysMoveThePiece(t *testing.T) { + m := testModel() + x := m.Game.Active.X + m = press(t, m, tea.KeyLeft, "") + if m.Game.Active.X != x-1 { + t.Fatalf("X = %d after left, want %d", m.Game.Active.X, x-1) + } + m = press(t, m, 'l', "l") + if m.Game.Active.X != x { + t.Fatalf("X = %d after l, want %d", m.Game.Active.X, x) + } +} + +func TestRotateAndHardDropAndHold(t *testing.T) { + m := testModel() + r := m.Game.Active.Rotation + m = press(t, m, 'x', "x") + if m.Game.Active.Rotation == r { + t.Fatal("x must rotate clockwise") + } + m = press(t, m, 'z', "z") + if m.Game.Active.Rotation != r { + t.Fatal("z must rotate counter-clockwise") + } + m = press(t, m, 'c', "c") + if m.Game.Hold == nil { + t.Fatal("c must hold") + } + before := m.Game.Score + m = press(t, m, tea.KeySpace, " ") + if m.Game.Score <= before { + t.Fatal("space must hard drop and score") + } +} + +func TestSoftDropScores(t *testing.T) { + m := testModel() + m = press(t, m, 'j', "j") + if m.Game.Score != 1 { + t.Fatalf("Score = %d after a soft drop, want 1", m.Game.Score) + } +} + +func TestPauseFreezesGravity(t *testing.T) { + m := testModel() + m = press(t, m, 'p', "p") + if m.State != render.PhasePaused { + t.Fatalf("State = %v, want PhasePaused", m.State) + } + y := m.Game.Active.Y + m.LastFrame = time.Now().Add(-time.Second) + next, _ := m.Update(FrameMsg{Now: time.Now()}) + m = next.(Model) + if m.Game.Active.Y != y { + t.Fatal("gravity must not advance while paused") + } + m = press(t, m, 'p', "p") + if m.State != render.PhasePlaying { + t.Fatal("p must resume") + } +} + +func TestHelpTogglesAndDoesNotPauseInput(t *testing.T) { + m := testModel() + m = press(t, m, '?', "?") + if m.State != render.PhaseHelp { + t.Fatalf("State = %v, want PhaseHelp", m.State) + } + m = press(t, m, '?', "?") + if m.State != render.PhasePlaying { + t.Fatal("? must toggle back") + } +} + +func TestRestartResetsTheGame(t *testing.T) { + m := testModel() + m = press(t, m, tea.KeySpace, " ") + m = press(t, m, 'r', "r") + if m.Game.Score != 0 || m.Game.Lines != 0 { + t.Fatalf("r must restart: %+v", m.Game) + } + if m.State != render.PhasePlaying { + t.Fatalf("State = %v after restart, want PhasePlaying", m.State) + } + if m.Game.Seed != 8675309 { + t.Fatalf("restart changed the seed to %d", m.Game.Seed) + } +} + +func TestQuitReturnsQuitCmd(t *testing.T) { + m := testModel() + _, cmd := m.Update(tea.KeyPressMsg{Code: 'q', Text: "q"}) + if cmd == nil { + t.Fatal("q must return a command") + } + if _, ok := cmd().(tea.QuitMsg); !ok { + t.Fatal("q must return tea.Quit") + } +} + +func TestFrameMsgAdvancesGravity(t *testing.T) { + m := testModel() + y := m.Game.Active.Y + m.LastFrame = time.Now().Add(-game.GravityInterval(1)) + next, cmd := m.Update(FrameMsg{Now: time.Now()}) + m = next.(Model) + if m.Game.Active.Y != y+1 { + t.Fatalf("Y = %d after one gravity interval, want %d", m.Game.Active.Y, y+1) + } + if cmd == nil { + t.Fatal("a FrameMsg must schedule the next frame") + } +} + +// Review Focus 4: a suspend-sized dt is clamped before it reaches the engine. +func TestLongStallIsClamped(t *testing.T) { + m := testModel() + y := m.Game.Active.Y + m.LastFrame = time.Now().Add(-45 * time.Second) + next, _ := m.Update(FrameMsg{Now: time.Now()}) + m = next.(Model) + if m.Game.Active.Y > y+1 { + t.Fatalf("Y jumped from %d to %d after a 45s stall; dt must be clamped to %v", + y, m.Game.Active.Y, MaxFrameDT) + } +} + +func TestGameOverPhaseAndOnlyRQAccepted(t *testing.T) { + m := testModel() + for i := 0; i < 400 && !m.Game.Over(); i++ { + m = press(t, m, tea.KeySpace, " ") + } + if !m.Game.Over() { + t.Fatal("400 hard drops should have ended the game") + } + next, _ := m.Update(FrameMsg{Now: time.Now()}) + m = next.(Model) + if m.State != render.PhaseGameOver { + t.Fatalf("State = %v, want PhaseGameOver", m.State) + } + m = press(t, m, tea.KeyLeft, "") + if m.State != render.PhaseGameOver { + t.Fatal("movement keys must be ignored after game over") + } + m = press(t, m, 'r', "r") + if m.State != render.PhasePlaying || m.Game.Over() { + t.Fatal("r must reboot the universe from the game-over screen") + } +} + +func TestResizeIsLiveAndNeverPanics(t *testing.T) { + m := testModel() + for _, s := range [][2]int{{0, 0}, {1, 1}, {40, 24}, {200, 80}, {39, 23}, {80, 30}} { + next, _ := m.Update(tea.WindowSizeMsg{Width: s[0], Height: s[1]}) + m = next.(Model) + if m.Width != s[0] || m.Height != s[1] { + t.Fatalf("size = %dx%d, want %dx%d", m.Width, m.Height, s[0], s[1]) + } + _ = m.View() // must not panic at any size + } +} + +func TestViewIsAltScreenAndNonEmpty(t *testing.T) { + v := testModel().View() + if !v.AltScreen { + t.Fatal("the game runs in the alternate screen") + } + if v.Content == "" { + t.Fatal("View produced no content") + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go get charm.land/bubbletea/v2@latest charm.land/bubbles/v2@latest && go test ./internal/app/ -v` +Expected: FAIL — `undefined: New`. + +- [ ] **Step 3: Implement the four `internal/app` files** + +`Init` returns `frameCmd()` and sets `LastFrame` lazily on the first `FrameMsg` (a zero `LastFrame` yields `dt = 0`). `Update` on `FrameMsg`: `dt := msg.Now.Sub(m.LastFrame)`, clamp to `MaxFrameDT`, drop negatives, set `LastFrame`, and call `m.Game.Advance(dt)` only when `m.State == render.PhasePlaying`; always return `frameCmd()` so the clock never stops. After advancing, if `m.Game.Over()` set `m.State = render.PhaseGameOver`. Key handling uses `key.Matches(msg, m.Keys.X)`; movement/rotate/drop/hold keys are ignored unless `m.State == render.PhasePlaying`; `p`, `?`, `r`, and quit work in every phase except that `p` does nothing on the game-over screen. `View` builds `renderInput()` and returns `tea.NewView(render.Render(in))` with `AltScreen = true`. `HelpBody` comes from `m.Help.View(m.Keys)` — set `m.Help.ShowAll = true` when `State == PhaseHelp`. Engine events are collected and discarded here; Plan 3 forwards them to the FX world. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./... -count=1` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . +git add go.mod go.sum internal/app/ internal/app/update_test.go +git commit -m "feat(app): Bubble Tea model, key map, single-clock frame loop" +``` + +--- + +### Task 9: Golden layout snapshots and the size sweep + +**Files:** +- Create: `internal/render/golden_test.go` +- Create: `internal/render/testdata/*.golden` (generated) +- Test: as above + +**Interfaces:** +- Consumes: `RenderGrid`, `Render`, `Input` (Task 7). +- Produces: the §41 snapshot suite: `wide`, `medium`, `small`, `pause`, `gameover`, `help`, `ascii`. + +- [ ] **Step 1: Write the failing test** + +```go +package render + +import ( + "flag" + "os" + "path/filepath" + "strings" + "testing" + + "cosmic-tetris/internal/game" + "github.com/charmbracelet/x/ansi" +) + +var update = flag.Bool("update", false, "rewrite the golden files") + +func goldenInput(w, h int) Input { + g := game.New(8675309) + // A fixed, hand-built board so the snapshot never depends on gameplay timing. + for x := 0; x < game.BoardWidth-1; x++ { + g.Board.Cells[game.BoardHeight-1][x] = game.Cell{Filled: true, Kind: game.KindL} + } + for x := 0; x < 4; x++ { + g.Board.Cells[game.BoardHeight-2][x] = game.Cell{Filled: true, Kind: game.KindS} + } + g.Active = game.Piece{Kind: game.KindT, Rotation: 0, X: 4, Y: game.HiddenRows + 3} + held := game.KindI + g.Hold = &held + g.Score, g.Lines, g.Level, g.Combo = 129340, 42, 7, 3 + return Input{Game: g, Width: w, Height: h, Mode: ModeFull, Phase: PhasePlaying, + Mission: "GRAVITY TAX INCREASED"} +} + +func assertGolden(t *testing.T, name, got string) { + t.Helper() + path := filepath.Join("testdata", name+".golden") + if *update { + if err := os.MkdirAll("testdata", 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(got), 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(path) + if err != nil { + t.Fatalf("%v — run: go test ./internal/render/ -update", err) + } + if got != string(want) { + t.Errorf("%s does not match the golden file.\n--- got ---\n%s\n--- want ---\n%s", name, got, want) + } +} + +func TestGoldenLayouts(t *testing.T) { + cases := []struct { + name string + build func() Input + }{ + {"wide", func() Input { return goldenInput(80, 30) }}, + {"medium", func() Input { return goldenInput(48, 26) }}, + {"small", func() Input { return goldenInput(40, 24) }}, + {"pause", func() Input { in := goldenInput(80, 30); in.Phase = PhasePaused; return in }}, + {"gameover", func() Input { + in := goldenInput(80, 30) + in.Phase = PhaseGameOver + return in + }}, + {"help", func() Input { + in := goldenInput(80, 30) + in.Phase = PhaseHelp + in.HelpBody = "← → / h l move spacecraft\nSPACE YEET" + return in + }}, + {"ascii", func() Input { in := goldenInput(80, 30); in.Mode = ModeASCII; return in }}, + {"toosmall", func() Input { return goldenInput(34, 19) }}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + assertGolden(t, c.name, RenderGrid(c.build()).PlainString()) + }) + } +} + +func TestStyledOutputStripsToThePlainFrame(t *testing.T) { + in := goldenInput(80, 30) + if got, want := ansi.Strip(Render(in)), RenderGrid(in).PlainString(); got != want { + t.Errorf("styled output does not strip to the plain frame.\n--- got ---\n%s\n--- want ---\n%s", got, want) + } +} + +// Review Focus 5: every supported size renders inside its bounds, board interior stays clean. +func TestEverySupportedSizeRendersCleanly(t *testing.T) { + glyphs := GlyphsFor(ModeFull) + allowed := map[rune]bool{' ': true} + for _, s := range []string{glyphs.Block, glyphs.Ghost} { + for _, r := range s { + allowed[r] = true + } + } + for w := MinCols; w <= 120; w += 3 { + for h := MinRows; h <= 60; h += 3 { + in := goldenInput(w, h) + g := RenderGrid(in) + if g.W != w || g.H != h { + t.Fatalf("%dx%d: grid is %dx%d", w, h, g.W, g.H) + } + for _, row := range strings.Split(g.PlainString(), "\n") { + if len([]rune(row)) > w { + t.Fatalf("%dx%d: row overflows: %q", w, h, row) + } + } + l := Compute(w, h) + inner := l.Inner + for y := inner.Y; y < inner.Y+inner.H; y++ { + for x := inner.X; x < inner.X+inner.W; x++ { + if r := g.At(x, y).Rune; !allowed[r] { + t.Fatalf("%dx%d: HUD leaked %q into the board interior at (%d,%d)", w, h, r, x, y) + } + } + } + } + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go get github.com/charmbracelet/x/ansi && go test ./internal/render/ -run Golden -v` +Expected: FAIL — missing `testdata/wide.golden`. + +- [ ] **Step 3: Generate the golden files and read them** + +Run: `go test ./internal/render/ -update` + +Then open each file in `internal/render/testdata/` and check it by eye against §4's intent and §41's goals: the board is 22 columns of border-plus-cells, nothing overlaps, the HUD sits beside the board, the mission line and controls are on their own rows. Fix the renderer (not the golden file) if anything is wrong, and regenerate. §49.7 applies: §4's mockup is mood, these files are the contract. + +- [ ] **Step 4: Run the full suite** + +Run: `go test ./... -count=1` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . +git add go.mod go.sum internal/render/golden_test.go internal/render/testdata +git commit -m "test(render): golden layout snapshots and full size sweep" +``` + +--- + +### Task 10: The `cosmic-tetris` binary and its CLI + +**Files:** +- Create: `cmd/cosmic-tetris/main.go` +- Create: `LICENSE` +- Modify: `README.md` +- Test: `cmd/cosmic-tetris/main_test.go` + +**Interfaces:** +- Consumes: `app.New`, `app.Options`, `render.Mode`. +- Produces: + ```go + type config struct { + Seed int64 + ASCII bool + NoFX bool + ReducedMotion bool + } + func parseFlags(args []string, stderr io.Writer) (config, error) // testable; no os.Exit inside + func (c config) options() app.Options // Mode: ASCII -> render.ModeASCII + ``` + Final CLI surface (§49.5): bare, `--seed N`, `--ascii`, `--no-fx`, `--reduced-motion`, `--help`. Nothing else. + +- [ ] **Step 1: Write the failing test** + +```go +package main + +import ( + "io" + "strings" + "testing" + + "cosmic-tetris/internal/render" +) + +func TestParseFlagsDefaults(t *testing.T) { + c, err := parseFlags(nil, io.Discard) + if err != nil { + t.Fatal(err) + } + if c.ASCII || c.NoFX || c.ReducedMotion { + t.Fatalf("defaults should be all-off: %+v", c) + } + if c.Seed == 0 { + t.Fatal("a bare invocation needs a nonzero seed so the universe differs each run") + } + if got := c.options().Mode; got != render.ModeFull { + t.Fatalf("default Mode = %v, want ModeFull", got) + } + if !c.options().FXEnabled { + t.Fatal("FX are on by default") + } +} + +func TestParseFlagsEachFlag(t *testing.T) { + c, err := parseFlags([]string{"--seed", "8675309", "--ascii", "--no-fx", "--reduced-motion"}, io.Discard) + if err != nil { + t.Fatal(err) + } + if c.Seed != 8675309 { + t.Fatalf("Seed = %d, want 8675309", c.Seed) + } + o := c.options() + if o.Mode != render.ModeASCII || o.FXEnabled || !o.ReducedMotion { + t.Fatalf("options = %+v", o) + } +} + +func TestParseFlagsRejectsUnknownFlags(t *testing.T) { + if _, err := parseFlags([]string{"--networking"}, io.Discard); err == nil { + t.Fatal("an unknown flag must be an error") + } +} + +func TestHelpMentionsEveryFlag(t *testing.T) { + var sb strings.Builder + if _, err := parseFlags([]string{"--help"}, &sb); err == nil { + t.Fatal("--help must return an error so main exits without starting the game") + } + for _, want := range []string{"--seed", "--ascii", "--no-fx", "--reduced-motion"} { + if !strings.Contains(sb.String(), want) { + t.Errorf("help text is missing %q:\n%s", want, sb.String()) + } + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./cmd/cosmic-tetris/ -v` +Expected: FAIL — `undefined: parseFlags`. + +- [ ] **Step 3: Implement `cmd/cosmic-tetris/main.go`, `LICENSE`, and README updates** + +`parseFlags` builds a `flag.FlagSet` with `ContinueOnError`, sets `SetOutput(stderr)`, and returns `flag.ErrHelp` for `--help`. A zero `--seed` (or absent flag) becomes `time.Now().UnixNano()` — this is the one clock read in the program that is not the frame loop, and it lives in `main`, never in `internal/game` (§49.2). `main` calls `parseFlags(os.Args[1:], os.Stderr)`, exits 0 on `flag.ErrHelp` and 2 on any other error, then runs `tea.NewProgram(app.New(cfg.options())).Run()`. + +`LICENSE`: MIT, current year, "Jesse Vincent". README: what it is, `go run ./cmd/cosmic-tetris`, the flag table, the control table from §39, and a note that `--no-fx` is still a good game. + +- [ ] **Step 4: Verify the binary builds, plays, and the suite passes** + +Run: `go build ./... && go vet ./... && go test ./... -count=1` +Expected: all pass. + +Then play it: `go run ./cmd/cosmic-tetris --seed 8675309`. Confirm by hand: pieces fall, arrows and `hjkl` move, `x`/`z` rotate, space hard-drops, `c` holds, ghost tracks the landing spot, `p` pauses, `?` shows the manual, `r` restarts, `q` quits cleanly to a restored terminal, and resizing the window re-lays-out live including down through the too-small notice and back. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . +git add cmd/ LICENSE README.md +git commit -m "feat(cmd): cosmic-tetris binary with the full CLI surface" +``` + +--- + +## What Plan 3 plugs into + +- `render.Input.Stars`, `.BoardFX`, `.GlobalFX` — `[]render.Sprite` in absolute grid coordinates; board FX shift with the shake, the others do not. +- `render.Input.Shake` — a `Rect` whose `X`/`Y` offset the board panel, clamped inside the terminal. +- `render.Input.Banner`, `.BootScreen`, `.BorderStyle`, `.Mission`. +- `render.Compute(w, h) Layout` — hand `Layout.Inner` to the FX world so particles know where the board is. +- `app.Model.Update` already collects `[]game.Event` from every engine call; Plan 3 forwards them to `fx.World.Observe`. diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/Q2-reader-recipe-cosmic-32/2026-09-17-cosmic-tetris-3-cosmic-fx.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/Q2-reader-recipe-cosmic-32/2026-09-17-cosmic-tetris-3-cosmic-fx.md new file mode 100644 index 000000000..143234af3 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/Q2-reader-recipe-cosmic-32/2026-09-17-cosmic-tetris-3-cosmic-fx.md @@ -0,0 +1,2416 @@ +# Cosmic Tetris — Plan 3: The Cosmic Effects System + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the terminal lose its mind — starfield, animated border, piece trails, hard-drop impact with screen shake, line-clear supernova, shockwaves, hyperdrive, the four-line spectacle, combo escalation, level-up notices, mission-control commentary, boot sequence, and a game-over black hole — all as an independent simulation that observes game events and can never touch game state. + +**Architecture:** `internal/fx` is a self-contained simulation: `fx.World` owns its own `*rand.Rand`, a particle pool, three star layers, and a handful of timers. It learns about gameplay two ways only — `Observe(events, Snapshot)` and `Advance(dt, Snapshot)` — where `Snapshot` is a by-value copy of the few numbers FX is allowed to know. It emits `[]render.Sprite`, a shake offset, a border style, a banner, and a notice; `internal/app` copies those into `render.Input` each frame. `internal/flavor` holds the mission-control message pool and imports nothing but `internal/game`. + +**Tech Stack:** Go 1.26, `charm.land/lipgloss/v2` (styles only), `math/rand/v2`, `internal/render` for `Sprite`/`Rect`/`Mode`. + +**Spec:** `design.md` (this plan implements §14–§25, §27, §28, §29, §43, §44, §45, and pinned decisions §49.5, §49.6) + +**Depends on:** Plan 1 (`internal/game` events) and Plan 2 (`render.Input` FX fields, `render.Compute`, the app frame loop). + +## Global Constraints + +- **FX may never modify `GameState`** (§14, §44). `fx.World` never receives a `*game.Game` — only `fx.Snapshot`, a by-value struct. Every task's tests may read game state; none may write it through FX. +- **Two independent RNGs** (§35, §49.6). `fx.New(cfg, seed)` builds `rand.New(rand.NewPCG(uint64(seed)^0xDEADBEEFCAFEBABE, 0x2545F4914F6CDD1D))`. The FX generator is never passed to `internal/game`, and no FX code path calls an engine method. +- **No clock reads in `internal/fx`.** Like the engine, the world advances only via `Advance(dt time.Duration, snap Snapshot)`. +- **No goroutine per particle or per frame** (§38). One slice, reused; `MaxParticles = 400` and new emissions are dropped once the pool is full. +- **No filesystem access and no synchronous logging during gameplay** (§38) — nothing in `internal/fx` or `internal/flavor` touches `os` beyond what the compiler needs. +- **Restraint rules are requirements** (§44): never obscure the active piece, never delay gameplay for animation, screen shake never exceeds one cell, particles never permanently alter the board, board readability wins over spectacle. +- **`--no-fx`** (`Config.Enabled == false`): `Advance`/`Observe` return immediately and every sprite accessor returns nil, the shake is zero, and the border style is the static palette border. The boot sequence is skipped. The game must remain fully playable. +- **`--reduced-motion`** (§49.5): suppresses screen shake, hyperdrive acceleration, and shockwaves; leaves color, trails, and particles alone. +- Effects reduce automatically at small terminal sizes (§31): `TierSmall` halves the star budget and skips shockwaves. +- Run `gofmt -l .` before every commit; it must print nothing. Golden files touched by a task are regenerated in that task with `go test ./internal/render/ -update` and the diff read before committing. + +## Review Focus + +Five failure modes the spec implies but no task's own tests would otherwise exercise. Each has a test added to the task that owns the code. + +1. **Sustained four-line clears** must not grow the particle pool without bound — `MaxParticles` is enforced on emission, and a hundred consecutive eruptions must leave the pool at or below the cap. (Task 4) +2. **A `dt` spike** (suspend/resume, a slow frame) must not teleport particles across the screen, produce NaN positions, or leave the shake stuck on — FX clamps its own `dt` per step and sub-steps long frames. (Task 4, shake in Task 6) +3. **Off-grid particle and sprite coordinates** (a burst near the board edge, negative positions after drag) must be clipped at sprite generation, never written outside the grid. (Task 4) +4. **A resize while a shake, banner, or collapse is mid-flight** must recompute against the new viewport and keep drawing inside it. (Task 2) +5. **`--no-fx` and `--reduced-motion`** must genuinely suppress their effects while leaving the game playable and the frame stable, and neither may draw from the game RNG. (Task 2 for the wiring, Task 8 for hyperdrive, Task 6 for shake) + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `internal/fx/world.go` | `Config`, `Snapshot`, `World`, `New`, `Resize`, `Advance`, `Observe`, all sprite/shake/banner accessors. | +| `internal/fx/particle.go` | `Particle`, integration step, emitters (burst, debris, meteor, sparks). | +| `internal/fx/starfield.go` | Three star layers, drift, level scaling, hyperdrive speed, shooting star. | +| `internal/fx/trails.go` | Piece trails and hard-drop ion columns. | +| `internal/fx/impact.go` | Hard-drop shake pattern, border flash, impact burst. | +| `internal/fx/lineclear.go` | The §19 three-phase supernova and its debris. | +| `internal/fx/spectacle.go` | Hyperdrive, shockwaves, four-line sequence, combo escalation, level-up notice. | +| `internal/fx/border.go` | Slow border color cycle and event-driven energy state. | +| `internal/fx/collapse.go` | Boot sequence text and the game-over black hole. | +| `internal/flavor/messages.go` | Mission-control message pool and cadence. | +| `internal/render/render.go` | (modify) the `Notice`/`HUDStyle` inputs and `PhaseCollapse` handling in the pipeline. | +| `internal/render/overlay.go` | (modify) `DrawNotice`, and the game-over card's cause subtitle. | +| `internal/render/hud.go` | (modify) honour `HUDStyle` when it is set. | +| `internal/render/board.go` | (modify) `BoardSprites`, so the app can hand the board to the collapse. | +| `internal/app/model.go`, `update.go` | (modify) own the world, forward events, track the session best, fill the FX fields. | + +--- + +### Task 1: `fx.World` skeleton — config, snapshot, clock, accessors + +**Files:** +- Create: `internal/fx/world.go` +- Test: `internal/fx/world_test.go` + +**Interfaces:** +- Consumes: `render.Sprite`, `render.Rect`, `render.Mode`, `render.Palette`; `game.Event` types. +- Produces: + ```go + type Config struct { + Enabled bool + ReducedMotion bool + Mode render.Mode + Palette *render.Palette + } + + type Snapshot struct { + Level, Combo, Score, Lines int + Active game.Piece + GhostY int + Tier render.Tier + Over bool + } + + type World struct{ /* unexported */ } + + func New(cfg Config, seed int64) *World + func (w *World) Resize(view, board render.Rect) + func (w *World) Advance(dt time.Duration, snap Snapshot) + func (w *World) Observe(evs []game.Event, snap Snapshot) + + func (w *World) Stars() []render.Sprite + func (w *World) BoardFX() []render.Sprite + func (w *World) GlobalFX() []render.Sprite + func (w *World) Shake() render.Rect + func (w *World) BorderStyle() *lipgloss.Style + func (w *World) Banner() string + func (w *World) Mission() string + ``` + `Notice()` and `HUDStyle()` arrive in Task 9 together with the renderer fields they feed; nothing before then needs them. + +- [ ] **Step 1: Write the failing test** + +```go +package fx + +import ( + "slices" + "testing" + "time" + + "cosmic-tetris/internal/game" + "cosmic-tetris/internal/render" +) + +func testConfig(enabled bool) Config { + return Config{Enabled: enabled, Mode: render.ModeFull, Palette: render.NewPalette(render.ModeFull)} +} + +// worldWith is the constructor every later fx test uses: standard 80x30 +// viewport, board interior at 15,3. +func worldWith(t *testing.T, cfg Config, seed int64) *World { + t.Helper() + w := New(cfg, seed) + w.Resize(render.Rect{X: 0, Y: 0, W: 80, H: 30}, render.Rect{X: 15, Y: 3, W: 20, H: 20}) + return w +} + +func testWorld(t *testing.T, enabled bool) *World { + t.Helper() + return worldWith(t, testConfig(enabled), 8675309) +} + +func reducedWorld(t *testing.T) *World { + t.Helper() + cfg := testConfig(true) + cfg.ReducedMotion = true + return worldWith(t, cfg, 8675309) +} + +func testSnapshot() Snapshot { + return Snapshot{Level: 1, Active: game.SpawnPiece(game.KindT), GhostY: 20, Tier: render.TierWide} +} + +func TestDisabledWorldProducesNothing(t *testing.T) { + w := testWorld(t, false) + w.Observe([]game.Event{game.PieceHardDropped{Distance: 18}}, testSnapshot()) + for i := 0; i < 60; i++ { + w.Advance(16*time.Millisecond, testSnapshot()) + } + if len(w.Stars()) != 0 || len(w.BoardFX()) != 0 || len(w.GlobalFX()) != 0 { + t.Fatal("--no-fx must produce no sprites") + } + if got := w.Shake(); got.X != 0 || got.Y != 0 { + t.Fatalf("Shake = %+v with FX disabled, want zero", got) + } + if w.Banner() != "" { + t.Fatal("no banners with FX disabled") + } +} + +func TestWorldIsDeterministicPerSeed(t *testing.T) { + run := func() []render.Sprite { + w := testWorld(t, true) + for i := 0; i < 120; i++ { + w.Observe([]game.Event{game.PieceMoved{DX: 1}}, testSnapshot()) + w.Advance(16*time.Millisecond, testSnapshot()) + } + return append(append([]render.Sprite{}, w.Stars()...), w.GlobalFX()...) + } + a, b := run(), run() + if len(a) != len(b) { + t.Fatalf("same seed produced %d and %d sprites", len(a), len(b)) + } + for i := range a { + if a[i].X != b[i].X || a[i].Y != b[i].Y || a[i].Glyph != b[i].Glyph { + t.Fatalf("sprite %d diverged: %+v vs %+v", i, a[i], b[i]) + } + } +} + +func TestDifferentFXSeedsDiverge(t *testing.T) { + spriteSum := func(seed int64) int { + w := worldWith(t, testConfig(true), seed) + sum := 0 + for i := 0; i < 60; i++ { + w.Advance(16*time.Millisecond, testSnapshot()) + } + for _, s := range w.Stars() { + sum += s.X*31 + s.Y + } + return sum + } + if spriteSum(1) == spriteSum(2) { + t.Fatal("two FX seeds produced identical starfields") + } +} + +func TestAdvanceIgnoresNonPositiveDT(t *testing.T) { + w := testWorld(t, true) + w.Advance(50*time.Millisecond, testSnapshot()) + before := append([]render.Sprite{}, w.Stars()...) + w.Advance(0, testSnapshot()) + w.Advance(-time.Second, testSnapshot()) + after := w.Stars() + for i := range before { + if before[i] != after[i] { + t.Fatal("a non-positive dt must not move anything") + } + } +} + +func TestWorldNeverTouchesTheGame(t *testing.T) { + g := game.New(4242) + w := testWorld(t, true) + evs := g.HardDrop() + snap := Snapshot{Level: g.Level, Combo: g.Combo, Score: g.Score, Active: g.Active, GhostY: g.GhostY()} + + board, active, score, lines, level, combo := g.Board, g.Active, g.Score, g.Lines, g.Level, g.Combo + next := append([]game.PieceKind{}, g.Next...) + + w.Observe(evs, snap) + for i := 0; i < 100; i++ { + w.Advance(16*time.Millisecond, snap) + } + + if g.Board != board || g.Active != active { + t.Fatal("FX mutated the board or the active piece (§14)") + } + if g.Score != score || g.Lines != lines || g.Level != level || g.Combo != combo { + t.Fatal("FX mutated the score state (§14)") + } + if !slices.Equal(g.Next, next) { + t.Fatal("FX drew from the game bag (§35, §49.6)") + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -v` +Expected: FAIL — `undefined: New`. + +- [ ] **Step 3: Implement `internal/fx/world.go`** + +`World` holds `cfg`, `rng`, `view`/`board` rects, and (as later tasks add them) the particle slice, star layers, and timers. `Advance` returns immediately when `!cfg.Enabled` or `dt <= 0`. `Resize` stores the new rects and drops anything whose coordinates no longer make sense (Task 2 tests this). Accessors return `nil` / zero values while their subsystems do not exist yet, so this task compiles and passes on its own. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/fx/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . +git add internal/fx/world.go internal/fx/world_test.go +git commit -m "feat(fx): world skeleton with isolated RNG and read-only snapshot" +``` + +--- + +### Task 2: Wire the world into the app and the renderer + +**Files:** +- Modify: `internal/app/model.go`, `internal/app/update.go` +- Modify: `cmd/cosmic-tetris/main.go` (pass `ReducedMotion` and `Mode` into `fx.Config`) +- Test: `internal/app/fx_test.go` + +**Interfaces:** +- Consumes: `fx.New`, `fx.Config`, `fx.Snapshot`, all `World` accessors (Task 1); `render.Input` FX fields (Plan 2 Task 7). +- Produces: + ```go + // model.go + type Model struct { + // ...existing fields... + FX *fx.World + } + func (m Model) snapshot() fx.Snapshot + ``` + `Update` forwards every `[]game.Event` it collects (from key handling and from `Advance`) to `m.FX.Observe`, calls `m.FX.Advance(dt, snap)` on each `FrameMsg` — even while paused, at a reduced rate, so background stars keep drifting (§30) — and `renderInput()` copies `Stars/BoardFX/GlobalFX/Shake/Banner/BorderStyle/Mission` across. Task 9 adds `Notice`/`HUDStyle` to this list and Task 12 adds `BootScreen`. `tea.WindowSizeMsg` calls `m.FX.Resize(view, layout.Inner)`. + +- [ ] **Step 1: Write the failing test** + +```go +package app + +import ( + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "cosmic-tetris/internal/render" +) + +func fxModel(t *testing.T, fxOn bool) Model { + t.Helper() + m := New(Options{Seed: 8675309, Mode: render.ModeFull, FXEnabled: fxOn}) + next, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 30}) + return next.(Model) +} + +func advance(t *testing.T, m Model, frames int) Model { + t.Helper() + for i := 0; i < frames; i++ { + m.LastFrame = time.Now().Add(-16 * time.Millisecond) + next, _ := m.Update(FrameMsg{Now: time.Now()}) + m = next.(Model) + } + return m +} + +func TestFXSpritesReachTheRenderInput(t *testing.T) { + m := advance(t, fxModel(t, true), 60) + in := m.renderInput() + if len(in.Stars) == 0 { + t.Fatal("the starfield never reached render.Input") + } + if in.Mission == "" { + t.Fatal("mission control never reached render.Input") + } +} + +func TestNoFXModeLeavesInputClean(t *testing.T) { + m := advance(t, fxModel(t, false), 60) + in := m.renderInput() + if len(in.Stars) != 0 || len(in.BoardFX) != 0 || len(in.GlobalFX) != 0 { + t.Fatal("--no-fx must leave the FX fields empty") + } + if in.Shake.X != 0 || in.Shake.Y != 0 { + t.Fatal("--no-fx must not shake") + } + if got := render.RenderGrid(in).PlainString(); got == "" { + t.Fatal("the game must still render with FX off") + } + m = press(t, m, tea.KeySpace, " ") + if m.Game.Score == 0 { + t.Fatal("the game must still be playable with FX off") + } +} + +func TestReducedMotionReachesTheWorld(t *testing.T) { + m := New(Options{Seed: 1, Mode: render.ModeFull, FXEnabled: true, ReducedMotion: true}) + next, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 30}) + m = next.(Model) + m = press(t, m, tea.KeySpace, " ") + m = advance(t, m, 5) + if got := m.renderInput().Shake; got.X != 0 || got.Y != 0 { + t.Fatalf("Shake = %+v with --reduced-motion, want zero (§49.5)", got) + } +} + +// Review Focus 4: resizing mid-effect keeps every sprite inside the new viewport. +func TestResizeDuringEffectsKeepsSpritesInBounds(t *testing.T) { + m := fxModel(t, true) + m = press(t, m, tea.KeySpace, " ") // start an impact + shake + m = advance(t, m, 2) + for _, size := range [][2]int{{40, 24}, {200, 60}, {41, 25}} { + next, _ := m.Update(tea.WindowSizeMsg{Width: size[0], Height: size[1]}) + m = next.(Model) + m = advance(t, m, 3) + in := m.renderInput() + for _, group := range [][]render.Sprite{in.Stars, in.BoardFX, in.GlobalFX} { + for _, s := range group { + if s.X < 0 || s.Y < 0 || s.X >= size[0] || s.Y >= size[1] { + t.Fatalf("%dx%d: sprite %+v is outside the viewport", size[0], size[1], s) + } + } + } + g := render.RenderGrid(in) + if g.W != size[0] || g.H != size[1] { + t.Fatalf("grid is %dx%d, want %dx%d", g.W, g.H, size[0], size[1]) + } + } +} + +func TestPausedStarsKeepDriftingButGameplayFXFreeze(t *testing.T) { + m := advance(t, fxModel(t, true), 30) + m = press(t, m, 'p', "p") + before := m.renderInput().Stars + m = advance(t, m, 30) + after := m.renderInput().Stars + moved := false + for i := range before { + if i < len(after) && before[i] != after[i] { + moved = true + break + } + } + if !moved { + t.Fatal("background stars may keep drifting while paused (§30)") + } + y := m.Game.Active.Y + if m.Game.Active.Y != y { + t.Fatal("gameplay must stay frozen while paused") + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/app/ -run FX -v` +Expected: FAIL — `m.FX undefined`. + +- [ ] **Step 3: Wire it up** + +`app.New` builds `fx.New(fx.Config{Enabled: opts.FXEnabled, ReducedMotion: opts.ReducedMotion, Mode: opts.Mode, Palette: render.NewPalette(opts.Mode)}, opts.Seed)` — the FX world derives its own generator from the same seed, so a `--seed` run is reproducible in both simulations while the two RNGs stay separate (§49.6). While paused, call `Advance` with a scaled-down `dt` (a quarter) and skip `Observe`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./... -count=1` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . +git add internal/app cmd/cosmic-tetris/main.go +git commit -m "feat(app): forward game events to the FX world and composite its sprites" +``` + +--- + +### Task 3: Starfield + +**Files:** +- Create: `internal/fx/starfield.go` +- Modify: `internal/fx/world.go` +- Test: `internal/fx/starfield_test.go` + +**Interfaces:** +- Consumes: `World`, `Snapshot`, `Config` (Task 1). +- Produces: + ```go + type Star struct { + X, Y float64 + Layer int // 0 far, 1 mid, 2 near + Glyph rune + } + const StarLayers = 3 + var LayerSpeed = [StarLayers]float64{1.2, 3.0, 7.0} // rows per second at level 1 + func (w *World) seedStars() // called by Resize, at full density + func (w *World) advanceStars(dt time.Duration, snap Snapshot) + // Stars() converts to sprites, dimmest layer first + ``` + Glyph pools (§15): far `.`, mid `· ˚`, near `✦ ✧ *`; ASCII mode substitutes `. : +`. Density: one star per ~55 terminal cells at `TierWide`/`TierMedium`, halved at `TierSmall`. `Resize` seeds at full density (the tier is not known until a snapshot arrives); `advanceStars` tops up or trims to the tier's budget each frame, so `Stars()` is never empty before the first `Advance`. + +- [ ] **Step 1: Write the failing test** + +```go +package fx + +import ( + "testing" + "time" + + "cosmic-tetris/internal/render" +) + +func TestStarfieldFillsTheViewport(t *testing.T) { + w := testWorld(t, true) + stars := w.Stars() + if len(stars) < 10 { + t.Fatalf("only %d stars in an 80x30 viewport", len(stars)) + } + for _, s := range stars { + if s.X < 0 || s.X >= 80 || s.Y < 0 || s.Y >= 30 { + t.Fatalf("star %+v is outside the viewport", s) + } + } +} + +func TestStarsDriftDownward(t *testing.T) { + w := testWorld(t, true) + before := w.Stars() + for i := 0; i < 60; i++ { + w.Advance(16*time.Millisecond, testSnapshot()) + } + after := w.Stars() + down := 0 + for i := range before { + if i < len(after) && after[i].Y > before[i].Y { + down++ + } + } + if down == 0 { + t.Fatal("stars must drift downward (§15)") + } +} + +func TestStarsWrapInsteadOfLeaking(t *testing.T) { + w := testWorld(t, true) + for i := 0; i < 600; i++ { + w.Advance(16*time.Millisecond, Snapshot{Level: 12, Tier: render.TierWide}) + } + if len(w.Stars()) == 0 { + t.Fatal("stars must recycle, not disappear") + } + for _, s := range w.Stars() { + if s.Y < 0 || s.Y >= 30 || s.X < 0 || s.X >= 80 { + t.Fatalf("star %+v escaped the viewport", s) + } + } +} + +func TestHigherLevelMovesStarsFaster(t *testing.T) { + travel := func(level int) float64 { + w := worldWith(t, testConfig(true), 99) + start := w.rawStars() + for i := 0; i < 30; i++ { + w.Advance(16*time.Millisecond, Snapshot{Level: level, Tier: render.TierWide}) + } + end := w.rawStars() + sum := 0.0 + for i := range start { + sum += end[i].Y - start[i].Y + } + return sum + } + if travel(10) <= travel(1) { + t.Fatal("star velocity must increase with gravity/level (§15)") + } +} + +func TestSmallTierHalvesTheStarBudget(t *testing.T) { + wide := testWorld(t, true) + small := New(testConfig(true), 8675309) + small.Resize(render.Rect{W: 40, H: 24}, render.Rect{X: 1, Y: 1, W: 20, H: 20}) + small.Advance(16*time.Millisecond, Snapshot{Level: 1, Tier: render.TierSmall}) + perCellWide := float64(len(wide.Stars())) / float64(80*30) + perCellSmall := float64(len(small.Stars())) / float64(40*24) + if perCellSmall >= perCellWide { + t.Fatalf("small terminals must reduce star density: %.4f vs %.4f", perCellSmall, perCellWide) + } +} + +func TestASCIIStarsAreASCII(t *testing.T) { + w := worldWith(t, Config{Enabled: true, Mode: render.ModeASCII, Palette: render.NewPalette(render.ModeASCII)}, 3) + for _, s := range w.Stars() { + if s.Glyph > 127 { + t.Fatalf("ASCII mode emitted a non-ASCII star glyph %q", s.Glyph) + } + } +} + +func TestShootingStarEventuallyAppears(t *testing.T) { + w := testWorld(t, true) + seen := false + for i := 0; i < 60*90 && !seen; i++ { // up to ~90 seconds of idle + w.Advance(16*time.Millisecond, testSnapshot()) + if w.shootingStarActive() { + seen = true + } + } + if !seen { + t.Fatal("an idle board should occasionally get a shooting star (§45)") + } +} +``` + +`rawStars()` and `shootingStarActive()` are unexported test seams in the same package — keep them tiny. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -run Star -v` +Expected: FAIL — `undefined: Star`. + +- [ ] **Step 3: Implement `internal/fx/starfield.go`** + +Stars carry float positions; `advanceStars` adds `LayerSpeed[layer] * levelScale * dt` where `levelScale = 1 + 0.06*(level-1)`, capped at 2.5, multiplied by the hyperdrive factor Task 8 introduces. A star past the bottom wraps to `Y = 0` with a fresh random `X` drawn from the FX RNG. Near-layer stars use the brightest style, far stars `Palette.Dim` — never brighter than the board glyphs (§15's readability rule). The shooting star is a short diagonal streak of three sprites with a random cooldown between 20 and 60 seconds. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/fx/ -v && go test ./... -count=1` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . +git add internal/fx/starfield.go internal/fx/world.go internal/fx/starfield_test.go +git commit -m "feat(fx): three-layer starfield with level-scaled drift" +``` + +--- + +### Task 4: Particle simulation and emitters + +**Files:** +- Create: `internal/fx/particle.go` +- Modify: `internal/fx/world.go` +- Test: `internal/fx/particle_test.go` + +**Interfaces:** +- Consumes: `World`, `Config`. +- Produces: + ```go + const ( + MaxParticles = 400 + MaxStepDT = 20 * time.Millisecond // long frames are sub-stepped + ParticleDrag = 0.90 // per 16ms step + ParticleGravity = 26.0 // rows per second squared + ) + + type Particle struct { + X, Y float64 + VX, VY float64 + Life float64 // seconds remaining + MaxLife float64 + Glyph rune + Brightness float64 // 0..1 + Board bool // true: a board-local sprite that shakes with the board + } + + func (w *World) emit(p Particle) bool // false when the pool is full + func (w *World) advanceParticles(dt time.Duration) + func (w *World) emitBurst(x, y float64, count int, speed float64, glyphs []rune, board bool) + func (w *World) emitDebris(x, y float64, vx float64, glyphs []rune, board bool) + func (w *World) particleSprites(board bool) []render.Sprite + ``` + Integration per step (§23): `pos += vel*dt; vel.Y += ParticleGravity*dt; vel *= drag^(dt/16ms); life -= dt`. Particles die at `life <= 0` or outside the viewport. Brightness picks the style ramp: `> 0.66` bright, `> 0.33` mid, else dim. + +- [ ] **Step 1: Write the failing test** + +```go +package fx + +import ( + "math" + "testing" + "time" +) + +func TestParticleMovesDecaysAndDies(t *testing.T) { + w := testWorld(t, true) + w.emit(Particle{X: 40, Y: 10, VX: 4, VY: -2, Life: 0.3, MaxLife: 0.3, Glyph: '✦', Brightness: 1}) + first := w.rawParticles()[0] + w.advanceParticles(32 * time.Millisecond) + p := w.rawParticles()[0] + if p.X <= first.X { + t.Fatalf("X did not advance: %v -> %v", first.X, p.X) + } + if p.Life >= first.Life { + t.Fatal("life must decay") + } + if math.Abs(p.VX) >= math.Abs(first.VX) { + t.Fatal("drag must reduce velocity") + } + for i := 0; i < 40; i++ { + w.advanceParticles(16 * time.Millisecond) + } + if len(w.rawParticles()) != 0 { + t.Fatal("a particle past its lifetime must be reaped") + } +} + +func TestParticlesLeavingTheViewportAreReaped(t *testing.T) { + w := testWorld(t, true) + w.emit(Particle{X: 79, Y: 29, VX: 200, VY: 200, Life: 5, MaxLife: 5, Glyph: '*', Brightness: 1}) + w.advanceParticles(16 * time.Millisecond) + if len(w.rawParticles()) != 0 { + t.Fatal("a particle outside the viewport must be reaped (§23)") + } +} + +// Review Focus 1: the pool is capped no matter how many eruptions happen. +func TestParticlePoolIsCapped(t *testing.T) { + w := testWorld(t, true) + for i := 0; i < 100; i++ { + w.emitBurst(40, 15, 200, 20, []rune{'·', '*', '✦', '+'}, false) + } + if got := len(w.rawParticles()); got > MaxParticles { + t.Fatalf("pool holds %d particles, want at most %d", got, MaxParticles) + } + if !w.emitDropped() { + t.Fatal("emissions past the cap must be dropped, not queued") + } +} + +// Review Focus 2: a dt spike is sub-stepped, never a teleport, never NaN. +func TestDTSpikeDoesNotTeleportOrProduceNaN(t *testing.T) { + w := testWorld(t, true) + w.emit(Particle{X: 40, Y: 15, VX: 30, VY: -30, Life: 10, MaxLife: 10, Glyph: '✦', Brightness: 1}) + w.advanceParticles(5 * time.Second) + for _, p := range w.rawParticles() { + if math.IsNaN(p.X) || math.IsNaN(p.Y) || math.IsInf(p.VX, 0) { + t.Fatalf("particle went non-finite: %+v", p) + } + } + // The particle either died or stayed in the viewport — never both alive and elsewhere. + for _, p := range w.rawParticles() { + if p.X < 0 || p.X >= 80 || p.Y < 0 || p.Y >= 30 { + t.Fatalf("live particle %+v is outside the viewport", p) + } + } +} + +// Review Focus 3: sprite generation clips to the viewport. +func TestParticleSpritesAreClipped(t *testing.T) { + w := testWorld(t, true) + w.emit(Particle{X: -3, Y: -3, Life: 1, MaxLife: 1, Glyph: 'Q', Brightness: 1}) + w.emit(Particle{X: 500, Y: 500, Life: 1, MaxLife: 1, Glyph: 'Q', Brightness: 1}) + for _, s := range w.particleSprites(false) { + if s.X < 0 || s.Y < 0 || s.X >= 80 || s.Y >= 30 { + t.Fatalf("sprite %+v escaped the viewport", s) + } + } +} + +func TestBurstSpreadsInEveryDirection(t *testing.T) { + w := testWorld(t, true) + w.emitBurst(40, 15, 40, 15, []rune{'·', '*'}, false) + var left, right, up, down bool + for _, p := range w.rawParticles() { + left = left || p.VX < -1 + right = right || p.VX > 1 + up = up || p.VY < -1 + down = down || p.VY > 1 + } + if !left || !right || !up || !down { + t.Fatal("a radial burst must throw particles in every direction (§23)") + } +} + +func TestDebrisInheritsHorizontalVelocity(t *testing.T) { + w := testWorld(t, true) + w.emitDebris(40, 15, -8, []rune{'·'}, true) + w.emitDebris(40, 15, 8, []rune{'·'}, true) + ps := w.rawParticles() + if len(ps) < 2 || ps[0].VX >= 0 || ps[1].VX <= 0 { + t.Fatalf("debris must inherit the supplied horizontal velocity: %+v", ps) + } +} + +func TestBoardAndGlobalParticlesAreSeparated(t *testing.T) { + w := testWorld(t, true) + w.emit(Particle{X: 20, Y: 10, Life: 1, MaxLife: 1, Glyph: 'b', Brightness: 1, Board: true}) + w.emit(Particle{X: 21, Y: 10, Life: 1, MaxLife: 1, Glyph: 'g', Brightness: 1}) + if len(w.particleSprites(true)) != 1 || len(w.particleSprites(false)) != 1 { + t.Fatal("board-local and global particles must be reported separately") + } +} +``` + +`rawParticles()` and `emitDropped()` are unexported test seams. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -run Particle -v` +Expected: FAIL — `undefined: Particle`. + +- [ ] **Step 3: Implement `internal/fx/particle.go`** + +`advanceParticles` splits `dt` into steps of at most `MaxStepDT` and integrates each; reaping compacts the slice in place (`particles = particles[:n]`) so the backing array is reused (§38). `emitBurst` draws angles from the FX RNG spread evenly over the circle with random jitter, halving `VY` magnitude relative to `VX` so the ellipse reads correctly in a terminal's 2:1 cells. Wire `advanceParticles` and both sprite accessors into `World.Advance`, `BoardFX`, and `GlobalFX`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/fx/ -v && go test ./... -count=1` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . +git add internal/fx/particle.go internal/fx/world.go internal/fx/particle_test.go +git commit -m "feat(fx): capped particle simulation with radial and debris emitters" +``` + +--- + +### Task 5: Piece trails + +**Files:** +- Create: `internal/fx/trails.go` +- Modify: `internal/fx/world.go` +- Test: `internal/fx/trails_test.go` + +**Interfaces:** +- Consumes: `World`, `game.PieceMoved`, `game.PieceRotated`, `game.PieceHardDropped`. +- Produces: + ```go + const TrailLife = 140 * time.Millisecond // §17: ~100-160ms + var TrailGlyphs = [3]rune{'▓', '▒', '░'} // ASCII: '#', '=', '.'; each board cell is two columns, + // so one trail cell emits two sprites with the same rune + + type trail struct { + Cells []game.Point // board cells (X 0..BoardWidth-1, Y 0..BoardHeight-1) + Age time.Duration + Kind game.PieceKind + } + func (w *World) recordTrail(p game.Piece) + func (w *World) advanceTrails(dt time.Duration) + func (w *World) trailSprites() []render.Sprite // board-local, terminal coordinates + func (w *World) trailCells() []game.Point // test seam: live trail cells, board coordinates + ``` + +- [ ] **Step 1: Write the failing test** + +```go +package fx + +import ( + "testing" + "time" + + "cosmic-tetris/internal/game" +) + +func TestMovingLeavesATrailThatFades(t *testing.T) { + w := testWorld(t, true) + snap := testSnapshot() + w.Observe([]game.Event{game.PieceMoved{Piece: snap.Active, DX: 1}}, snap) + if len(w.trailSprites()) == 0 { + t.Fatal("moving must leave a trail (§17)") + } + glyphs := map[rune]bool{} + for i := 0; i < 6; i++ { + w.Advance(16*time.Millisecond, snap) + for _, s := range w.trailSprites() { + glyphs[s.Glyph] = true + } + } + if len(glyphs) < 2 { + t.Fatalf("a trail must fade through several glyphs, saw %v", glyphs) + } +} + +func TestTrailsExpire(t *testing.T) { + w := testWorld(t, true) + snap := testSnapshot() + w.Observe([]game.Event{game.PieceMoved{Piece: snap.Active, DX: -1}}, snap) + for i := 0; i < 20; i++ { + w.Advance(16*time.Millisecond, snap) + } + if len(w.trailSprites()) != 0 { + t.Fatalf("trails must not outlive %v", TrailLife) + } +} + +func TestRotationAlsoLeavesATrail(t *testing.T) { + w := testWorld(t, true) + snap := testSnapshot() + w.Observe([]game.Event{game.PieceRotated{Piece: snap.Active, Delta: 1}}, snap) + if len(w.trailSprites()) == 0 { + t.Fatal("rotation must leave a trail") + } +} + +func TestHardDropLeavesAStrongerVerticalTrail(t *testing.T) { + w := testWorld(t, true) + snap := testSnapshot() + w.Observe([]game.Event{game.PieceMoved{Piece: snap.Active, DX: 1}}, snap) + side := len(w.trailSprites()) + w2 := testWorld(t, true) + dropped := game.Piece{Kind: game.KindI, Rotation: 1, X: 4, Y: 19} + w2.Observe([]game.Event{game.PieceHardDropped{Piece: dropped, Distance: 17}}, snap) + if len(w2.trailSprites()) <= side { + t.Fatal("a hard drop must draw a stronger vertical trail than a sideways move (§17, §18.1)") + } +} + +func TestTrailSpritesStayInsideTheBoard(t *testing.T) { + w := testWorld(t, true) + snap := testSnapshot() + w.Observe([]game.Event{game.PieceHardDropped{Piece: game.Piece{Kind: game.KindI, Rotation: 0, X: 0, Y: 21}, Distance: 21}}, snap) + board := w.boardRect() + for _, s := range w.trailSprites() { + if !board.Contains(s.X, s.Y) { + t.Fatalf("trail sprite %+v is outside the board interior %+v", s, board) + } + } +} + +func TestTrailsNeverCoverTheActivePiece(t *testing.T) { + w := testWorld(t, true) + snap := testSnapshot() + snap.Active = game.Piece{Kind: game.KindT, Rotation: 0, X: 4, Y: 10} + w.Observe([]game.Event{game.PieceMoved{Piece: snap.Active, DX: 1}}, snap) + w.Advance(16*time.Millisecond, snap) + occupied := map[[2]int]bool{} + for _, c := range snap.Active.Cells() { + occupied[[2]int{c.X, c.Y}] = true + } + for _, cell := range w.trailCells() { + if occupied[[2]int{cell.X, cell.Y}] { + t.Fatalf("a trail covered the active piece at %+v (§44)", cell) + } + } +} +``` + +`boardRect()` returns the board interior in terminal coordinates — an unexported test seam alongside `trailCells()`. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -run Trail -v` +Expected: FAIL — `undefined: TrailLife`. + +- [ ] **Step 3: Implement `internal/fx/trails.go`** + +Record the piece's cells at its **previous** position (from the event's `Piece` plus the inverse of `DX`/`DY`), age each trail, pick the glyph by `Age / (TrailLife/3)`, and drop any cell currently occupied by `snap.Active` before emitting sprites (§44's "never obscure the active piece"). A hard drop records one trail per row crossed, so the column reads as a streak. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/fx/ -v && go test ./... -count=1` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . +git add internal/fx/trails.go internal/fx/world.go internal/fx/trails_test.go +git commit -m "feat(fx): short-lived ion trails for moves, rotations, and hard drops" +``` + +--- + +### Task 6: Hard-drop impact — shake, border flash, debris + +**Files:** +- Create: `internal/fx/impact.go` +- Modify: `internal/fx/world.go` +- Test: `internal/fx/impact_test.go` + +**Interfaces:** +- Consumes: `World`, `emitBurst` (Task 4), `game.PieceHardDropped`, `game.PieceLocked`. +- Produces: + ```go + const ( + ShakeDuration = 80 * time.Millisecond + ShakeStep = 16 * time.Millisecond + BorderFlash = 120 * time.Millisecond + ) + var ShakePattern = [5]render.Rect{{X: 0, Y: 1}, {X: -1, Y: 0}, {X: 1, Y: 0}, {X: 0, Y: -1}, {}} // §18.3 + + func (w *World) startShake() + func (w *World) advanceImpact(dt time.Duration) + // Shake() returns ShakePattern[step] while a shake runs, zero otherwise; always zero + // when ReducedMotion or !Enabled. + ``` + Impact particles use the §18.2 glyph set `· * ✦ +` (ASCII: `. * + x`). + +- [ ] **Step 1: Write the failing test** + +```go +package fx + +import ( + "testing" + "time" + + "cosmic-tetris/internal/game" +) + +func hardDropEvents() []game.Event { + p := game.Piece{Kind: game.KindI, Rotation: 1, X: 4, Y: 18} + return []game.Event{game.PieceHardDropped{Piece: p, Distance: 17}, game.PieceLocked{Piece: p}} +} + +func TestHardDropShakesForAboutEightyMilliseconds(t *testing.T) { + w := testWorld(t, true) + w.Observe(hardDropEvents(), testSnapshot()) + if got := w.Shake(); got.X == 0 && got.Y == 0 { + t.Fatal("the shake must start immediately on impact") + } + elapsed := time.Duration(0) + for elapsed < ShakeDuration+ShakeStep { + w.Advance(ShakeStep, testSnapshot()) + elapsed += ShakeStep + } + if got := w.Shake(); got.X != 0 || got.Y != 0 { + t.Fatalf("Shake = %+v after %v, want zero", got, elapsed) + } +} + +func TestShakeNeverExceedsOneCell(t *testing.T) { + w := testWorld(t, true) + w.Observe(hardDropEvents(), testSnapshot()) + for i := 0; i < 20; i++ { + s := w.Shake() + if s.X < -1 || s.X > 1 || s.Y < -1 || s.Y > 1 { + t.Fatalf("Shake = %+v, must never exceed one cell (§44)", s) + } + w.Advance(8*time.Millisecond, testSnapshot()) + } +} + +func TestShakeFollowsTheDeterministicPattern(t *testing.T) { + w := testWorld(t, true) + w.Observe(hardDropEvents(), testSnapshot()) + for i, want := range ShakePattern { + if got := w.Shake(); got != want { + t.Fatalf("step %d: Shake = %+v, want %+v (§18.3)", i, got, want) + } + w.Advance(ShakeStep, testSnapshot()) + } +} + +// Review Focus 5: reduced motion removes the shake and nothing else. +func TestReducedMotionSuppressesShakeButKeepsParticles(t *testing.T) { + w := reducedWorld(t) + w.Observe(hardDropEvents(), testSnapshot()) + if got := w.Shake(); got.X != 0 || got.Y != 0 { + t.Fatalf("Shake = %+v with reduced motion, want zero (§49.5)", got) + } + if len(w.rawParticles()) == 0 { + t.Fatal("reduced motion keeps particles (§49.5)") + } +} + +// Review Focus 2 (shake half): a dt spike must not leave the shake stuck on. +func TestShakeClearsAfterADTSpike(t *testing.T) { + w := testWorld(t, true) + w.Observe(hardDropEvents(), testSnapshot()) + w.Advance(9*time.Second, testSnapshot()) + if got := w.Shake(); got.X != 0 || got.Y != 0 { + t.Fatalf("Shake = %+v after a 9s frame, want zero", got) + } +} + +func TestImpactEmitsDebrisAtTheContactArea(t *testing.T) { + w := testWorld(t, true) + w.Observe(hardDropEvents(), testSnapshot()) + ps := w.rawParticles() + if len(ps) == 0 { + t.Fatal("impact must emit debris (§18.2)") + } + board := w.boardRect() + near := false + for _, p := range ps { + if p.Board && p.Y > float64(board.Y+board.H/2) { + near = true + } + } + if !near { + t.Fatal("debris must come from the contact area near the landing row") + } +} + +func TestImpactFlashesTheBorder(t *testing.T) { + w := testWorld(t, true) + calm := w.BorderStyle() + w.Observe(hardDropEvents(), testSnapshot()) + if w.BorderStyle() == calm { + t.Fatal("impact must brighten the border (§18.4)") + } + for i := 0; i < 20; i++ { + w.Advance(16*time.Millisecond, testSnapshot()) + } + if w.borderFlashing() { + t.Fatalf("the border flash must decay within %v", BorderFlash) + } +} + +func TestSoftLandingDoesNotShake(t *testing.T) { + w := testWorld(t, true) + w.Observe([]game.Event{game.PieceLocked{Piece: game.SpawnPiece(game.KindO)}}, testSnapshot()) + if got := w.Shake(); got.X != 0 || got.Y != 0 { + t.Fatal("only hard drops shake the screen (§18)") + } +} +``` + +`borderFlashing()` is an unexported test seam. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -run 'Shake|Impact|Reduced|Soft' -v` +Expected: FAIL — `undefined: ShakeDuration`. + +- [ ] **Step 3: Implement `internal/fx/impact.go`** + +Track `shakeElapsed time.Duration`; the pattern index is `min(int(shakeElapsed/ShakeStep), len(ShakePattern)-1)` and the shake ends once `shakeElapsed >= ShakeDuration`, which makes a huge `dt` end it rather than freeze it. `PieceHardDropped` with `Distance == 0` still locks but should not shake the screen for a piece that fell nowhere — gate the shake on `Distance > 0`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/fx/ -v && go test ./... -count=1` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . +git add internal/fx/impact.go internal/fx/world.go internal/fx/impact_test.go +git commit -m "feat(fx): hard-drop impact with bounded shake, debris, and border flash" +``` + +--- + +### Task 7: Line-clear supernova + +**Files:** +- Create: `internal/fx/lineclear.go` +- Modify: `internal/fx/world.go` +- Test: `internal/fx/lineclear_test.go` + +**Interfaces:** +- Consumes: `game.LinesCleared` (with `Rows` and `Kinds`), `emitDebris` (Task 4). +- Produces: + ```go + const ( + ClearTotal = 220 * time.Millisecond + ClearPhaseA = 70 * time.Millisecond // critical mass + ClearPhaseB = 150 * time.Millisecond // supernova, center outward + // ClearPhaseC runs from ClearPhaseB to ClearTotal: collapse into debris + ) + type clearFX struct { + Rows []int + Kinds [][game.BoardWidth]game.PieceKind + Age time.Duration + } + func (w *World) startClear(ev game.LinesCleared) + func (w *World) advanceClears(dt time.Duration) + func (w *World) clearSprites() []render.Sprite // board-local, at the pre-collapse row positions + ``` + Glyph ramp by phase and distance from the row center (§19), one rune per column: phase A `▓` and `█`, phase B `░ ▓ █ ✦`, phase C nothing (the debris particles carry it). ASCII: `= # [ *`. + +- [ ] **Step 1: Write the failing test** + +```go +package fx + +import ( + "testing" + "time" + + "cosmic-tetris/internal/game" +) + +func clearEvent(rows ...int) game.LinesCleared { + ev := game.LinesCleared{Rows: rows, Count: len(rows), Score: 100} + for range rows { + var kinds [game.BoardWidth]game.PieceKind + for x := range kinds { + kinds[x] = game.KindI + } + ev.Kinds = append(ev.Kinds, kinds) + } + return ev +} + +func TestClearAnimationRunsForAboutTwoTwentyMilliseconds(t *testing.T) { + w := testWorld(t, true) + w.Observe([]game.Event{clearEvent(game.BoardHeight - 1)}, testSnapshot()) + if len(w.clearSprites()) == 0 { + t.Fatal("a clear must animate (§19)") + } + elapsed := time.Duration(0) + for elapsed < ClearTotal { + w.Advance(16*time.Millisecond, testSnapshot()) + elapsed += 16 * time.Millisecond + } + w.Advance(32*time.Millisecond, testSnapshot()) + if len(w.clearSprites()) != 0 { + t.Fatalf("the clear animation must finish by %v", ClearTotal) + } +} + +func TestClearPhasesUseDifferentGlyphs(t *testing.T) { + w := testWorld(t, true) + w.Observe([]game.Event{clearEvent(game.BoardHeight - 1)}, testSnapshot()) + phaseGlyphs := func() map[rune]bool { + m := map[rune]bool{} + for _, s := range w.clearSprites() { + m[s.Glyph] = true + } + return m + } + a := phaseGlyphs() + w.Advance(ClearPhaseA+8*time.Millisecond, testSnapshot()) + b := phaseGlyphs() + if len(a) == 0 || len(b) == 0 { + t.Fatal("both phases must draw something") + } + same := true + for g := range a { + if !b[g] { + same = false + } + } + if same && len(a) == len(b) { + t.Fatal("phase B must look different from phase A (§19)") + } +} + +func TestSupernovaSpreadsFromTheCenterOutward(t *testing.T) { + w := testWorld(t, true) + w.Observe([]game.Event{clearEvent(game.BoardHeight - 1)}, testSnapshot()) + w.Advance(ClearPhaseA+4*time.Millisecond, testSnapshot()) + early := w.brightClearColumns() + w.Advance(40*time.Millisecond, testSnapshot()) + late := w.brightClearColumns() + if spread(early) >= spread(late) { + t.Fatalf("the explosion must widen over time: %v then %v", early, late) + } +} + +func TestCollapsePhaseEmitsDebrisWithOutwardVelocity(t *testing.T) { + w := testWorld(t, true) + w.Observe([]game.Event{clearEvent(game.BoardHeight - 1)}, testSnapshot()) + w.Advance(ClearPhaseB+8*time.Millisecond, testSnapshot()) + ps := w.rawParticles() + if len(ps) == 0 { + t.Fatal("phase C must fragment the row into debris (§19)") + } + var left, right bool + for _, p := range ps { + if p.VX < 0 { + left = true + } + if p.VX > 0 { + right = true + } + } + if !left || !right { + t.Fatal("debris must inherit horizontal velocity from its position relative to center (§19)") + } +} + +func TestClearSpritesStayInsideTheBoard(t *testing.T) { + w := testWorld(t, true) + w.Observe([]game.Event{clearEvent(game.BoardHeight-1, game.BoardHeight-2, game.BoardHeight-3, game.BoardHeight-4)}, testSnapshot()) + board := w.boardRect() + for i := 0; i < 14; i++ { + for _, s := range w.clearSprites() { + if !board.Contains(s.X, s.Y) { + t.Fatalf("clear sprite %+v escaped the board %+v", s, board) + } + } + w.Advance(16*time.Millisecond, testSnapshot()) + } +} + +func TestClearAnimationDoesNotDelayAnything(t *testing.T) { + // The engine has already collapsed the rows; FX only overlays. Two clears + // back to back must both animate rather than queue. + w := testWorld(t, true) + w.Observe([]game.Event{clearEvent(game.BoardHeight - 1)}, testSnapshot()) + w.Advance(32*time.Millisecond, testSnapshot()) + w.Observe([]game.Event{clearEvent(game.BoardHeight - 2)}, testSnapshot()) + if n := w.activeClears(); n != 2 { + t.Fatalf("activeClears = %d, want 2 concurrent animations", n) + } +} + +func spread(cols []int) int { + if len(cols) == 0 { + return 0 + } + lo, hi := cols[0], cols[0] + for _, c := range cols { + lo, hi = min(lo, c), max(hi, c) + } + return hi - lo +} +``` + +Test seams: `brightClearColumns() []int` (board columns at peak brightness this frame) and `activeClears() int`. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -run Clear -v` +Expected: FAIL — `undefined: ClearTotal`. + +- [ ] **Step 3: Implement `internal/fx/lineclear.go`** + +Sprites are drawn at the row's **pre-collapse** board position — the engine has already collapsed the board, and §19 explicitly allows the render to lag. Phase B's brightness front is `front = float64(game.BoardWidth/2) * (Age-ClearPhaseA)/(ClearPhaseB-ClearPhaseA)`; a column is at peak brightness when `|x - center| <= front` and dimmer beyond it. Phase C emits debris once per clear (guard with a bool) using `vx = (float64(x) - center) * 3.5` so outer fragments fly outward faster. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/fx/ -v && go test ./... -count=1` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . +git add internal/fx/lineclear.go internal/fx/world.go internal/fx/lineclear_test.go +git commit -m "feat(fx): three-phase line-clear supernova with outward debris" +``` + +--- + +### Task 8: Hyperdrive + +**Files:** +- Create: `internal/fx/spectacle.go` +- Modify: `internal/fx/world.go`, `internal/fx/starfield.go`, `internal/app/model.go`, `internal/app/update.go` +- Test: `internal/fx/hyperdrive_test.go`, `internal/app/highscore_test.go` + +**Interfaces:** +- Consumes: star advance (Task 3), `game.LinesCleared`, `game.ComboChanged`. +- Produces: + ```go + const HyperTotal = 1100 * time.Millisecond + const HyperPeak = 8.0 + // §16 timeline: 0 pause, 50 stretch, 100 accelerate, 500 peak, 800 decay, 1100 normal + func (w *World) startHyperdrive() + func (w *World) hyperSpeed() float64 // 0 at the pause, 1 when idle, up to HyperPeak at peak + func (w *World) NewHighScore(score int) + + // internal/app: Model gains Best int — the highest score of this session, which + // survives restarts. Nothing in the design persists a high score to disk (§26 has + // no best-score field), so "new high score" means beating the session best. + ``` + Triggers (§16): a four-line clear, a combo of 4 or more, and a new high score. Suppressed entirely when `ReducedMotion` — the starfield keeps its normal speed. + +- [ ] **Step 1: Write the failing test** + +```go +package fx + +import ( + "testing" + "time" + + "cosmic-tetris/internal/game" +) + +func TestFourLineClearTriggersHyperdrive(t *testing.T) { + w := testWorld(t, true) + w.Observe([]game.Event{clearEvent(18, 19, 20, 21)}, testSnapshot()) + if !w.hyperActive() { + t.Fatal("a four-line clear must trigger hyperdrive (§16)") + } +} + +func TestBigComboTriggersHyperdrive(t *testing.T) { + w := testWorld(t, true) + w.Observe([]game.Event{game.ComboChanged{Combo: 4}}, testSnapshot()) + if !w.hyperActive() { + t.Fatal("a large combo must trigger hyperdrive (§16)") + } +} + +func TestNewHighScoreTriggersHyperdrive(t *testing.T) { + w := testWorld(t, true) + w.NewHighScore(1000) + if !w.hyperActive() { + t.Fatal("a new high score must trigger hyperdrive (§16)") + } +} + +func TestHyperdriveFollowsTheTimeline(t *testing.T) { + at := func(d time.Duration) float64 { + w := testWorld(t, true) + w.Observe([]game.Event{clearEvent(18, 19, 20, 21)}, testSnapshot()) + w.Advance(d, testSnapshot()) + return w.hyperSpeed() + } + if s := at(20 * time.Millisecond); s > 0.2 { + t.Fatalf("stars must pause at the start: speed %v", s) + } + if at(600*time.Millisecond) <= at(150*time.Millisecond) { + t.Fatal("stars must accelerate toward peak speed by 500ms") + } + if s := at(600 * time.Millisecond); s < HyperPeak*0.8 { + t.Fatalf("peak speed = %v, want near %v", s, HyperPeak) + } + if at(1000*time.Millisecond) >= at(600*time.Millisecond) { + t.Fatal("stars must decay after 800ms") + } + if got := at(HyperTotal + 50*time.Millisecond); got < 0.9 || got > 1.1 { + t.Fatalf("speed must return to normal after %v, got %v", HyperTotal, got) + } +} + +func TestHyperdriveActuallyMovesStarsFaster(t *testing.T) { + travel := func(hyper bool) float64 { + w := worldWith(t, testConfig(true), 77) + if hyper { + w.startHyperdrive() + w.Advance(500*time.Millisecond, testSnapshot()) // reach peak + } + start := w.rawStars() + sum := 0.0 + w.Advance(48*time.Millisecond, testSnapshot()) + end := w.rawStars() + for i := range start { + sum += end[i].Y - start[i].Y + } + return sum + } + if travel(true) <= travel(false) { + t.Fatal("hyperdrive must visibly accelerate the starfield") + } +} + +// Review Focus 5: reduced motion cancels the acceleration. +func TestReducedMotionSuppressesHyperdrive(t *testing.T) { + w := reducedWorld(t) + w.Observe([]game.Event{clearEvent(18, 19, 20, 21)}, testSnapshot()) + w.Advance(500*time.Millisecond, testSnapshot()) + if got := w.hyperSpeed(); got < 0.9 || got > 1.1 { + t.Fatalf("hyperSpeed = %v with reduced motion, want 1 (§49.5)", got) + } +} + +func TestRetriggerRestartsRatherThanStacks(t *testing.T) { + w := testWorld(t, true) + w.startHyperdrive() + w.Advance(700*time.Millisecond, testSnapshot()) + w.startHyperdrive() + if s := w.hyperSpeed(); s > 0.2 { + t.Fatalf("a retrigger restarts the timeline at the pause: %v", s) + } +} +``` + +`hyperActive()` is an unexported test seam. + +And the app side, `internal/app/highscore_test.go`: + +```go +package app + +import ( + "testing" + + tea "charm.land/bubbletea/v2" +) + +func TestSessionBestTracksAcrossRestarts(t *testing.T) { + m := fxModel(t, true) + for i := 0; i < 6; i++ { + m = press(t, m, tea.KeySpace, " ") + } + scored := m.Game.Score + if scored == 0 { + t.Fatal("hard drops must score something") + } + if m.Best != scored { + t.Fatalf("Best = %d, want %d", m.Best, scored) + } + m = press(t, m, 'r', "r") + if m.Best != scored { + t.Fatalf("Best = %d after restart, want the session best %d kept", m.Best, scored) + } + if m.Game.Score != 0 { + t.Fatal("a restart resets the current score") + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -run Hyper -v; go test ./internal/app/ -run SessionBest -v` +Expected: FAIL — `undefined: HyperTotal`, `m.Best undefined`. + +- [ ] **Step 3: Implement hyperdrive in `internal/fx/spectacle.go`** + +`hyperSpeed` is a piecewise interpolation over `hyperElapsed`: `[0,50)` → 0; `[50,100)` → 0.5; `[100,500)` → linear 1 → `HyperPeak`; `[500,800)` → `HyperPeak`; `[800,1100)` → linear `HyperPeak` → 1; otherwise 1. Return 1 when `ReducedMotion`. Multiply it into `advanceStars`, and during the `[50,100)` stretch window swap the near-layer glyph for `|` so the streak reads as motion blur. + +In `internal/app`: after every state change, if `m.Game.Score > m.Best` set `m.Best = m.Game.Score` and call `m.FX.NewHighScore(m.Best)` — but only once the session has had a score to beat, so the very first point of the session does not fire hyperdrive. A restart clears the game and keeps `Best`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/fx/ -v && go test ./... -count=1` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . +git add internal/fx/spectacle.go internal/fx/starfield.go internal/fx/world.go internal/fx/hyperdrive_test.go internal/app +git commit -m "feat(fx): hyperdrive timeline driving the starfield" +``` + +--- + +### Task 9: The four-line spectacle, shockwaves, combo escalation, level-up notice + +**Files:** +- Modify: `internal/fx/spectacle.go`, `internal/fx/world.go` +- Modify: `internal/render/render.go` (the two `Input` fields plus their pipeline steps), `internal/render/overlay.go` (`DrawNotice`), `internal/render/hud.go` (honour `HUDStyle`) +- Modify: `internal/app/model.go` (`renderInput` copies the two new fields) +- Modify: `internal/render/testdata/*.golden` (regenerate — new fields are zero in the existing cases, so ideally no diff) +- Test: `internal/fx/spectacle_test.go`, `internal/render/notice_test.go` + +**Interfaces:** +- Consumes: `emitBurst`, `startHyperdrive`, `startShake`. +- Produces: + ```go + // internal/render + type Notice struct { + Title, Subtitle string + Offset int // rows to shift the box upward as it slides away + } + // Input gains: Notice Notice; HUDStyle *lipgloss.Style — drawn after the HUD, + // before the overlays; app.renderInput copies them from World.Notice/HUDStyle. + func DrawNotice(g *Grid, area Rect, n Notice, p *Palette, gl Glyphs) + + // internal/fx + const ( + BannerDuration = 700 * time.Millisecond // §20 + NoticeDuration = 1400 * time.Millisecond // §22 slide + fade + ShockwaveLife = 300 * time.Millisecond // §24 + ) + var TetrisBanners = []string{ + "✦ EVENT HORIZON ✦", + "QUADRUPLE COSMIC INCIDENT", + "FOUR ROWS HAVE LEFT THE CHAT", + "SPACE-TIME HAS FILED A COMPLAINT", + } + var LevelSubtitles = []string{ + "GRAVITY TAX INCREASED", + "LOCAL PHYSICS UPDATED WITHOUT CONSENT", + "PLEASE SECURE ALL LOOSE TETROMINOES", + } + func (w *World) startShockwave(cx, cy float64) + func (w *World) Notice() render.Notice // zero value when nothing is showing + func (w *World) HUDStyle() *lipgloss.Style // pulsing from combo 4 (§21), nil otherwise + ``` + Combo escalation (§21): combo 2 → a small spark burst; combo 3 → meteor particles (fast, shallow angle); combo 4+ → HUD pulse; combo 5+ → the mission channel escalates (Task 11 owns the copy). + +- [ ] **Step 1: Write the failing test** + +```go +package fx + +import ( + "strings" + "testing" + "time" + + "cosmic-tetris/internal/game" + "cosmic-tetris/internal/render" +) + +func TestFourLineClearFiresEverything(t *testing.T) { + w := testWorld(t, true) + w.Observe([]game.Event{clearEvent(18, 19, 20, 21)}, testSnapshot()) + if !w.hyperActive() { + t.Error("four-line clear must trigger hyperdrive (§20)") + } + if s := w.Shake(); s.X == 0 && s.Y == 0 { + t.Error("four-line clear must shake the screen (§20)") + } + if w.Banner() == "" { + t.Error("four-line clear must show a banner (§20)") + } + if len(w.rawParticles()) == 0 { + t.Error("four-line clear must erupt particles (§20)") + } + if !w.shockwaveActive() { + t.Error("four-line clear must emit a shockwave (§20, §24)") + } + if w.starDensityBoost() <= 1 { + t.Error("four-line clear must temporarily increase star density (§20)") + } + if w.HUDStyle() == nil { + t.Error("four-line clear must flash the HUD (§20)") + } +} + +func TestBannerTextIsFromTheSpecAndExpires(t *testing.T) { + w := testWorld(t, true) + w.Observe([]game.Event{clearEvent(18, 19, 20, 21)}, testSnapshot()) + found := false + for _, b := range TetrisBanners { + if w.Banner() == b { + found = true + } + } + if !found { + t.Fatalf("Banner = %q, want one of the §20 banners", w.Banner()) + } + w.Advance(BannerDuration+50*time.Millisecond, testSnapshot()) + if w.Banner() != "" { + t.Fatalf("the banner must clear after %v", BannerDuration) + } +} + +func TestSingleClearDoesNotBanner(t *testing.T) { + w := testWorld(t, true) + w.Observe([]game.Event{clearEvent(21)}, testSnapshot()) + if w.Banner() != "" { + t.Fatalf("a single clear must not banner, got %q", w.Banner()) + } +} + +func TestShockwaveExpandsAndExpires(t *testing.T) { + w := testWorld(t, true) + w.startShockwave(40, 15) + first := len(w.GlobalFX()) + w.Advance(120*time.Millisecond, testSnapshot()) + if len(w.GlobalFX()) <= first { + t.Fatal("a shockwave ring must expand (§24)") + } + w.Advance(ShockwaveLife, testSnapshot()) + if w.shockwaveActive() { + t.Fatalf("a shockwave must expire within %v", ShockwaveLife) + } +} + +func TestShockwaveSuppressedByReducedMotionAndSmallTerminals(t *testing.T) { + w := reducedWorld(t) + w.Observe([]game.Event{clearEvent(18, 19, 20, 21)}, testSnapshot()) + if w.shockwaveActive() { + t.Fatal("reduced motion suppresses shockwaves (§49.5)") + } + small := testWorld(t, true) + snap := testSnapshot() + snap.Tier = render.TierSmall + small.Observe([]game.Event{clearEvent(18, 19, 20, 21)}, snap) + if small.shockwaveActive() { + t.Fatal("small terminals skip shockwaves (§31)") + } +} + +func TestComboEscalation(t *testing.T) { + steps := []struct { + combo int + check func(*World) bool + what string + }{ + {2, func(w *World) bool { return len(w.rawParticles()) > 0 }, "sparks at combo 2"}, + {3, func(w *World) bool { return w.meteorCount() > 0 }, "meteors at combo 3"}, + {4, func(w *World) bool { return w.HUDStyle() != nil }, "HUD pulse at combo 4"}, + } + for _, s := range steps { + w := testWorld(t, true) + w.Observe([]game.Event{game.ComboChanged{Combo: s.combo}}, testSnapshot()) + if !s.check(w) { + t.Errorf("missing %s (§21)", s.what) + } + } + w := testWorld(t, true) + w.Observe([]game.Event{game.ComboChanged{Combo: 0}}, testSnapshot()) + if w.HUDStyle() != nil { + t.Error("a reset combo must stop the HUD pulse") + } +} + +func TestLevelUpNoticeSlidesAndClears(t *testing.T) { + w := testWorld(t, true) + w.Observe([]game.Event{game.LevelChanged{Level: 8}}, testSnapshot()) + n := w.Notice() + if !strings.Contains(n.Title, "GRAVITY ANOMALY DETECTED") { + t.Fatalf("Notice.Title = %q, want the §22 headline", n.Title) + } + if !strings.Contains(n.Subtitle, "LEVEL 08") && !containsAny(n.Subtitle, LevelSubtitles) { + t.Fatalf("Notice.Subtitle = %q, want the level or a §22 subtitle", n.Subtitle) + } + start := w.Notice().Offset + w.Advance(NoticeDuration/2, testSnapshot()) + if w.Notice().Offset == start { + t.Fatal("the notice must slide as it leaves (§22)") + } + w.Advance(NoticeDuration, testSnapshot()) + if w.Notice().Title != "" { + t.Fatalf("the notice must clear after %v", NoticeDuration) + } +} + +func containsAny(s string, opts []string) bool { + for _, o := range opts { + if strings.Contains(s, o) { + return true + } + } + return false +} +``` + +And in `internal/render/notice_test.go`: + +```go +package render + +import ( + "strings" + "testing" +) + +func TestDrawNoticeBoxAndOffset(t *testing.T) { + g := NewGrid(60, 24) + area := Rect{X: 0, Y: 0, W: 60, H: 24} + DrawNotice(g, area, Notice{Title: "GRAVITY ANOMALY DETECTED", Subtitle: "LEVEL 08"}, NewPalette(ModeFull), GlyphsFor(ModeFull)) + out := g.PlainString() + if !strings.Contains(out, "GRAVITY ANOMALY DETECTED") || !strings.Contains(out, "LEVEL 08") { + t.Fatalf("notice missing its text:\n%s", out) + } + g2 := NewGrid(60, 24) + DrawNotice(g2, area, Notice{Title: "T", Subtitle: "S", Offset: 3}, NewPalette(ModeFull), GlyphsFor(ModeFull)) + rowOf := func(g *Grid, s string) int { + for y, row := range strings.Split(g.PlainString(), "\n") { + if strings.Contains(row, s) { + return y + } + } + return -1 + } + g3 := NewGrid(60, 24) + DrawNotice(g3, area, Notice{Title: "T", Subtitle: "S"}, NewPalette(ModeFull), GlyphsFor(ModeFull)) + if rowOf(g2, "T") >= rowOf(g3, "T") { + t.Fatal("a positive Offset must move the notice upward") + } +} + +func TestNoticeAndHUDStyleFlowThroughRender(t *testing.T) { + in := baseInput(80, 30) + in.Notice = Notice{Title: "GRAVITY ANOMALY DETECTED", Subtitle: "LEVEL 08"} + if !strings.Contains(RenderGrid(in).PlainString(), "GRAVITY ANOMALY") { + t.Fatal("Input.Notice must render") + } + st := NewPalette(ModeFull).Banner + in.HUDStyle = &st + if RenderGrid(in).PlainString() == "" { + t.Fatal("HUDStyle must not break the frame") + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -run 'FourLine|Banner|Shock|Combo|Notice' -v; go test ./internal/render/ -run Notice -v` +Expected: FAIL — `undefined: TetrisBanners`, `undefined: DrawNotice`. + +- [ ] **Step 3: Implement the spectacle and the two renderer additions** + +Shockwave rings use the §24 glyph progression `· ○ ◌ ◯` with radius growing over `ShockwaveLife`, sampled around an ellipse with `x` scaled ×2 for cell aspect; skip when `ReducedMotion` or `snap.Tier == render.TierSmall`. The star density boost multiplies the layer budget by 1.6 for 900ms. `HUDStyle` returns a bright pulsing style while combo ≥ 4 or a four-line flash is live, and nil otherwise; `DrawStats`/`DrawMission` use it in place of `Palette.Value`/`Palette.Mission` when non-nil. + +`DrawNotice` draws the §22 rounded box centred horizontally in `area` at row `area.Y + 4`, shifted up by `Offset`, clipped by `Grid.Set` if it runs off the top. The banner text comes from `TetrisBanners` via the FX RNG; the notice subtitle is `LEVEL %02d` most of the time and a random `LevelSubtitles` entry roughly one time in three. `Offset` grows from 0 to 2 over the second half of `NoticeDuration` so the box slides away (§22). + +- [ ] **Step 4: Run the tests, regenerate goldens, and read the diff** + +Run: `go test ./... -count=1` then `go test ./internal/render/ -update && git diff --stat internal/render/testdata` +Expected: tests PASS; the golden diff is empty (the new fields are zero in every existing case). If it is not empty, look at why before committing. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . +git add internal/fx internal/render +git commit -m "feat(fx): four-line spectacle, shockwaves, combo escalation, level-up notice" +``` + +--- + +### Task 10: Animated board border + +**Files:** +- Create: `internal/fx/border.go` +- Modify: `internal/fx/world.go` +- Test: `internal/fx/border_test.go` + +**Interfaces:** +- Consumes: `Config.Palette`, event observation. +- Produces: + ```go + var BorderCycle = []string{"#6D28D9", "#22E4F7", "#D946EF", "#3B6BFF", "#F8FAFC"} // §25 palette + const BorderCycleTime = 12 * time.Second // slow and subtle + func (w *World) advanceBorder(dt time.Duration, snap Snapshot) + // BorderStyle() blends the cycle position; energy events (clear, level up, tetris) + // speed the cycle up briefly; the hard-drop flash from Task 6 still wins. + ``` + ASCII/Reduced mode: pick the nearest cycle entry rather than blending. + +- [ ] **Step 1: Write the failing test** + +```go +package fx + +import ( + "testing" + "time" + + "cosmic-tetris/internal/game" + "cosmic-tetris/internal/render" +) + +func TestBorderColorShiftsOverTime(t *testing.T) { + w := testWorld(t, true) + first := w.borderColorHex() + w.Advance(BorderCycleTime/4, testSnapshot()) + if w.borderColorHex() == first { + t.Fatal("the border color must shift over time (§25)") + } +} + +func TestBorderCycleIsSlowUnderNormalPlay(t *testing.T) { + w := testWorld(t, true) + first := w.borderPhase() + w.Advance(100*time.Millisecond, testSnapshot()) + if delta := w.borderPhase() - first; delta > 0.05 { + t.Fatalf("the border moved %.3f of its cycle in 100ms; §25 wants subtle", delta) + } +} + +func TestMajorEventAcceleratesTheBorder(t *testing.T) { + calm := testWorld(t, true) + calm.Advance(200*time.Millisecond, testSnapshot()) + + hot := testWorld(t, true) + hot.Observe([]game.Event{clearEvent(18, 19, 20, 21)}, testSnapshot()) + hot.Advance(200*time.Millisecond, testSnapshot()) + + if hot.borderPhase() <= calm.borderPhase() { + t.Fatal("a major event must run the gradient around the border rapidly (§25)") + } +} + +func TestBorderReturnsToCalm(t *testing.T) { + w := testWorld(t, true) + w.Observe([]game.Event{clearEvent(18, 19, 20, 21)}, testSnapshot()) + w.Advance(3*time.Second, testSnapshot()) + before := w.borderPhase() + w.Advance(100*time.Millisecond, testSnapshot()) + if delta := w.borderPhase() - before; delta > 0.05 { + t.Fatalf("the border must settle back to a slow cycle, moved %.3f", delta) + } +} + +func TestDisabledFXKeepsTheStaticPaletteBorder(t *testing.T) { + w := testWorld(t, false) + w.Advance(5*time.Second, testSnapshot()) + if w.BorderStyle() != nil { + t.Fatal("--no-fx must leave the border to the palette (nil override)") + } +} + +func TestASCIIBorderUsesNoHexColor(t *testing.T) { + w := worldWith(t, Config{Enabled: true, Mode: render.ModeASCII, Palette: render.NewPalette(render.ModeASCII)}, 2) + w.Advance(time.Second, testSnapshot()) + if hex := w.borderColorHex(); len(hex) > 0 && hex[0] == '#' { + t.Fatalf("ASCII mode must not emit a truecolor border: %s", hex) + } +} +``` + +`borderPhase()` and `borderColorHex()` are unexported test seams. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -run Border -v` +Expected: FAIL — `undefined: BorderCycle`. + +- [ ] **Step 3: Implement `internal/fx/border.go`** + +`borderPhase` advances by `dt / BorderCycleTime * energy`, where `energy` decays from ~14 back to 1 over about a second after a major event. Blend between adjacent `BorderCycle` entries with `lipgloss`'s color blending (or a manual RGB lerp) in Full mode; snap to the nearest entry in Reduced and ASCII. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/fx/ -v && go test ./... -count=1` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . +git add internal/fx/border.go internal/fx/world.go internal/fx/border_test.go +git commit -m "feat(fx): board border as the game's energy state indicator" +``` + +--- + +### Task 11: Mission control + +**Files:** +- Create: `internal/flavor/messages.go` +- Modify: `internal/fx/world.go` +- Test: `internal/flavor/messages_test.go` + +**Interfaces:** +- Consumes: `game.Event` types only — `internal/flavor` must not import `internal/fx` or `internal/render`. +- Produces: + ```go + const ( + MinDwell = 2500 * time.Millisecond // §27: give messages time to breathe + IdleHint = 20 * time.Second // §45: "CAPTAIN?" before the first move + ) + + type Channel struct{ /* rng, current, dwell, lastScore, sawFirstMove */ } + func NewChannel(rng *rand.Rand) *Channel + func (c *Channel) Observe(evs []game.Event, level, combo, score int) + func (c *Channel) Advance(dt time.Duration) + func (c *Channel) Current() string + ``` + The channel keeps the previous `score` so it can spot a rollover (§45) without the caller diffing for it. + Pools, verbatim from the spec: the idle lines of §27 (`NOMINALISH`, `GRAVITY REMAINS MOSTLY LEGAL`, `STRUCTURAL VIBES: QUESTIONABLE`, `LOCAL UNIVERSE STABLE*`, `* DEFINITION OF STABLE UNDER REVIEW`, `MOON NOTIFIED`, `ORBITAL OSHA HAS ENTERED THE CHAT`, `WE HAVE EXCEEDED THE RECOMMENDED NUMBER OF BLOCKS`, `PHYSICS TEAM SAYS KEEP GOING`); the combo lines of §21 (`COMBO 5 // UNAUTHORIZED ORBITAL MANEUVER`, `COMBO 6 // STRUCTURAL REALITY FAILURE`, `COMBO 7 // NASA DENIES EVERYTHING`); and the §45 rarities (`DID YOU KNOW YOU'RE IN A TERMINAL?`, `NUMBER BECAME BIGGER`, `KINETIC ROD DEPLOYED` for a vertically hard-dropped `I`, `CUBE ADJACENT OBJECT SECURED` for holding an `O`, `CAPTAIN?` after a long idle). + +- [ ] **Step 1: Write the failing test** + +```go +package flavor + +import ( + "math/rand/v2" + "strings" + "testing" + "time" + + "cosmic-tetris/internal/game" +) + +func newChannelForTest() *Channel { + return NewChannel(rand.New(rand.NewPCG(7, 11))) +} + +func TestChannelStartsWithSomething(t *testing.T) { + if got := newChannelForTest().Current(); got == "" { + t.Fatal("the channel must always have a line to show") + } +} + +func TestMessagesGetTimeToBreathe(t *testing.T) { + c := newChannelForTest() + c.Observe([]game.Event{game.LinesCleared{Count: 1, Rows: []int{21}}}, 1, 1, 100) + first := c.Current() + c.Advance(200 * time.Millisecond) + c.Observe([]game.Event{game.PieceLocked{}}, 1, 0, 100) + if c.Current() != first { + t.Fatalf("a message must hold for %v before being replaced (§27)", MinDwell) + } + c.Advance(MinDwell) + c.Observe([]game.Event{game.LevelChanged{Level: 3}}, 3, 0, 100) + if c.Current() == first { + t.Fatal("after the dwell time a new event must be able to speak") + } +} + +func TestBigComboSpeaksTheComboLine(t *testing.T) { + c := newChannelForTest() + c.Advance(MinDwell) + c.Observe([]game.Event{game.ComboChanged{Combo: 5}}, 4, 5, 5000) + if !strings.Contains(c.Current(), "COMBO 5") { + t.Fatalf("Current = %q, want the §21 combo 5 line", c.Current()) + } +} + +func TestKineticRodOnVerticalI(t *testing.T) { + c := newChannelForTest() + c.Advance(MinDwell) + c.Observe([]game.Event{game.PieceHardDropped{Piece: game.Piece{Kind: game.KindI, Rotation: 1}, Distance: 12}}, 1, 0, 0) + if !strings.Contains(c.Current(), "KINETIC ROD DEPLOYED") { + t.Fatalf("Current = %q, want KINETIC ROD DEPLOYED (§45)", c.Current()) + } +} + +func TestCubeAdjacentOnHoldingAnO(t *testing.T) { + c := newChannelForTest() + c.Advance(MinDwell) + c.Observe([]game.Event{game.HoldUsed{Stored: game.KindO, Incoming: game.KindT}}, 1, 0, 0) + if !strings.Contains(c.Current(), "CUBE ADJACENT OBJECT SECURED") { + t.Fatalf("Current = %q, want CUBE ADJACENT OBJECT SECURED (§45)", c.Current()) + } +} + +func TestScoreRolloverSaysNumberBecameBigger(t *testing.T) { + c := newChannelForTest() + c.Observe(nil, 1, 0, 940) + c.Advance(MinDwell) + // crossing a power-of-ten boundary in the running score + c.Observe([]game.Event{game.LinesCleared{Count: 1, Rows: []int{21}, Score: 240}}, 1, 1, 1180) + if !strings.Contains(c.Current(), "NUMBER BECAME BIGGER") { + t.Fatalf("Current = %q, want NUMBER BECAME BIGGER on rollover (§45)", c.Current()) + } +} + +func TestLongIdleAsksForTheCaptain(t *testing.T) { + c := newChannelForTest() + c.Advance(IdleHint + time.Second) + if !strings.Contains(c.Current(), "CAPTAIN?") { + t.Fatalf("Current = %q, want CAPTAIN? after %v of no input (§45)", c.Current(), IdleHint) + } + c2 := newChannelForTest() + c2.Observe([]game.Event{game.PieceMoved{DX: 1}}, 1, 0, 0) + c2.Advance(IdleHint + time.Second) + if strings.Contains(c2.Current(), "CAPTAIN?") { + t.Fatal("the idle hint is only for a player who has not moved yet (§45)") + } +} + +func TestIdleRotationIsNotConstant(t *testing.T) { + c := newChannelForTest() + changes := 0 + last := c.Current() + for i := 0; i < 40; i++ { // 40 seconds of nothing happening + c.Advance(time.Second) + if c.Current() != last { + changes++ + last = c.Current() + } + } + if changes == 0 { + t.Fatal("the channel should occasionally offer a new idle line") + } + if changes > 14 { + t.Fatalf("changed %d times in 40s — §27 says do not rotate constantly", changes) + } +} + +func TestGameOverSpeaksTheCause(t *testing.T) { + c := newChannelForTest() + c.Advance(MinDwell) + c.Observe([]game.Event{game.GameOver{Score: 100, Lines: 4, Level: 1}}, 1, 0, 100) + if c.Current() != "CAUSE: EXCESSIVE GEOMETRY" { + t.Fatalf("Current = %q, want the §28 cause line", c.Current()) + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/flavor/ -v` +Expected: FAIL — `undefined: NewChannel`. + +- [ ] **Step 3: Implement `internal/flavor/messages.go` and hook it into the world** + +Priority order inside `Observe`: game over > combo ≥ 5 > level change > the §45 specials > line clear > lock. A candidate replaces the current line only when `dwell >= MinDwell`. Idle rotation picks a new line from the idle pool on a randomised 6–12 second timer. `GameOver` always yields the §28 cause line `CAUSE: EXCESSIVE GEOMETRY`, which Task 12 renders as the game-over card's subtitle. A score "rollover" is the running score crossing a power-of-ten boundary (940 → 1180 crosses 1000); `DID YOU KNOW YOU'RE IN A TERMINAL?` is extremely rare — roughly one idle rotation in fifty (§45). + +In `internal/fx/world.go`: hold a `*flavor.Channel` built from the FX RNG, feed it in `Observe`/`Advance`, and return `c.Current()` from `World.Mission()`. With FX disabled, `Mission()` returns `NOMINALISH` so the HUD row is never empty in `--no-fx`, and `CAUSE: EXCESSIVE GEOMETRY` once `snap.Over` — the game-over card's subtitle should read the same with effects off. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./... -count=1` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . +git add internal/flavor internal/fx/world.go +git commit -m "feat(flavor): mission control channel with cadence and rare lines" +``` + +--- + +### Task 12: Boot sequence and the game-over black hole + +**Files:** +- Create: `internal/fx/collapse.go` +- Modify: `internal/fx/world.go`, `internal/app/update.go`, `internal/app/model.go`, `internal/render/layout.go` (add `PhaseCollapse`), `internal/render/render.go`, `internal/render/board.go` (add `BoardSprites`), `internal/render/overlay.go` (the card's cause subtitle) +- Test: `internal/fx/collapse_test.go`, `internal/app/phases_test.go` + +**Interfaces:** +- Produces: + ```go + // internal/render + const PhaseCollapse Phase = ... // appended to the enum; the board's own cells are not + // drawn in this phase — only FX sprites. + // The app needs the board as sprites to hand to the collapse: + func BoardSprites(g *game.Game, inner Rect, p *Palette, gl Glyphs) []Sprite + + // internal/fx + const ( + BootDuration = 1000 * time.Millisecond // §29: about one second of drama + CollapseFreeze = 300 * time.Millisecond // §28 + CollapseInfall = 900 * time.Millisecond + CollapseTotal = 1300 * time.Millisecond + ) + func (w *World) BootScreen() string // progressive §29 checklist; "" once done + func (w *World) BootDone() bool + func (w *World) SkipBoot() + func (w *World) StartCollapse(snap Snapshot, cells []render.Sprite) // cells: the board as it stood + func (w *World) CollapseDone() bool + func (w *World) bootFinalLine() string // test seam: the last line revealed so far + ``` + `app` starts in `render.PhaseBoot` when FX are enabled (skipped entirely with `--no-fx`), copies `w.BootScreen()` into `Input.BootScreen`, moves to `PhasePlaying` on `BootDone()` or any keypress (`SkipBoot`), and on a `game.GameOver` event enters `PhaseCollapse`, handing the world a snapshot of the currently drawn board cells; when `CollapseDone()` it moves to `PhaseGameOver` and shows the §28 card. + +- [ ] **Step 1: Write the failing test** + +```go +package fx + +import ( + "strings" + "testing" + "time" + + "cosmic-tetris/internal/render" +) + +func TestBootSequenceProgressesAndFinishes(t *testing.T) { + w := testWorld(t, true) + first := w.BootScreen() + if !strings.Contains(first, "C O S M I C") { + t.Fatalf("boot screen = %q, want the §29 title", first) + } + w.Advance(BootDuration/2, testSnapshot()) + mid := w.BootScreen() + if mid == first { + t.Fatal("the boot checklist must fill in over time (§29)") + } + if !strings.Contains(mid, "gravity") { + t.Fatalf("boot screen = %q, want the gravity check line", mid) + } + w.Advance(BootDuration, testSnapshot()) + if !w.BootDone() { + t.Fatalf("boot must finish within about %v", BootDuration) + } + if !strings.Contains(w.bootFinalLine(), "UNIVERSE ONLINE") { + t.Fatal("boot must end with UNIVERSE ONLINE (§29)") + } +} + +func TestSkipBootIsImmediate(t *testing.T) { + w := testWorld(t, true) + w.SkipBoot() + if !w.BootDone() { + t.Fatal("any key must skip the boot sequence (§29)") + } +} + +func TestNoFXSkipsBootEntirely(t *testing.T) { + w := testWorld(t, false) + if !w.BootDone() { + t.Fatal("--no-fx starts straight in the game") + } +} + +func TestCollapseTimelineEndsWithABlackHole(t *testing.T) { + w := testWorld(t, true) + cells := []render.Sprite{{X: 20, Y: 10, Glyph: '█'}, {X: 22, Y: 12, Glyph: '█'}} + w.StartCollapse(testSnapshot(), cells) + + if !strings.Contains(w.Banner(), "SIGNAL LOST") { + t.Fatalf("Banner = %q, want SIGNAL LOST in the freeze phase (§28)", w.Banner()) + } + frozen := w.BoardFX() + w.Advance(CollapseFreeze/2, testSnapshot()) + for i := range frozen { + if i < len(w.BoardFX()) && frozen[i] != w.BoardFX()[i] { + t.Fatal("everything freezes for the first 300ms (§28)") + } + } + + w.Advance(CollapseFreeze+100*time.Millisecond, testSnapshot()) + board := w.boardRect() + cx, cy := board.X+board.W/2, board.Y+board.H/2 + distBefore := spriteDistance(w.BoardFX(), cx, cy) + w.Advance(300*time.Millisecond, testSnapshot()) + if spriteDistance(w.BoardFX(), cx, cy) >= distBefore { + t.Fatal("blocks must fall inward toward the center (§28)") + } + + w.Advance(CollapseTotal, testSnapshot()) + if !w.CollapseDone() { + t.Fatalf("the collapse must finish by %v", CollapseTotal) + } +} + +func TestCollapseSpritesStayInsideTheBoard(t *testing.T) { + w := testWorld(t, true) + var cells []render.Sprite + board := w.boardRect() + for y := board.Y; y < board.Y+board.H; y++ { + cells = append(cells, render.Sprite{X: board.X, Y: y, Glyph: '█'}) + } + w.StartCollapse(testSnapshot(), cells) + for i := 0; i < 100; i++ { + w.Advance(16*time.Millisecond, testSnapshot()) + for _, s := range w.BoardFX() { + if !board.Contains(s.X, s.Y) { + t.Fatalf("collapse sprite %+v escaped the board %+v", s, board) + } + } + } +} + +func spriteDistance(ss []render.Sprite, cx, cy int) float64 { + sum := 0.0 + for _, s := range ss { + dx, dy := float64(s.X-cx), float64(s.Y-cy) + sum += dx*dx + dy*dy + } + return sum +} +``` + +And in `internal/app/phases_test.go`: + +```go +package app + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "cosmic-tetris/internal/render" +) + +func TestBootPhaseThenPlay(t *testing.T) { + m := fxModel(t, true) + if m.State != render.PhaseBoot { + t.Fatalf("State = %v, want PhaseBoot with FX on (§29)", m.State) + } + if !strings.Contains(render.RenderGrid(m.renderInput()).PlainString(), "C O S M I C") { + t.Fatal("the boot screen must render") + } + m = advance(t, m, 80) // ~1.3s + if m.State != render.PhasePlaying { + t.Fatalf("State = %v, want PhasePlaying after boot", m.State) + } +} + +func TestAnyKeySkipsBoot(t *testing.T) { + m := fxModel(t, true) + m = press(t, m, 'k', "k") + if m.State != render.PhasePlaying { + t.Fatal("any key must skip the boot sequence (§29)") + } +} + +func TestNoFXStartsPlaying(t *testing.T) { + if got := fxModel(t, false).State; got != render.PhasePlaying { + t.Fatalf("State = %v with --no-fx, want PhasePlaying", got) + } +} + +func TestGameOverGoesThroughTheCollapse(t *testing.T) { + m := fxModel(t, true) + m = press(t, m, 'k', "k") // skip boot + for i := 0; i < 400 && !m.Game.Over(); i++ { + m = press(t, m, tea.KeySpace, " ") + } + m = advance(t, m, 1) + if m.State != render.PhaseCollapse { + t.Fatalf("State = %v, want PhaseCollapse (§28)", m.State) + } + out := render.RenderGrid(m.renderInput()).PlainString() + if !strings.Contains(out, "SIGNAL LOST") { + t.Fatalf("the collapse must show SIGNAL LOST:\n%s", out) + } + if strings.Contains(out, "UNIVERSE EXPIRED") { + t.Fatal("the card must not appear until the collapse finishes (§28)") + } + m = advance(t, m, 120) // ~1.9s + if m.State != render.PhaseGameOver { + t.Fatalf("State = %v, want PhaseGameOver after the collapse", m.State) + } + card := render.RenderGrid(m.renderInput()).PlainString() + if !strings.Contains(card, "UNIVERSE EXPIRED") { + t.Fatal("the §28 card must appear at the end") + } + if !strings.Contains(card, "CAUSE: EXCESSIVE GEOMETRY") { + t.Fatalf("the card must carry the §28 cause subtitle:\n%s", card) + } + m = press(t, m, 'r', "r") + if m.State != render.PhasePlaying { + t.Fatal("r must reboot the universe straight into play") + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/fx/ -run 'Boot|Collapse' -v; go test ./internal/app/ -run 'Boot|Phase|Collapse' -v` +Expected: FAIL — `undefined: BootDuration`, `undefined: PhaseCollapse`. + +- [ ] **Step 3: Implement the boot and collapse phases** + +Boot text is the §29 block verbatim (`✦`, `C O S M I C`, `T E T R I S`, `INITIALIZING LOCAL UNIVERSE...`, `gravity ........ OK`, `spacetime ...... OK`, `tetrominoes .... QUESTIONABLE`, then `UNIVERSE ONLINE`), revealed line by line across `BootDuration`, with a `bubbles/spinner` frame on the `INITIALIZING LOCAL UNIVERSE...` line (§3 allows Bubbles for exactly this). The collapse converts the handed-in board cells into particles whose velocity points at the board center with magnitude proportional to distance, clamped so nothing overshoots outside the board; the banner reads `SIGNAL LOST` during the freeze; at `CollapseInfall` it swaps to the §28 black-hole glyph art centred in the board and holds until `CollapseTotal`. `RenderGrid` skips `DrawLockedCells`/`DrawGhost`/`DrawActive` in `PhaseCollapse` so the board really does dissolve. + +In `internal/render`: the game-over card gains one line — `in.Mission` drawn as its subtitle, which is where §28's `CAUSE: EXCESSIVE GEOMETRY` lands (Task 11 makes that the game-over mission line). + +In `internal/app`: with FX disabled, keep Plan 2's behavior — game over goes straight to `PhaseGameOver`, no boot, no collapse — so Plan 2's own tests keep passing unchanged. + +- [ ] **Step 4: Run the tests, regenerate goldens, read the diff** + +Run: `go test ./... -count=1 && go test ./internal/render/ -update && git diff internal/render/testdata` +Expected: PASS. Exactly one existing golden changes — `gameover.golden` gains the mission subtitle row inside the card. Read that diff and confirm the card still fits its box; every other case is untouched. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . +git add internal/fx internal/app internal/render +git commit -m "feat(fx): boot sequence and game-over black-hole collapse" +``` + +--- + +### Task 13: Acceptance pass — the coolness test and the definition of done + +**Files:** +- Create: `internal/render/fx_golden_test.go`, `internal/app/restraint_test.go` +- Modify: `README.md` +- Test: as above, plus a manual play session + +**Interfaces:** +- Consumes: everything. +- Produces: two golden snapshots with FX populated (`fx_wide`, `fx_ascii`) proving effects composite without corrupting the board, an input-latency test for §44, and a README that documents the flags and modes. + +- [ ] **Step 1: Write the failing test** + +```go +package render + +import ( + "strings" + "testing" +) + +func fxInput(w, h int, mode Mode) Input { + in := goldenInput(w, h) + in.Mode = mode + st := NewPalette(mode).Banner + in.Stars = []Sprite{{X: 2, Y: 2, Glyph: '·'}, {X: 70, Y: 20, Glyph: '✦'}} + in.BoardFX = []Sprite{{X: Compute(w, h).Inner.X, Y: Compute(w, h).Inner.Y, Glyph: '▓'}} + in.GlobalFX = []Sprite{{X: 60, Y: 6, Glyph: '*'}} + in.Banner = "✦ EVENT HORIZON ✦" + in.Notice = Notice{Title: "GRAVITY ANOMALY DETECTED", Subtitle: "LEVEL 08"} + in.Shake = Rect{X: 1, Y: 0} + in.BorderStyle = &st + in.HUDStyle = &st + return in +} + +func TestFXGoldenLayouts(t *testing.T) { + for _, c := range []struct { + name string + in Input + }{ + {"fx_wide", fxInput(80, 30, ModeFull)}, + {"fx_ascii", fxInput(80, 30, ModeASCII)}, + } { + t.Run(c.name, func(t *testing.T) { + assertGolden(t, c.name, RenderGrid(c.in).PlainString()) + }) + } +} + +func TestFXNeverChangesTheFrameSize(t *testing.T) { + for w := MinCols; w <= 100; w += 4 { + for h := MinRows; h <= 45; h += 4 { + g := RenderGrid(fxInput(w, h, ModeFull)) + if g.W != w || g.H != h { + t.Fatalf("%dx%d: FX changed the frame to %dx%d", w, h, g.W, g.H) + } + for _, row := range strings.Split(g.PlainString(), "\n") { + if len([]rune(row)) > w { + t.Fatalf("%dx%d: FX pushed a row past the width: %q", w, h, row) + } + } + } + } +} + +func TestASCIIModeWithFXStaysASCIIish(t *testing.T) { + out := RenderGrid(fxInput(80, 30, ModeASCII)).PlainString() + if strings.Contains(out, "██") || strings.Contains(out, "╔") { + t.Fatalf("ASCII mode leaked Unicode board glyphs with FX on:\n%s", out) + } +} +``` + +And `internal/app/restraint_test.go` — §44's "never make controls lag", as a test rather than a hope: + +```go +package app + +import ( + "testing" + + tea "charm.land/bubbletea/v2" +) + +func TestInputIsHandledWhileEffectsAreLive(t *testing.T) { + m := fxModel(t, true) + m = press(t, m, 'k', "k") // skip boot + // Fill the well and clear four rows at once to light every effect at once. + for i := 0; i < 40 && m.Game.Lines == 0; i++ { + m = press(t, m, tea.KeySpace, " ") + m = advance(t, m, 1) + } + x := m.Game.Active.X + m = press(t, m, tea.KeyLeft, "") + if m.Game.Active.X != x-1 { + t.Fatal("a keypress during effects must move the piece on the same update (§44)") + } + before := m.Game.Score + m = press(t, m, tea.KeySpace, " ") + if m.Game.Score == before { + t.Fatal("a hard drop during effects must still score immediately (§44)") + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/render/ -run FX -v; go test ./internal/app/ -run Restraint -v` +Expected: FAIL — missing `testdata/fx_wide.golden`. + +(The app test is named `TestInputIsHandledWhileEffectsAreLive`; run it with `go test ./internal/app/ -run Input -v` and expect it to pass immediately if the frame loop is already correct — if it fails, the fix belongs in `Update`, not the test.) + +- [ ] **Step 3: Generate the goldens, read them, and update the README** + +Run: `go test ./internal/render/ -update`, then read `testdata/fx_wide.golden` and `testdata/fx_ascii.golden`: the board interior must still be recognisable, the banner and notice must not sit on top of the active piece, and no HUD text may land inside the board (§44). + +README: add the modes and flags table (`--ascii`, `--no-fx`, `--reduced-motion`, `--seed`), a line on the two-RNG guarantee, and one on which effects each flag suppresses. + +- [ ] **Step 4: Verify against §43 and §47 by playing it** + +Run: `go vet ./... && go test ./... -count=1 && go run ./cmd/cosmic-tetris` + +Walk the §43 coolness acceptance test and check each item off out loud: +- within 30 seconds of play: moving starfield, animated board border, piece trails, hard-drop impact, particles, mission-control commentary; +- on the first completed line: supernova clear animation, debris, border reaction; +- on a four-line clear: banner, hyperdrive, shockwave, eruption, HUD flash — the "LOL WHAT THE FUCK" reaction is the actual requirement. + +Then walk §47's definition of done: playable start to game over, immediate controls, live resize, hold, ghost, next queue, deterministic piece generation (`--seed 8675309` twice gives the same pieces), isolated RNGs, correct clearing, rising gravity, pause, restart, ASCII fallback, `--no-fx`, engine unit tests, renderer snapshots, no visible flicker, animations never blocking input, FX never modifying game state, glorious four-line clears, the black-hole game over, fun with effects off and much funnier with them on. + +Note anything that fails and fix it before committing; that is the point of this step. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . +git add internal/render/fx_golden_test.go internal/render/testdata internal/app/restraint_test.go README.md +git commit -m "test(render): FX composite goldens; docs: modes and flags" +``` diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/Q2-reader-recipe-ledgerlite-71.plan.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/Q2-reader-recipe-ledgerlite-71.plan.md new file mode 100644 index 000000000..4be3dc816 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/Q2-reader-recipe-ledgerlite-71.plan.md @@ -0,0 +1,675 @@ +# ledgerlite Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build `ledgerlite`, a stdlib-only Python CLI that reads a transactions CSV, categorizes each row from a rules file, and prints per-category totals plus the closing balance. + +**Architecture:** Five small pure modules under `ledgerlite/` — parsing (`parse.py`), rules (`rules.py`), balance arithmetic (`balance.py`), formatting (`report.py`) — composed by a thin argparse shell (`cli.py`) that owns all I/O, all stderr messages, and all exit codes. Every module below `cli.py` takes and returns values (text in, data out), so every behavior in the spec is testable without touching the filesystem. + +**Tech Stack:** Python 3.11+, standard library only (`csv`, `decimal`, `datetime`, `argparse`, `pathlib`, `unittest`). + +**Spec:** `design.md` + +## Global Constraints + +- Python 3.11+. Standard library only — no third-party dependencies. +- Money is `decimal.Decimal` everywhere. Never `float`, not even transiently. +- Package lives in `ledgerlite/`; tests live at the repo root as `test_.py` and run with `python3 -m unittest`. +- Amounts are printed with exactly two fractional digits, a leading `-` for negatives, and no thousands separators (`-12.50`, `0.00`, `1200.00`). +- Exit codes: `0` success, `1` a file cannot be read, `2` a malformed CSV row. +- Error messages go to stderr and are prefixed `ledgerlite: `. + +## Review Focus + +Spec-implied inputs that no task's own happy-path tests would exercise. Each has a test assigned to the task that owns the code: + +1. A CSV with only a header row (or no rows at all) — should print the opening amount as the closing balance and exit 0, not crash on an empty sequence. → Task 1 (`test_header_only_file_has_no_transactions`), Task 4 (`test_no_transactions_prints_only_the_closing_balance`), Task 5 (`test_header_only_file_reports_the_opening_balance`). +2. Blank or whitespace-only lines in the CSV (a trailing newline is normal) — must be skipped, not reported as a malformed row. → Task 1 (`test_blank_lines_are_skipped`). +3. A category total that comes out as negative zero — must print `0.00`, never `-0.00`. → Task 4 (`test_negative_zero_prints_as_zero`). +4. `--opening` given a non-number or more than two fractional digits — must produce a usage error, not a traceback and not silent rounding. → Task 5 (`test_invalid_opening_exits_two`). +5. A `--rules` path that cannot be read — the spec names only TRANSACTIONS, but an unreadable rules file must give the same `cannot read` message and exit 1 rather than a traceback. → Task 5 (`test_unreadable_rules_file_returns_one`). + +Two other silences resolved by decision rather than by test, so the implementer does not have to guess: `nan`/`Infinity` are valid `Decimal` inputs and are rejected as invalid amounts (Task 1); the header line is skipped unconditionally without validating its contents (Task 1). + +--- + +### Task 1: Transaction model and CSV parsing + +**Files:** +- Create: `ledgerlite/__init__.py` (empty) +- Create: `ledgerlite/model.py` +- Create: `ledgerlite/parse.py` +- Test: `test_parse.py` + +**Interfaces:** +- Consumes: nothing. +- Produces: + - `Transaction` — frozen dataclass in `ledgerlite/model.py` with fields `date: datetime.date`, `amount: decimal.Decimal`, `description: str`, in that order. + - `ParseError(Exception)` in `ledgerlite/parse.py` with attributes `line: int` and `message: str`; `str(err)` is `f"{line}: {message}"`. + - `parse_transactions(text: str) -> list[Transaction]` — input order preserved, raises `ParseError` on the first bad row. + - `parse_amount(text: str) -> Decimal` — raises `ValueError` whose `str()` is the exact message `ParseError.message` will carry. + +- [ ] **Step 1: Write the failing tests** + +Create `test_parse.py`: + +```python +import unittest +from datetime import date +from decimal import Decimal + +from ledgerlite.model import Transaction +from ledgerlite.parse import ParseError, parse_amount, parse_transactions + +CSV = ( + "date,amount,description\n" + "2026-03-04,-7.50,Coffee Shop\n" + "2026-03-01,2500.00,Salary\n" +) + + +class TestParseTransactions(unittest.TestCase): + def test_parses_rows_in_input_order(self): + self.assertEqual( + parse_transactions(CSV), + [ + Transaction(date(2026, 3, 4), Decimal("-7.50"), "Coffee Shop"), + Transaction(date(2026, 3, 1), Decimal("2500.00"), "Salary"), + ], + ) + + def test_header_only_file_has_no_transactions(self): + self.assertEqual(parse_transactions("date,amount,description\n"), []) + + def test_blank_lines_are_skipped(self): + self.assertEqual(len(parse_transactions(CSV + "\n \n")), 2) + + def test_fields_are_stripped(self): + text = "date,amount,description\n 2026-03-04 , -7.50 , Coffee Shop \n" + self.assertEqual( + parse_transactions(text), + [Transaction(date(2026, 3, 4), Decimal("-7.50"), "Coffee Shop")], + ) + + def test_wrong_column_count(self): + with self.assertRaises(ParseError) as ctx: + parse_transactions("date,amount,description\n2026-03-04,-7.50\n") + self.assertEqual(ctx.exception.line, 2) + self.assertEqual(ctx.exception.message, "expected 3 columns, got 2") + + def test_unparseable_date(self): + with self.assertRaises(ParseError) as ctx: + parse_transactions("date,amount,description\n2026-13-04,-7.50,x\n") + self.assertEqual(ctx.exception.line, 2) + self.assertEqual(ctx.exception.message, "invalid date: '2026-13-04'") + + def test_amount_with_three_fractional_digits(self): + with self.assertRaises(ParseError) as ctx: + parse_transactions("date,amount,description\n2026-03-04,1.005,x\n") + self.assertEqual( + ctx.exception.message, + "amount has more than two fractional digits: '1.005'", + ) + + def test_error_names_the_offending_line_number(self): + text = ( + "date,amount,description\n" + "2026-03-04,1.00,ok\n" + "2026-03-05,nope,bad\n" + ) + with self.assertRaises(ParseError) as ctx: + parse_transactions(text) + self.assertEqual(ctx.exception.line, 3) + self.assertEqual(ctx.exception.message, "invalid amount: 'nope'") + + def test_str_of_parse_error_includes_line_and_message(self): + self.assertEqual(str(ParseError(7, "invalid amount: 'x'")), "7: invalid amount: 'x'") + + +class TestParseAmount(unittest.TestCase): + def test_accepts_zero_one_or_two_fractional_digits(self): + self.assertEqual(parse_amount("-12"), Decimal("-12")) + self.assertEqual(parse_amount("1.5"), Decimal("1.5")) + self.assertEqual(parse_amount("1.50"), Decimal("1.50")) + + def test_rejects_non_numeric(self): + with self.assertRaises(ValueError) as ctx: + parse_amount("abc") + self.assertEqual(str(ctx.exception), "invalid amount: 'abc'") + + def test_rejects_non_finite(self): + for text in ("nan", "Infinity", "-inf"): + with self.subTest(text=text): + with self.assertRaises(ValueError) as ctx: + parse_amount(text) + self.assertEqual(str(ctx.exception), f"invalid amount: '{text}'") + + def test_rejects_more_than_two_fractional_digits(self): + with self.assertRaises(ValueError) as ctx: + parse_amount("1.005") + self.assertEqual( + str(ctx.exception), + "amount has more than two fractional digits: '1.005'", + ) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_parse -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite'` + +- [ ] **Step 3: Create `ledgerlite/__init__.py` (empty) and the `Transaction` dataclass in `ledgerlite/model.py`** + +`@dataclass(frozen=True)`, fields in the order given in Interfaces. + +- [ ] **Step 4: Implement `ParseError`, `parse_amount(text: str) -> Decimal`, and `parse_transactions(text: str) -> list[Transaction]` in `ledgerlite/parse.py`** + +`parse_amount`: build `Decimal(text)`, converting `decimal.InvalidOperation` into `ValueError(f"invalid amount: {text!r}")`; raise the same message when the result is not `is_finite()`; reject when `-amount.as_tuple().exponent > 2`. + +`parse_transactions`: `csv.reader(text.splitlines())` with `enumerate(..., start=1)` so line numbers match the file. Skip line 1 (the header, unvalidated) and any row whose fields are all empty after stripping. Strip each of the three fields. Use `datetime.date.fromisoformat` for the date, converting `ValueError` into the `invalid date: ...` message. Column-count check comes first, then date, then amount. + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `python3 -m unittest test_parse -v` +Expected: PASS (all tests) + +- [ ] **Step 6: Commit** + +```bash +git add ledgerlite/__init__.py ledgerlite/model.py ledgerlite/parse.py test_parse.py +git commit -m "feat: parse transactions CSV into Transaction records" +``` + +--- + +### Task 2: Rules parsing and categorization + +**Files:** +- Create: `ledgerlite/rules.py` +- Test: `test_rules.py` + +**Interfaces:** +- Consumes: nothing. +- Produces: + - `parse_rules(text: str) -> list[tuple[str, str]]` — `(substring, category)` pairs in file order. **Substrings are already lowercased** by this function. + - `categorize(description: str, rules: list[tuple[str, str]]) -> str | None` — first match wins, `None` when nothing matches. Assumes rule substrings are lowercased, as `parse_rules` returns them. + +- [ ] **Step 1: Write the failing tests** + +Create `test_rules.py`: + +```python +import unittest + +from ledgerlite.rules import categorize, parse_rules + + +class TestParseRules(unittest.TestCase): + def test_parses_pairs_in_file_order_with_lowercased_substrings(self): + self.assertEqual( + parse_rules("Coffee=food\nrent=housing\n"), + [("coffee", "food"), ("rent", "housing")], + ) + + def test_strips_whitespace_and_skips_blank_lines(self): + self.assertEqual(parse_rules("\n coffee = food \n\n"), [("coffee", "food")]) + + def test_skips_lines_with_no_separator_or_an_empty_side(self): + self.assertEqual( + parse_rules("nonsense\n=food\ncoffee=\ncoffee=food\n"), + [("coffee", "food")], + ) + + def test_splits_on_the_first_equals_only(self): + self.assertEqual(parse_rules("a=b=c\n"), [("a", "b=c")]) + + def test_empty_text_has_no_rules(self): + self.assertEqual(parse_rules(""), []) + + +class TestCategorize(unittest.TestCase): + RULES = [("coffee", "food"), ("shop", "retail")] + + def test_matches_case_insensitively(self): + self.assertEqual(categorize("COFFEE SHOP", self.RULES), "food") + + def test_first_matching_rule_wins(self): + self.assertEqual(categorize("Corner Shop", self.RULES), "retail") + + def test_returns_none_when_nothing_matches(self): + self.assertIsNone(categorize("Rent", self.RULES)) + + def test_no_rules_means_no_category(self): + self.assertIsNone(categorize("Coffee", [])) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_rules -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.rules'` + +- [ ] **Step 3: Implement `parse_rules` and `categorize` in `ledgerlite/rules.py`** + +`parse_rules`: split on `str.partition("=")`, strip both sides, lowercase the substring, skip a line when either side is empty. `categorize`: lowercase the description once, return the category of the first rule whose substring is `in` it. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_rules -v` +Expected: PASS (all tests) + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/rules.py test_rules.py +git commit -m "feat: parse rules file and categorize descriptions" +``` + +--- + +### Task 3: Date ordering and closing balance + +**Files:** +- Create: `ledgerlite/balance.py` +- Test: `test_balance.py` + +**Interfaces:** +- Consumes: `Transaction` from `ledgerlite.model` (Task 1). +- Produces: + - `order_by_date(transactions: list[Transaction]) -> list[Transaction]` — new list, stable sort by date so ties keep input order; the argument is not mutated. + - `closing_balance(opening: Decimal, transactions: list[Transaction]) -> Decimal` — `opening` plus every amount, added in date order; `opening` when there are none. + +- [ ] **Step 1: Write the failing tests** + +Create `test_balance.py`: + +```python +import unittest +from datetime import date +from decimal import Decimal + +from ledgerlite.balance import closing_balance, order_by_date +from ledgerlite.model import Transaction + + +def tx(day, amount): + return Transaction(date(2026, 3, day), Decimal(amount), f"row {day} {amount}") + + +class TestOrderByDate(unittest.TestCase): + def test_orders_by_date_keeping_input_order_for_ties(self): + a, b, c = tx(4, "1.00"), tx(1, "2.00"), tx(1, "3.00") + self.assertEqual(order_by_date([a, b, c]), [b, c, a]) + + def test_does_not_mutate_the_argument(self): + a, b = tx(4, "1.00"), tx(1, "2.00") + original = [a, b] + order_by_date(original) + self.assertEqual(original, [a, b]) + + def test_empty_list(self): + self.assertEqual(order_by_date([]), []) + + +class TestClosingBalance(unittest.TestCase): + def test_adds_every_amount_to_the_opening_amount(self): + transactions = [tx(4, "-7.50"), tx(1, "2500.00"), tx(2, "-900.00")] + self.assertEqual( + closing_balance(Decimal("100"), transactions), Decimal("1692.50") + ) + + def test_no_transactions_returns_the_opening_amount(self): + self.assertEqual(closing_balance(Decimal("100"), []), Decimal("100")) + + def test_result_is_a_decimal(self): + self.assertIsInstance(closing_balance(Decimal("0"), [tx(1, "1.00")]), Decimal) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_balance -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.balance'` + +- [ ] **Step 3: Implement `order_by_date` and `closing_balance` in `ledgerlite/balance.py`** + +`sorted(..., key=lambda t: t.date)` is stable, which is what the tie rule needs. `closing_balance` accumulates over `order_by_date(transactions)`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_balance -v` +Expected: PASS (all tests) + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/balance.py test_balance.py +git commit -m "feat: order transactions by date and compute closing balance" +``` + +--- + +### Task 4: Per-category totals and report formatting + +**Files:** +- Create: `ledgerlite/report.py` +- Test: `test_report.py` + +**Interfaces:** +- Consumes: `Transaction` (Task 1); `categorize` (Task 2); `closing_balance`, `order_by_date` (Task 3). +- Produces: + - `UNCATEGORIZED = "uncategorized"` constant. + - `format_amount(amount: Decimal) -> str` + - `category_totals(transactions: list[Transaction], rules: list[tuple[str, str]]) -> list[tuple[str, Decimal]]` — case-insensitively alphabetical, `UNCATEGORIZED` last and only when some transaction is uncategorized. + - `format_report(transactions: list[Transaction], rules: list[tuple[str, str]], opening: Decimal) -> str` — the whole report, ending in a newline. + +- [ ] **Step 1: Write the failing tests** + +Create `test_report.py`: + +```python +import unittest +from datetime import date +from decimal import Decimal + +from ledgerlite.model import Transaction +from ledgerlite.report import category_totals, format_amount, format_report + +RULES = [("coffee", "food"), ("rent", "housing")] +EXAMPLE = [ + Transaction(date(2026, 3, 4), Decimal("-7.50"), "Coffee Shop"), + Transaction(date(2026, 3, 1), Decimal("2500.00"), "Salary"), + Transaction(date(2026, 3, 2), Decimal("-900.00"), "Rent March"), +] + + +class TestFormatAmount(unittest.TestCase): + def test_always_two_fractional_digits(self): + self.assertEqual(format_amount(Decimal("-12.5")), "-12.50") + self.assertEqual(format_amount(Decimal("0")), "0.00") + self.assertEqual(format_amount(Decimal("1200")), "1200.00") + + def test_no_thousands_separators(self): + self.assertEqual(format_amount(Decimal("1234567.8")), "1234567.80") + + def test_negative_zero_prints_as_zero(self): + self.assertEqual(format_amount(Decimal("-0.00")), "0.00") + + +class TestCategoryTotals(unittest.TestCase): + def test_sums_each_category_alphabetically_with_uncategorized_last(self): + self.assertEqual( + category_totals(EXAMPLE, RULES), + [ + ("food", Decimal("-7.50")), + ("housing", Decimal("-900.00")), + ("uncategorized", Decimal("2500.00")), + ], + ) + + def test_orders_categories_case_insensitively(self): + transactions = [ + Transaction(date(2026, 3, 1), Decimal("1.00"), "b thing"), + Transaction(date(2026, 3, 1), Decimal("2.00"), "a thing"), + ] + rules = [("b thing", "Zebra"), ("a thing", "apples")] + self.assertEqual( + [name for name, _ in category_totals(transactions, rules)], + ["apples", "Zebra"], + ) + + def test_omits_uncategorized_when_every_transaction_matches(self): + transactions = [Transaction(date(2026, 3, 1), Decimal("-1.00"), "Coffee")] + self.assertEqual( + category_totals(transactions, RULES), [("food", Decimal("-1.00"))] + ) + + def test_no_transactions_has_no_categories(self): + self.assertEqual(category_totals([], RULES), []) + + def test_without_rules_everything_is_uncategorized(self): + self.assertEqual( + category_totals(EXAMPLE, []), [("uncategorized", Decimal("1592.50"))] + ) + + +class TestFormatReport(unittest.TestCase): + def test_matches_the_design_example(self): + self.assertEqual( + format_report(EXAMPLE, RULES, Decimal("100")), + "food: -7.50\n" + "housing: -900.00\n" + "uncategorized: 2500.00\n" + "\n" + "closing balance: 1692.50\n", + ) + + def test_no_transactions_prints_only_the_closing_balance(self): + self.assertEqual( + format_report([], RULES, Decimal("100")), + "\nclosing balance: 100.00\n", + ) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_report -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.report'` + +- [ ] **Step 3: Implement `format_amount`, `category_totals`, and `format_report` in `ledgerlite/report.py`** + +`format_amount`: `quantize(Decimal("0.01"))` then format; map a zero result to `abs()` first so negative zero cannot print a sign. + +`category_totals`: accumulate into a dict keyed by `categorize(...) or UNCATEGORIZED` while walking `order_by_date(transactions)`, then sort the non-`UNCATEGORIZED` keys with `key=lambda name: (name.lower(), name)` and append `UNCATEGORIZED` if present. + +`format_report`: one `f"{name}: {format_amount(total)}\n"` per row from `category_totals`, then `"\n"`, then `f"closing balance: {format_amount(closing_balance(opening, transactions))}\n"`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_report -v` +Expected: PASS (all tests) + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/report.py test_report.py +git commit -m "feat: format per-category totals and closing balance report" +``` + +--- + +### Task 5: CLI entry point + +**Files:** +- Create: `ledgerlite/cli.py` +- Create: `ledgerlite/__main__.py` (not in the design's layout; without it there is no way to invoke the tool — it is three lines delegating to `cli.main`) +- Test: `test_cli.py` + +**Interfaces:** +- Consumes: `ParseError`, `parse_amount`, `parse_transactions` (Task 1); `parse_rules` (Task 2); `format_report` (Task 4). +- Produces: `main(argv: list[str] | None = None) -> int`. + +Command line: `ledgerlite report TRANSACTIONS [--rules RULES] [--opening AMOUNT]`, `--opening` defaulting to `Decimal("0")`. + +- [ ] **Step 1: Write the failing tests** + +Create `test_cli.py`: + +```python +import contextlib +import errno +import io +import os +import tempfile +import unittest +from pathlib import Path + +from ledgerlite.cli import main + +CSV = ( + "date,amount,description\n" + "2026-03-04,-7.50,Coffee Shop\n" + "2026-03-01,2500.00,Salary\n" + "2026-03-02,-900.00,Rent March\n" +) +RULES = "coffee=food\nrent=housing\n" +REPORT = ( + "food: -7.50\n" + "housing: -900.00\n" + "uncategorized: 2500.00\n" + "\n" + "closing balance: 1692.50\n" +) + + +def run(argv): + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = main(argv) + return code, out.getvalue(), err.getvalue() + + +class TestMain(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.dir = Path(tmp.name) + self.csv = self.dir / "tx.csv" + self.csv.write_text(CSV, encoding="utf-8") + self.rules = self.dir / "rules.txt" + self.rules.write_text(RULES, encoding="utf-8") + + def test_reports_categories_and_closing_balance(self): + code, out, err = run( + ["report", str(self.csv), "--rules", str(self.rules), "--opening", "100"] + ) + self.assertEqual((code, out, err), (0, REPORT, "")) + + def test_opening_defaults_to_zero(self): + code, out, _ = run(["report", str(self.csv), "--rules", str(self.rules)]) + self.assertEqual(code, 0) + self.assertIn("closing balance: 1592.50\n", out) + + def test_without_rules_everything_is_uncategorized(self): + code, out, _ = run(["report", str(self.csv)]) + self.assertEqual( + (code, out), + (0, "uncategorized: 1592.50\n\nclosing balance: 1592.50\n"), + ) + + def test_header_only_file_reports_the_opening_balance(self): + empty = self.dir / "empty.csv" + empty.write_text("date,amount,description\n", encoding="utf-8") + code, out, _ = run(["report", str(empty), "--opening", "-5"]) + self.assertEqual((code, out), (0, "\nclosing balance: -5.00\n")) + + def test_missing_transactions_file_returns_one(self): + missing = self.dir / "nope.csv" + code, out, err = run(["report", str(missing)]) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertEqual( + err, + f"ledgerlite: cannot read {missing}: {os.strerror(errno.ENOENT)}\n", + ) + + def test_unreadable_rules_file_returns_one(self): + missing = self.dir / "nope.txt" + code, out, err = run(["report", str(self.csv), "--rules", str(missing)]) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertEqual( + err, + f"ledgerlite: cannot read {missing}: {os.strerror(errno.ENOENT)}\n", + ) + + def test_malformed_row_returns_two_and_prints_no_report(self): + bad = self.dir / "bad.csv" + bad.write_text( + "date,amount,description\n2026-03-04,1.00,ok\n2026-03-05,nope,bad\n", + encoding="utf-8", + ) + code, out, err = run(["report", str(bad)]) + self.assertEqual(code, 2) + self.assertEqual(out, "") + self.assertEqual(err, f"ledgerlite: {bad}:3: invalid amount: 'nope'\n") + + def test_invalid_opening_exits_two(self): + for value in ("abc", "1.005"): + with self.subTest(value=value): + with self.assertRaises(SystemExit) as ctx: + run(["report", str(self.csv), "--opening", value]) + self.assertEqual(ctx.exception.code, 2) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_cli -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.cli'` + +- [ ] **Step 3: Implement `main(argv: list[str] | None = None) -> int` in `ledgerlite/cli.py`** + +`argparse.ArgumentParser(prog="ledgerlite")` with a required `report` subcommand, positional `transactions`, optional `--rules`, and `--opening` whose `type=` wraps `parse_amount` and re-raises its `ValueError` as `argparse.ArgumentTypeError` (this is what makes an invalid `--opening` exit 2 through argparse), `default=Decimal("0")`. + +Read both files inside one `try`, with `Path(...).read_text(encoding="utf-8")`: + +```python +except OSError as exc: + print(f"ledgerlite: cannot read {exc.filename}: {exc.strerror}", file=sys.stderr) + return 1 +except ParseError as exc: + print(f"ledgerlite: {args.transactions}:{exc.line}: {exc.message}", file=sys.stderr) + return 2 +``` + +Then print `format_report(...)` with `end=""` and return 0. Nothing may reach stdout before parsing has succeeded. + +- [ ] **Step 4: Create `ledgerlite/__main__.py`** + +```python +import sys + +from .cli import main + +sys.exit(main()) +``` + +- [ ] **Step 5: Run the whole suite to verify it passes** + +Run: `python3 -m unittest -v` +Expected: PASS — every test in `test_parse`, `test_rules`, `test_balance`, `test_report`, `test_cli` + +- [ ] **Step 6: Verify the tool runs end to end** + +```bash +printf 'date,amount,description\n2026-03-04,-7.50,Coffee Shop\n2026-03-01,2500.00,Salary\n2026-03-02,-900.00,Rent March\n' > /tmp/tx.csv +printf 'coffee=food\nrent=housing\n' > /tmp/rules.txt +python3 -m ledgerlite report /tmp/tx.csv --rules /tmp/rules.txt --opening 100 +``` + +Expected: exactly the example report from `design.md` (`food: -7.50`, `housing: -900.00`, `uncategorized: 2500.00`, blank line, `closing balance: 1692.50`). + +- [ ] **Step 7: Commit** + +```bash +git add ledgerlite/cli.py ledgerlite/__main__.py test_cli.py +git commit -m "feat: add ledgerlite report command line interface" +``` diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/Q2-reader-recipe-ledgerlite-72.plan.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/Q2-reader-recipe-ledgerlite-72.plan.md new file mode 100644 index 000000000..0e882ebb2 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/Q2-reader-recipe-ledgerlite-72.plan.md @@ -0,0 +1,815 @@ +# ledgerlite Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build `ledgerlite`, a standard-library-only CLI that reads a transactions CSV, categorizes each row with a rules file, and prints per-category totals plus the closing balance. + +**Architecture:** A pipeline of small pure modules — `parse` turns CSV text into `Transaction` values (raising `ParseError` with a line number), `rules` turns rules text into ordered `(substring, category)` pairs, `balance` orders by date and accumulates, `report` aggregates and formats — with `cli` as the only module that touches the filesystem, stdout/stderr, and exit codes. All money is `decimal.Decimal` end to end; no module below `cli` prints or reads files, so every behavior is testable as a function call. + +**Tech Stack:** Python 3.11+, standard library only (`csv`, `decimal`, `datetime`, `dataclasses`, `argparse`, `pathlib`, `re`, `unittest`). + +**Spec:** `design.md` + +## Global Constraints + +- Python 3.11+. Standard library only — no third-party dependencies, no `pip install`, no `pyproject.toml` needed. +- All monetary values are `decimal.Decimal`, never `float`. No module may construct a `float` from an amount. +- Tests live at the repo root as `test_.py` and run with `python3 -m unittest`. +- Amounts are printed with exactly two fractional digits, a leading `-` for negatives, and no thousands separators: `-12.50`, `0.00`, `1200.00`. +- Exit codes and stderr formats are exact: unreadable file → `ledgerlite: cannot read : ` and exit 1; malformed row → `ledgerlite: :: ` and exit 2; success → report on stdout and exit 0. On exit 1 or 2, nothing is written to stdout. +- Line numbers in errors are 1-based physical file lines; the header row is line 1. +- Package layout is fixed by the spec: `ledgerlite/{__init__,model,parse,rules,balance,report,cli}.py`. This plan adds one file the spec omits: `ledgerlite/__main__.py`, so the tool can be run as `python3 -m ledgerlite`. +- Work directly on `main`; commit after every task. + +## Review Focus + +These are the input classes the spec implies but does not spell out. Each line names the behavior a reasonable user expects and the task whose tests pin it. + +1. **An existing-but-empty or header-only CSV** — an empty ledger, not a crash and not an error: zero transactions, report prints just the blank line and `closing balance: ` (Task 2, Task 7). +2. **A description containing a comma or double quotes** (`"AMZN, INC"`) — must be read with the `csv` module, not `str.split(",")`, or every such row becomes a bogus "wrong column count" error (Task 2). +3. **Error line numbers when the file has blank lines or a quoted field containing a newline** — the reported line must be the physical line of the bad row, and a wrong or missing header must be named as such rather than reported as a bad date (Task 2). +4. **A total that is exactly zero, or an amount written `-0.00`** — prints `0.00`, never `-0.00`; the spec reserves the leading `-` for negatives (Task 6). +5. **A rules file with blank lines, no `=`, an empty substring, or that cannot be read** — junk lines are skipped rather than crashing, and an unreadable `--rules` path fails the same way an unreadable CSV does (`cannot read`, exit 1) (Task 3, Task 7). + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `ledgerlite/__init__.py` | Empty package marker. | +| `ledgerlite/model.py` | `Transaction` frozen dataclass. | +| `ledgerlite/parse.py` | `ParseError`; field validators `parse_date` / `parse_amount`; `parse_transactions` (CSV text → `list[Transaction]`). | +| `ledgerlite/rules.py` | `parse_rules` (rules text → ordered pairs); `categorize`. | +| `ledgerlite/balance.py` | `order_by_date`; `closing_balance`. | +| `ledgerlite/report.py` | `category_totals`; `format_amount`; `format_report`. | +| `ledgerlite/cli.py` | argparse wiring, file reading, exit codes; `main(argv) -> int`. | +| `ledgerlite/__main__.py` | `sys.exit(main())` so `python3 -m ledgerlite` works. | +| `test_parse.py`, `test_rules.py`, `test_balance.py`, `test_report.py`, `test_cli.py` | Root-level unittest modules. | + +--- + +### Task 1: Package skeleton, `Transaction`, and field validators + +**Files:** +- Create: `ledgerlite/__init__.py` (empty), `ledgerlite/model.py`, `ledgerlite/parse.py`, `.gitignore` +- Test: `test_parse.py` + +**Interfaces:** +- Consumes: nothing. +- Produces: + - `ledgerlite.model.Transaction` — frozen dataclass with fields `date: datetime.date`, `amount: decimal.Decimal`, `description: str` (in that order). + - `ledgerlite.parse.ParseError(Exception)` — constructed as `ParseError(line: int, message: str)`, exposing `.line: int` and `.message: str`. + - `ledgerlite.parse.parse_date(value: str) -> datetime.date` — raises `ValueError("invalid date: ")`. + - `ledgerlite.parse.parse_amount(value: str) -> decimal.Decimal` — raises `ValueError("invalid amount: ")` or `ValueError("amount has more than two fractional digits: ")`. + +- [ ] **Step 1: Write the failing tests** + +```python +# test_parse.py +import unittest +from datetime import date +from decimal import Decimal + +from ledgerlite.model import Transaction +from ledgerlite.parse import ParseError, parse_amount, parse_date + + +class TransactionTest(unittest.TestCase): + def test_holds_date_amount_and_description(self): + txn = Transaction(date(2026, 3, 4), Decimal("-7.50"), "Coffee") + self.assertEqual(txn.date, date(2026, 3, 4)) + self.assertEqual(txn.amount, Decimal("-7.50")) + self.assertEqual(txn.description, "Coffee") + + +class ParseErrorTest(unittest.TestCase): + def test_exposes_line_and_message(self): + err = ParseError(7, "invalid amount: abc") + self.assertEqual(err.line, 7) + self.assertEqual(err.message, "invalid amount: abc") + + +class ParseDateTest(unittest.TestCase): + def test_parses_iso_date(self): + self.assertEqual(parse_date("2026-03-04"), date(2026, 3, 4)) + + def test_rejects_non_iso_and_impossible_dates(self): + for value in ("03/04/2026", "20260304", "2026-13-40", "2026-3-4", "", "today"): + with self.subTest(value=value): + with self.assertRaises(ValueError) as caught: + parse_date(value) + self.assertEqual(str(caught.exception), f"invalid date: {value}") + + +class ParseAmountTest(unittest.TestCase): + def test_parses_zero_one_and_two_fractional_digits(self): + self.assertEqual(parse_amount("-7.50"), Decimal("-7.50")) + self.assertEqual(parse_amount("1.5"), Decimal("1.5")) + self.assertEqual(parse_amount("1200"), Decimal("1200")) + self.assertEqual(parse_amount("+5"), Decimal("5")) + + def test_rejects_more_than_two_fractional_digits(self): + with self.assertRaises(ValueError) as caught: + parse_amount("1.005") + self.assertEqual( + str(caught.exception), + "amount has more than two fractional digits: 1.005", + ) + + def test_rejects_values_that_are_not_decimal_numbers(self): + for value in ("abc", "", "1e5", "NaN", "Infinity", "1.2.3", "1,50", "1."): + with self.subTest(value=value): + with self.assertRaises(ValueError) as caught: + parse_amount(value) + self.assertEqual(str(caught.exception), f"invalid amount: {value}") +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_parse -v` +Expected: FAIL with `ModuleNotFoundError: No module named 'ledgerlite'` + +- [ ] **Step 3: Create the package and `Transaction` in `ledgerlite/model.py`** + +Empty `ledgerlite/__init__.py`. `Transaction` is a `@dataclass(frozen=True)`. Add `.gitignore` containing `__pycache__/`. + +- [ ] **Step 4: Implement `ParseError`, `parse_date`, `parse_amount` in `ledgerlite/parse.py`** + +`ParseError.__init__(self, line, message)` stores both attributes and calls `super().__init__(message)`. + +`parse_date` must reject anything that is not exactly `YYYY-MM-DD`, and neither stdlib option does that alone: `date.fromisoformat` also accepts `20260304`, and `strptime("%Y-%m-%d")` also accepts the unpadded `2026-3-4`. So guard with `re.compile(r"\d{4}-\d{2}-\d{2}").fullmatch(value)` first, then `datetime.datetime.strptime(value, "%Y-%m-%d").date()` for calendar validity, converting either failure into the `invalid date:` message. + +`parse_amount` must distinguish the two failures, so validate the shape before constructing the `Decimal`: + +```python +_AMOUNT = re.compile(r"[+-]?\d+(\.\d+)?") + +def parse_amount(value: str) -> Decimal: + if not _AMOUNT.fullmatch(value): + raise ValueError(f"invalid amount: {value}") + _, _, fraction = value.partition(".") + if len(fraction) > 2: + raise ValueError(f"amount has more than two fractional digits: {value}") + return Decimal(value) +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `python3 -m unittest test_parse -v` +Expected: PASS (all tests) + +- [ ] **Step 6: Commit** + +```bash +git add .gitignore ledgerlite/__init__.py ledgerlite/model.py ledgerlite/parse.py test_parse.py +git commit -m "feat: add Transaction model and transaction field validators" +``` + +--- + +### Task 2: `parse_transactions` + +**Files:** +- Modify: `ledgerlite/parse.py` +- Test: `test_parse.py` + +**Interfaces:** +- Consumes: `Transaction`, `ParseError`, `parse_date`, `parse_amount` from Task 1. +- Produces: `ledgerlite.parse.parse_transactions(text: str) -> list[Transaction]` — returns transactions in input order, raising `ParseError` on the first malformed row. + +Pinned decisions (the spec leaves these open): + +- Read with `csv.reader(io.StringIO(text))` and take line numbers from `reader.line_num`, so quoted commas and quoted newlines are handled and reported lines are physical lines. +- Rows that parse to `[]` (blank lines) are skipped anywhere in the file. +- A file with no non-blank rows at all (`""`, `"\n\n"`) is an empty ledger: return `[]`. A header-only file likewise returns `[]`. +- The first non-blank row is the header. It is valid when its three fields, stripped and lowercased, are `date`, `amount`, `description`; otherwise `ParseError(line, "expected header date,amount,description")`. +- Every field is stripped of surrounding whitespace before use, including the description. +- Wrong column count → `ParseError(line, "expected 3 columns, got ")`. +- A `ValueError` from `parse_date`/`parse_amount` becomes `ParseError(line, str(err))`. + +- [ ] **Step 1: Write the failing tests (append to `test_parse.py`)** + +```python +from ledgerlite.parse import parse_transactions + +HEADER = "date,amount,description\n" + + +class ParseTransactionsTest(unittest.TestCase): + def test_returns_rows_in_input_order(self): + text = HEADER + "2026-03-05,2500.00,Salary\n2026-03-04,-7.50,Coffee\n" + self.assertEqual( + parse_transactions(text), + [ + Transaction(date(2026, 3, 5), Decimal("2500.00"), "Salary"), + Transaction(date(2026, 3, 4), Decimal("-7.50"), "Coffee"), + ], + ) + + def test_empty_and_header_only_files_have_no_transactions(self): + for text in ("", "\n\n", HEADER, HEADER + "\n"): + with self.subTest(text=text): + self.assertEqual(parse_transactions(text), []) + + def test_strips_whitespace_around_fields(self): + text = HEADER + " 2026-03-04 , -7.50 , Coffee \n" + self.assertEqual( + parse_transactions(text), + [Transaction(date(2026, 3, 4), Decimal("-7.50"), "Coffee")], + ) + + def test_reads_quoted_description_containing_a_comma(self): + text = HEADER + '2026-03-04,-7.50,"COFFEE, LTD"\n' + self.assertEqual(parse_transactions(text)[0].description, "COFFEE, LTD") + + def test_skips_blank_lines_and_reports_physical_line_numbers(self): + text = HEADER + "\n2026-03-04,-7.50,Coffee\n2026-03-05,abc,Rent\n" + with self.assertRaises(ParseError) as caught: + parse_transactions(text) + self.assertEqual(caught.exception.line, 4) + self.assertEqual(caught.exception.message, "invalid amount: abc") + + def test_reports_physical_line_after_a_quoted_newline(self): + text = HEADER + '2026-03-04,-7.50,"COFFEE\nLTD"\n2026-03-05,abc,Rent\n' + with self.assertRaises(ParseError) as caught: + parse_transactions(text) + self.assertEqual(caught.exception.line, 4) + + def test_rejects_wrong_column_count(self): + for row, count in (("2026-03-04,-7.50,Coffee,extra\n", 4), ("2026-03-04,-7.50\n", 2)): + with self.subTest(row=row): + with self.assertRaises(ParseError) as caught: + parse_transactions(HEADER + row) + self.assertEqual(caught.exception.line, 2) + self.assertEqual(caught.exception.message, f"expected 3 columns, got {count}") + + def test_rejects_bad_date_with_its_line(self): + with self.assertRaises(ParseError) as caught: + parse_transactions(HEADER + "03/04/2026,-7.50,Coffee\n") + self.assertEqual(caught.exception.line, 2) + self.assertEqual(caught.exception.message, "invalid date: 03/04/2026") + + def test_rejects_a_wrong_header(self): + with self.assertRaises(ParseError) as caught: + parse_transactions("d,a,desc\n2026-03-04,-7.50,Coffee\n") + self.assertEqual(caught.exception.line, 1) + self.assertEqual( + caught.exception.message, "expected header date,amount,description" + ) + + def test_accepts_header_with_odd_case_and_spacing(self): + text = "Date, Amount ,DESCRIPTION\n2026-03-04,-7.50,Coffee\n" + self.assertEqual(len(parse_transactions(text)), 1) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_parse -v` +Expected: FAIL with `ImportError: cannot import name 'parse_transactions'` + +- [ ] **Step 3: Implement `parse_transactions(text: str) -> list[Transaction]` in `ledgerlite/parse.py`** + +Follow the pinned decisions above. A single pass over `csv.reader`: skip `[]` rows; treat the first surviving row as the header and validate it; for later rows check length, then build a `Transaction` from the stripped fields, wrapping `ValueError` in `ParseError(reader.line_num, str(err))`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_parse -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/parse.py test_parse.py +git commit -m "feat: parse transactions CSV into Transaction list" +``` + +--- + +### Task 3: Rules parsing and categorization + +**Files:** +- Create: `ledgerlite/rules.py` +- Test: `test_rules.py` + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: + - `ledgerlite.rules.parse_rules(text: str) -> list[tuple[str, str]]` — ordered `(substring, category)` pairs; substrings are lowercased, categories keep their case. + - `ledgerlite.rules.categorize(description: str, rules: list[tuple[str, str]]) -> str | None` — the category of the first rule whose substring occurs in the lowercased description, else `None`. + +Pinned decisions (the spec leaves these open): a line is split on its **first** `=`, so a category may contain `=` but a substring may not; both sides are stripped; a line that is blank, has no `=`, or has an empty substring or empty category after stripping is skipped rather than raising — the spec defines no exit code for a bad rules file. + +- [ ] **Step 1: Write the failing tests** + +```python +# test_rules.py +import unittest + +from ledgerlite.rules import categorize, parse_rules + + +class ParseRulesTest(unittest.TestCase): + def test_keeps_order_and_lowercases_substrings(self): + self.assertEqual( + parse_rules("Coffee=food\nRENT=housing\n"), + [("coffee", "food"), ("rent", "housing")], + ) + + def test_strips_whitespace_around_both_sides(self): + self.assertEqual(parse_rules(" coffee = food \n"), [("coffee", "food")]) + + def test_splits_on_the_first_equals(self): + self.assertEqual(parse_rules("a=b=c\n"), [("a", "b=c")]) + + def test_skips_blank_and_malformed_lines(self): + text = "\n \nnoequals\n=food\ncoffee=\ncoffee=food\n" + self.assertEqual(parse_rules(text), [("coffee", "food")]) + + def test_empty_text_gives_no_rules(self): + self.assertEqual(parse_rules(""), []) + + +class CategorizeTest(unittest.TestCase): + def test_matches_substring_case_insensitively(self): + self.assertEqual(categorize("MORNING COFFEE", [("coffee", "food")]), "food") + + def test_first_matching_rule_wins(self): + rules = [("coffee", "food"), ("shop", "retail")] + self.assertEqual(categorize("Coffee Shop", rules), "food") + + def test_returns_none_when_nothing_matches(self): + self.assertIsNone(categorize("Salary", [("coffee", "food")])) + + def test_returns_none_with_no_rules(self): + self.assertIsNone(categorize("Coffee", [])) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_rules -v` +Expected: FAIL with `ModuleNotFoundError: No module named 'ledgerlite.rules'` + +- [ ] **Step 3: Implement `parse_rules` and `categorize` in `ledgerlite/rules.py`** + +Use `str.partition("=")` for the first-`=` split; iterate `text.splitlines()`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_rules -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/rules.py test_rules.py +git commit -m "feat: add rules parsing and description categorization" +``` + +--- + +### Task 4: Date ordering and closing balance + +**Files:** +- Create: `ledgerlite/balance.py` +- Test: `test_balance.py` + +**Interfaces:** +- Consumes: `Transaction` from Task 1. +- Produces: + - `ledgerlite.balance.order_by_date(transactions: list[Transaction]) -> list[Transaction]` — ascending by date, input order preserved within a date (a stable sort; do not mutate the argument). + - `ledgerlite.balance.closing_balance(transactions: list[Transaction], opening: Decimal) -> Decimal` — the running balance after the last transaction, accumulated in the order given; `opening` when the list is empty. + +- [ ] **Step 1: Write the failing tests** + +```python +# test_balance.py +import unittest +from datetime import date +from decimal import Decimal + +from ledgerlite.balance import closing_balance, order_by_date +from ledgerlite.model import Transaction + +COFFEE = Transaction(date(2026, 3, 4), Decimal("-7.50"), "Coffee") +RENT = Transaction(date(2026, 3, 2), Decimal("-900.00"), "Rent") +SALARY = Transaction(date(2026, 3, 4), Decimal("2500.00"), "Salary") + + +class OrderByDateTest(unittest.TestCase): + def test_sorts_ascending_by_date(self): + self.assertEqual(order_by_date([COFFEE, RENT]), [RENT, COFFEE]) + + def test_keeps_input_order_within_the_same_date(self): + self.assertEqual(order_by_date([SALARY, RENT, COFFEE]), [RENT, SALARY, COFFEE]) + + def test_does_not_mutate_its_argument(self): + given = [COFFEE, RENT] + order_by_date(given) + self.assertEqual(given, [COFFEE, RENT]) + + def test_empty_list(self): + self.assertEqual(order_by_date([]), []) + + +class ClosingBalanceTest(unittest.TestCase): + def test_adds_every_amount_to_the_opening_balance(self): + ordered = order_by_date([COFFEE, RENT, SALARY]) + self.assertEqual(closing_balance(ordered, Decimal("100")), Decimal("1692.50")) + + def test_no_transactions_leaves_the_opening_balance(self): + self.assertEqual(closing_balance([], Decimal("100")), Decimal("100")) + self.assertEqual(closing_balance([], Decimal("0")), Decimal("0")) + + def test_negative_opening_balance(self): + self.assertEqual(closing_balance([COFFEE], Decimal("-10.00")), Decimal("-17.50")) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_balance -v` +Expected: FAIL with `ModuleNotFoundError: No module named 'ledgerlite.balance'` + +- [ ] **Step 3: Implement `order_by_date` and `closing_balance` in `ledgerlite/balance.py`** + +`sorted(..., key=...)` is already stable, which is what "ties keeping input order" requires. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_balance -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/balance.py test_balance.py +git commit -m "feat: add date ordering and closing balance" +``` + +--- + +### Task 5: Per-category totals + +**Files:** +- Create: `ledgerlite/report.py` +- Test: `test_report.py` + +**Interfaces:** +- Consumes: `Transaction` (Task 1), `categorize` (Task 3). +- Produces: `ledgerlite.report.category_totals(transactions: list[Transaction], rules: list[tuple[str, str]]) -> list[tuple[str, Decimal]]` — one pair per category that has at least one transaction, alphabetical, with `uncategorized` last. + +Pinned decisions (the spec leaves these open): "alphabetically" means case-insensitive, with the raw name as tiebreak (`key=lambda name: (name.lower(), name)`); a category with no matching transaction is not listed at all; if a rules file names a category `uncategorized` it merges into the same `uncategorized` line that unmatched transactions go to, still listed last. + +- [ ] **Step 1: Write the failing tests** + +```python +# test_report.py +import unittest +from datetime import date +from decimal import Decimal + +from ledgerlite.model import Transaction +from ledgerlite.report import category_totals + +RULES = [("coffee", "food"), ("rent", "housing")] +COFFEE = Transaction(date(2026, 3, 4), Decimal("-7.50"), "Coffee") +RENT = Transaction(date(2026, 3, 2), Decimal("-900.00"), "Rent") +SALARY = Transaction(date(2026, 3, 4), Decimal("2500.00"), "Salary") + + +class CategoryTotalsTest(unittest.TestCase): + def test_alphabetical_with_uncategorized_last(self): + self.assertEqual( + category_totals([SALARY, RENT, COFFEE], RULES), + [ + ("food", Decimal("-7.50")), + ("housing", Decimal("-900.00")), + ("uncategorized", Decimal("2500.00")), + ], + ) + + def test_sums_several_transactions_in_one_category(self): + latte = Transaction(date(2026, 3, 6), Decimal("-3.25"), "COFFEE BAR") + self.assertEqual( + category_totals([COFFEE, latte], RULES), [("food", Decimal("-10.75"))] + ) + + def test_omits_categories_with_no_transactions(self): + self.assertEqual(category_totals([COFFEE], RULES), [("food", Decimal("-7.50"))]) + + def test_no_rules_means_everything_is_uncategorized(self): + self.assertEqual( + category_totals([COFFEE, SALARY], []), + [("uncategorized", Decimal("2492.50"))], + ) + + def test_no_transactions_gives_no_totals(self): + self.assertEqual(category_totals([], RULES), []) + + def test_alphabetical_order_is_case_insensitive(self): + rules = [("coffee", "Zoo"), ("rent", "apple")] + self.assertEqual( + [name for name, _ in category_totals([COFFEE, RENT], rules)], + ["apple", "Zoo"], + ) + + def test_a_rule_named_uncategorized_merges_and_stays_last(self): + rules = [("coffee", "uncategorized"), ("rent", "housing")] + self.assertEqual( + category_totals([COFFEE, RENT, SALARY], rules), + [("housing", Decimal("-900.00")), ("uncategorized", Decimal("2492.50"))], + ) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_report -v` +Expected: FAIL with `ModuleNotFoundError: No module named 'ledgerlite.report'` + +- [ ] **Step 3: Implement `category_totals` in `ledgerlite/report.py`** + +Accumulate into a dict keyed by `categorize(...) or "uncategorized"`, then emit the non-`uncategorized` keys sorted by `(name.lower(), name)` followed by `uncategorized` if present. Define the literal once as a module constant `UNCATEGORIZED = "uncategorized"`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_report -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/report.py test_report.py +git commit -m "feat: add per-category totals with uncategorized last" +``` + +--- + +### Task 6: Amount and report formatting + +**Files:** +- Modify: `ledgerlite/report.py` +- Test: `test_report.py` + +**Interfaces:** +- Consumes: `category_totals` output shape from Task 5. +- Produces: + - `ledgerlite.report.format_amount(amount: Decimal) -> str` — exactly two fractional digits, leading `-` only for negatives, no thousands separators. + - `ledgerlite.report.format_report(totals: list[tuple[str, Decimal]], closing: Decimal) -> str` — `: ` per line, then a blank line, then `closing balance: `, ending with a single trailing newline. The blank line is always present, so a ledger with no transactions renders as `"\nclosing balance: 0.00\n"`. + +- [ ] **Step 1: Write the failing tests (append to `test_report.py`)** + +```python +from ledgerlite.report import format_amount, format_report + + +class FormatAmountTest(unittest.TestCase): + def test_always_two_fractional_digits(self): + self.assertEqual(format_amount(Decimal("1.5")), "1.50") + self.assertEqual(format_amount(Decimal("1.50")), "1.50") + self.assertEqual(format_amount(Decimal("0")), "0.00") + self.assertEqual(format_amount(Decimal("1200")), "1200.00") + + def test_negatives_keep_a_leading_minus(self): + self.assertEqual(format_amount(Decimal("-12.5")), "-12.50") + self.assertEqual(format_amount(Decimal("-900.00")), "-900.00") + + def test_no_thousands_separators(self): + self.assertEqual(format_amount(Decimal("1234567.8")), "1234567.80") + + def test_negative_zero_prints_as_zero(self): + self.assertEqual(format_amount(Decimal("-0.00")), "0.00") + self.assertEqual(format_amount(Decimal("-7.50") + Decimal("7.50")), "0.00") + + +class FormatReportTest(unittest.TestCase): + def test_matches_the_design_example(self): + totals = [ + ("food", Decimal("-7.50")), + ("housing", Decimal("-900.00")), + ("uncategorized", Decimal("2500.00")), + ] + self.assertEqual( + format_report(totals, Decimal("1692.50")), + "food: -7.50\n" + "housing: -900.00\n" + "uncategorized: 2500.00\n" + "\n" + "closing balance: 1692.50\n", + ) + + def test_no_categories_still_prints_the_closing_balance(self): + self.assertEqual(format_report([], Decimal("0")), "\nclosing balance: 0.00\n") +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_report -v` +Expected: FAIL with `ImportError: cannot import name 'format_amount'` + +- [ ] **Step 3: Implement `format_amount` and `format_report` in `ledgerlite/report.py`** + +`format_amount` quantizes to two places and normalizes negative zero, then formats with `f"{...:f}"` (never `%f`/`float`): + +```python +def format_amount(amount: Decimal) -> str: + quantized = amount.quantize(Decimal("0.01")) + if quantized == 0: + quantized = abs(quantized) + return f"{quantized:f}" +``` + +`format_report` joins `f"{name}: {format_amount(total)}"` lines, then `""`, then `f"closing balance: {format_amount(closing)}"`, with `"\n".join(...) + "\n"`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_report -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/report.py test_report.py +git commit -m "feat: add amount and report formatting" +``` + +--- + +### Task 7: CLI + +**Files:** +- Create: `ledgerlite/cli.py`, `ledgerlite/__main__.py` +- Test: `test_cli.py` + +**Interfaces:** +- Consumes: `parse_transactions`, `ParseError`, `parse_amount` (Tasks 1–2); `parse_rules` (Task 3); `order_by_date`, `closing_balance` (Task 4); `category_totals`, `format_report` (Tasks 5–6). +- Produces: `ledgerlite.cli.main(argv: list[str] | None = None) -> int` — writes the report to stdout, errors to stderr, and returns the exit code without calling `sys.exit`. + +Argument surface: `ledgerlite report TRANSACTIONS [--rules RULES] [--opening AMOUNT]` via `ArgumentParser(prog="ledgerlite")` plus a required subparser named `report`. `--opening` defaults to `Decimal("0")` and uses a `type=` converter that calls `parse_amount` and re-raises failures as `argparse.ArgumentTypeError(str(err))`, so `--opening 1.005` is rejected the same way a row amount is. + +Pinned decisions (the spec leaves these open): an unreadable `--rules` path produces the same `cannot read` message and exit 1 as an unreadable transactions file; the reason text is the OS message (`err.strerror or str(err)`); reading is `utf-8`, and a decoding failure is also a `cannot read`. + +- [ ] **Step 1: Write the failing tests** + +```python +# test_cli.py +import contextlib +import io +import pathlib +import subprocess +import sys +import tempfile +import unittest + +from ledgerlite.cli import main + +TRANSACTIONS = ( + "date,amount,description\n" + "2026-03-04,-7.50,Coffee\n" + "2026-03-02,-900.00,Rent\n" + "2026-03-04,2500.00,Salary\n" +) +RULES = "coffee=food\nrent=housing\n" +EXPECTED = ( + "food: -7.50\n" + "housing: -900.00\n" + "uncategorized: 2500.00\n" + "\n" + "closing balance: 1692.50\n" +) + + +def run(argv): + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = main(argv) + return code, out.getvalue(), err.getvalue() + + +class CliTest(unittest.TestCase): + def setUp(self): + self.dir = pathlib.Path(tempfile.mkdtemp()) + + def write(self, name, text): + path = self.dir / name + path.write_text(text, encoding="utf-8") + return str(path) + + def test_prints_the_design_example(self): + csv_path = self.write("t.csv", TRANSACTIONS) + rules_path = self.write("r.txt", RULES) + code, out, err = run( + ["report", csv_path, "--rules", rules_path, "--opening", "100"] + ) + self.assertEqual((code, out, err), (0, EXPECTED, "")) + + def test_without_rules_everything_is_uncategorized(self): + code, out, _ = run(["report", self.write("t.csv", TRANSACTIONS)]) + self.assertEqual(code, 0) + self.assertEqual( + out, "uncategorized: 1592.50\n\nclosing balance: 1592.50\n" + ) + + def test_empty_file_reports_the_opening_balance(self): + code, out, err = run(["report", self.write("t.csv", ""), "--opening", "100"]) + self.assertEqual((code, out, err), (0, "\nclosing balance: 100.00\n", "")) + + def test_negative_opening_balance(self): + csv_path = self.write("t.csv", "date,amount,description\n2026-03-04,-7.50,Coffee\n") + code, out, _ = run(["report", csv_path, "--opening", "-12.50"]) + self.assertEqual(code, 0) + self.assertIn("closing balance: -20.00\n", out) + + def test_unreadable_transactions_file_returns_1(self): + missing = str(self.dir / "nope.csv") + code, out, err = run(["report", missing]) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertTrue(err.startswith(f"ledgerlite: cannot read {missing}: ")) + self.assertIn("No such file or directory", err) + + def test_unreadable_rules_file_returns_1(self): + csv_path = self.write("t.csv", TRANSACTIONS) + missing = str(self.dir / "nope.txt") + code, out, err = run(["report", csv_path, "--rules", missing]) + self.assertEqual(code, 1) + self.assertEqual(out, "") + self.assertTrue(err.startswith(f"ledgerlite: cannot read {missing}: ")) + + def test_malformed_row_returns_2_and_prints_nothing_to_stdout(self): + csv_path = self.write( + "t.csv", "date,amount,description\n2026-03-04,-7.50,Coffee\n2026-03-05,abc,Rent\n" + ) + code, out, err = run(["report", csv_path]) + self.assertEqual(code, 2) + self.assertEqual(out, "") + self.assertEqual(err, f"ledgerlite: {csv_path}:3: invalid amount: abc\n") + + def test_rejects_an_opening_amount_with_three_fractional_digits(self): + csv_path = self.write("t.csv", TRANSACTIONS) + with contextlib.redirect_stderr(io.StringIO()): + with self.assertRaises(SystemExit) as caught: + main(["report", csv_path, "--opening", "1.005"]) + self.assertEqual(caught.exception.code, 2) + + +class ModuleEntryPointTest(unittest.TestCase): + def test_runs_as_python_m_ledgerlite(self): + root = pathlib.Path(__file__).parent + with tempfile.TemporaryDirectory() as tmp: + csv_path = pathlib.Path(tmp) / "t.csv" + csv_path.write_text(TRANSACTIONS, encoding="utf-8") + rules_path = pathlib.Path(tmp) / "r.txt" + rules_path.write_text(RULES, encoding="utf-8") + done = subprocess.run( + [ + sys.executable, "-m", "ledgerlite", "report", str(csv_path), + "--rules", str(rules_path), "--opening", "100", + ], + capture_output=True, text=True, cwd=root, + ) + self.assertEqual(done.returncode, 0, done.stderr) + self.assertEqual(done.stdout, EXPECTED) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_cli -v` +Expected: FAIL with `ModuleNotFoundError: No module named 'ledgerlite.cli'` + +- [ ] **Step 3: Implement `main(argv: list[str] | None = None) -> int` in `ledgerlite/cli.py`** + +Build the parser as described above, then: read the transactions file → `parse_transactions` → read and parse the rules file if `--rules` was given → `order_by_date` → `closing_balance` → `category_totals` → `sys.stdout.write(format_report(...))` → return 0. Because nothing may reach stdout on failure, do all reading and parsing before the first write. + +Two small helpers keep the error paths honest: + +```python +def _read(path: str) -> str: + return pathlib.Path(path).read_text(encoding="utf-8") + + +def _reason(err: Exception) -> str: + return getattr(err, "strerror", None) or str(err) +``` + +Each read is wrapped in `except (OSError, UnicodeDecodeError) as err:` → print `f"ledgerlite: cannot read {path}: {_reason(err)}"` to `sys.stderr` and return 1. `parse_transactions` is wrapped in `except ParseError as err:` → print `f"ledgerlite: {path}:{err.line}: {err.message}"` to `sys.stderr` and return 2. + +- [ ] **Step 4: Create `ledgerlite/__main__.py`** + +`sys.exit(main())` under an `if __name__ == "__main__":` guard, importing `main` from `.cli`. + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `python3 -m unittest test_cli -v` +Expected: PASS + +- [ ] **Step 6: Run the whole suite** + +Run: `python3 -m unittest -v` +Expected: PASS, all five test modules, zero failures and zero errors + +- [ ] **Step 7: Commit** + +```bash +git add ledgerlite/cli.py ledgerlite/__main__.py test_cli.py +git commit -m "feat: add ledgerlite report CLI" +``` diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/Q2-reader-recipe-ledgerlite-73.plan.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/Q2-reader-recipe-ledgerlite-73.plan.md new file mode 100644 index 000000000..597f0508b --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/Q2-reader-recipe-ledgerlite-73.plan.md @@ -0,0 +1,956 @@ +# ledgerlite Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build `ledgerlite`, a stdlib-only CLI that reads a transactions CSV, categorizes each row from a rules file, and prints per-category totals plus the closing balance. + +**Architecture:** Six small modules, each one responsibility, wired bottom-up: `model` (data), `parse` (CSV text → transactions), `rules` (rules text → matchers), `balance` (ordering and closing balance), `report` (totals and formatting), `cli` (argparse, file I/O, exit codes). All I/O and all user-facing error text live in `cli`; the lower modules take and return strings and objects and raise `ParseError`, so every rule is unit-testable without touching the filesystem. + +**Tech Stack:** Python 3.11+, standard library only (`csv`, `decimal`, `datetime`, `dataclasses`, `argparse`, `re`, `unittest`). + +**Spec:** `design.md` (repo root) + +## Global Constraints + +- Python 3.11+. Standard library only — no third-party dependencies. +- Money is `decimal.Decimal` everywhere. Never `float`, at any point, including in tests. +- Package lives at `ledgerlite/` in the repo root. Tests live at the repo root as `test_.py` and run with `python3 -m unittest` from the repo root. +- Exit codes: `0` success, `1` a named input file cannot be read, `2` input content is malformed (includes argparse usage errors). +- All error text goes to stderr, one line, ending in a newline. On any error nothing is written to stdout. +- Fixed error message formats (used verbatim, `{}` filled in): + - `ledgerlite: cannot read {path}: {reason}` + - `ledgerlite: {path}:{line}: {message}` + - `ledgerlite: --opening: {message}` +- Fixed `{message}` values (`!r` means Python `repr`, e.g. `'1.005'`): + - `expected header 'date,amount,description'` + - `expected 3 columns, got {n}` + - `invalid date {value!r}` + - `invalid amount {value!r}` + - `amount {value!r} has more than two decimal places` + - `rule has no '='` +- Amounts print with exactly two fractional digits, a leading `-` only for values below zero, and no thousands separators. +- `line` in messages is the 1-based physical line number of the input file, so the first data row of a CSV is line 2. + +## Review Focus + +These are input classes the spec implies but does not enumerate. Each has a test in the task that owns the code; they are listed here because they are the ones most likely to bite a real user. + +1. **Amount strings `Decimal()` happily accepts but a ledger must not** — `NaN`, `Infinity`, `1e5`, `""`. `Decimal("NaN")` succeeds and then poisons every sum, so amounts must be validated by regex before construction, not by catching `InvalidOperation`. (Task 1) +2. **Sloppy or impossible dates** — `date.fromisoformat` accepts the compact form `20260304`, so a shape check must come first; `2026-3-4`, `2026-02-30`, and `04/03/2026` must all be rejected as malformed rather than silently reinterpreted. (Task 1) +3. **Real-world CSV shape** — a UTF-8 BOM from a spreadsheet export, CRLF line endings, a quoted description containing a comma, and a missing or misnamed header row. A file whose header is absent must not have its first transaction silently swallowed as a header. (Tasks 2 and 7) +4. **Zero and negative zero** — a category whose amounts cancel, and a literal `-0.00` in the input, must both print `0.00`, not `-0.00`, and a transaction-free file must still print a closing balance. (Task 6) +5. **Unreadable inputs other than "file not found"** — a directory passed as the transactions path, a file containing non-UTF-8 bytes, and an unreadable `--rules` path all take the exit-1 "cannot read" path, not a traceback. (Task 7) + +--- + +## File Structure + +| File | Responsibility | +| --- | --- | +| `ledgerlite/__init__.py` | Empty package marker. | +| `ledgerlite/model.py` | `Transaction` frozen dataclass. No logic. | +| `ledgerlite/parse.py` | `ParseError`; field parsers (`parse_amount`, `parse_date`); `parse_transactions(text)`. | +| `ledgerlite/rules.py` | `parse_rules(text)`; `categorize(description, rules)`. | +| `ledgerlite/balance.py` | `order_by_date`; `closing_balance`. | +| `ledgerlite/report.py` | `category_totals`; `format_amount`; `format_report`. | +| `ledgerlite/cli.py` | argparse wiring, file reading, error messages, exit codes, `main(argv) -> int`. | +| `ledgerlite/__main__.py` | `sys.exit(main())` so `python3 -m ledgerlite` works. | +| `test_parse.py`, `test_rules.py`, `test_balance.py`, `test_report.py`, `test_cli.py` | Unit tests per module; `test_cli.py` also holds the end-to-end tests. | + +--- + +## Task 1: Transaction model and field parsers + +**Files:** +- Create: `ledgerlite/__init__.py` (empty) +- Create: `ledgerlite/model.py` +- Create: `ledgerlite/parse.py` +- Test: `test_parse.py` + +**Interfaces:** +- Consumes: nothing. +- Produces: + - `model.Transaction` — frozen dataclass, fields `date: datetime.date`, `amount: decimal.Decimal`, `description: str`. + - `parse.ParseError(Exception)` — `__init__(self, line: int, message: str)`, attributes `.line: int` and `.message: str`, `str(err) == err.message`. + - `parse.parse_amount(text: str) -> decimal.Decimal` — raises `ValueError`. + - `parse.parse_date(text: str) -> datetime.date` — raises `ValueError`. + - Neither field parser strips whitespace; callers strip first. + +- [ ] **Step 1: Write the failing tests** + +Create `test_parse.py`: + +```python +import dataclasses +import datetime +import unittest +from decimal import Decimal + +from ledgerlite.model import Transaction +from ledgerlite.parse import ParseError, parse_amount, parse_date + + +class TransactionTest(unittest.TestCase): + def test_holds_three_fields_and_is_frozen(self): + txn = Transaction( + date=datetime.date(2026, 3, 4), + amount=Decimal("-7.50"), + description="Coffee Bar", + ) + self.assertEqual(txn.date, datetime.date(2026, 3, 4)) + self.assertEqual(txn.amount, Decimal("-7.50")) + self.assertEqual(txn.description, "Coffee Bar") + with self.assertRaises(dataclasses.FrozenInstanceError): + txn.amount = Decimal("0") + + +class ParseErrorTest(unittest.TestCase): + def test_carries_line_and_message(self): + err = ParseError(7, "invalid amount 'abc'") + self.assertEqual(err.line, 7) + self.assertEqual(err.message, "invalid amount 'abc'") + self.assertEqual(str(err), "invalid amount 'abc'") + + +class ParseAmountTest(unittest.TestCase): + def test_accepts_zero_one_or_two_fractional_digits(self): + self.assertEqual(parse_amount("-12.50"), Decimal("-12.50")) + self.assertEqual(parse_amount("1.5"), Decimal("1.5")) + self.assertEqual(parse_amount("2500"), Decimal("2500")) + self.assertEqual(parse_amount("+7.25"), Decimal("7.25")) + self.assertEqual(parse_amount("-0.00"), Decimal("-0.00")) + + def test_rejects_more_than_two_fractional_digits(self): + with self.assertRaises(ValueError) as ctx: + parse_amount("1.005") + self.assertEqual( + str(ctx.exception), "amount '1.005' has more than two decimal places" + ) + + def test_rejects_non_decimal_text(self): + for bad in ("", "abc", "NaN", "nan", "Infinity", "-inf", "1e5", + "1,000.00", "$5.00", "1.2.3", "1.", ".5", " 1.50"): + with self.subTest(bad=bad): + with self.assertRaises(ValueError) as ctx: + parse_amount(bad) + self.assertEqual(str(ctx.exception), f"invalid amount {bad!r}") + + +class ParseDateTest(unittest.TestCase): + def test_accepts_iso_date(self): + self.assertEqual(parse_date("2026-03-04"), datetime.date(2026, 3, 4)) + + def test_rejects_sloppy_and_impossible_dates(self): + for bad in ("", "2026-3-4", "20260304", "2026-02-30", "2026-13-01", + "04/03/2026", "2026-03-04T10:00:00", "not a date"): + with self.subTest(bad=bad): + with self.assertRaises(ValueError) as ctx: + parse_date(bad) + self.assertEqual(str(ctx.exception), f"invalid date {bad!r}") +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_parse -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite'` + +- [ ] **Step 3: Create the package and `Transaction` in `ledgerlite/model.py`** + +Empty `ledgerlite/__init__.py`. `Transaction` is `@dataclasses.dataclass(frozen=True)` with the three fields in the order `date, amount, description`. + +- [ ] **Step 4: Implement `ParseError`, `parse_amount`, and `parse_date` in `ledgerlite/parse.py`** + +`parse_amount` matches `^[+-]?\d+(?:\.\d+)?$` (fullmatch) first — anything else raises `ValueError(f"invalid amount {text!r}")`; then, if a `.` is present and more than two digits follow it, raises `ValueError(f"amount {text!r} has more than two decimal places")`; then returns `Decimal(text)`. + +`parse_date` matches `^\d{4}-\d{2}-\d{2}$` (fullmatch) first, then returns `datetime.date.fromisoformat(text)`, converting its `ValueError` into `ValueError(f"invalid date {text!r}")`. + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `python3 -m unittest test_parse -v` +Expected: PASS (7 tests) + +- [ ] **Step 6: Commit** + +```bash +git add ledgerlite/__init__.py ledgerlite/model.py ledgerlite/parse.py test_parse.py +git commit -m "feat: add Transaction model and amount/date field parsers" +``` + +--- + +## Task 2: CSV row parsing + +**Files:** +- Modify: `ledgerlite/parse.py` (add `parse_transactions`) +- Test: `test_parse.py` (add cases) + +**Interfaces:** +- Consumes: `Transaction`, `ParseError`, `parse_amount`, `parse_date` from Task 1. +- Produces: `parse.parse_transactions(text: str) -> list[Transaction]` — returns transactions in input order (no sorting), raises `ParseError` on the first bad line. Line 1 must be the header; data rows start at line 2. + +- [ ] **Step 1: Write the failing tests** + +Append to `test_parse.py` (add `from ledgerlite.parse import parse_transactions` to the imports): + +```python +CSV = """date,amount,description +2026-03-01,-900.00,Rent March +2026-03-04,-7.50,Coffee Bar +""" + + +class ParseTransactionsTest(unittest.TestCase): + def test_returns_rows_in_input_order(self): + txns = parse_transactions(CSV) + self.assertEqual(len(txns), 2) + self.assertEqual(txns[0].description, "Rent March") + self.assertEqual(txns[0].amount, Decimal("-900.00")) + self.assertEqual(txns[0].date, datetime.date(2026, 3, 1)) + self.assertEqual(txns[1].description, "Coffee Bar") + + def test_header_only_file_has_no_transactions(self): + self.assertEqual(parse_transactions("date,amount,description\n"), []) + + def test_header_may_vary_in_case_and_spacing(self): + text = "Date, Amount , DESCRIPTION\n2026-03-04,-7.50,Coffee Bar\n" + self.assertEqual(len(parse_transactions(text)), 1) + + def test_rejects_missing_or_wrong_header(self): + for text in ("", "2026-03-04,-7.50,Coffee Bar\n", "date,amount\n"): + with self.subTest(text=text): + with self.assertRaises(ParseError) as ctx: + parse_transactions(text) + self.assertEqual(ctx.exception.line, 1) + self.assertEqual( + ctx.exception.message, + "expected header 'date,amount,description'", + ) + + def test_reports_wrong_column_count_with_line_number(self): + text = "date,amount,description\n2026-03-01,-900.00,Rent\n2026-03-04,-7.50\n" + with self.assertRaises(ParseError) as ctx: + parse_transactions(text) + self.assertEqual(ctx.exception.line, 3) + self.assertEqual(ctx.exception.message, "expected 3 columns, got 2") + + def test_blank_line_is_a_column_count_error(self): + text = "date,amount,description\n\n2026-03-04,-7.50,Coffee\n" + with self.assertRaises(ParseError) as ctx: + parse_transactions(text) + self.assertEqual(ctx.exception.line, 2) + self.assertEqual(ctx.exception.message, "expected 3 columns, got 0") + + def test_propagates_field_errors_with_line_number(self): + cases = [ + ("2026-13-01,-7.50,Coffee", "invalid date '2026-13-01'"), + ("2026-03-04,abc,Coffee", "invalid amount 'abc'"), + ("2026-03-04,1.005,Coffee", "amount '1.005' has more than two decimal places"), + ] + for row, message in cases: + with self.subTest(row=row): + with self.assertRaises(ParseError) as ctx: + parse_transactions(f"date,amount,description\n{row}\n") + self.assertEqual(ctx.exception.line, 2) + self.assertEqual(ctx.exception.message, message) + + def test_quoted_description_may_contain_a_comma(self): + text = 'date,amount,description\n2026-03-04,-7.50,"Coffee, large"\n' + self.assertEqual(parse_transactions(text)[0].description, "Coffee, large") + + def test_accepts_crlf_line_endings(self): + text = "date,amount,description\r\n2026-03-04,-7.50,Coffee Bar\r\n" + self.assertEqual(parse_transactions(text)[0].description, "Coffee Bar") + + def test_strips_surrounding_whitespace_from_fields(self): + text = "date,amount,description\n 2026-03-04 , -7.50 , Coffee Bar \n" + txn = parse_transactions(text)[0] + self.assertEqual(txn.date, datetime.date(2026, 3, 4)) + self.assertEqual(txn.amount, Decimal("-7.50")) + self.assertEqual(txn.description, "Coffee Bar") + + def test_two_rows_may_share_a_date(self): + text = ("date,amount,description\n" + "2026-03-04,-7.50,Coffee\n2026-03-04,-2.00,Tea\n") + self.assertEqual(len(parse_transactions(text)), 2) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_parse -v` +Expected: FAIL — `ImportError: cannot import name 'parse_transactions'` + +- [ ] **Step 3: Implement `parse_transactions(text: str) -> list[Transaction]` in `ledgerlite/parse.py`** + +Read rows with `csv.reader(text.splitlines())`, enumerating from line 1. Line 1 is the header: accept it when its fields, stripped and lowercased, equal `["date", "amount", "description"]`; otherwise raise. No rows at all (empty text) is also a header error at line 1. For each later row: reject when `len(row) != 3`, then strip each field and pass them to `parse_date` / `parse_amount`, converting a `ValueError` into `ParseError(line, str(err))`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_parse -v` +Expected: PASS (18 tests) + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/parse.py test_parse.py +git commit -m "feat: parse transactions CSV with per-line error reporting" +``` + +--- + +## Task 3: Rules parsing and categorization + +**Files:** +- Create: `ledgerlite/rules.py` +- Test: `test_rules.py` + +**Interfaces:** +- Consumes: `ParseError` from Task 1. +- Produces: + - `rules.parse_rules(text: str) -> list[tuple[str, str]]` — `(substring, category)` pairs in file order, substring lowercased, category kept as written; raises `ParseError` for a line with no `=`. + - `rules.categorize(description: str, rules: list[tuple[str, str]]) -> str | None` — first matching rule wins, matching case-insensitively. + +- [ ] **Step 1: Write the failing tests** + +Create `test_rules.py`: + +```python +import unittest + +from ledgerlite.parse import ParseError +from ledgerlite.rules import categorize, parse_rules + + +class ParseRulesTest(unittest.TestCase): + def test_returns_pairs_in_file_order(self): + self.assertEqual( + parse_rules("coffee=food\nrent=housing\n"), + [("coffee", "food"), ("rent", "housing")], + ) + + def test_lowercases_substring_and_keeps_category_case(self): + self.assertEqual(parse_rules("Coffee=Food & Drink\n"), + [("coffee", "Food & Drink")]) + + def test_strips_whitespace_around_line_and_parts(self): + self.assertEqual(parse_rules(" coffee = food \n"), [("coffee", "food")]) + + def test_skips_blank_lines(self): + self.assertEqual(parse_rules("\ncoffee=food\n\n \n"), + [("coffee", "food")]) + + def test_empty_text_has_no_rules(self): + self.assertEqual(parse_rules(""), []) + + def test_splits_on_first_equals_only(self): + self.assertEqual(parse_rules("a=b=c\n"), [("a", "b=c")]) + + def test_line_without_equals_is_an_error_with_its_line_number(self): + with self.assertRaises(ParseError) as ctx: + parse_rules("coffee=food\noops\n") + self.assertEqual(ctx.exception.line, 2) + self.assertEqual(ctx.exception.message, "rule has no '='") + + +class CategorizeTest(unittest.TestCase): + RULES = [("coffee", "food"), ("rent", "housing")] + + def test_matches_substring_case_insensitively(self): + self.assertEqual(categorize("BIG COFFEE BAR", self.RULES), "food") + self.assertEqual(categorize("Rent March", self.RULES), "housing") + + def test_first_matching_rule_wins(self): + rules = [("coffee", "food"), ("bar", "leisure")] + self.assertEqual(categorize("Coffee Bar", rules), "food") + + def test_returns_none_when_nothing_matches(self): + self.assertIsNone(categorize("Salary", self.RULES)) + + def test_returns_none_when_there_are_no_rules(self): + self.assertIsNone(categorize("Coffee Bar", [])) + + def test_empty_substring_matches_everything(self): + self.assertEqual(categorize("Salary", [("", "other")]), "other") +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_rules -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.rules'` + +- [ ] **Step 3: Implement `parse_rules` and `categorize` in `ledgerlite/rules.py`** + +`parse_rules`: enumerate `text.splitlines()` from 1, strip each line, skip empties, `partition("=")` and raise `ParseError(line, "rule has no '='")` when there is no separator, then append `(substring.strip().lower(), category.strip())`. + +`categorize`: lowercase the description once, return the first category whose substring is `in` it, else `None`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_rules -v` +Expected: PASS (12 tests) + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/rules.py test_rules.py +git commit -m "feat: add rules file parsing and description categorization" +``` + +--- + +## Task 4: Date ordering and closing balance + +**Files:** +- Create: `ledgerlite/balance.py` +- Test: `test_balance.py` + +**Interfaces:** +- Consumes: `Transaction` from Task 1. +- Produces: + - `balance.order_by_date(transactions: Iterable[Transaction]) -> list[Transaction]` — stable sort by `date`, ties keep input order; does not mutate its argument. + - `balance.closing_balance(opening: Decimal, transactions: Iterable[Transaction]) -> Decimal` — `opening` plus each amount added in the order given. + +- [ ] **Step 1: Write the failing tests** + +Create `test_balance.py`: + +```python +import datetime +import unittest +from decimal import Decimal + +from ledgerlite.balance import closing_balance, order_by_date +from ledgerlite.model import Transaction + + +def txn(day, amount, description): + return Transaction( + date=datetime.date(2026, 3, day), + amount=Decimal(amount), + description=description, + ) + + +class OrderByDateTest(unittest.TestCase): + def test_sorts_by_date(self): + rows = [txn(4, "-7.50", "Coffee"), txn(1, "-900.00", "Rent")] + self.assertEqual([t.description for t in order_by_date(rows)], + ["Rent", "Coffee"]) + + def test_ties_keep_input_order(self): + rows = [txn(4, "-7.50", "Coffee"), txn(4, "-2.00", "Tea"), + txn(1, "-900.00", "Rent")] + self.assertEqual([t.description for t in order_by_date(rows)], + ["Rent", "Coffee", "Tea"]) + + def test_does_not_mutate_input(self): + rows = [txn(4, "-7.50", "Coffee"), txn(1, "-900.00", "Rent")] + order_by_date(rows) + self.assertEqual([t.description for t in rows], ["Coffee", "Rent"]) + + def test_empty_input(self): + self.assertEqual(order_by_date([]), []) + + +class ClosingBalanceTest(unittest.TestCase): + def test_adds_every_amount_to_the_opening(self): + rows = [txn(1, "-900.00", "Rent"), txn(2, "2500.00", "Salary"), + txn(4, "-7.50", "Coffee")] + self.assertEqual(closing_balance(Decimal("100"), rows), + Decimal("1692.50")) + + def test_no_transactions_returns_the_opening(self): + self.assertEqual(closing_balance(Decimal("100"), []), Decimal("100")) + self.assertEqual(closing_balance(Decimal("0"), []), Decimal("0")) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_balance -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.balance'` + +- [ ] **Step 3: Implement `order_by_date` and `closing_balance` in `ledgerlite/balance.py`** + +`order_by_date` returns `sorted(transactions, key=...)` on the date — `sorted` is stable, which is what "ties keep input order" requires. `closing_balance` folds the amounts onto `opening` with a loop or `sum(..., start=opening)`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_balance -v` +Expected: PASS (6 tests) + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/balance.py test_balance.py +git commit -m "feat: add date ordering and closing balance" +``` + +--- + +## Task 5: Per-category totals + +**Files:** +- Create: `ledgerlite/report.py` +- Test: `test_report.py` + +**Interfaces:** +- Consumes: `Transaction` (Task 1), `categorize` (Task 3). +- Produces: + - `report.UNCATEGORIZED = "uncategorized"`. + - `report.category_totals(transactions: Iterable[Transaction], rules: list[tuple[str, str]]) -> dict[str, Decimal]` — maps category name (or `UNCATEGORIZED`) to the sum of its amounts. Categories with no transactions are absent from the dict. + +- [ ] **Step 1: Write the failing tests** + +Create `test_report.py`: + +```python +import datetime +import unittest +from decimal import Decimal + +from ledgerlite.model import Transaction +from ledgerlite.report import UNCATEGORIZED, category_totals + +RULES = [("coffee", "food"), ("rent", "housing")] + + +def txn(day, amount, description): + return Transaction( + date=datetime.date(2026, 3, day), + amount=Decimal(amount), + description=description, + ) + + +class CategoryTotalsTest(unittest.TestCase): + def test_sums_each_category_and_the_uncategorized_rest(self): + rows = [txn(1, "-900.00", "Rent March"), txn(2, "2500.00", "Salary"), + txn(4, "-7.50", "Coffee Bar")] + self.assertEqual( + category_totals(rows, RULES), + {"housing": Decimal("-900.00"), UNCATEGORIZED: Decimal("2500.00"), + "food": Decimal("-7.50")}, + ) + + def test_two_substrings_may_share_a_category(self): + rules = [("coffee", "food"), ("tea", "food")] + rows = [txn(1, "-7.50", "Coffee Bar"), txn(2, "-2.00", "Tea House")] + self.assertEqual(category_totals(rows, rules), + {"food": Decimal("-9.50")}) + + def test_without_rules_everything_is_uncategorized(self): + rows = [txn(1, "-900.00", "Rent March"), txn(2, "2500.00", "Salary")] + self.assertEqual(category_totals(rows, []), + {UNCATEGORIZED: Decimal("1600.00")}) + + def test_a_rule_named_uncategorized_merges_with_the_unmatched(self): + rows = [txn(1, "-7.50", "Coffee Bar"), txn(2, "2500.00", "Salary")] + self.assertEqual(category_totals(rows, [("coffee", "uncategorized")]), + {UNCATEGORIZED: Decimal("2492.50")}) + + def test_no_transactions_gives_no_categories(self): + self.assertEqual(category_totals([], RULES), {}) + + def test_uncategorized_key_is_absent_when_every_row_matches(self): + rows = [txn(1, "-900.00", "Rent March")] + self.assertEqual(category_totals(rows, RULES), + {"housing": Decimal("-900.00")}) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_report -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.report'` + +- [ ] **Step 3: Implement `UNCATEGORIZED` and `category_totals` in `ledgerlite/report.py`** + +For each transaction, `categorize(...) or UNCATEGORIZED` gives the key; accumulate into a dict with `Decimal("0")` as the starting value. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_report -v` +Expected: PASS (6 tests) + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/report.py test_report.py +git commit -m "feat: add per-category totals" +``` + +--- + +## Task 6: Amount and report formatting + +**Files:** +- Modify: `ledgerlite/report.py` (add `format_amount`, `format_report`) +- Test: `test_report.py` (add cases) + +**Interfaces:** +- Consumes: `order_by_date`, `closing_balance` (Task 4), `category_totals` (Task 5). +- Produces: + - `report.format_amount(amount: Decimal) -> str` — exactly two fractional digits, `-` only when the value is less than zero (so `Decimal("-0.00")` prints `0.00`), no thousands separators. + - `report.format_report(opening: Decimal, transactions: list[Transaction], rules: list[tuple[str, str]]) -> str` — the whole report, ending with a trailing newline: one `: ` line per category sorted with plain `sorted()`, `uncategorized` forced last, then a blank line, then `closing balance: `. + +- [ ] **Step 1: Write the failing tests** + +Append to `test_report.py` (add `format_amount, format_report` to the `ledgerlite.report` import): + +```python +class FormatAmountTest(unittest.TestCase): + def test_formats_with_exactly_two_fractional_digits(self): + cases = [ + (Decimal("-12.50"), "-12.50"), + (Decimal("0"), "0.00"), + (Decimal("0.00"), "0.00"), + (Decimal("-0.00"), "0.00"), + (Decimal("1200"), "1200.00"), + (Decimal("-0.5"), "-0.50"), + (Decimal("1.5"), "1.50"), + (Decimal("1234567.5"), "1234567.50"), + ] + for amount, expected in cases: + with self.subTest(amount=amount): + self.assertEqual(format_amount(amount), expected) + + def test_cancelling_amounts_print_as_positive_zero(self): + total = Decimal("-1.50") + Decimal("1.50") + self.assertEqual(format_amount(total), "0.00") + + +class FormatReportTest(unittest.TestCase): + def test_matches_the_example_from_the_design(self): + rows = [txn(1, "-900.00", "Rent March"), txn(4, "-7.50", "Coffee Bar"), + txn(2, "2500.00", "Salary")] + self.assertEqual( + format_report(Decimal("100"), rows, RULES), + "food: -7.50\n" + "housing: -900.00\n" + "uncategorized: 2500.00\n" + "\n" + "closing balance: 1692.50\n", + ) + + def test_categories_are_alphabetical_with_uncategorized_last(self): + rules = [("zoo", "zebras"), ("apple", "apples")] + rows = [txn(1, "-1.00", "Zoo trip"), txn(2, "-2.00", "Salary"), + txn(3, "-4.00", "Apple Store")] + self.assertEqual( + format_report(Decimal("0"), rows, rules), + "apples: -4.00\n" + "zebras: -1.00\n" + "uncategorized: -2.00\n" + "\n" + "closing balance: -7.00\n", + ) + + def test_no_uncategorized_line_when_every_row_matches(self): + rows = [txn(1, "-900.00", "Rent March")] + self.assertEqual( + format_report(Decimal("1000"), rows, RULES), + "housing: -900.00\n\nclosing balance: 100.00\n", + ) + + def test_no_transactions_still_prints_the_closing_balance(self): + self.assertEqual(format_report(Decimal("0"), [], RULES), + "\nclosing balance: 0.00\n") + self.assertEqual(format_report(Decimal("100"), [], RULES), + "\nclosing balance: 100.00\n") +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_report -v` +Expected: FAIL — `ImportError: cannot import name 'format_amount'` + +- [ ] **Step 3: Implement `format_amount` in `ledgerlite/report.py`** + +Quantize to `Decimal("0.01")` with `ROUND_HALF_UP` (inputs already have at most two places, so nothing rounds), then format the absolute value with `f"{value:f}"` and prepend `-` only when the amount is strictly less than zero — this is what keeps `-0.00` from reaching the output. + +- [ ] **Step 4: Implement `format_report` in `ledgerlite/report.py`** + +Order transactions with `order_by_date`, take `category_totals`, sort the keys with plain `sorted()` and move `UNCATEGORIZED` to the end if present, emit `f"{name}: {format_amount(total)}"` per key, then an empty line, then `f"closing balance: {format_amount(closing_balance(opening, ordered))}"`. Join with `"\n"` and end the string with a newline. + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `python3 -m unittest test_report -v` +Expected: PASS (12 tests) + +- [ ] **Step 6: Commit** + +```bash +git add ledgerlite/report.py test_report.py +git commit -m "feat: format amounts and the full report" +``` + +--- + +## Task 7: CLI + +**Files:** +- Create: `ledgerlite/cli.py` +- Test: `test_cli.py` + +**Interfaces:** +- Consumes: `parse_transactions`, `ParseError` (Tasks 1–2), `parse_rules` (Task 3), `format_report` (Task 6), `parse_amount` (Task 1, for `--opening`). +- Produces: `cli.main(argv: list[str] | None = None) -> int` — never raises `SystemExit`; writes the report to `sys.stdout` and errors to `sys.stderr`. + +**Behavior decisions this task locks in:** +- `argparse` with `prog="ledgerlite"` and one subcommand `report`: positional `transactions`, options `--rules` (default `None`) and `--opening` (default `"0"`, kept as a string and validated with `parse_amount`). +- argparse usage errors and `--help` are caught: `main` wraps only `parse_args` in `try/except SystemExit` and returns `exc.code` when it is an `int`, else `2`. +- Files are read with `encoding="utf-8-sig"` so a spreadsheet BOM is transparent. `OSError` → reason is `err.strerror`; `UnicodeDecodeError` → reason is the literal `invalid UTF-8`. +- `--opening` is validated with the same rules as a row amount; on failure print `ledgerlite: --opening: {message}` and return 2. +- The report is written with `print(text, end="")` because `format_report` already ends with a newline. + +- [ ] **Step 1: Write the failing tests** + +Create `test_cli.py`: + +```python +import contextlib +import io +import pathlib +import tempfile +import unittest + +from ledgerlite.cli import main + +CSV = """date,amount,description +2026-03-01,-900.00,Rent March +2026-03-04,-7.50,Coffee Bar +2026-03-02,2500.00,Salary +""" +RULES = "coffee=food\nrent=housing\n" +EXPECTED = ( + "food: -7.50\n" + "housing: -900.00\n" + "uncategorized: 2500.00\n" + "\n" + "closing balance: 1692.50\n" +) + + +def run(argv): + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = main(argv) + return code, out.getvalue(), err.getvalue() + + +class CliTestCase(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.dir = pathlib.Path(self.tmp.name) + + def write(self, name, text, encoding="utf-8"): + path = self.dir / name + path.write_text(text, encoding=encoding) + return str(path) + + +class ReportSuccessTest(CliTestCase): + def test_prints_the_report_and_returns_zero(self): + csv = self.write("txns.csv", CSV) + rules = self.write("rules.txt", RULES) + code, out, err = run(["report", csv, "--rules", rules, "--opening", "100"]) + self.assertEqual((code, out, err), (0, EXPECTED, "")) + + def test_without_rules_everything_is_uncategorized(self): + csv = self.write("txns.csv", CSV) + code, out, err = run(["report", csv]) + self.assertEqual(code, 0) + self.assertEqual(out, "uncategorized: 1592.50\n\nclosing balance: 1592.50\n") + + def test_opening_defaults_to_zero(self): + csv = self.write("txns.csv", "date,amount,description\n2026-03-04,-7.50,X\n") + code, out, _ = run(["report", csv]) + self.assertEqual(code, 0) + self.assertIn("closing balance: -7.50\n", out) + + def test_negative_opening_is_accepted(self): + csv = self.write("txns.csv", "date,amount,description\n2026-03-04,-7.50,X\n") + code, out, _ = run(["report", csv, "--opening", "-50"]) + self.assertEqual(code, 0) + self.assertIn("closing balance: -57.50\n", out) + + def test_reads_a_file_with_a_utf8_bom(self): + csv = self.write("bom.csv", CSV, encoding="utf-8-sig") + code, out, err = run(["report", csv, "--opening", "100"]) + self.assertEqual((code, err), (0, "")) + self.assertEqual(out, EXPECTED) + + +class UnreadableInputTest(CliTestCase): + def test_missing_transactions_file_returns_one(self): + missing = str(self.dir / "nope.csv") + code, out, err = run(["report", missing]) + self.assertEqual((code, out), (1, "")) + self.assertEqual( + err, f"ledgerlite: cannot read {missing}: No such file or directory\n" + ) + + def test_directory_as_path_returns_one(self): + code, out, err = run(["report", str(self.dir)]) + self.assertEqual((code, out), (1, "")) + self.assertTrue(err.startswith(f"ledgerlite: cannot read {self.dir}: ")) + self.assertTrue(err.endswith("\n")) + + def test_non_utf8_file_returns_one(self): + path = self.dir / "bad.csv" + path.write_bytes(b"date,amount,description\n2026-03-04,-7.50,caf\xe9\n") + code, out, err = run(["report", str(path)]) + self.assertEqual((code, out), (1, "")) + self.assertEqual(err, f"ledgerlite: cannot read {path}: invalid UTF-8\n") + + def test_missing_rules_file_returns_one(self): + csv = self.write("txns.csv", CSV) + missing = str(self.dir / "nope.txt") + code, out, err = run(["report", csv, "--rules", missing]) + self.assertEqual((code, out), (1, "")) + self.assertEqual( + err, f"ledgerlite: cannot read {missing}: No such file or directory\n" + ) + + +class MalformedInputTest(CliTestCase): + def test_bad_row_returns_two_with_path_and_line(self): + csv = self.write( + "txns.csv", + "date,amount,description\n2026-03-01,-900.00,Rent\n2026-03-04,abc,Coffee\n", + ) + code, out, err = run(["report", csv]) + self.assertEqual((code, out), (2, "")) + self.assertEqual(err, f"ledgerlite: {csv}:3: invalid amount 'abc'\n") + + def test_missing_header_returns_two(self): + csv = self.write("txns.csv", "2026-03-04,-7.50,Coffee\n") + code, out, err = run(["report", csv]) + self.assertEqual((code, out), (2, "")) + self.assertEqual( + err, + f"ledgerlite: {csv}:1: expected header 'date,amount,description'\n", + ) + + def test_bad_rules_line_returns_two_with_the_rules_path(self): + csv = self.write("txns.csv", CSV) + rules = self.write("rules.txt", "coffee=food\noops\n") + code, out, err = run(["report", csv, "--rules", rules]) + self.assertEqual((code, out), (2, "")) + self.assertEqual(err, f"ledgerlite: {rules}:2: rule has no '='\n") + + def test_bad_opening_returns_two(self): + csv = self.write("txns.csv", CSV) + code, out, err = run(["report", csv, "--opening", "1.005"]) + self.assertEqual((code, out), (2, "")) + self.assertEqual( + err, + "ledgerlite: --opening: amount '1.005' has more than two decimal places\n", + ) + + def test_unparseable_opening_returns_two(self): + csv = self.write("txns.csv", CSV) + code, _, err = run(["report", csv, "--opening", "lots"]) + self.assertEqual(code, 2) + self.assertEqual(err, "ledgerlite: --opening: invalid amount 'lots'\n") + + +class UsageErrorTest(CliTestCase): + def test_missing_arguments_return_two_without_raising(self): + for argv in ([], ["report"], ["nonsense"]): + with self.subTest(argv=argv): + self.assertEqual(run(argv)[0], 2) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_cli -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite.cli'` + +- [ ] **Step 3: Implement `ledgerlite/cli.py`** + +Structure: a private `_build_parser()` returning the argparse parser described above; a private `_read(path) -> str` that opens with `encoding="utf-8-sig"` and raises a small internal error (or returns via exception) carrying the `cannot read` reason — `err.strerror` for `OSError`, `"invalid UTF-8"` for `UnicodeDecodeError`; and `main(argv=None)` that parses args (catching `SystemExit`), validates `--opening` via `parse_amount`, reads and parses the transactions file, reads and parses the rules file when `--rules` was given, prints `format_report(...)` with `end=""`, and returns 0. Each failure path prints its one line to `sys.stderr` and returns 1 or 2 per the Global Constraints. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_cli -v` +Expected: PASS (15 tests) + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/cli.py test_cli.py +git commit -m "feat: add ledgerlite report CLI" +``` + +--- + +## Task 8: `python3 -m ledgerlite` entry point and end-to-end check + +**Files:** +- Create: `ledgerlite/__main__.py` +- Modify: `test_cli.py` (add an end-to-end test class) + +**Interfaces:** +- Consumes: `cli.main` (Task 7). +- Produces: `python3 -m ledgerlite report ...` as the runnable command; nothing importable. + +- [ ] **Step 1: Write the failing test** + +Append to `test_cli.py` (add `import subprocess` and `import sys` to the imports): + +```python +class EndToEndTest(CliTestCase): + def run_module(self, args): + return subprocess.run( + [sys.executable, "-m", "ledgerlite", *args], + cwd=str(pathlib.Path(__file__).parent), + capture_output=True, + text=True, + ) + + def test_prints_the_design_example(self): + csv = self.write("txns.csv", CSV) + rules = self.write("rules.txt", RULES) + done = self.run_module(["report", csv, "--rules", rules, "--opening", "100"]) + self.assertEqual(done.returncode, 0, done.stderr) + self.assertEqual(done.stdout, EXPECTED) + self.assertEqual(done.stderr, "") + + def test_malformed_file_exits_two_with_empty_stdout(self): + csv = self.write("txns.csv", "date,amount,description\n2026-03-04,abc,X\n") + done = self.run_module(["report", csv]) + self.assertEqual(done.returncode, 2) + self.assertEqual(done.stdout, "") + self.assertEqual(done.stderr, f"ledgerlite: {csv}:2: invalid amount 'abc'\n") + + def test_missing_file_exits_one(self): + done = self.run_module(["report", str(self.dir / "nope.csv")]) + self.assertEqual(done.returncode, 1) + self.assertEqual(done.stdout, "") +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `python3 -m unittest test_cli.EndToEndTest -v` +Expected: FAIL — `No module named ledgerlite.__main__` in the subprocess's stderr + +- [ ] **Step 3: Implement `ledgerlite/__main__.py`** + +Under `if __name__ == "__main__":`, `sys.exit(main())`. + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `python3 -m unittest test_cli.EndToEndTest -v` +Expected: PASS (3 tests) + +- [ ] **Step 5: Run the whole suite** + +Run: `python3 -m unittest -v` +Expected: PASS — all 5 test modules, no failures, no errors + +- [ ] **Step 6: Commit** + +```bash +git add ledgerlite/__main__.py test_cli.py +git commit -m "feat: add module entry point and end-to-end tests" +``` diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/T1-test-lines-cosmic-61/2026-09-17-cosmic-tetris-1-engine.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/T1-test-lines-cosmic-61/2026-09-17-cosmic-tetris-1-engine.md new file mode 100644 index 000000000..1c13bc758 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/T1-test-lines-cosmic-61/2026-09-17-cosmic-tetris-1-engine.md @@ -0,0 +1,823 @@ +# Cosmic Tetris — Plan 1: Headless Game Engine Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build `internal/game`, a fully tested, headless, deterministic falling-block engine with no rendering, no clock reads, and no dependencies outside the standard library. + +**Architecture:** One package, six files, no interfaces. A `Game` value owns a `Board`, an active `Piece`, a 7-bag, and two time accumulators. The outside world drives it with exactly two calls: `Apply(Input)` for a player action and `Advance(dt)` for elapsed time. Both return `[]Event` describing what happened, which the FX layer (Plan 3) later consumes read-only. Nothing in this package reads a clock or touches a terminal. + +**Tech Stack:** Go 1.26, standard library only (`math`, `math/rand`, `time` for `time.Duration` arithmetic). + +**Spec:** `design.md` (this plan implements §5–§13, §34, §35, §40 Board/Pieces/Bag/Hold/Drop/Score/Game over/Determinism, §49.1, §49.2, §49.4 board dimensions, §49.6) + +**Plan 2** (`plans/2026-09-17-cosmic-tetris-2-terminal.md`) builds the Bubble Tea app and renderer on top of this package. **Plan 3** (`plans/2026-09-17-cosmic-tetris-3-cosmic-fx.md`) builds the FX layer. Both consume the exact type and function names pinned here; do not rename anything in this plan without updating them. + +## Global Constraints + +- Module path: `cosmic-tetris`. Go directive: `go 1.26`. +- `internal/game` imports **only** the standard library. No Bubble Tea, no Lip Gloss, no rendering concerns (design.md §14, §33). +- Nothing under `internal/game` calls `time.Now()`, `time.Since()`, `time.Tick`, or any other clock. Elapsed time arrives as the `dt` argument to `Advance` (§49.2). +- Randomness comes only from the `*rand.Rand` stored on `Game`, created with `rand.New(rand.NewSource(seed))` from `math/rand` (not `math/rand/v2`). It drives the 7-bag and nothing else (§49.6). +- Board is 10 wide × 22 tall; rows 0–1 are hidden spawn rows, rows 2–21 are the 20 visible rows. `y` increases **downward** (§5). +- Every exported function is pure or mutates only the receiver. No package-level mutable state. +- Run `gofmt -l .` before every commit; it must print nothing. + +## Review Focus + +These are input classes the spec implies but never names. Each has a test pinned in the task that owns the code. + +- **Enormous `dt`** (laptop lid closed, debugger pause, CI stall): a single `Advance(10s)` must not teleport the piece to the floor. `dt` is clamped to `MaxAdvanceStep` (250ms) per call — Task 7. +- **Zero or negative `dt`** (clock skew, duplicate frame): must change nothing and return no events — Task 7. +- **Wall kicks at the boundaries**: no kick may place a cell at `x < 0`, `x >= 10`, or `y >= 22`; `y < 0` is legal empty space above the board — Tasks 2 and 6. +- **Very high level**: `GravityInterval(100)` must clamp at 60ms and never return zero or negative — Task 4. +- **Hold whose incoming piece cannot spawn** (stack reaching the ceiling): game over, never an active piece overlapping locked cells — Task 9. + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `go.mod` | module `cosmic-tetris`, `go 1.26` | +| `internal/game/piece.go` | `PieceKind`, `Point`, `Piece`, base shape table, rotation, spawn position | +| `internal/game/board.go` | `Cell`, `Board`, bounds/collision/lock/row completion/row clearing | +| `internal/game/bag.go` | `Bag`, seeded 7-bag generation | +| `internal/game/scoring.go` | score/level/gravity pure functions and all tuning constants | +| `internal/game/events.go` | `EventKind`, `Event` | +| `internal/game/game.go` | `Game`, `Input`, `New`, `Apply`, `Advance`, `GhostY`, `Restart`, spawn/lock internals | +| `internal/game/rules.go` | rotation kick table and the kick-resolution helper | + +Tests live beside their subject: `piece_test.go`, `board_test.go`, `bag_test.go`, `scoring_test.go`, `game_test.go`, `hold_test.go`, `determinism_test.go`. + +--- + +### Task 1: Module bootstrap, pieces, and rotation + +**Files:** +- Create: `go.mod`, `internal/game/piece.go` +- Test: `internal/game/piece_test.go` + +**Interfaces:** +- Consumes: nothing. +- Produces: + ```go + type PieceKind int + const ( + KindI PieceKind = iota + KindJ + KindL + KindO + KindS + KindT + KindZ + ) + const KindCount = 7 + func (k PieceKind) String() string // "I","J","L","O","S","T","Z" + func AllKinds() [KindCount]PieceKind + + type Point struct{ X, Y int } + + type Piece struct { + Kind PieceKind + Rotation int // 0..3, always normalized + X, Y int // top-left corner of the piece's square bounding box, board coords + } + + func BoxSize(k PieceKind) int // KindI: 4, KindO: 2, all others: 3 + func (p Piece) Cells() [4]Point // the 4 occupied cells in board coords + func SpawnPiece(k PieceKind) Piece // Rotation 0, Y = 1, X = (Width-BoxSize(k))/2 + ``` + +- [ ] **Step 1: Create `go.mod`** + +```bash +go mod init cosmic-tetris +``` + +Then edit the `go` directive to `go 1.26` if `go mod init` wrote a different patch-level form. + +- [ ] **Step 2: Write the failing tests in `internal/game/piece_test.go`** + +Rotation 0 shapes, asserted as `Cells()` sets for a piece at `X:0, Y:0` (order-independent comparison — sort the points or compare as a `map[Point]bool`): + +- `test_shape_I_rot0`: `Piece{KindI,0,0,0}.Cells()` -> `{0,1},{1,1},{2,1},{3,1}` +- `test_shape_J_rot0`: -> `{0,0},{0,1},{1,1},{2,1}` +- `test_shape_L_rot0`: -> `{2,0},{0,1},{1,1},{2,1}` +- `test_shape_O_rot0`: -> `{0,0},{1,0},{0,1},{1,1}` +- `test_shape_S_rot0`: -> `{1,0},{2,0},{0,1},{1,1}` +- `test_shape_T_rot0`: -> `{1,0},{0,1},{1,1},{2,1}` +- `test_shape_Z_rot0`: -> `{0,0},{1,0},{1,1},{2,1}` +- `test_rotation_is_clockwise_in_box`: for every kind and every `r` in 0..3, each cell of rotation `r` equals `(N-1-y, x)` applied `r` times to the matching rotation-0 cell, where `N = BoxSize(kind)`. Assert by computing the expected set from the rotation-0 set. +- `test_all_rotations_have_four_cells`: for every kind, every `r` in 0..3, `len(unique(Cells()))` -> `4` +- `test_all_rotations_stay_in_box`: every cell of every rotation satisfies `0 <= x-p.X < N` and `0 <= y-p.Y < N` +- `test_O_rotation_invariant`: `Piece{KindO,r,0,0}.Cells()` is the same set for all `r` in 0..3 +- `test_rotation_normalized`: `Piece{KindT,4,0,0}.Cells()` equals `Piece{KindT,0,0,0}.Cells()`, and `Rotation: -1` equals `Rotation: 3` +- `test_cells_translate_with_position`: `Piece{KindT,0,3,5}.Cells()` equals rotation-0 cells each offset by `(3,5)` +- `test_spawn_positions`: `SpawnPiece(KindI)` -> `Piece{KindI,0,3,1}`; `SpawnPiece(KindO)` -> `Piece{KindO,0,4,1}`; `SpawnPiece(KindT)` -> `Piece{KindT,0,3,1}` +- `test_spawn_touches_visible_row`: for every kind, the maximum `Cells()[i].Y` of `SpawnPiece(k)` is `>= 2` (the piece is partially visible the instant it spawns) +- `test_kind_string`: `KindZ.String()` -> `"Z"` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `go test ./internal/game/ -run TestShape -v` +Expected: FAIL — undefined: `Piece`, `KindI`, … + +- [ ] **Step 4: Implement `internal/game/piece.go`** + +Store base shapes as a `[KindCount][4]Point` table of rotation-0 cells relative to the bounding box. `Cells()` rotates each base cell `Rotation & 3` times with the clockwise-in-box transform `(x, y) -> (N-1-y, x)`, then adds `p.X, p.Y`. + +`Width` is defined in Task 2's `board.go`; for this task declare it in `board.go` early or inline the value `10` in `SpawnPiece` and fix it up in Task 2. Prefer creating `board.go` now with just the dimension constants. + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS (all piece tests) + +- [ ] **Step 6: Commit** + +```bash +git add go.mod internal/game/piece.go internal/game/board.go internal/game/piece_test.go +git commit -m "feat(game): tetromino shapes, rotation, and spawn positions" +``` + +--- + +### Task 2: Board — bounds, collision, locking, row clearing + +**Files:** +- Modify: `internal/game/board.go` +- Test: `internal/game/board_test.go` + +**Interfaces:** +- Consumes: `Piece`, `Point`, `PieceKind` (Task 1). +- Produces: + ```go + const ( + Width = 10 + Height = 22 + HiddenRows = 2 + VisibleRows = 20 // Height - HiddenRows + ) + + type Cell int8 + const CellEmpty Cell = -1 + func CellOf(k PieceKind) Cell + func (c Cell) Empty() bool + func (c Cell) Kind() PieceKind // undefined for CellEmpty + + type Board struct { + Cells [Height][Width]Cell + } + + func NewBoard() Board // every cell CellEmpty + func (b *Board) At(x, y int) Cell // CellEmpty when out of range + func (b *Board) Blocked(x, y int) bool // true if x<0 || x>=Width || y>=Height || cell filled; false when y<0 + func (b *Board) Collides(p Piece) bool + func (b *Board) Lock(p Piece) + func (b *Board) RowFilled(y int) bool + func (b *Board) CompleteRows() []int // ascending y, nil when none + func (b *Board) ClearRows(rows []int) // collapse survivors downward, new CellEmpty rows at top + func (b *Board) TopFilledRow() int // lowest y that has any filled cell, Height when empty + ``` + +- [ ] **Step 1: Write the failing tests in `internal/game/board_test.go`** + +Use a helper `boardFromRows(t *testing.T, rows ...string) Board` that reads bottom-up strings of length 10 where `.` is empty and any other rune is a filled cell of `KindT`, so tests read as pictures. + +- `test_new_board_empty`: every cell of `NewBoard()` is `CellEmpty`; `TopFilledRow()` -> `22` +- `test_blocked_left_wall`: `Blocked(-1, 10)` -> `true` +- `test_blocked_right_wall`: `Blocked(10, 10)` -> `true` +- `test_blocked_floor`: `Blocked(0, 22)` -> `true` +- `test_above_board_is_free`: `Blocked(0, -1)` -> `false` and `Blocked(0, -5)` -> `false` +- `test_blocked_filled_cell`: after `Lock`, the locked cells report `Blocked` -> `true` +- `test_at_out_of_range`: `At(-1, 0)`, `At(0, 22)`, `At(99, 99)` -> `CellEmpty` +- `test_collides_with_floor`: `Piece{KindO,0,0,20}` (cells at y 20,21) -> `Collides` `false`; `Y:21` (cells at y 21,22) -> `true` +- `test_collides_with_wall`: `Piece{KindO,0,9,0}` -> `true` (cell x 10) +- `test_collides_with_stack`: board with row 21 filled at x 0..3; `Piece{KindO,0,0,20}` -> `true` +- `test_no_collision_above_board`: `Piece{KindI,0,3,-2}` on an empty board -> `false` +- `test_lock_writes_kind`: `Lock(Piece{KindS,0,0,20})` then `At(1,20).Kind()` -> `KindS` +- `test_row_filled`: a row with 9 of 10 cells -> `false`; all 10 -> `true` +- `test_complete_rows_ascending`: rows 19 and 21 full -> `[]int{19, 21}` +- `test_complete_rows_none`: empty board -> `nil` +- `test_clear_single_row_collapses`: rows 20 (partial: x0 only) and 21 (full); `ClearRows([]int{21})` -> the partial content that was at 20 is now at 21, row 20 empty +- `test_clear_multiple_non_adjacent`: rows 18 and 20 full, row 19 holds a marker at x5, row 21 holds a marker at x9; after `ClearRows([]int{18,20})` the x9 marker is at row 21 and the x5 marker at row 20, rows 0..19 have no markers +- `test_clear_four_rows`: rows 18..21 full, row 17 has a marker at x0; `ClearRows([]int{18,19,20,21})` -> marker now at row 21, `CompleteRows()` -> `nil` +- `test_clear_leaves_top_rows_empty`: after clearing 4 rows, rows 0..3 are all `CellEmpty` +- `test_top_filled_row`: marker at row 15 only -> `TopFilledRow()` -> `15` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/game/ -run TestBoard -v` +Expected: FAIL — undefined: `NewBoard`, `Blocked`, … + +- [ ] **Step 3: Implement the board in `internal/game/board.go`** + +`ClearRows` is the only non-obvious one: walk a write cursor from `Height-1` upward and a read cursor from `Height-1` upward, skipping read rows that are in `rows`; fill remaining rows above the write cursor with `CellEmpty`. `Collides` returns true if any of `p.Cells()` is `Blocked`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/board.go internal/game/board_test.go +git commit -m "feat(game): board bounds, collision, locking, and row clearing" +``` + +--- + +### Task 3: Seeded 7-bag + +**Files:** +- Create: `internal/game/bag.go` +- Test: `internal/game/bag_test.go` + +**Interfaces:** +- Consumes: `PieceKind`, `AllKinds` (Task 1). +- Produces: + ```go + type Bag struct { + rng *rand.Rand + remaining []PieceKind + } + func NewBag(rng *rand.Rand) Bag + func (b *Bag) Next() PieceKind + func (b *Bag) Remaining() int + ``` + +- [ ] **Step 1: Write the failing tests in `internal/game/bag_test.go`** + +- `test_first_bag_contains_each_kind_once`: draw 7 from `NewBag(rand.New(rand.NewSource(1)))`, sort -> `[I J L O S T Z]` +- `test_every_bag_contains_each_kind_once`: draw 70, each consecutive group of 7 sorts to all seven kinds +- `test_same_seed_same_sequence`: two bags with seed `8675309` produce identical 70-draw sequences +- `test_different_seed_differs`: seed `1` and seed `2` produce different 70-draw sequences +- `test_remaining_counts_down`: fresh bag `Remaining()` -> `7`; after one `Next()` -> `6`; after 7 -> `7` again (refilled lazily is also acceptable if `Remaining()` reports `0` after 7 draws and `7` after the 8th — pin the lazy form: refill happens inside `Next` when empty, so after exactly 7 draws `Remaining()` -> `0`) +- `test_bag_is_shuffled`: over seeds 1..50, the first-7 sequence is not always the same ordering (at least 2 distinct orderings observed) + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/game/ -run TestBag -v` +Expected: FAIL — undefined: `NewBag` + +- [ ] **Step 3: Implement `internal/game/bag.go`** + +Do not use `rng.Shuffle`; use this explicit Fisher–Yates so the sequence is pinned to `Intn` calls and cannot drift with standard-library internals: + +```go +func (b *Bag) refill() { + b.remaining = append(b.remaining[:0], KindI, KindJ, KindL, KindO, KindS, KindT, KindZ) + for i := len(b.remaining) - 1; i > 0; i-- { + j := b.rng.Intn(i + 1) + b.remaining[i], b.remaining[j] = b.remaining[j], b.remaining[i] + } +} +``` + +`Next` refills when `len(remaining) == 0`, then pops the last element. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/bag.go internal/game/bag_test.go +git commit -m "feat(game): seeded 7-bag piece generator" +``` + +--- + +### Task 4: Scoring, level, and gravity functions + +**Files:** +- Create: `internal/game/scoring.go` +- Test: `internal/game/scoring_test.go` + +**Interfaces:** +- Consumes: nothing. +- Produces: + ```go + const ( + BaseGravityInterval = 800 * time.Millisecond + MinGravityInterval = 60 * time.Millisecond + GravityDecay = 0.86 + LinesPerLevel = 10 + LockDelay = 500 * time.Millisecond + MaxLockResets = 15 + MaxAdvanceStep = 250 * time.Millisecond + SoftDropPointsPerCell = 1 + HardDropPointsPerCell = 2 + NextQueueLen = 5 + ) + + func LineScore(lines, level int) int // 0 for lines<=0 or lines>4 + func ComboBonus(combo, level int) int // 0 when combo < 2 + func LevelFor(linesCleared int) int // linesCleared/LinesPerLevel + 1 + func GravityInterval(level int) time.Duration + ``` + +- [ ] **Step 1: Write the failing tests in `internal/game/scoring_test.go`** + +- `test_line_score_level_1`: `LineScore(1,1)` -> `100`; `LineScore(2,1)` -> `300`; `LineScore(3,1)` -> `500`; `LineScore(4,1)` -> `800` +- `test_line_score_scales_with_level`: `LineScore(4,7)` -> `5600`; `LineScore(1,13)` -> `1300` +- `test_line_score_zero_and_out_of_range`: `LineScore(0,5)` -> `0`; `LineScore(5,5)` -> `0`; `LineScore(-1,5)` -> `0` +- `test_combo_bonus_first_clear_is_free`: `ComboBonus(0,3)` -> `0`; `ComboBonus(1,3)` -> `0` (§49.1: a lone clear earns no combo bonus) +- `test_combo_bonus_starts_at_two`: `ComboBonus(2,1)` -> `50`; `ComboBonus(2,4)` -> `200`; `ComboBonus(5,3)` -> `600` +- `test_level_for_lines`: `LevelFor(0)` -> `1`; `LevelFor(9)` -> `1`; `LevelFor(10)` -> `2`; `LevelFor(29)` -> `3`; `LevelFor(130)` -> `14` +- `test_gravity_level_1`: `GravityInterval(1)` -> `800 * time.Millisecond` +- `test_gravity_decays`: `GravityInterval(2)` is within 1ms of `688 * time.Millisecond`; `GravityInterval(5)` is within 1ms of `437 * time.Millisecond` +- `test_gravity_monotonic`: for `level` 1..40, `GravityInterval(level+1) <= GravityInterval(level)` +- `test_gravity_clamped`: `GravityInterval(100)` -> `MinGravityInterval`; `GravityInterval(1000)` -> `MinGravityInterval` +- `test_gravity_never_zero`: for `level` 1..1000, `GravityInterval(level) >= MinGravityInterval` +- `test_gravity_below_level_one`: `GravityInterval(0)` -> `BaseGravityInterval` (defensive clamp on the low end too) + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/game/ -run TestGravity -v` +Expected: FAIL — undefined: `GravityInterval` + +- [ ] **Step 3: Implement `internal/game/scoring.go`** + +`GravityInterval` computes `float64(BaseGravityInterval) * math.Pow(GravityDecay, float64(level-1))`, truncates to `time.Duration`, then clamps to `[MinGravityInterval, BaseGravityInterval]`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/scoring.go internal/game/scoring_test.go +git commit -m "feat(game): scoring, level progression, and gravity curve" +``` + +--- + +### Task 5: Events, `Game` construction, next queue, and ghost + +**Files:** +- Create: `internal/game/events.go`, `internal/game/game.go` +- Test: `internal/game/game_test.go` + +**Interfaces:** +- Consumes: everything from Tasks 1–4. +- Produces: + ```go + type EventKind int + const ( + EventPieceMoved EventKind = iota + EventPieceRotated + EventPieceHardDropped + EventPieceLocked + EventHoldUsed + EventLinesCleared + EventComboChanged + EventLevelChanged + EventGameOver + ) + func (k EventKind) String() string // "PieceMoved", "PieceRotated", ... "GameOver" + + // Event is the engine's only outbound channel. Plan 3's fx.World consumes + // these and may never touch Game itself (design.md §14). + type Event struct { + Kind EventKind + Piece Piece // the piece involved, in its post-action state + Cells []Point // Locked: the 4 locked cells. HardDropped: every cell traversed, top row first. + Rows []int // LinesCleared: cleared row indices, ascending, pre-collapse coordinates. + Value int // LinesCleared: row count. ComboChanged: new combo. LevelChanged: new level. HardDropped: cells fallen. + } + + type Game struct { + Board Board + Active Piece + Hold *PieceKind + CanHold bool + Next []PieceKind // always NextQueueLen long + Bag Bag + + Score int + Lines int + Level int + Combo int + + GravityAccumulator time.Duration + LockAccumulator time.Duration + Grounded bool + LockResets int + Over bool + + Seed int64 + rng *rand.Rand + } + + func New(seed int64) *Game + func (g *Game) GhostY() int // Active.Y of the piece dropped as far as it will go + func (g *Game) DropDistance() int // cells between Active.Y and GhostY() + ``` + +- [ ] **Step 1: Write the failing tests in `internal/game/game_test.go`** + +- `test_new_game_defaults`: `New(1)` -> `Level` 1, `Score` 0, `Lines` 0, `Combo` 0, `Over` false, `Hold` nil, `CanHold` true, `Seed` 1 +- `test_new_game_next_queue_length`: `len(g.Next)` -> `5` +- `test_new_game_active_is_spawned`: `g.Active` equals `SpawnPiece(g.Active.Kind)` +- `test_new_game_board_empty`: `g.Board.TopFilledRow()` -> `22` +- `test_new_game_reproducible`: `New(42)` twice -> same `Active.Kind` and same `Next` slice +- `test_game_and_bag_share_one_rng`: draining `g.Next` via repeated spawns for seed 42 yields the same kind sequence as `NewBag(rand.New(rand.NewSource(42)))` drawn 20 times (the queue is filled from the same generator in the same order) +- `test_ghost_on_empty_board`: `New(1)` with `Active = SpawnPiece(KindO)` -> `GhostY()` -> `20` (the O box bottom rests on row 21) +- `test_ghost_on_stack`: board with row 21 filled at x 4..5, `Active = SpawnPiece(KindO)` (x 4..5) -> `GhostY()` -> `19` +- `test_ghost_does_not_move_active`: calling `GhostY()` twice leaves `g.Active` unchanged +- `test_drop_distance`: with `Active.Y == 1` and `GhostY() == 20` -> `DropDistance()` -> `19` +- `test_event_kind_string`: `EventLinesCleared.String()` -> `"LinesCleared"` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/game/ -run TestNewGame -v` +Expected: FAIL — undefined: `New` + +- [ ] **Step 3: Implement `events.go` and the construction half of `game.go`** + +`New(seed)` creates the rng, the bag, fills `Next` with `NextQueueLen` kinds, then calls the unexported `spawn()`. + +`spawn()` is used by Tasks 7–10 as well, so implement it fully now: + +```go +// spawn pops Next[0], refills the queue from the bag, resets the per-piece +// state, and reports GameOver if the new piece cannot be placed. +func (g *Game) spawn() []Event +``` +It sets `Active = SpawnPiece(kind)`, `CanHold = true`, zeroes `GravityAccumulator`, `LockAccumulator`, `LockResets`, and `Grounded`. If `g.Board.Collides(g.Active)` it sets `g.Over = true` and returns one `Event{Kind: EventGameOver, Piece: g.Active}`; otherwise it returns `nil`. + +`GhostY` copies `Active` into a local and steps `Y` down while the copy does not collide. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/events.go internal/game/game.go internal/game/game_test.go +git commit -m "feat(game): game state, event type, next queue, and ghost position" +``` + +--- + +### Task 6: `Apply` — movement, soft drop, and rotation with wall kicks + +**Files:** +- Create: `internal/game/rules.go` +- Modify: `internal/game/game.go` +- Test: `internal/game/game_test.go` (append) + +**Interfaces:** +- Consumes: `Game`, `Event`, `Board.Collides`, `SoftDropPointsPerCell`. +- Produces: + ```go + type Input int + const ( + InputNone Input = iota + InputLeft + InputRight + InputSoftDrop + InputHardDrop // implemented in Task 8 + InputRotateCW + InputRotateCCW + InputHold // implemented in Task 9 + ) + + func (g *Game) Apply(in Input) []Event + + // rules.go + var KickOffsets = [8]Point{{0,0},{-1,0},{1,0},{-2,0},{2,0},{0,-1},{-1,-1},{1,-1}} + func TryRotate(b *Board, p Piece, delta int) (Piece, bool) + ``` + `Y` grows downward, so `{0,-1}` lifts the piece one row (design.md §7). + +- [ ] **Step 1: Write the failing tests in `internal/game/game_test.go`** + +- `test_move_left`: `Apply(InputLeft)` decrements `Active.X` by 1 and returns one `EventPieceMoved` whose `Piece` is the new position +- `test_move_right`: symmetric +- `test_move_left_blocked_by_wall`: with `Active.X` such that the leftmost cell is at x 0, `Apply(InputLeft)` returns `nil` and `Active.X` is unchanged +- `test_move_right_blocked_by_wall`: symmetric at x 9 +- `test_move_blocked_by_stack`: a filled column beside the piece blocks the move, returns `nil` +- `test_soft_drop_moves_and_scores`: `Apply(InputSoftDrop)` increments `Active.Y`, adds `1` to `Score`, returns one `EventPieceMoved` +- `test_soft_drop_at_floor`: piece already resting -> returns `nil`, `Score` unchanged, `Active.Y` unchanged +- `test_input_none_is_noop`: `Apply(InputNone)` -> `nil`, no field changes +- `test_rotate_cw_free_space`: `Active.Rotation` goes 0 -> 1 and one `EventPieceRotated` is returned +- `test_rotate_ccw_free_space`: rotation goes 0 -> 3 +- `test_rotate_wall_kick_left_wall`: an `I` at `X:-1` rotated so it would poke through the left wall is kicked right and succeeds; final cells all satisfy `0 <= x < 10` +- `test_rotate_wall_kick_right_wall`: mirror case at the right wall +- `test_rotate_kick_upward`: a `T` resting on the floor whose rotation needs `(0,-1)` succeeds with `Y` one row higher +- `test_rotate_kick_order`: a `T` in a pocket where both `(-1,0)` and `(1,0)` would work ends up at the `(-1,0)` position (offsets are tried in `KickOffsets` order and the first valid one wins) +- `test_rotate_fails_when_boxed_in`: a `T` fully surrounded by locked cells -> `Apply(InputRotateCW)` returns `nil`, `Rotation` unchanged +- `test_rotate_o_is_noop_but_succeeds`: `KindO` rotation changes `Rotation` and returns `EventPieceRotated`; cells are unchanged +- `test_kick_never_leaves_board`: for every kind, every rotation, and every `X` in `-2..11` and `Y` in `-2..21` on an empty board, if `TryRotate` reports success then every resulting cell satisfies `0 <= x < 10` and `y < 22` +- `test_apply_noop_when_over`: with `g.Over = true`, every `Input` returns `nil` and changes nothing + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/game/ -run TestMove -v` +Expected: FAIL — undefined: `Apply`, `TryRotate` + +- [ ] **Step 3: Implement `rules.go` and `Apply` in `game.go`** + +`TryRotate` builds the rotated candidate, then for each offset in `KickOffsets` in order returns the first candidate that does not collide. + +`Apply` dispatches on `Input`. Every successful move or rotation while `g.Grounded` also calls the shared helper `resetLockTimer()` (Task 7 owns it; declare it here as a method that zeroes `LockAccumulator` and increments `LockResets` when `LockResets < MaxLockResets`). + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/rules.go internal/game/game.go internal/game/game_test.go +git commit -m "feat(game): movement, soft drop, and forgiving wall kicks" +``` + +--- + +### Task 7: `Advance` — gravity, lock delay, line clears, and scoring + +**Files:** +- Modify: `internal/game/game.go` +- Test: `internal/game/game_test.go` (append) + +**Interfaces:** +- Consumes: `GravityInterval`, `LockDelay`, `MaxLockResets`, `MaxAdvanceStep`, `LineScore`, `ComboBonus`, `LevelFor`, `Board.CompleteRows`, `Board.ClearRows`, `spawn`. +- Produces: + ```go + func (g *Game) Advance(dt time.Duration) []Event + func (g *Game) resetLockTimer() // used by Apply (Task 6) + func (g *Game) lockActive() []Event // used by HardDrop (Task 8) + ``` + Event order emitted by `lockActive`: `EventPieceLocked`, then (if rows cleared) `EventLinesCleared`, then `EventComboChanged` when the combo value changed, then `EventLevelChanged` when the level changed, then any events from `spawn` (i.e. `EventGameOver`). + +- [ ] **Step 1: Write the failing tests in `internal/game/game_test.go`** + +- `test_advance_below_interval_does_not_drop`: `Advance(100ms)` at level 1 -> `Active.Y` unchanged, `nil` events, `GravityAccumulator` 100ms +- `test_advance_at_interval_drops_one`: `Advance(800ms)` -> `Active.Y` +1, one `EventPieceMoved` +- `test_advance_accumulates_across_calls`: eight `Advance(100ms)` calls -> exactly one drop total +- `test_advance_multiple_steps_in_one_call`: at `Level` 20 (interval 60ms) `Advance(250ms)` -> `Active.Y` +4 +- `test_advance_clamps_huge_dt`: on an empty board at level 1, `Advance(10*time.Second)` -> `Active.Y` +0 and the piece has not locked; `GravityAccumulator` <= `MaxAdvanceStep` +- `test_advance_zero_dt`: `Advance(0)` -> `nil`, no field changes +- `test_advance_negative_dt`: `Advance(-5*time.Second)` -> `nil`, no field changes, `GravityAccumulator` unchanged +- `test_gravity_does_not_score`: after several dropping `Advance` calls, `Score` -> `0` +- `test_grounded_starts_lock_timer`: piece resting on the floor, `Advance(100ms)` -> `Grounded` true, `LockAccumulator` 100ms, piece not locked +- `test_locks_after_lock_delay`: resting piece, `Advance(500ms)` -> `EventPieceLocked` emitted, board has the cells, a new piece is active +- `test_move_resets_lock_timer`: resting piece, `Advance(400ms)`, `Apply(InputLeft)` -> `LockAccumulator` 0, `LockResets` 1; a further `Advance(400ms)` does not lock +- `test_rotation_resets_lock_timer`: same with `InputRotateCW` +- `test_failed_move_does_not_reset_lock_timer`: resting piece against a wall, `Advance(400ms)`, a blocked `Apply(InputLeft)` -> `LockAccumulator` still 400ms, `LockResets` 0 +- `test_lock_resets_capped`: alternate `Apply(InputLeft)`/`Apply(InputRight)` with `Advance(10ms)` between them 30 times -> `LockResets` -> `15` and the piece has locked +- `test_lock_clears_single_row`: pre-fill row 21 leaving one gap, drop a piece into it -> `EventLinesCleared` with `Rows` `[21]`, `Value` 1; `Lines` 1; `Score` `100` +- `test_lock_clears_four_rows`: build a well and clear 4 rows with an `I` -> `Rows` has 4 entries ascending, `Value` 4, `Score` `800` +- `test_combo_sequence`: two consecutive clearing placements at level 1 clearing one row each -> `Score` `100 + (100 + 50)` = `250`; `Combo` -> `2`; `EventComboChanged` `Value` 2 +- `test_combo_resets_on_empty_placement`: after a clear, a placement clearing nothing -> `Combo` -> `0` and one `EventComboChanged` with `Value` 0 +- `test_no_combo_event_when_unchanged`: two consecutive non-clearing placements -> only the first emits `EventComboChanged` +- `test_level_up_at_ten_lines`: clear 10 lines total -> `Level` 2 and one `EventLevelChanged` with `Value` 2 +- `test_level_changes_gravity`: after reaching `Level` 2, `Advance(700ms)` drops the piece (interval is now 688ms) +- `test_locked_cells_keep_kind`: locking a `KindS` piece leaves `KindS` cells on the board +- `test_can_hold_restored_after_lock`: set `CanHold` false, lock a piece -> `CanHold` -> `true` +- `test_advance_noop_when_over`: with `Over` true, `Advance(1s)` -> `nil`, nothing changes + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/game/ -run TestAdvance -v` +Expected: FAIL — undefined: `Advance` + +- [ ] **Step 3: Implement `Advance`, `resetLockTimer`, and `lockActive`** + +```go +func (g *Game) Advance(dt time.Duration) []Event { + if g.Over || dt <= 0 { + return nil + } + if dt > MaxAdvanceStep { + dt = MaxAdvanceStep + } + // Gravity: accumulate, then step down while a full interval is banked. + // Lock: when the piece cannot descend, accumulate LockAccumulator and + // call lockActive once it reaches LockDelay. Reset LockAccumulator to 0 + // and Grounded to false whenever the piece does descend. +} +``` + +`lockActive` runs the §12 order: `Board.Lock`, `CompleteRows`, `ClearRows`, update `Lines`/`Score`/`Combo`/`Level`, emit events in the order pinned in the Interfaces block, then `spawn()`. + +Combo rule (§49.1): a placement clearing ≥1 row sets `Combo++`; a placement clearing none sets `Combo = 0`. Score for a clearing placement is `LineScore(n, level) + ComboBonus(newCombo, level)`, using the level **before** the level-up from these lines. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/game.go internal/game/game_test.go +git commit -m "feat(game): gravity, lock delay, line clears, scoring, and level-up" +``` + +--- + +### Task 8: Hard drop + +**Files:** +- Modify: `internal/game/game.go` +- Test: `internal/game/game_test.go` (append) + +**Interfaces:** +- Consumes: `GhostY`, `DropDistance`, `lockActive`, `HardDropPointsPerCell`. +- Produces: `InputHardDrop` handling inside `Apply`. Emits `EventPieceHardDropped` (with `Cells` = every cell the piece occupied on the way down, topmost row first, and `Value` = cells fallen) **before** the `lockActive` events. Locking is immediate — no lock delay after a hard drop. + +- [ ] **Step 1: Write the failing tests in `internal/game/game_test.go`** + +- `test_hard_drop_lands_at_ghost`: on an empty board, `Apply(InputHardDrop)` puts the locked cells where `GhostY()` predicted +- `test_hard_drop_scores_two_per_cell`: dropping 19 cells -> `Score` gains `38` plus any line-clear score +- `test_hard_drop_locks_immediately`: after `Apply(InputHardDrop)` a different piece is active and the board holds the dropped one, with no `Advance` call +- `test_hard_drop_event_order`: returned kinds -> `[PieceHardDropped, PieceLocked, ComboChanged]` for a non-clearing drop on an empty board +- `test_hard_drop_traversed_cells`: dropping an `O` from `Y:1` to `Y:20` -> `Cells` contains one entry per cell per row crossed, `Cells[0].Y` is the topmost, and `len(Cells)` -> `2 * 21` +- `test_hard_drop_zero_distance`: a piece already resting -> `Value` 0, no score gained, still locks +- `test_hard_drop_onto_stack`: with a 5-row stack, the piece lands directly on top of it +- `test_hard_drop_clears_lines`: hard drop into a gap that completes a row -> `EventLinesCleared` follows `EventPieceLocked` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/game/ -run TestHardDrop -v` +Expected: FAIL + +- [ ] **Step 3: Implement hard drop in `Apply`** + +Compute `dist := g.DropDistance()`, collect traversed cells row by row from the starting `Y` through the landing `Y`, add `dist * HardDropPointsPerCell` to `Score`, set `Active.Y = GhostY()`, emit `EventPieceHardDropped`, then append `lockActive()`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/game.go internal/game/game_test.go +git commit -m "feat(game): hard drop with traversal cells and drop scoring" +``` + +--- + +### Task 9: Hold + +**Files:** +- Modify: `internal/game/game.go` +- Test: `internal/game/hold_test.go` + +**Interfaces:** +- Consumes: `Hold`, `CanHold`, `SpawnPiece`, `spawn`. +- Produces: `InputHold` handling inside `Apply`. Emits `EventHoldUsed` whose `Piece` is the **outgoing** piece (the one being stored), followed by `EventGameOver` if the incoming piece cannot be placed. + +- [ ] **Step 1: Write the failing tests in `internal/game/hold_test.go`** + +- `test_first_hold_stores_and_spawns_next`: `Hold` nil, active is `K`, `Next[0]` is `M`; `Apply(InputHold)` -> `*Hold` `K`, `Active.Kind` `M`, `Next` still length 5, one `EventHoldUsed` with `Piece.Kind` `K` +- `test_hold_swaps`: with `Hold` = `KindO` and active `KindT`, `Apply(InputHold)` -> `Active.Kind` `KindO`, `*Hold` `KindT`, next queue untouched +- `test_held_piece_returns_at_spawn_rotation`: hold a piece with `Rotation: 2`, swap it back in -> `Active` equals `SpawnPiece(kind)` (rotation 0, spawn X/Y) +- `test_second_hold_blocked`: two `Apply(InputHold)` in a row -> the second returns `nil` and changes nothing +- `test_hold_available_again_after_lock`: hold, hard drop, hold -> the second hold succeeds +- `test_hold_resets_lock_state`: grounded piece with `LockAccumulator` 300ms and `LockResets` 4; `Apply(InputHold)` -> both zero, `Grounded` false +- `test_hold_does_not_consume_bag`: record `Next` before a swap-style hold -> unchanged afterwards +- `test_hold_game_over_when_incoming_blocked`: fill rows 0..3 except the columns the incoming piece needs, so the swapped-in piece collides at spawn -> `Over` true, last event kind `EventGameOver`, and the board still contains no overlapping cells from the incoming piece +- `test_hold_noop_when_over`: `Over` true -> `nil` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/game/ -run TestHold -v` +Expected: FAIL + +- [ ] **Step 3: Implement hold in `Apply`** + +On `InputHold` with `CanHold`: capture the outgoing kind, then either store it and call `spawn()` (empty hold) or replace `Active` with `SpawnPiece(*Hold)` and store the outgoing kind. Set `CanHold = false`, zero the per-piece timers, and check `Board.Collides(g.Active)` for the swap path — on collision set `g.Over = true` and append `EventGameOver`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/game.go internal/game/hold_test.go +git commit -m "feat(game): hold with once-per-piece rule and spawn-collision game over" +``` + +--- + +### Task 10: Game over and restart + +**Files:** +- Modify: `internal/game/game.go` +- Test: `internal/game/game_test.go` (append) + +**Interfaces:** +- Consumes: `spawn`, `New`. +- Produces: + ```go + func (g *Game) Restart() // same Seed, fresh rng, fresh board and queue + ``` + `Restart` reseeds from `g.Seed` so a `--seed` run replays identically after `r`. + +- [ ] **Step 1: Write the failing tests in `internal/game/game_test.go`** + +- `test_top_out_by_lock`: fill rows 2..21 in columns 3..6 to the ceiling, then hard drop until the next spawn collides -> `Over` true and an `EventGameOver` was returned by the call that locked +- `test_game_over_event_is_last`: the returned slice from the fatal placement ends with `EventGameOver` +- `test_no_events_after_game_over`: `Apply(InputLeft)`, `Apply(InputHardDrop)`, `Advance(1s)` all return `nil` once `Over` +- `test_restart_resets_state`: after scoring and clearing, `Restart()` -> `Score` 0, `Lines` 0, `Level` 1, `Combo` 0, `Over` false, `Hold` nil, `CanHold` true, `Board.TopFilledRow()` 22, `len(Next)` 5 +- `test_restart_replays_same_seed`: record the first 20 spawned kinds of `New(99)`, `Restart()`, record 20 again -> identical +- `test_restart_keeps_seed`: `Seed` is unchanged by `Restart()` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/game/ -run TestRestart -v` +Expected: FAIL — undefined: `Restart` + +- [ ] **Step 3: Implement `Restart`** + +Assign `*g = *New(g.Seed)`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/game.go internal/game/game_test.go +git commit -m "feat(game): game over on blocked spawn and seed-preserving restart" +``` + +--- + +### Task 11: Determinism replay test + +**Files:** +- Create: `internal/game/determinism_test.go` +- Test: same file + +**Interfaces:** +- Consumes: `New`, `Apply`, `Advance`, `Input`. +- Produces: nothing exported. This task's deliverable is the proof of design.md §35 and §49.2. + +- [ ] **Step 1: Write the failing tests in `internal/game/determinism_test.go`** + +Define a step type and a canned script: + +```go +type step struct { + in Input + dt time.Duration +} +``` + +The script is a deterministic pseudo-script generated inside the test from a fixed `rand.New(rand.NewSource(7))` **that is separate from the game's rng** — it picks 4000 steps, each an `Input` from the full set plus a `dt` in `{0, 16ms, 33ms, 250ms}`. + +- `test_replay_is_reproducible`: run the script against `New(8675309)` twice; assert equal `Score`, `Lines`, `Level`, `Combo`, `Over`, `Active`, `Hold`, `Next`, and `Board.Cells` +- `test_replay_differs_by_seed`: the same script against `New(1)` and `New(2)` produces different final `Board.Cells` +- `test_replay_event_stream_is_reproducible`: concatenate `EventKind` values from every call in both runs -> identical slices +- `test_replay_reaches_game_over`: the 4000-step script ends with `Over` true (if it does not, extend the script length until it does, and pin that length in the test) +- `test_engine_never_reads_the_clock`: a source check — walk every `.go` file in `internal/game` that is not a `_test.go` file and assert none contains `time.Now`, `time.Since`, `time.Tick`, or `time.After` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/game/ -run TestReplay -v` +Expected: FAIL (or PASS trivially if the script is empty — verify the assertions are real by temporarily seeding the second run differently) + +- [ ] **Step 3: Fix any determinism leak the test exposes** + +Likely culprits if it fails: map iteration order in event construction, a shared slice aliased across events, or `rng` used for anything other than the bag. + +- [ ] **Step 4: Run the whole suite with the race detector** + +Run: `go test ./... -race -count=2` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/determinism_test.go +git commit -m "test(game): canned replay proves seeded determinism and clock isolation" +``` + +--- + +## Done when + +- `go test ./... -race` passes and `gofmt -l .` is silent. +- `internal/game` has no imports outside the standard library. +- Every §40 engine bullet (Board, Pieces, Bag, Hold, Drop, Score, Game over, Determinism) has named tests. +- Plan 2 can be started against the exact signatures in the Interfaces blocks above. diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/T1-test-lines-cosmic-61/2026-09-17-cosmic-tetris-2-terminal.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/T1-test-lines-cosmic-61/2026-09-17-cosmic-tetris-2-terminal.md new file mode 100644 index 000000000..8f1f67725 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/T1-test-lines-cosmic-61/2026-09-17-cosmic-tetris-2-terminal.md @@ -0,0 +1,990 @@ +# Cosmic Tetris — Plan 2: Playable Terminal Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Turn Plan 1's engine into a genuinely good, fully playable terminal game — keyboard control, board, ghost, hold, next queue, HUD, pause, help, restart, game over, live resize, ASCII fallback — with no cosmic effects yet. + +**Architecture:** Three layers, one direction of dependency: `internal/game` (Plan 1) ← `internal/render` ← `internal/app` ← `cmd/cosmic-tetris`. The renderer never mutates game state; it draws into a `render.Canvas` (a rune + foreground-color grid) and returns one string. Drawing cell-by-cell into a canvas rather than nesting Lip Gloss boxes is deliberate: it makes the §41 golden tests exact, and it is what lets Plan 3 paint a starfield behind the board and shift the board by one cell without re-laying-out anything. Lip Gloss still owns all color and styling; Bubble Tea stays visible as the event loop. + +**Tech Stack:** Go 1.26, `charm.land/bubbletea/v2` v2.0.9, `charm.land/lipgloss/v2` v2.0.6, `charm.land/bubbles/v2` v2.2.1, `github.com/charmbracelet/x/ansi` (width measurement and ANSI stripping in tests). + +**Spec:** `design.md` (this plan implements §4, §8, §9 mechanics, §10, §26 colors, §28 final panel, §30, §31, §32, §33, §36, §37, §39, §41, §42 Phase 2, §46 partial, §49.3, §49.4, §49.7) + +**Depends on:** `plans/2026-09-17-cosmic-tetris-1-engine.md` — completed, with `internal/game` exporting the signatures in its Interfaces blocks. +**Followed by:** `plans/2026-09-17-cosmic-tetris-3-cosmic-fx.md`, which adds `internal/fx`, `internal/flavor`, `--no-fx`, and `--reduced-motion`. + +## Verified library facts + +These were checked against the actual modules; do not substitute v1 idioms. + +- `tea.Model` is `Init() tea.Cmd`, `Update(tea.Msg) (tea.Model, tea.Cmd)`, **`View() tea.View`** — not `View() string`. Build one with `v := tea.NewView(s)`. +- Full-window mode is a field, not a command: `v.AltScreen = true` on the returned `tea.View`. +- Key presses arrive as `tea.KeyPressMsg` (a `Key` with `Code rune`, `Text string`, `Mod`, `IsRepeat`). Its `String()` yields `"left"`, `"space"`, `"ctrl+c"`, `"h"`, and so on, so `key.Matches(msg, bindings...)` from `charm.land/bubbles/v2/key` works directly on it. +- The terminal's color capability arrives once at startup as `tea.ColorProfileMsg` wrapping a `colorprofile.Profile` (`TrueColor`, `ANSI256`, `ANSI`, `ASCII`, `NoTTY`, `Unknown`). +- Timers: `tea.Tick(d, func(time.Time) tea.Msg)`. Resize: `tea.WindowSizeMsg{Width, Height}`. Quit: `tea.Quit`. +- `lipgloss.Color("#22E4F7")` returns a comparable `color.Color`; `lipgloss.NewStyle().Foreground(c).Bold(b).Render(s)` styles a string. +- `ansi.Strip(s)` and `ansi.StringWidth(s)` come from `github.com/charmbracelet/x/ansi`. + +## Global Constraints + +- Dependency direction is `game ← render ← app ← cmd`. `internal/render` must not import `internal/app`; `internal/game` must not import anything of ours (design.md §33). +- The renderer must not mutate game state (§37). Every render entry point takes `*game.Game` and only reads it. +- One animation clock. There is exactly one recurring `tea.Tick` in the program, at 60 Hz, producing `app.FrameMsg`. Gravity is driven by passing the measured `dt` to `game.Advance` (§36). Deliberate deviation from §36's sketch: there is no separate `GravityMsg` and no `GameEventMsg` round trip — §36's own "prefer one animation clock and accumulated elapsed time rather than spawning multiple timing loops" is the binding instruction. +- Key presses are handled in the `Update` that receives them and never wait for a tick (§8, §44). +- Auto-repeat for held left/right comes from the terminal's own key repeat (repeated `tea.KeyPressMsg`). No DAS/ARR timers and no key-release handling. +- Every glyph the game draws must have display width 1 as measured by `ansi.StringWidth`; a logical block is two width-1 runes (§5). +- Minimum usable terminal is 40×24. Below that, draw the §31 notice. Resizing must never panic (§31). +- All copy that the spec quotes verbatim (§28 game-over panel, §30 pause panel, §31 too-small notice, §39 help overlay) is reproduced exactly, including capitalisation. +- Run `gofmt -l .` and `go vet ./...` before every commit; both must be silent. + +## Review Focus + +Input classes the spec implies but never names. Each has a test pinned in the task that owns the code. + +- **A zero-size or 1×1 window** (size unknown before the first `WindowSizeMsg`, some CI terminals, `WithoutRenderer` tests): must draw the too-small notice rather than panic or divide by zero — Tasks 4, 7, 8. +- **Resize below the minimum mid-game and back up**: game state and score survive, no panic, the tick keeps running — Task 8. +- **A very large window (300×100)**: the board stays centered, no rendered line exceeds the window width, panels do not stretch — Tasks 4, 7. +- **Keys that mean nothing in the current state**: `ctrl+c` always quits; gameplay keys do nothing while paused, while the help overlay is up, or after game over; unknown keys are ignored — Tasks 8, 9. +- **Line width in every render mode**: after `ansi.Strip`, every row of the frame is exactly `Width` display columns in Full, Reduced, and ASCII mode — Tasks 3, 7. + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `cmd/cosmic-tetris/main.go` | flag parsing, `tea.NewProgram`, exit codes | +| `internal/app/model.go` | `Config`, `State`, `Model`, `New`, `Init`, `View` | +| `internal/app/update.go` | `Update`: key dispatch, frame clock, resize, color profile | +| `internal/app/messages.go` | `FrameMsg`, `FrameInterval`, `frameCmd` | +| `internal/app/keys.go` | `KeyMap`, `DefaultKeyMap`, help bindings | +| `internal/render/palette.go` | `Mode`, `ModeForProfile`, glyphs, `Palette` colors, `Dim` | +| `internal/render/canvas.go` | `Canvas`, `Set`/`SetString`/`Box`/`Plain`/`String`, box-char sets | +| `internal/render/layout.go` | `Tier`, `Layout`, `Compute`, all geometry constants | +| `internal/render/board.go` | board border, locked cells, ghost, active piece | +| `internal/render/hud.go` | title, hold, next, stats, controls, mission-control line | +| `internal/render/overlay.go` | centered panels: pause, help, game over, too-small | +| `internal/render/render.go` | `Scene`, `Render` — the §37 pipeline | +| `README.md` | what it is, how to run, keys, flags | + +Tests: `internal/render/{canvas,layout,board,hud,glyphs,render}_test.go` with goldens under `internal/render/testdata/`, and `internal/app/{update,keys}_test.go`. + +--- + +### Task 1: Dependencies and a Bubble Tea v2 smoke program + +**Files:** +- Modify: `go.mod` +- Create: `cmd/cosmic-tetris/main.go`, `internal/app/model.go`, `internal/app/messages.go` +- Test: `internal/app/model_test.go` + +**Interfaces:** +- Consumes: `game.New` (Plan 1). +- Produces: + ```go + // internal/app/messages.go + const FrameInterval = time.Second / 60 + type FrameMsg struct{ Now time.Time } + func frameCmd() tea.Cmd + + // internal/app/model.go + type Config struct { + Seed int64 + SeedFixed bool // true when --seed was given: restart reuses Seed + ASCII bool + NoFX bool // consumed by Plan 3 + ReducedMotion bool // consumed by Plan 3 + } + + type State int + const ( + StatePlaying State = iota + StatePaused + StateGameOver + ) + + type Model struct { + Cfg Config + Game *game.Game + Width, Height int + Mode render.Mode + State State + ShowHelp bool + LastFrame time.Time + Keys KeyMap + } + + func New(cfg Config) Model + func (m Model) Init() tea.Cmd + func (m Model) Update(tea.Msg) (tea.Model, tea.Cmd) + func (m Model) View() tea.View + ``` + This task's `Update` handles only `tea.WindowSizeMsg`, `FrameMsg`, and a `q`/`ctrl+c` quit; `View` returns a placeholder string containing the score. Tasks 5–10 fill it in. + +- [ ] **Step 1: Add the dependencies** + +```bash +go get charm.land/bubbletea/v2@v2.0.9 charm.land/lipgloss/v2@v2.0.6 charm.land/bubbles/v2@v2.2.1 github.com/charmbracelet/x/ansi +``` + +- [ ] **Step 2: Write the failing tests in `internal/app/model_test.go`** + +- `test_new_model_defaults`: `New(Config{Seed: 5})` -> `State` `StatePlaying`, `ShowHelp` false, `Game.Seed` 5, `Mode` `render.ModeReduced` (the pre-`ColorProfileMsg` default) +- `test_init_returns_a_command`: `Init()` -> non-nil +- `test_window_size_msg_stored`: `Update(tea.WindowSizeMsg{Width: 80, Height: 30})` -> `Width` 80, `Height` 30 +- `test_frame_msg_returns_next_tick`: `Update(FrameMsg{Now: time.Now()})` -> non-nil `tea.Cmd` +- `test_first_frame_has_zero_dt`: fresh model (zero `LastFrame`), `Update(FrameMsg{Now: T})` -> `Game.GravityAccumulator` 0 and `LastFrame` == `T` +- `test_second_frame_advances`: after the first frame at `T`, `Update(FrameMsg{Now: T.Add(900*time.Millisecond)})` -> the active piece's `Y` increased by 1 +- `test_quit_key`: `Update(tea.KeyPressMsg{Code: 'q'})` returns a command; invoking it yields `tea.QuitMsg` +- `test_ctrl_c_quits`: `tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}` likewise +- `test_view_does_not_panic_at_zero_size`: `New(Config{}).View()` does not panic and returns a non-empty `Content` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `go test ./internal/app/ -v` +Expected: FAIL — undefined: `New`, `FrameMsg` + +- [ ] **Step 4: Implement the three files** + +`frameCmd` is `tea.Tick(FrameInterval, func(t time.Time) tea.Msg { return FrameMsg{Now: t} })`. In the `FrameMsg` branch: if `LastFrame.IsZero()` use `dt = 0`, else `dt = msg.Now.Sub(m.LastFrame)`; store `LastFrame`; call `m.Game.Advance(dt)` when `State == StatePlaying`; always return `frameCmd()`. + +`main.go` for now: `app.New(app.Config{Seed: time.Now().UnixNano()})`, `tea.NewProgram(m)`, `p.Run()`, exit 1 with the error on stderr. Flags come in Task 11. + +`View()` returns `tea.NewView(fmt.Sprintf("cosmic tetris — score %d", m.Game.Score))` with `AltScreen = true`. + +- [ ] **Step 5: Run tests and build** + +Run: `go test ./... -v && go build ./...` +Expected: PASS, binary builds + +- [ ] **Step 6: Run the program by hand to confirm the loop and altscreen work** + +Run: `go run ./cmd/cosmic-tetris` — the screen clears, the placeholder line shows, `q` exits cleanly and restores the terminal. + +- [ ] **Step 7: Commit** + +```bash +git add go.mod go.sum cmd internal/app +git commit -m "feat(app): bubble tea v2 program skeleton with a single 60Hz frame clock" +``` + +--- + +### Task 2: Render modes, glyphs, and palette + +**Files:** +- Create: `internal/render/palette.go` +- Test: `internal/render/palette_test.go`, `internal/render/glyphs_test.go` + +**Interfaces:** +- Consumes: `game.PieceKind`, `game.KindCount`. +- Produces: + ```go + type Mode int + const ( + ModeFull Mode = iota + ModeReduced + ModeASCII + ) + func (m Mode) String() string + func ModeForProfile(p colorprofile.Profile, forceASCII bool) Mode + + const CellCols = 2 // terminal columns per logical block (§5) + + type Glyphs struct { + Block [CellCols]rune // '█','█' | ASCII '[' ,']' + Ghost [CellCols]rune // '░','░' | ASCII '·','·' (§49.4) + Empty [CellCols]rune // ' ',' ' + Box BoxChars // BoxDouble for the board | BoxASCII + Panel BoxChars // BoxRound for panels | BoxASCII + Comet rune // '☄' | '*' + } + func GlyphsFor(m Mode) Glyphs + + type Palette struct { + Locked [game.KindCount]color.Color + Active [game.KindCount]color.Color + Ghost color.Color + Border color.Color // resting border color + BorderCycle []color.Color // §25 palette, used by Plan 3 + Label color.Color + Value color.Color + Mission color.Color + Banner color.Color + Dimmed color.Color + } + func NewPalette(m Mode) Palette + func Dim(c color.Color, f float64) color.Color // scale RGB by f, clamped to [0,1] + func Lerp(a, b color.Color, t float64) color.Color + ``` + +Pinned colors (§26, §49.4 — filled glyphs with a bright foreground, no background pairing): + +| Kind | Locked | Active | +|---|---|---| +| I plasma cyan | `#22E4F7` | `#9FF7FF` | +| J deep electric blue | `#3B5BFF` | `#93A7FF` | +| L solar orange | `#FF8A2B` | `#FFC08A` | +| O stellar gold | `#FFD447` | `#FFEBA3` | +| S alien green | `#43F58A` | `#A6FFC9` | +| T ultraviolet | `#A855F7` | `#D6ADFF` | +| Z supernova pink | `#FF3D77` | `#FF9BB8` | + +Others: `Ghost #4A5470`, `Border #6D28D9`, `Label #7C8AA5`, `Value #E8F0FF`, `Mission #9FF7FF`, `Banner #FFEBA3`, `Dimmed #3A4256`. `BorderCycle` = `#6D28D9` (deep violet), `#22E4F7` (electric cyan), `#FF3DE0` (magenta), `#3B5BFF` (stellar blue), `#F5F9FF` (hot white). + +All three modes use the same hex values — the terminal's color profile downsamples them. What changes per mode is glyphs (§32 "no special Unicode assumptions"), not the palette. + +- [ ] **Step 1: Write the failing tests in `internal/render/palette_test.go` and `glyphs_test.go`** + +- `test_mode_for_truecolor`: `ModeForProfile(colorprofile.TrueColor, false)` -> `ModeFull` +- `test_mode_for_256`: `colorprofile.ANSI256` -> `ModeReduced` +- `test_mode_for_ansi`: `colorprofile.ANSI` -> `ModeReduced` +- `test_mode_for_ascii_profile`: `colorprofile.ASCII` -> `ModeASCII`; `colorprofile.NoTTY` -> `ModeASCII`; `colorprofile.Unknown` -> `ModeASCII` +- `test_force_ascii_wins`: `ModeForProfile(colorprofile.TrueColor, true)` -> `ModeASCII` +- `test_glyphs_full_mode`: `GlyphsFor(ModeFull).Block` -> `{'█','█'}`; `.Ghost` -> `{'░','░'}` +- `test_glyphs_ascii_mode`: `.Block` -> `{'[',']'}`; `.Ghost` -> `{'·','·'}`; `.Box` -> `BoxASCII`; `.Comet` -> `'*'`. (§49.4 pins the ASCII ghost as `··` — U+00B7 is the one non-ASCII rune the spec deliberately keeps in ASCII mode.) +- `test_all_glyphs_are_single_width`: for every mode, every rune in `GlyphsFor(m)` plus every rune used by `BoxDouble`, `BoxRound`, `BoxASCII` satisfies `ansi.StringWidth(string(r))` -> `1` +- `test_palette_has_a_color_per_kind`: for every kind, `Locked[k]` and `Active[k]` are non-nil and differ from each other +- `test_active_is_brighter_than_locked`: for every kind, the sum of the RGBA components of `Active[k]` exceeds that of `Locked[k]` (§49.4) +- `test_border_cycle_length`: `len(BorderCycle)` -> `5` +- `test_dim_halves`: `Dim(lipgloss.Color("#808080"), 0.5)` -> RGB components within 1 of `0x40` +- `test_dim_clamps`: `Dim(c, 2.0)` never exceeds `0xFFFF` per component; `Dim(c, -1)` -> black +- `test_lerp_endpoints`: `Lerp(a, b, 0)` == `a`'s components; `Lerp(a, b, 1)` == `b`'s components; `t=0.5` is between + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/render/ -v` +Expected: FAIL — undefined: `ModeForProfile` + +- [ ] **Step 3: Implement `internal/render/palette.go`** + +`Dim`/`Lerp` work on the 16-bit values from `color.Color.RGBA()` and return `color.RGBA` (remember `RGBA()` returns alpha-premultiplied 16-bit values; divide by 257 to get 8-bit). + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/palette.go internal/render/palette_test.go internal/render/glyphs_test.go +git commit -m "feat(render): render modes, single-width glyph sets, neon space palette" +``` + +--- + +### Task 3: The canvas + +**Files:** +- Create: `internal/render/canvas.go` +- Test: `internal/render/canvas_test.go` + +**Interfaces:** +- Consumes: `Palette` colors. +- Produces: + ```go + type Attr uint8 + const ( + AttrBold Attr = 1 << iota + AttrFaint + ) + + type BoxChars struct{ TL, TR, BL, BR, H, V rune } + var ( + BoxDouble = BoxChars{'╔', '╗', '╚', '╝', '═', '║'} + BoxRound = BoxChars{'╭', '╮', '╰', '╯', '─', '│'} + BoxASCII = BoxChars{'+', '+', '+', '+', '-', '|'} + ) + + type Canvas struct { + W, H int + // unexported cell grid + } + func NewCanvas(w, h int) *Canvas // clamps negatives to 0 + func (c *Canvas) Clear() + func (c *Canvas) Set(x, y int, r rune, fg color.Color, a Attr) // silently clips out-of-bounds + func (c *Canvas) SetString(x, y int, s string, fg color.Color, a Attr) // left to right, clipped + func (c *Canvas) SetCentered(y int, s string, fg color.Color, a Attr) // centered horizontally, clipped + func (c *Canvas) Box(x, y, w, h int, b BoxChars, fg color.Color, a Attr) // border only, interior untouched + func (c *Canvas) FillRect(x, y, w, h int, r rune, fg color.Color, a Attr) + func (c *Canvas) At(x, y int) (rune, color.Color, Attr) // (' ', nil, 0) out of bounds + func (c *Canvas) Plain() []string // H rows, no ANSI, exactly W runes each + func (c *Canvas) String() string // H styled rows joined by "\n" + ``` + +- [ ] **Step 1: Write the failing tests in `internal/render/canvas_test.go`** + +- `test_new_canvas_is_blank`: `NewCanvas(4,2).Plain()` -> `[" ", " "]` +- `test_new_canvas_negative_size`: `NewCanvas(-5,-5)` -> `W` 0, `H` 0, `Plain()` -> empty slice, and `Set(0,0,'x',nil,0)` does not panic +- `test_set_and_at`: `Set(1,1,'X',c,AttrBold)`; `At(1,1)` -> `'X'`, same color, `AttrBold` +- `test_set_out_of_bounds_is_ignored`: `Set(-1,0,...)`, `Set(0,-1,...)`, `Set(99,0,...)`, `Set(0,99,...)` leave `Plain()` unchanged and do not panic +- `test_set_string_clips_at_right_edge`: on a 4-wide canvas, `SetString(2,0,"abcd",...)` -> row 0 `Plain()` is `" ab"` +- `test_set_string_negative_x_clips_left`: `SetString(-2,0,"abcd",...)` -> `"cd "` +- `test_set_string_multibyte`: `SetString(0,0,"██✦",...)` on a 4-wide canvas -> `Plain()[0]` has 3 runes plus a space, and `ansi.StringWidth(Plain()[0])` -> `4` +- `test_set_centered`: `SetCentered(0,"ab",...)` on width 6 -> `" ab "` +- `test_set_centered_too_long`: a 10-rune string on width 4 -> exactly 4 runes, no panic +- `test_box_draws_border_only`: `Box(0,0,4,3,BoxRound,...)` -> `["╭──╮","│ │","╰──╯"]` +- `test_box_minimum_size`: `Box(0,0,1,1,...)` and `Box(0,0,0,0,...)` do not panic +- `test_box_clipped_at_edge`: a box starting at `x = W-2` draws only its visible columns +- `test_fill_rect`: `FillRect(1,0,2,2,'#',...)` -> `["·##·"...]` pattern as expected (use `.` placeholders in the assertion by pre-filling) +- `test_plain_rows_have_exact_width`: for a canvas painted with a mixture of glyphs, every `Plain()` row has `ansi.StringWidth` equal to `W` +- `test_string_strips_to_plain`: `ansi.Strip(c.String())` split on `"\n"` equals `Plain()` +- `test_string_coalesces_runs`: a row of 20 identically-colored `'█'` produces at most 2 SGR sequences in that row (count `"\x1b["` occurrences) — proves adjacent same-style cells share one `Render` call +- `test_string_row_count`: `strings.Count(c.String(), "\n")` -> `H-1` +- `test_clear_resets`: after painting, `Clear()` -> `Plain()` all spaces + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/render/ -run TestCanvas -v` +Expected: FAIL — undefined: `NewCanvas` + +- [ ] **Step 3: Implement `internal/render/canvas.go`** + +Cells are `struct{ r rune; fg color.Color; a Attr }` in a single `[]cell` of length `W*H`. `String()` walks each row, grouping consecutive cells whose `fg` and `a` are equal (`color.Color` values from `lipgloss.Color` are comparable), and renders each run with one `lipgloss.NewStyle()` call; a run with `fg == nil` and `a == 0` is emitted unstyled. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/canvas.go internal/render/canvas_test.go +git commit -m "feat(render): rune+color canvas with clipping, boxes, and style coalescing" +``` + +--- + +### Task 4: Adaptive layout + +**Files:** +- Create: `internal/render/layout.go` +- Test: `internal/render/layout_test.go` + +**Interfaces:** +- Consumes: `game.Width`, `game.VisibleRows`, `CellCols`. +- Produces: + ```go + const ( + BoardCols = game.Width * CellCols // 20 + BoardRows = game.VisibleRows // 20 + BoardBoxW = BoardCols + 2 // 22 + BoardBoxH = BoardRows + 2 // 22 + PanelW = 10 // interior 8 = widest piece (4 cells) + HoldBoxH = 4 // interior 2 + StatsBoxH = 6 // 3 label+value pairs + StatsBoxHNoLabels = 3 + MinWidth = 40 + MinHeight = 24 + ) + + type Tier int + const ( + TierTooSmall Tier = iota + TierSmall + TierMedium + TierWide + ) + + type Rect struct{ X, Y, W, H int } + + type Layout struct { + Tier Tier + Width, Height int + Board Rect // the board box including its border + Hold Rect // zero W/H when hidden + Next Rect + Stats Rect + Frame Rect // the outer title frame; zero when hidden + MissionY int // -1 when hidden + ControlsY int + ShowTitle bool + ShowHold bool + ShowStatLabels bool + ShowMission bool + NextCount int // 5 at Medium/Wide, 3 at Small + } + + func Compute(width, height int) Layout + ``` + +Pinned rules: + +```text +Tier: width < 40 || height < 24 -> TierTooSmall + width >= 64 && height >= 27 -> TierWide + width >= 50 && height >= 25 -> TierMedium + otherwise -> TierSmall + +Drop order as height runs out (§49.3): + ShowTitle = height >= 27 + ShowMission = height >= 25 + ShowStatLabels = height >= 25 + controls = always + +ShowHold = Tier >= TierMedium +NextCount = 5 at Medium/Wide, 3 at Small (NEXT is always beside the board, never above or below it) + +Vertical stack: Board.Y = 1 if ShowTitle else 0 + y = Board.Y + BoardBoxH + MissionY = y then y++ (when ShowMission) + ControlsY = y + Frame = {0, 0, width, height} when ShowTitle + +Horizontal: leftW = PanelW+1 when ShowHold, else 0 + contentW = leftW + BoardBoxW + PanelW + 1 + x0 = max(0, (width - contentW) / 2) + Hold = {x0, Board.Y, PanelW, HoldBoxH} + Board = {x0 + leftW, Board.Y, BoardBoxW, BoardBoxH} + Next = {Board.X + BoardBoxW + 1, Board.Y, PanelW, 2 + 3*NextCount - 1} + Stats = {Hold.X or Next.X when no hold, panel bottom + 1, PanelW, + StatsBoxH or StatsBoxHNoLabels} +``` + +- [ ] **Step 1: Write the failing tests in `internal/render/layout_test.go`** + +- `test_too_small_width`: `Compute(39,40).Tier` -> `TierTooSmall` +- `test_too_small_height`: `Compute(80,23).Tier` -> `TierTooSmall` +- `test_zero_size`: `Compute(0,0).Tier` -> `TierTooSmall`, and no field is negative +- `test_negative_size`: `Compute(-5,-5)` does not panic and reports `TierTooSmall` +- `test_minimum_is_small`: `Compute(40,24).Tier` -> `TierSmall` +- `test_medium_threshold`: `Compute(50,25).Tier` -> `TierMedium`; `Compute(49,25)` -> `TierSmall` +- `test_wide_threshold`: `Compute(64,27).Tier` -> `TierWide`; `Compute(63,27)` -> `TierMedium` +- `test_small_hides_hold`: `Compute(40,24).ShowHold` -> `false`; `NextCount` -> `3` +- `test_small_hides_title_and_mission`: `Compute(40,24)` -> `ShowTitle` false, `ShowMission` false, `MissionY` -1, `ShowStatLabels` false +- `test_medium_shows_mission_not_title`: `Compute(50,25)` -> `ShowTitle` false, `ShowMission` true +- `test_wide_shows_everything`: `Compute(80,30)` -> `ShowTitle` true, `ShowMission` true, `ShowHold` true, `NextCount` 5 +- `test_board_dimensions_never_change`: for every `(w,h)` in a sweep of 40..200 × 24..60, `Board.W` -> `22` and `Board.H` -> `22` +- `test_nothing_overlaps`: for the same sweep, the `Board`, `Hold`, `Next`, `Stats` rects (skipping zero-sized ones) are pairwise non-intersecting, and no rect intersects the `MissionY` or `ControlsY` rows +- `test_everything_inside_the_window`: for the same sweep, every rect satisfies `X >= 0`, `Y >= 0`, `X+W <= width`, `Y+H <= height`; `ControlsY < height` +- `test_board_centered_when_wide`: `Compute(300,100)` -> the gap left of `Hold` is within 1 of the gap right of `Next` +- `test_huge_window_does_not_stretch_panels`: `Compute(300,100).Next.W` -> `10` +- `test_controls_below_board`: for the sweep, `ControlsY >= Board.Y + Board.H` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/render/ -run TestLayout -v` +Expected: FAIL — undefined: `Compute` + +- [ ] **Step 3: Implement `internal/render/layout.go`** + +Return early with `Tier: TierTooSmall` and everything else zeroed (`MissionY: -1`) when below the minimum. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/layout.go internal/render/layout_test.go +git commit -m "feat(render): responsive layout tiers with the pinned small-terminal drop order" +``` + +--- + +### Task 5: Board rendering — border, locked cells, ghost, active piece + +**Files:** +- Create: `internal/render/board.go` +- Test: `internal/render/board_test.go` + +**Interfaces:** +- Consumes: `Canvas`, `Layout`, `Palette`, `Glyphs`, `game.Game`, `game.GhostY`. +- Produces: + ```go + // DrawBoard paints the board box, locked cells, ghost, and active piece. + // offX/offY shift only the board contents and border (Plan 3's screen shake); + // pass 0,0 here. border is the border color for this frame. + // (Plan 3 Task 5 replaces the single `border color.Color` parameter with the + // animated `phase, flash float64` pair; nothing else in this signature moves.) + func DrawBoard(c *Canvas, l Layout, p Palette, g *Glyphs, gm *game.Game, border color.Color, offX, offY int) + + // BoardCellOrigin maps a board cell to the top-left screen column/row of its + // two-column glyph. Hidden rows map above the box interior. + func BoardCellOrigin(l Layout, bx, by int) (x, y int) + ``` + Formula: `x = l.Board.X + 1 + bx*CellCols`, `y = l.Board.Y + 1 + (by - game.HiddenRows)`. Plan 3 reuses this to place FX in board space. + +- [ ] **Step 1: Write the failing tests in `internal/render/board_test.go`** + +Helper: `paint(t, w, h, mode, gm) []string` builds a canvas of `w×h`, computes the layout, calls `DrawBoard`, returns `Plain()`. + +- `test_board_box_drawn`: on an 80×30 canvas the top-left of the board box is `'╔'` and the bottom-right is `'╝'` +- `test_board_interior_is_twenty_by_twenty`: the interior region is 20 columns × 20 rows and, on an empty board with the active piece removed from view, all spaces +- `test_hidden_rows_not_drawn`: lock a cell at board row 1 (hidden) -> no glyph appears anywhere in the interior +- `test_locked_cell_position`: lock a cell at board `(0,21)` -> the interior's bottom-left two columns are `'█','█'` +- `test_locked_cell_rightmost`: lock at `(9,21)` -> the bottom-right two interior columns are filled +- `test_locked_cell_color`: lock a `KindS` cell; `At` on that column reports `p.Locked[game.KindS]` +- `test_active_piece_drawn_brighter`: the active piece's cells report `p.Active[kind]`, not `p.Locked[kind]` +- `test_ghost_drawn_at_landing`: with the active piece high on an empty board, the two interior columns at `GhostY()`'s bottom row are `'░','░'` in `p.Ghost` +- `test_ghost_never_covers_locked`: pre-fill the row under the piece; assert no `'░'` appears on a cell that holds a locked block (§10) +- `test_active_covers_ghost`: when the piece is already at its landing row, the cells show the block glyph, not the ghost glyph +- `test_ascii_mode_glyphs`: in `ModeASCII` a locked cell renders `'[' ,']'` and the box corner is `'+'` +- `test_offset_shifts_everything`: `DrawBoard` with `offX: 1, offY: -1` moves the box corner one column right and one row up +- `test_offset_clips_at_edges`: `offX: 5` on a canvas only 2 columns wider than the layout does not panic and does not wrap glyphs to the next row (compare row widths) +- `test_does_not_mutate_game`: snapshot `*gm` (including `Board.Cells`) before and after `DrawBoard` -> `reflect.DeepEqual` true + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/render/ -run TestBoard -v` +Expected: FAIL — undefined: `DrawBoard` + +- [ ] **Step 3: Implement `internal/render/board.go`** + +Draw order inside the function follows §37 steps 3–5: locked cells, then ghost (skipping cells that already hold a locked block or that the active piece occupies), then the active piece, then the border box last so it is never overdrawn. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/board.go internal/render/board_test.go +git commit -m "feat(render): board border, locked cells, ghost piece, and active piece" +``` + +--- + +### Task 6: HUD — title, hold, next, stats, controls, mission line + +**Files:** +- Create: `internal/render/hud.go` +- Test: `internal/render/hud_test.go` + +**Interfaces:** +- Consumes: `Canvas`, `Layout`, `Palette`, `Glyphs`, `game.Game`, `game.PieceKind`. +- Produces: + ```go + func DrawFrame(c *Canvas, l Layout, p Palette, g *Glyphs, seed int64, border color.Color) + func DrawHold(c *Canvas, l Layout, p Palette, g *Glyphs, hold *game.PieceKind) + func DrawNext(c *Canvas, l Layout, p Palette, g *Glyphs, next []game.PieceKind) + func DrawStats(c *Canvas, l Layout, p Palette, gm *game.Game) + func DrawControls(c *Canvas, l Layout, p Palette) + func DrawMission(c *Canvas, l Layout, p Palette, g *Glyphs, msg string) + func UniverseID(seed int64) string // fmt.Sprintf("%04X", uint16(seed)) + func DrawPieceMini(c *Canvas, x, y int, p Palette, g *Glyphs, k game.PieceKind) // 2-row, ≤8-column preview + ``` + +Pinned copy: +- Title (drawn into the frame's top border, `ShowTitle` only): `─ ✦ COSMIC TETRIS ` then dashes, then ` LOCAL UNIVERSE ` then dashes to the right corner, where `` is `UniverseID(seed)`. +- Panel labels: `HOLD`, `NEXT`, `SCORE`, `LINES`, `LEVEL` — drawn only when `ShowStatLabels`. +- Stat values: `fmt.Sprintf("%08d", Score)`, `fmt.Sprintf("%03d", Lines)`, `fmt.Sprintf("%02d", Level)`. +- Controls line: `←→ move ↑ rotate ↓ descend SPACE YEET C hold ? help` in Full/Reduced; `<> move ^ rotate v descend SPACE YEET C hold ? help` in ASCII. Truncated from the right to fit. +- Mission line: ` MISSION CONTROL: `, truncated to fit; nothing drawn when `msg` is empty. + +- [ ] **Step 1: Write the failing tests in `internal/render/hud_test.go`** + +- `test_universe_id`: `UniverseID(0x7F3A)` -> `"7F3A"`; `UniverseID(-1)` -> `"FFFF"` +- `test_frame_title_present`: on 80×30 the top border row contains `"COSMIC TETRIS"` and `"LOCAL UNIVERSE"` +- `test_frame_row_width_exact`: every row of `Plain()` after `DrawFrame` has width equal to the canvas width +- `test_hold_empty`: `DrawHold(..., nil)` draws the panel box and the `HOLD` label, and its interior is blank +- `test_hold_shows_piece`: with `KindT`, block glyphs appear inside the hold panel in `p.Locked[KindT]` +- `test_next_draws_five`: `DrawNext` with 5 kinds and `NextCount` 5 -> five distinct 2-row previews, all inside `l.Next` +- `test_next_truncates_to_count`: with `NextCount` 3 and a 5-kind slice, only 3 previews are drawn and nothing is written below `l.Next` +- `test_next_short_slice`: a 1-kind slice does not panic +- `test_stats_values_formatted`: score 129340 -> `"00129340"` appears; lines 42 -> `"042"`; level 7 -> `"07"` +- `test_stats_labels_hidden`: with `ShowStatLabels` false, `"LINES"` does not appear but `"042"` does (§49.3) +- `test_controls_line_present`: the `ControlsY` row contains `"hold"` and `"help"` +- `test_controls_truncated_not_wrapped`: at width 40 the controls row is exactly 40 columns and no text appears on the following row +- `test_controls_ascii_mode`: in `ModeASCII` the controls row contains no rune outside U+0000..U+007F +- `test_mission_line`: `DrawMission(..., "NOMINALISH")` -> the `MissionY` row contains `"MISSION CONTROL: NOMINALISH"` +- `test_mission_empty_draws_nothing`: with `""` the `MissionY` row is all spaces +- `test_mission_long_message_truncated`: a 200-character message leaves the row exactly the canvas width +- `test_hud_never_writes_into_the_board`: paint the board with a sentinel via `FillRect` over `l.Board`, run every HUD draw, and assert every cell inside `l.Board` still holds the sentinel (§41 "HUD doesn't corrupt board") + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/render/ -run TestHud -v` +Expected: FAIL — undefined: `DrawHold` + +- [ ] **Step 3: Implement `internal/render/hud.go`** + +`DrawPieceMini` centers the kind's rotation-0 cells in an 8-column, 2-row region using `game.SpawnPiece(k).Cells()` normalized to the box. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/hud.go internal/render/hud_test.go +git commit -m "feat(render): title frame, hold/next panels, stats, controls, mission line" +``` + +--- + +### Task 7: Frame assembly, too-small notice, and golden snapshots + +**Files:** +- Create: `internal/render/render.go`, `internal/render/overlay.go`, `internal/render/testdata/*.golden` +- Test: `internal/render/render_test.go` +- Modify: `internal/app/model.go` (`View` calls `render.Render`) + +**Interfaces:** +- Consumes: everything from Tasks 2–6. +- Produces: + ```go + // Scene is the complete input to a frame. Plan 3 adds FX, Booting, and + // BootElapsed fields; nothing else about this struct changes. + type Scene struct { + Game *game.Game + Width int + Height int + Mode Mode + Paused bool + ShowHelp bool + GameOver bool + Seed int64 + Mission string + } + + func Render(s Scene) string + + // overlay.go + func DrawPanel(c *Canvas, p Palette, g *Glyphs, title string, lines []string) Rect // centered, clipped + func DrawTooSmall(c *Canvas, p Palette, w, h int) + ``` + +`Render` follows §37: compute layout; if `TierTooSmall`, draw only the notice and return. Otherwise draw frame, board, hold, next, stats, mission, controls, then overlays. Plan 3 inserts starfield before the board and FX composites after it, at the numbered §37 slots. + +Pinned §31 notice copy (centered, one blank row between blocks): + +```text +THIS UNIVERSE IS TOO SMALL + +resize terminal to continue + +current: 34 × 19 +needed: approximately 40 × 24 +``` + +- [ ] **Step 1: Write the failing tests in `internal/render/render_test.go`** + +Fixture: `fixtureScene(t, w, h, mode Mode) Scene` uses `game.New(8675309)` then applies this exact script so the board has content — `[]game.Input{InputLeft, InputLeft, InputHardDrop, InputRight, InputRotateCW, InputHardDrop, InputHold, InputHardDrop, InputRotateCCW, InputHardDrop, InputRight, InputRight, InputHardDrop}` with `Advance(120*time.Millisecond)` between each — and sets `Mission: "GRAVITY REMAINS MOSTLY LEGAL"`. + +Golden tests compare `ansi.Strip(Render(scene))` against `testdata/.golden`, regenerated with `go test ./internal/render/ -update`: + +- `test_golden_wide`: 80×30 +- `test_golden_medium`: 54×26 +- `test_golden_small`: 40×24 +- `test_golden_ascii`: 80×30 in `ModeASCII` +- `test_golden_too_small`: 34×19 — the golden contains the §31 copy including `current: 34 × 19` +- `test_golden_pause`: 80×30 with `Paused: true` +- `test_golden_help`: 80×30 with `ShowHelp: true` +- `test_golden_game_over`: 80×30 with `GameOver: true` + +Plus invariants that do not depend on goldens: + +- `test_every_row_is_exactly_width`: for `(w,h)` in `{40×24, 54×26, 80×30, 300×100, 34×19, 1×1, 0×0}` and all three modes, every line of `ansi.Strip(Render(s))` has `ansi.StringWidth` equal to `w` (or the output is empty when `w == 0`) +- `test_row_count_is_exactly_height`: same sweep, line count equals `h` +- `test_zero_size_no_panic`: `Render` with `Width: 0, Height: 0` returns `""` and does not panic +- `test_one_by_one_no_panic`: `Width: 1, Height: 1` does not panic +- `test_resize_sweep_no_panic`: every `(w,h)` in 0..90 × 0..40 renders without panicking +- `test_render_does_not_mutate_game`: deep-equal the `game.Game` before and after `Render` (§37) +- `test_overlays_do_not_change_size`: the pause, help, and game-over renders have the same line count and width as the plain render +- `test_help_contains_flight_manual`: the help render contains `"FLIGHT MANUAL"` and every §39 row label (`"YEET"`, `"quantum storage"`, `"suspend spacetime"`, `"reboot universe"`, `"abandon mission"`) +- `test_pause_contains_copy`: contains `"TEMPORAL SUSPENSION"` and `"SPACE IS PAUSED"` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/render/ -run TestGolden -v` +Expected: FAIL — undefined: `Render` + +- [ ] **Step 3: Implement `render.go` and `overlay.go`** + +The overlay panels' exact contents come from §30 (pause), §39 (help), and §28 (game over, wired in Task 10). Build them with `DrawPanel`, which sizes the box to the longest line plus padding and centers it in the canvas. + +- [ ] **Step 4: Generate and eyeball the goldens, then verify** + +Run: `go test ./internal/render/ -update && go test ./internal/render/ -v` +Expected: PASS. Read `testdata/wide.golden` and `testdata/small.golden` yourself: nothing overlaps, the board is 20×20 inside a 22×22 box, panels sit beside it, the controls line is the bottom row of content. + +- [ ] **Step 5: Wire `app.Model.View` to `render.Render`** + +Replace the placeholder view with a `render.Scene` built from the model: `Paused: m.State == StatePaused`, `GameOver: m.State == StateGameOver`, `ShowHelp: m.ShowHelp`, `Seed: m.Cfg.Seed`. Keep `AltScreen = true`. + +- [ ] **Step 6: Run everything** + +Run: `go test ./... -v && go run ./cmd/cosmic-tetris` +Expected: PASS, and a real board appears in the terminal. + +- [ ] **Step 7: Commit** + +```bash +git add internal/render internal/app/model.go +git commit -m "feat(render): frame pipeline, too-small notice, and golden layout snapshots" +``` + +--- + +### Task 8: Keys, input dispatch, resize, and the frame clock + +**Files:** +- Create: `internal/app/keys.go`, `internal/app/update.go` +- Modify: `internal/app/model.go` +- Test: `internal/app/keys_test.go`, `internal/app/update_test.go` + +**Interfaces:** +- Consumes: `game.Apply`, `game.Advance`, `render.ModeForProfile`, `FrameMsg`. +- Produces: + ```go + type KeyMap struct { + Left, Right, SoftDrop, RotateCW, RotateCCW, HardDrop, + Hold, Pause, Restart, Help, Quit key.Binding + } + func DefaultKeyMap() KeyMap + func (k KeyMap) ShortHelp() []key.Binding + func (k KeyMap) FullHelp() [][]key.Binding + func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) // full version + ``` + +Pinned bindings (§8 plus the optional WASD aliases): + +```text +Left left, h, a +Right right, l, d +SoftDrop down, j, s +RotateCW up, k, x, w +RotateCCW z +HardDrop space, " " +Hold c +Pause p +Restart r +Help ? +Quit q, esc, ctrl+c +``` + +- [ ] **Step 1: Write the failing tests in `internal/app/keys_test.go` and `update_test.go`** + +Helper: `press(m Model, code rune) Model` and `pressKey(m Model, k tea.KeyPressMsg) (Model, tea.Cmd)`. + +- `test_binding_keys`: `DefaultKeyMap().Left.Keys()` -> `["left","h","a"]`; `HardDrop.Keys()` contains `"space"` +- `test_full_help_has_every_action`: `FullHelp()` flattened has 11 bindings, each with a non-empty help description +- `test_left_moves_piece`: `tea.KeyPressMsg{Code: tea.KeyLeft}` -> `Active.X` decreases by 1 +- `test_h_moves_piece`: `Code: 'h'` behaves identically +- `test_a_alias`: `Code: 'a'` behaves identically +- `test_right_and_aliases`: `tea.KeyLeft`/`'l'`/`'d'` mirror +- `test_soft_drop_scores`: `tea.KeyDown` -> `Score` 1 +- `test_rotate_cw_and_aliases`: `tea.KeyUp`, `'k'`, `'x'`, `'w'` each advance `Rotation` by 1 +- `test_rotate_ccw`: `'z'` -> `Rotation` 3 +- `test_space_hard_drops`: `tea.KeyPressMsg{Code: tea.KeySpace}` -> the piece locks (board gains cells, a new piece is active) +- `test_c_holds`: `'c'` -> `Hold` non-nil +- `test_unknown_key_ignored`: `'§'` and `tea.KeyPressMsg{Code: tea.KeyF5}` change nothing and return no command +- `test_input_does_not_wait_for_a_tick`: five `Left` presses with no `FrameMsg` between them -> `Active.X` moved 5 (§44) +- `test_frame_advances_gravity`: frames at `T` and `T+900ms` -> the piece dropped exactly once at level 1 +- `test_frame_clamped_dt_is_engine_side`: a frame 10 s after the previous one does not lock the piece (relies on Plan 1's `MaxAdvanceStep`) +- `test_frame_always_returns_tick`: the returned command is non-nil in every state, including `StateGameOver` +- `test_color_profile_sets_mode`: `tea.ColorProfileMsg{Profile: colorprofile.TrueColor}` -> `Mode` `ModeFull`; with `Cfg.ASCII` true -> `ModeASCII` +- `test_resize_updates_size`: `tea.WindowSizeMsg{Width: 100, Height: 40}` -> stored +- `test_resize_below_minimum_preserves_game`: score and board are unchanged by a `30×10` resize, `View()` contains `"TOO SMALL"`, and a subsequent `80×30` resize renders the board again with the same score +- `test_resize_sweep_no_panic`: feed 200 random `WindowSizeMsg` sizes in 0..200 × 0..80, rendering `View()` after each -> no panic +- `test_ctrl_c_quits_from_every_state`: playing, paused, and game over each return a command yielding `tea.QuitMsg` +- `test_render_from_view_does_not_mutate_game`: deep-equal the game before and after `View()` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/app/ -v` +Expected: FAIL — undefined: `DefaultKeyMap` + +- [ ] **Step 3: Implement `keys.go` and the full `update.go`** + +Map bindings to `game.Input` values and call `m.Game.Apply` once per matched press. Gameplay keys are handled only when `m.State == StatePlaying && !m.ShowHelp`. After any `Apply` or `Advance`, set `m.State = StateGameOver` when `m.Game.Over`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./... -v` +Expected: PASS + +- [ ] **Step 5: Play it** + +Run: `go run ./cmd/cosmic-tetris` — move, rotate, soft drop, hard drop, hold, clear a line, watch the level rise. Hold left and confirm the piece walks across from terminal auto-repeat. + +- [ ] **Step 6: Commit** + +```bash +git add internal/app +git commit -m "feat(app): key bindings, immediate input dispatch, resize, and gravity clock" +``` + +--- + +### Task 9: Pause, help, and restart + +**Files:** +- Modify: `internal/app/update.go`, `internal/render/overlay.go`, `internal/render/render.go` +- Test: `internal/app/update_test.go` (append), `internal/render/render_test.go` (append) + +**Interfaces:** +- Consumes: `State`, `game.Restart`. +- Produces: `p` toggles `StatePlaying`/`StatePaused`; `?` toggles `ShowHelp`; `r` restarts. Pause freezes gameplay: while `StatePaused`, `FrameMsg` does not call `Advance`. + +Pinned copy — pause (§30) and help (§39) exactly as written in the spec, including `TEMPORAL SUSPENSION`, `SPACE IS PAUSED`, `p resume`, `FLIGHT MANUAL`, `move spacecraft`, `accelerate doom`, `rotate geometry`, `rotate other way`, `YEET`, `quantum storage`, `suspend spacetime`, `reboot universe`, `abandon mission`, `close this nonsense`. + +- [ ] **Step 1: Write the failing tests** + +- `test_pause_toggles`: `'p'` -> `StatePaused`; again -> `StatePlaying` +- `test_paused_freezes_gravity`: pause, then frames spanning 5 s -> the active piece has not moved and `Score` is unchanged +- `test_paused_ignores_gameplay_keys`: while paused, left/right/rotate/hard drop/hold change nothing +- `test_paused_view_shows_overlay`: `View().Content` contains `"TEMPORAL SUSPENSION"` +- `test_help_toggles`: `'?'` -> `ShowHelp` true; again -> false +- `test_help_ignores_gameplay_keys`: with help open, gameplay keys change nothing, but `'?'` closes it +- `test_help_does_not_pause`: with help open, a frame spanning 900 ms still drops the piece (§20/§44: overlays never block gameplay) — pin this as the intended behavior: the help overlay is non-modal for gravity +- `test_restart_resets_score`: score some points, `'r'` -> `Score` 0, `State` `StatePlaying`, `ShowHelp` false +- `test_restart_from_paused`: `'r'` while paused -> `StatePlaying` +- `test_restart_reuses_fixed_seed`: `Cfg{Seed: 7, SeedFixed: true}`, record the first 5 `Next` kinds, restart -> identical +- `test_restart_draws_new_seed_when_unfixed`: `Cfg{SeedFixed: false}` -> after restart `Game.Seed` differs from before (allow a retry loop against clock granularity) +- `test_golden_pause_regenerated` and `test_golden_help_regenerated`: the Task 7 goldens still match + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/app/ -run TestPause -v` +Expected: FAIL + +- [ ] **Step 3: Implement pause, help, and restart** + +Restart with `SeedFixed` calls `m.Game.Restart()`; without it, `m.Game = game.New(time.Now().UnixNano())` and `m.Cfg.Seed` is updated so the title's universe ID changes too. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./... -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/app internal/render +git commit -m "feat(app): pause, non-modal help overlay, and restart" +``` + +--- + +### Task 10: Game over panel + +**Files:** +- Modify: `internal/app/update.go`, `internal/render/overlay.go` +- Test: `internal/app/update_test.go` (append), `internal/render/render_test.go` (append) + +**Interfaces:** +- Consumes: `game.Over`, `DrawPanel`. +- Produces: `func DrawGameOver(c *Canvas, p Palette, g *Glyphs, gm *game.Game) Rect`. + +Pinned copy (§28 final panel), with `SCORE`/`LINES`/`LEVEL` values from the game and thousands separators as shown in the spec (`483,200`): + +```text +UNIVERSE EXPIRED + +SCORE 483,200 +LINES 127 +LEVEL 13 + +r REBOOT UNIVERSE +q ACCEPT COSMIC DEATH +``` + +Subtitle line under the title: `CAUSE: EXCESSIVE GEOMETRY`. + +- [ ] **Step 1: Write the failing tests** + +- `test_game_over_state_entered`: drive the engine to `Over` through `Update` calls -> `State` `StateGameOver` +- `test_game_over_freezes_gravity`: further frames leave `Score` and the board unchanged +- `test_game_over_ignores_gameplay_keys`: left/rotate/hard drop/hold change nothing +- `test_game_over_accepts_r`: `'r'` -> `StatePlaying`, `Score` 0 +- `test_game_over_accepts_q`: `'q'` -> quit command +- `test_game_over_panel_copy`: the render contains `"UNIVERSE EXPIRED"`, `"CAUSE: EXCESSIVE GEOMETRY"`, `"REBOOT UNIVERSE"`, `"ACCEPT COSMIC DEATH"` +- `test_game_over_shows_stats`: with score 483200, lines 127, level 13 -> contains `"483,200"`, `"127"`, `"13"` +- `test_thousands_separator`: score 1000 -> `"1,000"`; score 0 -> `"0"`; score 1234567 -> `"1,234,567"` +- `test_game_over_panel_fits_small_terminal`: at 40×24 the panel is fully inside the window and the frame's line widths are unchanged + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./... -run TestGameOver -v` +Expected: FAIL + +- [ ] **Step 3: Implement the panel and the state transition** + +Write the thousands separator as a small helper in `overlay.go`; do not add a dependency for it. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./... -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/app internal/render +git commit -m "feat(render): game over panel with final stats" +``` + +--- + +### Task 11: CLI flags and README + +**Files:** +- Modify: `cmd/cosmic-tetris/main.go` +- Create: `README.md`, `LICENSE` +- Test: `cmd/cosmic-tetris/main_test.go` + +**Interfaces:** +- Consumes: `app.Config`. +- Produces: + ```go + func parseFlags(args []string, stderr io.Writer) (app.Config, bool, error) // (cfg, showHelp, err) + const usage = `...` + ``` + Flags in this plan: `--seed`, `--ascii`, `--help`. `--no-fx` and `--reduced-motion` are added by Plan 3 Task 16; `usage` and `Config` already have the fields, so that task only wires them. + +- [ ] **Step 1: Write the failing tests in `cmd/cosmic-tetris/main_test.go`** + +- `test_no_flags_random_seed`: `parseFlags(nil, io.Discard)` -> `SeedFixed` false, `Seed` non-zero +- `test_seed_flag`: `["--seed","1234"]` -> `Seed` 1234, `SeedFixed` true +- `test_seed_flag_equals_form`: `["--seed=1234"]` -> same +- `test_seed_flag_invalid`: `["--seed","banana"]` -> error, non-nil +- `test_ascii_flag`: `["--ascii"]` -> `ASCII` true +- `test_help_flag`: `["--help"]` -> `showHelp` true +- `test_unknown_flag_errors`: `["--warp-drive"]` -> error +- `test_usage_lists_every_flag`: `usage` contains `--seed`, `--ascii`, `--no-fx`, `--reduced-motion`, `--help` (§49.5's final CLI surface) + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./cmd/... -v` +Expected: FAIL — undefined: `parseFlags` + +- [ ] **Step 3: Implement flag parsing with the standard `flag` package** + +Use a `flag.NewFlagSet` with `flag.ContinueOnError` and its output set to the passed writer so tests stay quiet. On `--help`, print `usage` to stdout and exit 0. + +- [ ] **Step 4: Write `README.md` and `LICENSE`** + +README: one-paragraph description, `go run ./cmd/cosmic-tetris`, the §8 control table, the §49.5 flag list, and a note that `internal/game` is deterministic and clock-free. LICENSE: MIT with the current year. + +- [ ] **Step 5: Verify the whole thing** + +Run: `go test ./... -race && go vet ./... && gofmt -l . && go run ./cmd/cosmic-tetris --seed 8675309` +Expected: tests pass, vet and gofmt silent, and two runs with the same seed deal the same opening pieces. + +- [ ] **Step 6: Commit** + +```bash +git add cmd README.md LICENSE +git commit -m "feat(cli): --seed, --ascii, --help, plus README and license" +``` + +--- + +## Done when + +- `go test ./... -race` passes; `go vet ./...` and `gofmt -l .` are silent. +- The game is playable start through game over with immediate controls, working hold, ghost, next queue, pause, restart, and help. +- Resizing between 20×10 and 300×100 never panics and the too-small notice appears below 40×24. +- `--ascii` produces a readable board with no non-ASCII runes except §49.4's pinned ghost glyph. +- Goldens exist for wide, medium, small, ASCII, too-small, pause, help, and game over. +- Plan 3 can begin: `render.Scene` and `render.BoardCellOrigin` are the seams it extends. diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/T1-test-lines-cosmic-61/2026-09-17-cosmic-tetris-3-cosmic-fx.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/T1-test-lines-cosmic-61/2026-09-17-cosmic-tetris-3-cosmic-fx.md new file mode 100644 index 000000000..c8964d690 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/T1-test-lines-cosmic-61/2026-09-17-cosmic-tetris-3-cosmic-fx.md @@ -0,0 +1,1260 @@ +# Cosmic Tetris — Plan 3: Cosmic Effects Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the universe lose its shit around the player — starfield, animated border, piece trails, hard-drop impact, particle physics, supernova line clears, shockwaves, hyperdrive, the four-line event, mission control, boot sequence, and a black-hole game over — without touching a single field of game state. + +**Architecture:** A new package `internal/fx` holds an independent simulation: it receives `[]game.Event` plus a few plain ints, owns its own `*rand.Rand`, and exposes read-only accessors. It has no reference to `*game.Game` and therefore *cannot* modify it — that is how design.md §14's rule is enforced, structurally rather than by convention. `internal/render` reads `fx.World` and composites its output into the existing canvas at the §37 slots. `internal/flavor` is pure string data. Dependency direction stays one-way: `game ← flavor ← fx ← render ← app`. + +**Tech Stack:** Go 1.26, `charm.land/bubbletea/v2`, `charm.land/lipgloss/v2`, `charm.land/bubbles/v2` (spinner for the boot screen), `github.com/charmbracelet/x/ansi` in tests. + +**Spec:** `design.md` (this plan implements §14–§29, §30 star behavior, §32 modes, §43, §44, §45, §46, §49.5) + +**Depends on:** `plans/2026-09-17-cosmic-tetris-1-engine.md` and `plans/2026-09-17-cosmic-tetris-2-terminal.md`, both complete. The seams it extends are `render.Scene`, `render.BoardCellOrigin`, `render.Palette`, and `app.Model`. + +## Global Constraints + +- `internal/fx` must not import `internal/render`, `internal/app`, or hold a `*game.Game`. It may import `internal/game` for `Event`/`Point`/`PieceKind` and `internal/flavor` for strings (§14, §33). +- `fx.World` owns a second `*rand.Rand`, seeded independently of the game seed. The two generators never share and FX randomness must never reach the bag (§35, §49.6). +- FX simulation runs on the same single 60 Hz `app.FrameMsg` clock as gravity. No new tickers, no goroutine per particle or per frame (§38). +- Screen shake never exceeds one terminal cell in any direction (§44). +- Effects never delay gameplay, never block input, never obscure the active piece, and never make the board harder to read (§44). Where a choice trades legibility for spectacle, legibility wins. +- `--no-fx` disables the starfield, particles, trails, shake, shockwaves, hyperdrive, banners, border animation, boot sequence, and game-over collapse. It keeps piece colors, the ghost, and the mission-control line: "the boring mode should still be a good game" (§32). +- `--reduced-motion` suppresses screen shake, hyperdrive acceleration, and shockwaves only; color, trails, and particles stay (§49.5). +- Total live particles are capped at `MaxParticles` (600). Reuse slices; do not reallocate per frame (§38). +- All flavor copy is reproduced verbatim from the spec sections that quote it (§20, §21, §22, §27, §28, §29, §45). +- `gofmt -l .` and `go vet ./...` silent before every commit. +- Every `fx` unit test calls `Resize(120, 40)` before asserting, unless the test is *about* canvas size. Task 15 scales emission counts by canvas area, so exact-count assertions only stay stable at full intensity. + +## Review Focus + +Input classes the spec implies but never names. Each has a test pinned in the task that owns the code. + +- **FX drawing outside the canvas** — particles drift off-screen, shake pushes the board against an edge, a shockwave radius exceeds the window: every write goes through `Canvas.Set`'s clipping and no row may change width — Tasks 2, 4, 7, 9. +- **Unbounded particle growth** during a long combo chain or a held hard-drop key: the live count must stay ≤ `MaxParticles` with the oldest dropped first — Task 2. +- **`--no-fx` and `--reduced-motion` together**: nothing animates, the frame is byte-stable across steps, gameplay is unaffected — Task 16. +- **A very large `dt`** reaching `Step` (suspended process, laptop lid): timers clamp to `MaxStepDt`, no negative alpha, no NaN positions, no banner stuck on screen forever — Task 1. +- **Paused**: gameplay particles, trails, banners, and shake freeze; only stars keep drifting, at 0.25× (§30) — Tasks 1, 3. + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `internal/fx/world.go` | `Config`, `Status`, `World`, `NewWorld`, `Consume`, `Step`, `Resize`, `SetBoardRect`, `ResetRun`, all accessors | +| `internal/fx/glyphs.go` | `GlyphSet`, `UnicodeGlyphs`, `ASCIIGlyphs` — logical glyph inventory, no render dependency | +| `internal/fx/particle.go` | `Particle`, integration, emitters, the particle cap | +| `internal/fx/starfield.go` | `Star`, three layers, level scaling, density boosts, shooting stars | +| `internal/fx/trails.go` | `Trail`, move and hard-drop ion trails | +| `internal/fx/impact.go` | screen shake, border flash, hard-drop impact composition | +| `internal/fx/clears.go` | `ClearBand` — the three-phase supernova | +| `internal/fx/shockwave.go` | `Shockwave` rings | +| `internal/fx/hyperdrive.go` | the §16 timeline state machine | +| `internal/fx/banner.go` | `Banner`, `Notice`, HUD pulse | +| `internal/fx/mission.go` | mission-control channel with priorities and dwell time | +| `internal/fx/collapse.go` | the game-over black hole | +| `internal/flavor/messages.go` | every string the spec quotes | +| `internal/render/fxdraw.go` | draws stars, trails, particles, bands, rings, banners, notices from `fx.World` | +| `internal/render/boot.go` | the §29 boot screen | +| `internal/render/collapse.go` | freeze / infall / singularity drawing | + +Tests sit beside each file; render FX goldens go under `internal/render/testdata/`. + +--- + +### Task 1: `fx.World` skeleton, clock discipline, and pause behavior + +**Files:** +- Create: `internal/fx/world.go`, `internal/fx/glyphs.go` +- Test: `internal/fx/world_test.go` + +**Interfaces:** +- Consumes: `game.Event`, `game.EventKind`, `game.Point`. +- Produces: + ```go + type Config struct { + Enabled bool // false under --no-fx + ReducedMotion bool + Glyphs GlyphSet + } + + // Status carries the only game facts FX is allowed to know. Plain ints by + // design: fx never receives a *game.Game, so it cannot modify game state + // (design.md §14). + type Status struct { + Level int + Combo int + Lines int + Score int + Best int + } + + type World struct{ /* unexported */ } + + func NewWorld(cfg Config, seed int64) *World + func (w *World) SetConfig(cfg Config) + func (w *World) Resize(width, height int) + func (w *World) SetBoardRect(x, y, cols, rows int) // interior of the board box, screen cells + func (w *World) Consume(evs []game.Event, st Status) + func (w *World) Step(dt time.Duration, paused bool) + func (w *World) ResetRun() // on restart: drop all live effects and per-run latches + func (w *World) Elapsed() time.Duration + + // glyphs.go + type GlyphSet struct { + Sparks []rune // '·','*','✦','+' + Debris []rune // '·','*','✦' + StarsFar []rune // '.' + StarsMid []rune // '·','˚' + StarsNear []rune // '✦','✧','*' + Trail [3]rune // 1/2/3 steps ago: '▓','▒','░' + Streak rune // hyperdrive stretch: '│' + Ring []rune // '·','○','◌','◯' + Clear [4]rune // '▓','█','░','✦' + Singularity rune // '●' + } + func UnicodeGlyphs() GlyphSet + func ASCIIGlyphs() GlyphSet // '.', '*', '+', '#','=','-', '|', 'o','O', '@' + + const ( + MaxStepDt = 100 * time.Millisecond + MaxParticles = 600 + ) + ``` + `Step` clamps `dt` to `MaxStepDt`, ignores `dt <= 0`, and advances `Elapsed` by the clamped amount. When `paused` is true it advances only the starfield (at `PausedStarScale`, Task 3) and freezes every other timer. + +- [ ] **Step 1: Write the failing tests in `internal/fx/world_test.go`** + +- `test_new_world_is_empty`: `NewWorld(Config{Enabled: true, Glyphs: UnicodeGlyphs()}, 1)` -> `Particles()`, `Trails()`, `Shockwaves()`, `ClearBands()` all empty; `Banner()` -> `ok` false +- `test_step_advances_elapsed`: two `Step(16ms, false)` -> `Elapsed()` 32ms +- `test_step_ignores_zero_and_negative_dt`: `Step(0,false)` and `Step(-1s,false)` -> `Elapsed()` unchanged +- `test_step_clamps_huge_dt`: `Step(30*time.Second, false)` -> `Elapsed()` `MaxStepDt` +- `test_huge_dt_does_not_strand_a_banner`: show a banner, then `Step(30s,false)` repeatedly 20 times -> `Banner()` `ok` false (timers converge; nothing sticks) +- `test_paused_freezes_elapsed_effects`: spawn effects, record counts, `Step(500ms, true)` -> particle count, trail count, and shake offset unchanged +- `test_disabled_world_ignores_events`: `Config{Enabled: false}`; `Consume` a `LinesCleared` event -> no particles, no bands, no banner, `ShakeOffset()` -> `(0,0)` +- `test_consume_never_needs_a_game`: compile-level assertion — a test that constructs `Consume([]game.Event{...}, Status{})` with no `*game.Game` in scope (documents the §14 boundary) +- `test_fx_rng_is_independent`: two worlds with the same fx seed produce identical particle positions after identical `Consume`+`Step` sequences, and a third world with a different fx seed differs — while the `game.Game` used to generate the events is untouched in all three (deep-equal) +- `test_reset_run_clears_everything`: after spawning effects, `ResetRun()` -> all accessors empty, `ShakeOffset()` `(0,0)` +- `test_resize_keeps_working`: `Resize(0,0)` then `Step` -> no panic; `Resize(200,80)` afterwards works +- `test_ascii_glyphs_are_ascii`: every rune in `ASCIIGlyphs()` is < U+0080 +- `test_all_glyphs_single_width`: every rune in both glyph sets satisfies `ansi.StringWidth(string(r))` -> `1` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ -v` +Expected: FAIL — undefined: `NewWorld` + +- [ ] **Step 3: Implement `world.go` and `glyphs.go`** + +`World` holds the config, board rect, canvas size, `rng`, `elapsed`, and empty slices for each subsystem, plus a `dispatch(ev game.Event, st Status)` switch that Tasks 5–13 fill in one case at a time. `Consume` returns immediately when `!cfg.Enabled`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/fx/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx +git commit -m "feat(fx): world skeleton with independent rng, clamped clock, and pause freeze" +``` + +--- + +### Task 2: Particle physics + +**Files:** +- Create: `internal/fx/particle.go` +- Test: `internal/fx/particle_test.go` + +**Interfaces:** +- Consumes: `World`, `GlyphSet`. +- Produces: + ```go + type Particle struct { + X, Y float64 + VX, VY float64 + Life float64 // seconds remaining + MaxLife float64 + Glyph rune + Brightness float64 // 0..1, derived from Life/MaxLife at spawn-time color + Hue Hue // which palette family render should color it with + } + + type Hue int + const ( + HueSpark Hue = iota // hot white / gold + HueDebris // inherits the cleared row's neon + HueIon // cyan + ) + + func (w *World) Particles() []Particle + func (w *World) ParticleCount() int + func (w *World) Emit(p Particle) // respects MaxParticles + func (w *World) EmitBurst(x, y float64, n int, minSpeed, maxSpeed, spreadDeg, aimDeg float64, h Hue, glyphs []rune) + + const ( + ParticleGravity = 14.0 // cells/s² downward + ParticleDrag = 0.40 // v *= pow(ParticleDrag, dt) + ) + ``` + Integration per §23, in this order: `X += VX*dt; Y += VY*dt; VY += ParticleGravity*dt; damp := pow(ParticleDrag, dt); VX *= damp; VY *= damp; Life -= dt`. A particle dies when `Life <= 0` or when it leaves the canvas by more than 2 cells on any side. `Brightness` is recomputed each step as `Life / MaxLife`. + +- [ ] **Step 1: Write the failing tests in `internal/fx/particle_test.go`** + +- `test_position_integrates`: a particle at `(5,5)` with `VX: 10, VY: 0`, `Step(100ms)` -> `X` ≈ `6.0` (within 0.01) +- `test_gravity_accelerates_downward`: `VY` after `Step(100ms)` from 0 -> ≈ `1.4 * pow(0.4,0.1)` (assert `VY > 0` and within 0.05 of the computed value) +- `test_drag_reduces_speed`: `VX: 10`, `Step(1s)` -> `VX` ≈ `4.0` +- `test_life_decreases`: `MaxLife: 0.5`, `Step(100ms)` -> `Life` ≈ `0.4`, `Brightness` ≈ `0.8` +- `test_dies_at_zero_life`: `Life: 0.05`, `Step(100ms)` -> `ParticleCount()` 0 +- `test_dies_outside_viewport`: a particle at `X: -10` on a 40×24 canvas -> removed on the next `Step` +- `test_survives_just_off_edge`: a particle at `X: -1` survives (the 2-cell grace margin lets debris fall back in) +- `test_no_nan_after_huge_dt`: `Step(30s)` -> no particle has a NaN or Inf coordinate +- `test_emit_burst_count`: `EmitBurst(x,y,20,...)` -> `ParticleCount()` 20 +- `test_emit_burst_spread`: with `aimDeg: -90` (straight up) and `spreadDeg: 40`, every particle's velocity angle is within 20° of straight up and speed is inside `[minSpeed, maxSpeed]` +- `test_emit_burst_is_seeded`: two worlds with the same seed produce identical bursts +- `test_particle_cap`: 100 calls of `EmitBurst(..., 20, ...)` -> `ParticleCount()` -> exactly `MaxParticles` +- `test_particle_cap_drops_oldest`: emit a uniquely-glyphed particle first, fill past the cap, and assert that unique glyph is gone while the most recent emissions survive +- `test_slice_is_reused`: after filling to the cap and letting all particles die, `cap(w.Particles())` is still ≥ `MaxParticles` (no reallocation churn per frame) + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ -run TestParticle -v` +Expected: FAIL — undefined: `EmitBurst` + +- [ ] **Step 3: Implement `particle.go`** + +Store particles in a `[]Particle` compacted in place each step (write-cursor filter, no allocation). `Emit` at the cap overwrites index `oldest` in a ring-buffer fashion or shifts the slice — either is fine as long as the dropped particle is the oldest. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/fx/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/particle.go internal/fx/particle_test.go +git commit -m "feat(fx): terminal-space particle physics with a hard particle cap" +``` + +--- + +### Task 3: Starfield + +**Files:** +- Create: `internal/fx/starfield.go` +- Test: `internal/fx/starfield_test.go` + +**Interfaces:** +- Consumes: `World`, `GlyphSet`, `Status.Level`. +- Produces: + ```go + type Star struct { + X, Y float64 + Glyph rune + Layer int // 0 far, 1 mid, 2 near + Bright float64 // 0.45 far, 0.7 mid, 1.0 near + } + func (w *World) Stars() []Star + func (w *World) StarSpeedScale() float64 // level scaling × hyperdrive multiplier + func (w *World) BoostStars(extra int, d time.Duration) + func (w *World) ShootingStar() (Particle, bool) // §45, occasional + + const ( + StarsFarPerCells = 60 + StarsMidPerCells = 110 + StarsNearPerCells = 220 + FarSpeed = 1.2 // cells/s downward + MidSpeed = 3.0 + NearSpeed = 6.5 + PausedStarScale = 0.25 + LevelSpeedStep = 0.06 + LevelSpeedMax = 2.5 + ShootingStarMinGap = 8 * time.Second + ShootingStarMaxGap = 20 * time.Second + ) + ``` + Population is `width*height / StarsXPerCells` per layer, recomputed on `Resize`. Stars drift downward and wrap to `Y = 0` at a fresh random `X` when they pass the bottom. + +- [ ] **Step 1: Write the failing tests in `internal/fx/starfield_test.go`** + +- `test_populates_three_layers`: `Resize(80,24)` -> `Stars()` contains stars with `Layer` 0, 1, and 2 +- `test_population_scales_with_area`: the count at `160×48` is roughly 4× the count at `80×24` (within 15%) +- `test_stars_start_inside_canvas`: every star satisfies `0 <= X < 80` and `0 <= Y < 24` +- `test_stars_drift_downward`: after `Step(1s, false)` a far star's `Y` increased by ≈ `1.2`, a mid star by ≈ `3.0`, a near star by ≈ `6.5` +- `test_stars_wrap`: a star placed at `Y: 23.9` on a 24-row canvas -> after a step it is near `Y` 0 and still inside the canvas +- `test_level_increases_speed`: `Consume` with `Status{Level: 6}` -> `StarSpeedScale()` ≈ `1.30`; `Level: 40` -> `LevelSpeedMax` +- `test_level_scale_floor`: `Level: 1` -> `1.0` +- `test_paused_stars_drift_slowly`: `Step(1s, true)` -> a far star moved ≈ `1.2 * PausedStarScale` (§30: background stars keep drifting very slowly) +- `test_glyphs_by_layer`: far stars use only `StarsFar` glyphs, mid only `StarsMid`, near only `StarsNear` +- `test_brightness_by_layer`: far `0.45`, mid `0.7`, near `1.0` +- `test_boost_adds_and_expires`: `BoostStars(40, 1*time.Second)` -> count grows by 40; after `Step` totalling 1.2 s the count is back to the base population +- `test_resize_to_zero`: `Resize(0,0)` -> `Stars()` empty, `Step` does not panic +- `test_disabled_world_has_no_stars`: `Config{Enabled: false}` -> `Stars()` empty +- `test_shooting_star_occasional`: over 60 s of stepping, `ShootingStar()` fired at least twice and at most eight times + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ -run TestStar -v` +Expected: FAIL — undefined: `Stars` + +- [ ] **Step 3: Implement `starfield.go`** + +Shooting stars are ordinary particles with a long life and a diagonal velocity, emitted on a countdown drawn from `[ShootingStarMinGap, ShootingStarMaxGap]`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/fx/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx/starfield.go internal/fx/starfield_test.go +git commit -m "feat(fx): three-layer starfield with level scaling and drifting pause behavior" +``` + +--- + +### Task 4: Wire FX into the render pipeline and the app + +**Files:** +- Create: `internal/render/fxdraw.go` +- Modify: `internal/render/render.go` (`Scene`), `internal/render/palette.go` (`Star` color), `internal/app/model.go`, `internal/app/update.go` +- Test: `internal/render/fxdraw_test.go`, `internal/app/update_test.go` (append) + +**Interfaces:** +- Consumes: `fx.World` accessors, `Canvas`, `Layout`, `Palette`. +- Produces: + ```go + // render + type Scene struct { /* Plan 2 fields */ FX *fx.World } // nil FX = nothing drawn + func DrawStarfield(c *Canvas, l Layout, p Palette, w *fx.World) + func DrawParticles(c *Canvas, p Palette, w *fx.World) + func ParticleColor(p Palette, h fx.Hue, bright float64) color.Color + + // palette additions + // Palette.Star = #C9D6FF ; Palette.Spark = #FFEBA3 ; Palette.Ion = #9FF7FF + + // app + // Model gains: FX *fx.World, Best int + func (m *Model) syncFX() // called on resize: Resize + SetBoardRect from the current layout + func fxGlyphs(m render.Mode) fx.GlyphSet // ModeASCII -> fx.ASCIIGlyphs(), otherwise fx.UnicodeGlyphs() + ``` + +Pipeline order in `Render`, filling §37's numbered slots: + +```text +1 layout +2 DrawStarfield (skipped inside the board interior — see below) +3 DrawBoard (locked, ghost, active; offset by FX.ShakeOffset) +6 DrawTrails, DrawClearBands, DrawShockwaves (board-local FX, Tasks 6/8/9) +7 board border (inside DrawBoard, colored by Task 5) +8 hold / next / stats +9 DrawParticles (global FX) +10 banners and notices (Task 11) +11 mission-control line +12 controls / help +``` + +Stars are drawn everywhere *outside* the board box, and inside the board interior only where the cell is empty — the board reads as a window onto space without stars ever sitting on top of a block (§15 "never make the background so busy that the board becomes harder to read"). Pin: `DrawStarfield` runs before `DrawBoard`, so board drawing naturally paints over it; stars never overwrite a block because they are drawn first. + +- [ ] **Step 1: Write the failing tests** + +- `test_starfield_drawn_outside_board`: with a populated world, at least one non-space glyph appears outside `l.Board` +- `test_stars_never_cover_locked_blocks`: fill the board with locked cells, render, and assert every interior cell holds a block glyph (no star glyph inside the board) +- `test_stars_never_cover_the_active_piece`: assert the active piece's cells hold `p.Active[kind]` after a full render with a dense starfield (§44) +- `test_particles_clipped_to_canvas`: emit particles at `(-5,-5)`, `(1000,1000)`, `(0,0)` -> render does not panic and every row keeps its exact width +- `test_particle_color_dims_with_brightness`: `ParticleColor(p, fx.HueSpark, 0.2)` is darker than at `1.0` (component sum comparison) +- `test_nil_fx_renders_plain`: `Scene{FX: nil}` produces byte-identical output to the Plan 2 golden for the same scene +- `test_row_widths_with_fx`: for `(w,h)` in `{40×24, 80×30, 300×100}` × three modes, every stripped row is exactly `w` columns with a populated world +- `test_app_syncs_fx_on_resize`: `Update(tea.WindowSizeMsg{Width: 80, Height: 30})` -> the world's board rect matches `render.Compute(80,30).Board` interior; a `20×10` resize does not panic +- `test_app_feeds_events_to_fx`: a hard-drop key press results in a non-empty `fx` particle or trail population +- `test_app_steps_fx_on_frame`: two frames 16 ms apart -> `FX.Elapsed()` 16ms +- `test_app_does_not_step_fx_gameplay_while_paused`: pause, then frames spanning 1 s -> particle count unchanged +- `test_fx_never_mutates_game`: deep-equal the `game.Game` across a 600-frame scripted session with FX enabled (§14) + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/render/ -run TestStarfield -v` +Expected: FAIL — undefined: `DrawStarfield` + +- [ ] **Step 3: Implement the wiring** + +`app.New` builds the world with `fx.NewWorld(fx.Config{Enabled: !cfg.NoFX, ReducedMotion: cfg.ReducedMotion, Glyphs: fxGlyphs(mode)}, time.Now().UnixNano())` — a clock-derived seed, deliberately unrelated to the game seed (§35). On `tea.ColorProfileMsg`, call `SetConfig` with the glyph set for the new mode. + +- [ ] **Step 4: Run tests and look at it** + +Run: `go test ./... -v && go run ./cmd/cosmic-tetris` +Expected: PASS, and stars drift behind the board while the game plays normally. + +- [ ] **Step 5: Commit** + +```bash +git add internal/render internal/app +git commit -m "feat(render): composite the starfield and particles into the frame pipeline" +``` + +--- + +### Task 5: Animated board border + +**Files:** +- Modify: `internal/fx/world.go`, `internal/render/board.go`, `internal/render/render.go` +- Create: `internal/fx/border.go` +- Test: `internal/fx/border_test.go`, `internal/render/board_test.go` (append) + +**Interfaces:** +- Produces: + ```go + func (w *World) BorderPhase() float64 // 0..1 position in Palette.BorderCycle + func (w *World) BorderFlash() float64 // 0..1 extra brightness, decaying + func (w *World) PulseBorder(d time.Duration) // rapid gradient travel during major events + func (w *World) FlashBorder(d time.Duration) + + // render + func BorderColorAt(p Palette, phase float64, flash float64, i, total int) color.Color + ``` + `BorderPhase` advances at `BorderCycleSpeed` (one full 5-color cycle per 24 s — deliberately subtle per §25) and at `BorderPulseSpeedFactor` (6×) while a pulse is active. `BorderColorAt` lerps between adjacent `BorderCycle` entries using `phase` plus a per-position offset `float64(i)/float64(total)*BorderGradientSpan`, then lerps toward hot white by `flash`. + +Constants: `BorderCycleSeconds = 24`, `BorderPulseSpeedFactor = 6`, `BorderGradientSpan = 0.35`, `BorderFlashDuration = 180 * time.Millisecond`. + +- [ ] **Step 1: Write the failing tests** + +- `test_border_phase_advances`: `Step` totalling 12 s -> `BorderPhase()` ≈ `0.5` +- `test_border_phase_wraps`: after 30 s, `BorderPhase()` is in `[0,1)` +- `test_border_phase_is_subtle`: over 1 s the phase advances less than `0.05` (§25 "the shift should be subtle") +- `test_pulse_speeds_up_the_cycle`: with `PulseBorder(600ms)` active, 0.5 s advances the phase ≈ 6× more than without +- `test_pulse_expires`: after the pulse duration the per-second advance is back to the base rate +- `test_flash_decays_to_zero`: `FlashBorder(180ms)` -> `BorderFlash()` 1.0 immediately, ≈0.5 at 90 ms, 0 at 200 ms +- `test_flash_never_negative`: after 10 s, `BorderFlash()` -> `0` +- `test_disabled_world_border_is_static`: `Enabled: false` -> `BorderPhase()` always `0`, `BorderFlash()` always `0` +- `test_border_color_varies_along_the_border`: `BorderColorAt` at `i=0` and `i=total/2` differ +- `test_border_color_flash_brightens`: `flash=1` yields a brighter color than `flash=0` at the same phase +- `test_board_border_uses_fx_color` (render): with a world at a known phase, the board box's corner cell color equals `BorderColorAt(...)` for that position +- `test_board_border_static_without_fx`: `Scene{FX: nil}` -> the border is `p.Border` everywhere + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ -run TestBorder -v` +Expected: FAIL + +- [ ] **Step 3: Implement `border.go` and pass the color into `DrawBoard`** + +`Render` computes the per-cell border color as it draws the box; `DrawBoard` takes a `func(i, total int) color.Color` or the `(phase, flash)` pair — pin the pair, so `DrawBoard`'s signature becomes `DrawBoard(c, l, p, g, gm, phase, flash float64, offX, offY int)` and Plan 2's `border color.Color` parameter is replaced. Update Plan 2's board tests accordingly. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./... -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx internal/render +git commit -m "feat(fx): slow border gradient cycle with event pulses and flashes" +``` + +--- + +### Task 6: Piece trails + +**Files:** +- Create: `internal/fx/trails.go`, `internal/render/trails.go` (or extend `fxdraw.go`) +- Test: `internal/fx/trails_test.go` + +**Interfaces:** +- Produces: + ```go + type Trail struct { + Cell game.Point // board cell coordinates + Kind game.PieceKind + Age float64 // seconds + Life float64 // seconds + } + func (w *World) Trails() []Trail + func TrailGlyph(g GlyphSet, t Trail) rune // Trail[0] until 1/3 life, Trail[1] until 2/3, then Trail[2] + + const ( + TrailLife = 140 * time.Millisecond + HardDropTrailLife = 220 * time.Millisecond + ) + ``` + On `EventPieceMoved` and `EventPieceRotated`, push one trail per cell of the piece's **previous** position (the world remembers the last piece it saw). On `EventPieceHardDropped`, push one trail per cell in `Event.Cells` with `HardDropTrailLife`. + +- [ ] **Step 1: Write the failing tests in `internal/fx/trails_test.go`** + +- `test_move_leaves_a_trail`: consume a `PieceMoved` after a prior known position -> 4 trails at the previous cells +- `test_first_event_leaves_no_trail`: the very first `PieceMoved` after `ResetRun` (no previous position known) -> 0 trails +- `test_trail_expires`: `Step` totalling 200 ms -> `Trails()` empty +- `test_trail_glyph_ramp`: at ages 0.02 s / 0.07 s / 0.12 s of a 0.14 s life -> `Trail[0]`, `Trail[1]`, `Trail[2]` respectively (§17) +- `test_hard_drop_trail_is_longer_lived`: hard-drop trails still exist at 150 ms while move trails do not +- `test_hard_drop_trail_covers_every_traversed_cell`: `Event.Cells` of length 42 -> 42 trails +- `test_rotation_leaves_a_trail`: `PieceRotated` -> 4 trails +- `test_trails_capped_by_life_not_count`: 200 consecutive move events -> `len(Trails())` stays bounded (assert < 400) because expiry runs each step +- `test_trails_frozen_while_paused`: `Step(100ms, true)` -> trail ages unchanged +- `test_trails_never_cover_the_active_piece` (render): with trails on the active piece's cells, the rendered cells show the active-piece color, because trails draw before the piece — assert the active cells' color is `p.Active[kind]` +- `test_disabled_world_no_trails`: `Enabled: false` -> empty + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ -run TestTrail -v` +Expected: FAIL + +- [ ] **Step 3: Implement trails and their drawing** + +Render draws trails inside the board interior at §37 slot 6, using `BoardCellOrigin`, colored `Dim(p.Locked[kind], 1 - age/life)`. Draw them **before** the active piece so the piece always wins (§44). + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./... -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx internal/render +git commit -m "feat(fx): short-lived ion trails behind moving and dropping pieces" +``` + +--- + +### Task 7: Hard-drop impact — trail, debris, shake, border flash + +**Files:** +- Create: `internal/fx/impact.go` +- Test: `internal/fx/impact_test.go` + +**Interfaces:** +- Produces: + ```go + func (w *World) ShakeOffset() (dx, dy int) + func (w *World) Shake(d time.Duration) + + var ShakePattern = [5][2]int{{0, 1}, {-1, 0}, {1, 0}, {0, -1}, {0, 0}} // §18 + + const ( + ShakeStepInterval = 16 * time.Millisecond + HardDropShake = 80 * time.Millisecond + TetrisShake = 160 * time.Millisecond + ImpactParticlesBase = 10 + ) + ``` + On `EventPieceHardDropped` (which arrives before `EventPieceLocked`): hard-drop trails (Task 6), `Shake(HardDropShake)`, `FlashBorder(BorderFlashDuration)`, and an impact burst of `ImpactParticlesBase + 2*min(level,10)` particles from the piece's contact cells, aimed upward (`aimDeg -90`, `spreadDeg 140`, speed 6–14 cells/s, `HueSpark`). + +- [ ] **Step 1: Write the failing tests in `internal/fx/impact_test.go`** + +- `test_shake_offset_follows_the_pattern`: `Shake(80ms)`; `ShakeOffset()` at elapsed 0 / 16 / 32 / 48 / 64 ms -> `(0,1)`, `(-1,0)`, `(1,0)`, `(0,-1)`, `(0,0)` +- `test_shake_expires`: after 100 ms -> `(0,0)` +- `test_shake_never_exceeds_one_cell`: sample `ShakeOffset()` every 4 ms across a `TetrisShake` -> every component in `[-1,1]` (§44) +- `test_reduced_motion_disables_shake`: `ReducedMotion: true` -> `ShakeOffset()` always `(0,0)` (§49.5) +- `test_hard_drop_spawns_particles`: consume a `PieceHardDropped` with `Value: 15` at `Status{Level: 1}` -> `ParticleCount()` -> `12` +- `test_impact_particle_count_scales_with_level`: `Level: 10` -> `30`; `Level: 50` -> `30` (capped) +- `test_impact_particles_start_at_the_contact_cells`: every spawned particle starts within 1 cell of a cell from the last row of `Event.Cells`, translated through the board rect +- `test_impact_particles_aim_upward`: at spawn, the majority have `VY < 0` +- `test_hard_drop_flashes_the_border`: `BorderFlash()` > 0.9 immediately after +- `test_hard_drop_shakes`: `ShakeOffset()` non-zero immediately after +- `test_zero_distance_hard_drop_is_quiet`: `Value: 0` -> no shake, no particles (a piece already resting should not fake an impact) +- `test_shake_offset_at_canvas_edge_is_safe` (render): with the board at `x=0` and a `(-1,0)` shake, the frame's row widths are unchanged and nothing wraps +- `test_disabled_world_no_impact`: `Enabled: false` -> nothing + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ -run TestShake -v` +Expected: FAIL + +- [ ] **Step 3: Implement `impact.go`** + +Shake index is `int(shakeElapsed / ShakeStepInterval) % len(ShakePattern)`. + +- [ ] **Step 4: Run tests to verify they pass, then feel it** + +Run: `go test ./... -v && go run ./cmd/cosmic-tetris` +Expected: PASS, and a hard drop reads as dropping a refrigerator from orbit (§18). + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx internal/render +git commit -m "feat(fx): hard-drop impact with debris, one-cell screen shake, and border flash" +``` + +--- + +### Task 8: Line-clear supernova + +**Files:** +- Create: `internal/fx/clears.go`, drawing in `internal/render/fxdraw.go` +- Test: `internal/fx/clears_test.go` + +**Interfaces:** +- Produces: + ```go + type ClearPhase int + const ( + ClearCriticalMass ClearPhase = iota // 0–70ms §19 Phase A + ClearSupernova // 70–150ms §19 Phase B + ClearCollapse // 150–220ms §19 Phase C + ) + + type ClearBand struct { + Row int // board row index, pre-collapse (from Event.Rows) + Age float64 + Phase ClearPhase + T float64 // 0..1 within the phase + } + func (w *World) ClearBands() []ClearBand + + const ClearBandDuration = 220 * time.Millisecond + ``` + On `EventLinesCleared`: one band per row in `Event.Rows`; a debris burst of 12 particles per row spread along the row with horizontal velocity `(x - centerX) * 1.6` cells/s and vertical `-6..-12` (§19 "particles inherit some horizontal velocity from their location relative to center"); `FlashBorder`; `Shake` only for a four-line clear (Task 11). + + Because gameplay has already collapsed the rows (§44 forbids delaying gameplay), the band is an overlay drawn at the cleared rows' former screen positions and fades in 220 ms. Render draws, per §19: Phase A the row in `Glyphs.Clear[0]`/`Clear[1]` (edges dimmer than center); Phase B an expanding bright core from the center outward using `Clear[2]`/`Clear[3]`; Phase C nothing — the debris particles carry it. + +- [ ] **Step 1: Write the failing tests in `internal/fx/clears_test.go`** + +- `test_band_per_cleared_row`: `Event{Rows: []int{19,20,21}}` -> 3 bands with those rows +- `test_band_phases_by_age`: at 30 ms / 100 ms / 180 ms -> `ClearCriticalMass`, `ClearSupernova`, `ClearCollapse` +- `test_band_phase_t_within_phase`: at 105 ms the phase is `ClearSupernova` and `T` ≈ `0.44` +- `test_band_expires`: after 250 ms -> `ClearBands()` empty +- `test_debris_count`: a one-row clear -> 12 particles; a four-row clear -> 48 +- `test_debris_inherits_horizontal_velocity`: particles left of the row center have `VX < 0`, right of it `VX > 0`, and the center-most is near 0 +- `test_debris_starts_on_the_cleared_row`: every debris particle's initial `Y` is within 1 of the row's screen row +- `test_clear_flashes_the_border`: `BorderFlash()` > 0.9 after a clear +- `test_single_clear_does_not_shake`: a one-row clear -> `ShakeOffset()` `(0,0)` (shake is reserved for hard drops and four-line clears, §44) +- `test_bands_frozen_while_paused`: `Step(100ms, true)` -> ages unchanged +- `test_band_row_outside_board_is_ignored`: `Rows: []int{999}` -> no panic, no band drawn (render clips) +- `test_render_band_keeps_row_widths` (render): with bands active at every board row, every stripped row is exactly the canvas width + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ -run TestClear -v` +Expected: FAIL + +- [ ] **Step 3: Implement `clears.go` and its drawing** + +- [ ] **Step 4: Run tests to verify they pass, then clear a line by hand** + +Run: `go test ./... -v && go run ./cmd/cosmic-tetris` +Expected: PASS, and a single line clear produces a visible supernova with debris (§43 "within the first completed line"). + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx internal/render +git commit -m "feat(fx): three-phase supernova line clear with center-weighted debris" +``` + +--- + +### Task 9: Shockwaves + +**Files:** +- Create: `internal/fx/shockwave.go` +- Test: `internal/fx/shockwave_test.go` + +**Interfaces:** +- Produces: + ```go + type Shockwave struct { + X, Y float64 // screen cells + Radius float64 // current radius in columns + MaxRadius float64 + Age, Life float64 + } + func (w *World) Shockwaves() []Shockwave + func (w *World) EmitShockwave(x, y, maxRadius float64) + + const ( + ShockwaveDuration = 300 * time.Millisecond + ShockwaveAspect = 0.5 // rows per column, to compensate for cell aspect ratio + ) + ``` + Render walks the ring: for each of 24 angles, plot `(X + R*cos θ, Y + R*ShockwaveAspect*sin θ)` and set `Glyphs.Ring[min(3, int(R/ MaxRadius * 4))]` dimmed by `1 - Age/Life`. Used sparingly (§24): four-line clears and combo ≥ 5 only. + +- [ ] **Step 1: Write the failing tests in `internal/fx/shockwave_test.go`** + +- `test_radius_grows`: `EmitShockwave(10,10,8)`; at half life `Radius` ≈ `4` +- `test_expires`: after 350 ms -> `Shockwaves()` empty +- `test_reduced_motion_suppresses`: `ReducedMotion: true` -> `EmitShockwave` adds nothing (§49.5) +- `test_disabled_world_suppresses`: `Enabled: false` -> nothing +- `test_frozen_while_paused`: `Step(100ms, true)` -> `Radius` unchanged +- `test_ring_stays_elliptical` (render): the drawn ring is wider than tall by roughly `1/ShockwaveAspect` +- `test_ring_clipped_at_edges` (render): a shockwave centered at `(0,0)` with radius 40 on a 40×24 canvas -> no panic, row widths unchanged +- `test_ring_glyph_progresses` (render): early rings use `Ring[0]`, late rings `Ring[3]` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ -run TestShockwave -v` +Expected: FAIL + +- [ ] **Step 3: Implement `shockwave.go` and its drawing** + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./... -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx internal/render +git commit -m "feat(fx): faked radial shockwave rings" +``` + +--- + +### Task 10: Hyperdrive + +**Files:** +- Create: `internal/fx/hyperdrive.go` +- Test: `internal/fx/hyperdrive_test.go` + +**Interfaces:** +- Produces: + ```go + func (w *World) TriggerHyperdrive() + func (w *World) HyperdriveMultiplier() float64 // multiplies star speed + func (w *World) StarStretch() bool // the 50–100ms stretch window + + const ( + HyperdriveDuration = 1100 * time.Millisecond + HyperdrivePeak = 14.0 + ) + ``` + The §16 timeline, exactly: `0–50ms` multiplier `0` (stars pause); `50–100ms` multiplier `0.5` and `StarStretch()` true; `100–500ms` lerp `1 → HyperdrivePeak`; `500–800ms` hold at peak; `800–1100ms` lerp `HyperdrivePeak → 1`; afterwards `1`. + + Triggers (§16): a four-line clear (Task 11), combo ≥ 5, and a new in-session high score — the latter detected inside `Consume` when `st.Score > st.Best && st.Best > 0`, latched once per run and cleared by `ResetRun`. + + `Status.Best` comes from `app.Model.Best`, which holds the best score of *previous completed runs in this session only* — the spec allows no persistence, no profiles, no database (§1). The app sets `Best = max(Best, Game.Score)` when it enters `StateGameOver`, and never mid-run, so "new high score" means beating your last game rather than beating yourself one point at a time. + +- [ ] **Step 1: Write the failing tests in `internal/fx/hyperdrive_test.go`** + +- `test_timeline_pause`: at 20 ms -> `0` +- `test_timeline_stretch`: at 75 ms -> `0.5` and `StarStretch()` true +- `test_stretch_window_only`: at 20 ms and 150 ms -> `StarStretch()` false +- `test_timeline_ramp`: at 300 ms -> ≈ `7.5` (within 0.5) +- `test_timeline_peak`: at 600 ms -> `HyperdrivePeak` +- `test_timeline_decay`: at 950 ms -> between `1` and `HyperdrivePeak`; at 1200 ms -> `1` +- `test_star_speed_uses_multiplier`: `StarSpeedScale()` at 600 ms of hyperdrive is `HyperdrivePeak ×` the level scale +- `test_stars_actually_move_faster`: a near star travels far more in 100 ms at peak than at rest +- `test_reduced_motion_no_acceleration`: `ReducedMotion: true` -> `HyperdriveMultiplier()` always `1` and `StarStretch()` always false (§49.5) +- `test_four_line_clear_triggers`: consume `LinesCleared` with `Value: 4` -> multiplier leaves `1` within the next 200 ms of stepping +- `test_single_clear_does_not_trigger`: `Value: 1` -> multiplier stays `1` +- `test_big_combo_triggers`: `ComboChanged` with `Value: 5` -> triggers; `Value: 4` -> does not +- `test_new_high_score_triggers_once`: `Consume(nil, Status{Score: 100, Best: 50})` triggers; a second `Consume` with a higher score does not re-trigger; after `ResetRun` it can trigger again +- `test_first_game_never_high_scores`: `Status{Score: 5000, Best: 0}` -> no trigger +- `test_retrigger_restarts_the_timeline`: triggering at 600 ms into an active hyperdrive resets it to the pause phase +- `test_best_updates_only_at_game_over` (app): mid-run, `Model.Best` stays at its previous value while `Game.Score` climbs; entering `StateGameOver` raises it to the run's score; a lower second run leaves it alone + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ -run TestHyperdrive -v` +Expected: FAIL + +- [ ] **Step 3: Implement `hyperdrive.go` and wire the star stretch into rendering** + +While `StarStretch()`, render draws mid and near stars as `Glyphs.Streak` instead of their normal glyph. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./... -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx internal/render +git commit -m "feat(fx): hyperdrive timeline for four-line clears, big combos, and high scores" +``` + +--- + +### Task 11: Banners, notices, and the four-line event + +**Files:** +- Create: `internal/fx/banner.go`, `internal/flavor/messages.go`, `internal/render/banner.go` +- Test: `internal/fx/banner_test.go`, `internal/flavor/messages_test.go` + +**Interfaces:** +- Produces: + ```go + // flavor — pure data, no imports beyond math/rand + func TetrisBanner(r *rand.Rand) string // the four §20 strings + func LevelSubtitle(r *rand.Rand) string // the three §22 strings + func ComboLine(combo int) string // §21: "COMBO 5 // UNAUTHORIZED ORBITAL MANEUVER", 6, 7; ≥8 cycles + func Routine(r *rand.Rand) string // the §27 list + func Rare() string // "DID YOU KNOW YOU'RE IN A TERMINAL?" (§45) + func Special(id SpecialID) string // §45 one-offs + func Boot() []BootLine // §29 + // The game-over panel copy stays in render (Plan 2 Task 10); do not duplicate it here. + type SpecialID int + const ( + SpecialKineticRod SpecialID = iota // "KINETIC ROD DEPLOYED" + SpecialCube // "CUBE ADJACENT OBJECT SECURED" + SpecialRollover // "NUMBER BECAME BIGGER" + SpecialCaptain // "CAPTAIN?" + ) + type BootLine struct{ At time.Duration; Text string } + + // fx + type Banner struct{ Text string; Age, Life float64 } + type Notice struct{ Title, Subtitle string; Age, Life float64 } + func (w *World) Banner() (Banner, bool) + func (w *World) Notice() (Notice, bool) + func (w *World) HUDPulse() float64 // 0..1, active at combo ≥ 4 (§21) + func (w *World) ShowBanner(text string, d time.Duration) + func (w *World) ShowNotice(title, subtitle string, d time.Duration) + + const ( + BannerDuration = 700 * time.Millisecond + NoticeDuration = 1200 * time.Millisecond + HUDPulsePeriod = 600 * time.Millisecond + ) + ``` + + The four-line composite (§20), all triggered from one `EventLinesCleared` with `Value: 4`: `TriggerHyperdrive()`, `Shake(TetrisShake)`, `PulseBorder(600ms)`, a 40-particle eruption, `BoostStars(40, 1200ms)`, `EmitShockwave` at the board center, `HUDPulse` for 700 ms, and `ShowBanner(flavor.TetrisBanner(rng), BannerDuration)`. + + The level-up notice (§22): on `EventLevelChanged`, `ShowNotice("GRAVITY ANOMALY DETECTED", fmt.Sprintf("LEVEL %02d", ev.Value), NoticeDuration)` — exactly the two lines of the §22 card — and the chosen `flavor.LevelSubtitle` goes to mission control at `PriorityMajor` (Task 12) rather than onto the card. Render draws the card with `BoxRound`, fading over its last 400 ms, and never pauses the game. + + Combo escalation (§21): combo 2 -> 6 sparks; combo 3 -> 12 meteor particles with stronger horizontal drift; combo 4 -> HUD pulse; combo ≥ 5 -> banner `flavor.ComboLine(combo)`, shockwave, and hyperdrive. + +- [ ] **Step 1: Write the failing tests** + +flavor: +- `test_tetris_banners_verbatim`: the returned set over 200 draws is exactly the four §20 strings, including `"FOUR ROWS HAVE LEFT THE CHAT"` and `"SPACE-TIME HAS FILED A COMPLAINT"` +- `test_level_subtitles_verbatim`: exactly the three §22 strings +- `test_combo_lines`: `ComboLine(5)` -> `"COMBO 5 // UNAUTHORIZED ORBITAL MANEUVER"`; `ComboLine(6)` and `ComboLine(7)` -> the §21 strings; `ComboLine(9)` -> non-empty and contains `"COMBO 9"` +- `test_routine_lines_include_spec_examples`: the §27 list contains `"LOCAL UNIVERSE STABLE*"` and `"ORBITAL OSHA HAS ENTERED THE CHAT"` +- `test_specials_verbatim`: each `SpecialID` maps to its §45 string +- `test_rare_line_verbatim`: `Rare()` -> `"DID YOU KNOW YOU'RE IN A TERMINAL?"` +- `test_boot_lines_ordered`: `Boot()` `At` values are strictly increasing and the last text is `"UNIVERSE ONLINE"` +- `test_all_flavor_is_deterministic_per_seed`: same seed -> same draws +- `test_no_flavor_string_is_empty` + +fx: +- `test_banner_expires`: `ShowBanner("X", 700ms)` -> gone after 750 ms +- `test_banner_replaced_by_newer`: a second `ShowBanner` replaces the first +- `test_notice_expires` +- `test_four_line_clear_fires_everything`: consume `LinesCleared{Value: 4}` -> banner present, shake non-zero, shockwave present, `HUDPulse() > 0`, hyperdrive active, particle count ≥ 40, star count boosted +- `test_four_line_banner_is_a_spec_string`: the banner text is one of the four §20 strings +- `test_four_line_does_not_block_anything`: `Consume` returns without any sleeping; a subsequent `Consume` of a `PieceMoved` is processed normally while the banner is up (§20 "must not block gameplay input") +- `test_level_change_shows_notice`: `LevelChanged{Value: 8}` -> notice title `"GRAVITY ANOMALY DETECTED"`, subtitle `"LEVEL 08"` +- `test_level_change_sets_mission_subtitle`: after the same event, `Mission()` is one of the three §22 subtitles +- `test_combo_two_sparks`: `ComboChanged{Value: 2}` -> 6 particles, no banner +- `test_combo_three_meteors`: `Value: 3` -> 12 particles +- `test_combo_four_pulses_hud`: `Value: 4` -> `HUDPulse() > 0` +- `test_combo_five_banner_and_shockwave`: `Value: 5` -> banner contains `"COMBO 5"`, one shockwave, hyperdrive triggered +- `test_combo_reset_stops_the_pulse`: `Value: 0` -> `HUDPulse()` decays to `0` +- `test_hud_pulse_bounded`: `HUDPulse()` is always in `[0,1]` + +render: +- `test_banner_centered_and_clipped`: an 80-character banner on a 40-wide canvas leaves every row exactly 40 columns +- `test_banner_does_not_cover_the_active_piece`: the banner row is chosen above the board's vertical center only if the active piece is in the lower half, and vice versa — assert the active piece's cells still show `p.Active[kind]` (§44) +- `test_notice_card_fits_small_terminal`: at 40×24 the card is fully inside the window + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/flavor/ ./internal/fx/ -run TestBanner -v` +Expected: FAIL + +- [ ] **Step 3: Implement flavor, banners, and the composites** + +- [ ] **Step 4: Run tests to verify they pass, then get a four-line clear** + +Run: `go test ./... -v && go run ./cmd/cosmic-tetris --seed 8675309` +Expected: PASS, and a tetris produces the §43 reaction. + +- [ ] **Step 5: Commit** + +```bash +git add internal/flavor internal/fx internal/render +git commit -m "feat(fx): banners, level-up notices, combo escalation, and the four-line event" +``` + +--- + +### Task 12: Mission control + +**Files:** +- Create: `internal/fx/mission.go` +- Modify: `internal/render/render.go` (feed `Scene.Mission` from `fx.World`), `internal/app/update.go` +- Test: `internal/fx/mission_test.go` + +**Interfaces:** +- Produces: + ```go + func (w *World) Mission() string + func (w *World) SetMission(text string, p Priority) + + type Priority int + const ( + PriorityIdle Priority = iota + PriorityRoutine + PriorityEvent + PriorityMajor + ) + + const ( + MissionMinDwell = 2500 * time.Millisecond + MissionIdleAfter = 5 * time.Second // §45 "long idle before first move" + MissionRareChance = 0.005 // §45 "extremely rare status line" + ) + ``` + A new message replaces the current one only when its priority is higher **or** the current one has been up for `MissionMinDwell` (§27 "do not rotate messages constantly … give them time to breathe"). + + Triggers: `EventPieceLocked` -> `PriorityRoutine` `flavor.Routine`, but at most once per `MissionMinDwell`; `EventLinesCleared` (1–3 rows) -> `PriorityEvent`; four-line and `EventLevelChanged` -> `PriorityMajor`; `EventHoldUsed` of an `O` -> `flavor.Special(SpecialCube)`; `EventPieceHardDropped` of a vertical `I` -> `SpecialKineticRod`; score crossing a multiple of 100,000 -> `SpecialRollover`; no input for `MissionIdleAfter` before the first move -> `SpecialCaptain`; `MissionRareChance` of any routine pick -> `flavor.Rare`. + +- [ ] **Step 1: Write the failing tests in `internal/fx/mission_test.go`** + +- `test_initial_message_not_empty`: a new world's `Mission()` is non-empty (something is on the channel from the start) +- `test_dwell_blocks_same_priority`: set a routine message, `Step(500ms)`, set another routine message -> `Mission()` is still the first +- `test_dwell_expires`: after `MissionMinDwell` the second routine message takes over +- `test_higher_priority_preempts`: a `PriorityMajor` message replaces a fresh `PriorityRoutine` one immediately +- `test_lower_priority_never_preempts`: `PriorityIdle` does not replace a fresh `PriorityEvent` +- `test_four_line_clear_is_major`: consuming `LinesCleared{Value: 4}` changes the message even if a routine one is 100 ms old +- `test_level_change_is_major` +- `test_holding_an_O_is_special`: `HoldUsed` with `Piece.Kind == game.KindO` -> `Mission()` contains `"CUBE ADJACENT OBJECT SECURED"` +- `test_vertical_i_hard_drop_is_special`: `PieceHardDropped` with a `KindI` piece at rotation 1 -> contains `"KINETIC ROD DEPLOYED"` +- `test_horizontal_i_hard_drop_is_not_special`: rotation 0 -> does not contain it +- `test_score_rollover`: `Consume(nil, Status{Score: 100_001})` after a previous status of `99_000` -> contains `"NUMBER BECAME BIGGER"` +- `test_rollover_fires_once_per_threshold`: a further status of `100_500` does not re-fire +- `test_idle_captain`: no events for `MissionIdleAfter` on a fresh run -> contains `"CAPTAIN?"` +- `test_idle_captain_only_before_the_first_move`: after any `PieceMoved`, waiting does not produce it +- `test_rare_line_is_rare`: over 5000 routine picks, the rare line appears at least once and in fewer than 3% of picks +- `test_mission_survives_no_fx`: with `Enabled: false`, `Mission()` still returns text and still updates on events (§32 — the boring mode keeps the commentary) + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ -run TestMission -v` +Expected: FAIL + +- [ ] **Step 3: Implement `mission.go` and feed it into the scene** + +Mission control is the one FX subsystem that runs with `Enabled: false`; guard the other subsystems on `cfg.Enabled` but not this one. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./... -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx internal/render internal/app +git commit -m "feat(fx): mission-control channel with priorities, dwell time, and easter eggs" +``` + +--- + +### Task 13: Boot sequence + +**Files:** +- Create: `internal/render/boot.go` +- Modify: `internal/app/model.go`, `internal/app/update.go`, `internal/render/render.go` +- Test: `internal/app/boot_test.go`, `internal/render/boot_test.go` + +**Interfaces:** +- Consumes: `flavor.Boot`, `spinner` from `charm.land/bubbles/v2/spinner`. +- Produces: + ```go + // app + const StateBoot State = 3 // appended to Plan 2's State set + // Model gains: BootStart time.Time, Spin spinner.Model + + // render + // Scene gains: Booting bool, BootElapsed time.Duration, Spinner string + func DrawBoot(c *Canvas, p Palette, g *Glyphs, elapsed time.Duration, spinner string) + + const BootTotal = 1000 * time.Millisecond + ``` + Pinned reveal schedule (§29 copy verbatim): + + ```text + 0ms ✦ + 150ms C O S M I C + 300ms T E T R I S + 450ms INITIALIZING LOCAL UNIVERSE... (spinner beside it) + 600ms gravity ........ OK + 700ms spacetime ...... OK + 800ms tetrominoes .... QUESTIONABLE + 950ms UNIVERSE ONLINE + 1000ms -> StatePlaying + ``` + Any key skips to `StatePlaying` except the quit bindings, which quit. `--no-fx` starts directly in `StatePlaying` (§32). + +- [ ] **Step 1: Write the failing tests** + +- `test_starts_in_boot`: `New(Config{})` -> `State` `StateBoot` +- `test_no_fx_skips_boot`: `New(Config{NoFX: true})` -> `StatePlaying` +- `test_boot_finishes_on_time`: frames advancing 1.05 s -> `StatePlaying` +- `test_boot_does_not_finish_early`: frames advancing 900 ms -> still `StateBoot` +- `test_any_key_skips`: `tea.KeyPressMsg{Code: 'x'}` during boot -> `StatePlaying` +- `test_quit_key_quits_during_boot`: `'q'` -> quit command, state not `StatePlaying` +- `test_gameplay_frozen_during_boot`: frames spanning 900 ms -> the active piece has not moved +- `test_spinner_ticks`: the model forwards `spinner.TickMsg` while booting and the rendered boot screen changes between two different spinner frames +- `test_boot_reveal_schedule` (render): at 100 ms only `"✦"` is present; at 350 ms `"C O S M I C"` and `"T E T R I S"` are present but not `"gravity"`; at 850 ms all three check lines are present; at 980 ms `"UNIVERSE ONLINE"` is present +- `test_boot_copy_verbatim` (render): the full-boot render contains `"INITIALIZING LOCAL UNIVERSE..."`, `"gravity ........ OK"`, `"spacetime ...... OK"`, `"tetrominoes .... QUESTIONABLE"` +- `test_boot_fits_small_terminal`: at 40×24 every row is exactly 40 columns +- `test_golden_boot`: a golden at 80×30 and `BootElapsed: 900ms` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/app/ -run TestBoot -v` +Expected: FAIL + +- [ ] **Step 3: Implement the boot state and screen** + +Stars are already drifting behind the boot screen — draw the starfield first, then the boot text, so the terminal feels alive from the first frame (§29, §43). + +- [ ] **Step 4: Run tests to verify they pass, then watch it boot** + +Run: `go test ./... -v && go run ./cmd/cosmic-tetris` +Expected: PASS, roughly one second of unnecessary drama, then the game. + +- [ ] **Step 5: Commit** + +```bash +git add internal/app internal/render +git commit -m "feat(app): one second of excessive boot drama, skippable with any key" +``` + +--- + +### Task 14: Game-over black hole + +**Files:** +- Create: `internal/fx/collapse.go`, `internal/render/collapse.go` +- Modify: `internal/app/update.go`, `internal/render/render.go` +- Test: `internal/fx/collapse_test.go`, `internal/render/collapse_test.go` + +**Interfaces:** +- Produces: + ```go + type CollapsePhase int + const ( + CollapseFreeze CollapsePhase = iota // 0–300ms: everything frozen, "SIGNAL LOST" + CollapseInfall // 300–900ms: blocks fall inward + CollapseSingularity // 900–1300ms: black hole + CollapseDone + ) + type Collapse struct { + Phase CollapsePhase + T float64 // 0..1 within the phase + Elapsed time.Duration + } + func (w *World) Collapse() (Collapse, bool) + + // render + func DrawCollapse(c *Canvas, l Layout, p Palette, g *Glyphs, gm *game.Game, col fx.Collapse) + + const CollapseTotal = 1300 * time.Millisecond + ``` + On `EventGameOver` the world starts the collapse. Render behavior per §28: during `CollapseFreeze` the board is drawn as-is with `SIGNAL LOST` centered over it; during `CollapseInfall` each locked cell is drawn at `lerp(cell, boardCenter, T)`; during `CollapseSingularity` the board interior is replaced by the §28 black-hole figure: + + ```text + · + ˚ + \ | / + --- ● --- + / | \ + * + ``` + The app shows Plan 2's game-over panel only once `Collapse()` reports `CollapseDone` or `ok` is false (which is the case with `--no-fx`, so that mode goes straight to the panel). + +- [ ] **Step 1: Write the failing tests** + +- `test_game_over_starts_collapse`: consume `EventGameOver` -> `ok` true, `Phase` `CollapseFreeze` +- `test_phase_schedule`: at 150 ms / 600 ms / 1000 ms / 1400 ms -> `CollapseFreeze`, `CollapseInfall`, `CollapseSingularity`, `CollapseDone` +- `test_phase_t`: at 600 ms -> `Phase` `CollapseInfall`, `T` ≈ `0.5` +- `test_no_collapse_without_fx`: `Enabled: false` -> `ok` false +- `test_collapse_ignores_pause`: `Step(dt, true)` still advances the collapse (the game is over; nothing to pause) +- `test_reset_run_clears_collapse`: `ResetRun()` -> `ok` false +- `test_signal_lost_drawn` (render): at 150 ms the render contains `"SIGNAL LOST"` +- `test_blocks_move_inward` (render): at `CollapseInfall` `T: 0.9`, every drawn block is closer to the board center than its original cell +- `test_singularity_drawn` (render): at 1000 ms the render contains `'●'` and the `"---"` arms +- `test_panel_appears_after_collapse` (app): frames spanning 1.4 s after game over -> `View()` contains `"UNIVERSE EXPIRED"` +- `test_panel_immediate_without_fx` (app): with `NoFX: true`, `View()` contains `"UNIVERSE EXPIRED"` on the first frame after game over +- `test_restart_during_collapse` (app): `'r'` mid-collapse -> `StatePlaying`, fresh game, `Collapse()` `ok` false +- `test_collapse_row_widths` (render): every phase keeps every row exactly the canvas width + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ -run TestCollapse -v` +Expected: FAIL + +- [ ] **Step 3: Implement the collapse and its drawing** + +- [ ] **Step 4: Run tests to verify they pass, then top out on purpose** + +Run: `go test ./... -v && go run ./cmd/cosmic-tetris` +Expected: PASS, and the universe collapses into a black hole before the panel appears. + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx internal/render internal/app +git commit -m "feat(fx): theatrical game over collapsing the board into a black hole" +``` + +--- + +### Task 15: Responsive FX intensity + +**Files:** +- Modify: `internal/fx/world.go`, `internal/render/fxdraw.go` +- Test: `internal/fx/intensity_test.go` + +**Interfaces:** +- Produces: + ```go + func (w *World) Intensity() float64 // 0.35 small canvas, 0.7 medium, 1.0 large + const ( + IntensitySmallMaxCells = 40 * 24 + IntensityMediumMaxCells = 54 * 26 + ) + ``` + Every emitter multiplies its particle count by `Intensity()` (minimum 1 particle when the unscaled count is ≥ 1), and star density is scaled the same way — §31's "effects automatically reduce outside the board" and §44's readability rules matter most in a cramped terminal. + +- [ ] **Step 1: Write the failing tests in `internal/fx/intensity_test.go`** + +- `test_intensity_by_canvas_size`: `Resize(40,24)` -> `0.35`; `Resize(54,26)` -> `0.7`; `Resize(120,40)` -> `1.0` +- `test_intensity_zero_size`: `Resize(0,0)` -> `0.35` and no division by zero +- `test_particle_counts_scale`: a hard-drop impact at 40×24 emits fewer particles than the same event at 120×40, and at least 1 +- `test_star_density_scales`: the star count per cell at 40×24 is lower than at 120×40 +- `test_effects_still_happen_when_small`: at 40×24 a four-line clear still produces a banner, a shake, particles, and a shockwave (reduced, not removed) +- `test_intensity_does_not_affect_gameplay`: the same scripted session at 40×24 and 120×40 ends with identical game state (§44 "never use random effects that alter gameplay") + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ -run TestIntensity -v` +Expected: FAIL + +- [ ] **Step 3: Implement intensity scaling** + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./... -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx internal/render +git commit -m "feat(fx): scale effect intensity down in small terminals" +``` + +--- + +### Task 16: `--no-fx` and `--reduced-motion` + +**Files:** +- Modify: `cmd/cosmic-tetris/main.go`, `internal/app/model.go` +- Test: `cmd/cosmic-tetris/main_test.go` (append), `internal/app/flags_test.go` + +**Interfaces:** +- Consumes: `app.Config.NoFX`, `app.Config.ReducedMotion`, `fx.Config`. +- Produces: the final §49.5 CLI surface, with both flags wired end to end. + +- [ ] **Step 1: Write the failing tests** + +- `test_no_fx_flag`: `parseFlags(["--no-fx"])` -> `NoFX` true +- `test_reduced_motion_flag`: `parseFlags(["--reduced-motion"])` -> `ReducedMotion` true +- `test_flags_combine`: `["--ascii","--no-fx","--reduced-motion","--seed","7"]` -> all four set +- `test_no_fx_disables_world`: `app.New(Config{NoFX: true})` -> the world reports no stars, no particles, static border, no boot +- `test_no_fx_frame_is_stable`: with `NoFX`, two `View()` calls 500 ms of frames apart produce identical output when no input occurred (nothing animates) +- `test_no_fx_is_still_playable`: a scripted session under `NoFX` reaches a line clear and the correct score (§32 "the boring mode should still be a good game") +- `test_reduced_motion_suppresses_the_three_things`: shake offset always `(0,0)`, hyperdrive multiplier always `1`, `Shockwaves()` always empty (§49.5) +- `test_reduced_motion_keeps_the_rest`: particles, trails, banners, mission control, and the border cycle are all still active +- `test_no_fx_and_reduced_motion_together`: nothing animates, no panic, gameplay identical to `NoFX` alone +- `test_gameplay_identical_across_fx_modes`: run the same `(input, dt)` script under all four flag combinations with the same game seed -> identical final game state in every case (§44) + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./... -run TestNoFX -v` +Expected: FAIL + +- [ ] **Step 3: Wire the flags** + +- [ ] **Step 4: Run tests to verify they pass, then compare the two extremes by hand** + +Run: `go test ./... -v && go run ./cmd/cosmic-tetris --no-fx` then `go run ./cmd/cosmic-tetris` +Expected: PASS; the first is a good plain game, the second is much funnier (§47). + +- [ ] **Step 5: Commit** + +```bash +git add cmd internal/app +git commit -m "feat(cli): --no-fx and --reduced-motion wired end to end" +``` + +--- + +### Task 17: FX goldens, performance sanity, and the coolness acceptance pass + +**Files:** +- Create: `internal/render/testdata/fx_*.golden`, `internal/fx/bench_test.go` +- Modify: `internal/render/render_test.go`, `README.md` +- Test: as listed + +**Interfaces:** +- Consumes: everything. +- Produces: no new API. This task is the §43/§47 gate. + +- [ ] **Step 1: Write the failing tests** + +Deterministic FX goldens use `fx.NewWorld(cfg, 4242)` plus a fixed event script and a fixed number of `Step(16ms)` calls: + +- `test_golden_fx_wide`: 80×30, mid-hyperdrive, particles alive, banner up +- `test_golden_fx_small`: 40×24 with the same script (reduced intensity) +- `test_golden_fx_ascii`: 80×30 in `ModeASCII` +- `test_golden_fx_collapse`: 80×30 at 1000 ms of collapse +- `test_golden_no_fx`: 80×30 with `Enabled: false` — must match the Plan 2 wide golden except for the mission line +- `test_frame_is_deterministic_for_a_seed`: the same fx seed and script renders byte-identical output twice +- `test_no_row_ever_changes_width`: across a 1200-frame scripted session at 40×24, 80×30, and 300×100, every frame's every row is exactly the canvas width +- `test_no_panic_across_a_long_session`: a 5000-frame session with random inputs, random resizes in 0..200 × 0..80, and all four flag combinations -> no panic +- `BenchmarkFrame`: `Step` + `Render` at 80×30 with `MaxParticles` live particles; record ns/op in the commit message +- `test_particle_cap_holds_under_abuse`: 2000 hard-drop and four-line events in one session -> `ParticleCount()` never exceeded `MaxParticles` +- `test_no_goroutines_leak`: `runtime.NumGoroutine()` before and after a 1000-frame session is unchanged (§38 "do not spawn a goroutine per particle/frame") + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/render/ -run TestGoldenFX -v` +Expected: FAIL — missing goldens + +- [ ] **Step 3: Generate the goldens and read them** + +Run: `go test ./internal/render/ -update && go test ./... -race -v` +Expected: PASS. Read `testdata/fx_wide.golden` and `fx_small.golden`: the board is still perfectly legible, the active piece is visible, no effect crosses the border it shouldn't. + +- [ ] **Step 4: Walk the §43 coolness acceptance test by hand** + +Run: `go run ./cmd/cosmic-tetris --seed 8675309` and confirm, within the first 30 seconds of normal play: a moving starfield, an animated board border, piece trails, a hard-drop impact, particles, and mission-control commentary. Within the first completed line: the supernova, debris, and a border reaction. Then force a four-line clear and confirm the §43 reaction. Note anything that falls flat and tune the constant responsible — the constants are all named and gathered at the top of their files for exactly this. + +- [ ] **Step 5: Walk the §47 definition-of-done list** + +Check every bullet of §47 against the built game. Anything unchecked is a bug to fix in this task, not a follow-up. + +- [ ] **Step 6: Update the README** + +Add the effects list, the `--no-fx` / `--reduced-motion` notes, and a one-paragraph architecture note: `game` is deterministic and clock-free, `fx` observes events and can never modify game state, `render` composites, `app` owns the clock. + +- [ ] **Step 7: Commit** + +```bash +git add internal/render internal/fx README.md +git commit -m "test(fx): FX goldens, particle cap and perf sanity, coolness acceptance pass" +``` + +--- + +## Done when + +- `go test ./... -race` passes; `go vet ./...` and `gofmt -l .` are silent. +- `internal/fx` imports neither `internal/render` nor `internal/app` and never holds a `*game.Game`. +- The same `(seed, input, dt)` script produces identical game state with FX on, off, reduced-motion, and ASCII. +- Every §43 bullet is observable in a real terminal, and every §47 bullet is true. +- A four-line clear is gloriously excessive; game over collapses the universe into a black hole; the game is fun with `--no-fx` and much funnier without it. diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/T1-test-lines-cosmic-63/2026-09-18-cosmic-tetris-01-engine-and-terminal.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/T1-test-lines-cosmic-63/2026-09-18-cosmic-tetris-01-engine-and-terminal.md new file mode 100644 index 000000000..d51f92600 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/T1-test-lines-cosmic-63/2026-09-18-cosmic-tetris-01-engine-and-terminal.md @@ -0,0 +1,1063 @@ +# Cosmic Tetris, Plan 1: Engine and Playable Terminal — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the deterministic headless tetromino engine and a genuinely good Bubble Tea terminal game on top of it — board, ghost, hold, next queue, HUD, pause, help, resize, restart — with no cosmic effects yet. + +**Architecture:** `internal/game` is a pure, clock-free simulation: every mutator returns `[]Event` and time enters only through `Advance(dt)`. `internal/render` is a pure function from a `Scene` value to a string and never mutates game state. `internal/app` owns the Bubble Tea program, the single ~60Hz frame clock, and key handling. Dependencies point one way: `app → render → game`, and `app → game`. + +**Tech Stack:** Go 1.26, `charm.land/bubbletea/v2`, `charm.land/lipgloss/v2`, `charm.land/bubbles/v2`. Standard library `math/rand` for the game RNG. No other dependencies. + +**Spec:** `design.md` (this repo root). Sections 5–13, 31–41, 42 Phases 1–2, 46, 49. + +**Follow-on plans:** Plan 2 (`plans/2026-09-18-cosmic-tetris-02-cosmic-effects.md`) adds `internal/fx` and `internal/flavor`. Plan 3 (`plans/2026-09-18-cosmic-tetris-03-theatrics-and-polish.md`) adds boot, game-over collapse, and final polish. This plan must leave the seams those plans plug into: a `render.Scene` with an unused `FX` field and an `Options` struct that already parses `--no-fx` and `--reduced-motion`. + +## Global Constraints + +- Language: Go. Module path: `github.com/jessev/cosmic-tetris` (local scratch repo, no remote; the path just has to be stable). +- UI libraries, exact import paths: `charm.land/bubbletea/v2`, `charm.land/lipgloss/v2`, `charm.land/bubbles/v2`. Do not abstract Bubble Tea behind a homegrown framework (§3). +- Board geometry, exact: `width 10`, `height 22`, `visible rows 20`, `hidden spawn rows 2` (§5). +- One logical block renders as **2 terminal columns × 1 terminal row** (§5). +- Nothing under `internal/game` may call `time.Now()`. The engine advances only via `func (g *Game) Advance(dt time.Duration) []Event` (§49.2). +- `Game` owns its own `rng *rand.Rand`, which drives the 7-bag and nothing else. `Seed int64` is recorded for display and restart only (§49.6). +- Rendering must not mutate game state (§37). +- Repository layout is fixed by §33. Do not add packages beyond `game`, `app`, `render`, `fx`, `flavor` under `internal/`. +- Do not build: networking, profiles, achievements, a plugin system, a database (§2). +- No goroutine per particle or per frame; no filesystem operations during gameplay (§38). +- Where §49 and an earlier section differ, §49 wins. +- Minimum usable terminal: ~40 columns × 24 rows (§31). Never crash on resize. +- Every task ends with tests passing and a commit. `go vet ./...` and `gofmt -l .` must be clean before each commit. + +## Review Focus + +These are inputs the spec implies but never names. Each line's test is assigned to the task that owns the code. + +1. **Degenerate terminal sizes.** A `WindowSizeMsg` of `0×0` (some terminals send this on startup or when a pane is hidden) must render the too-small notice, not panic or divide by zero. → Task 12. +2. **Rotation kicked above the top of the board.** Kick offsets include `(0,-1)`, `(-1,-1)`, `(1,-1)`, so a piece at spawn row 0 can be tested at `y == -1`. `Board.Collides` must treat negative `y` as a collision, not index a slice out of range. → Task 2. +3. **A very large `dt`.** Laptop sleep or a suspended terminal means `Advance` is called with tens of seconds. It must not silently drop the piece 40 rows or spin. Cap gravity steps per call. → Task 7. +4. **Bad CLI input.** `--seed abc` must exit with a message and a non-zero status, not panic; `--seed 0` must be a valid seed, not treated as "unset". → Task 18. +5. **Inputs that arrive in the wrong state.** Movement, rotation, hold, and drops pressed while paused or after game over must be no-ops that leave score and board untouched. → Task 17. + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `go.mod`, `go.sum` | Module and pinned deps | +| `cmd/cosmic-tetris/main.go` | Flag parsing, program start | +| `internal/game/piece.go` | `PieceKind`, `Piece`, the rotation shape table, `Cells()` | +| `internal/game/board.go` | Grid storage, bounds, collision, lock, row completion/clear/collapse | +| `internal/game/bag.go` | 7-bag generator | +| `internal/game/rules.go` | Gravity curve, lock timings, kick offset order, spawn position | +| `internal/game/scoring.go` | Line values, combo bonus, level progression | +| `internal/game/events.go` | `EventKind`, `Event` | +| `internal/game/game.go` | `Game` state and every mutator, including `Advance(dt)` | +| `internal/render/palette.go` | `Mode`, glyph tables, piece colors, border style | +| `internal/render/layout.go` | `Layout`, `Compute(w, h)`, element drop order | +| `internal/render/board.go` | Board panel: locked cells, ghost, active piece, border | +| `internal/render/hud.go` | HOLD, NEXT, stats, mission-control line, control hints | +| `internal/render/overlay.go` | Pause, help, game-over, too-small panels | +| `internal/render/render.go` | `Scene`, `Render(Scene) string` composition | +| `internal/app/keys.go` | `KeyMap` built from `bubbles/key` | +| `internal/app/messages.go` | `FrameMsg`, the frame command | +| `internal/app/model.go` | `Model`, `Init`, `View`, `Options` | +| `internal/app/update.go` | `Update`: frame ticks, keys, resize, state transitions | +| `README.md`, `LICENSE` | Docs | + +`internal/render/overlay.go` is one file beyond §33's listing; it exists because pause/help/game-over/too-small panels are one responsibility that would otherwise bloat `hud.go`. + +--- + +### Task 1: Module scaffold and the piece shape table + +**Files:** +- Create: `go.mod`, `internal/game/piece.go` +- Test: `internal/game/piece_test.go` + +**Interfaces:** +- Consumes: nothing. +- Produces: `type PieceKind uint8` with constants `KindI, KindJ, KindL, KindO, KindS, KindT, KindZ` and `AllKinds [7]PieceKind`; `func (k PieceKind) String() string` returning `"I" "J" "L" "O" "S" "T" "Z"`; `type Cell struct { X, Y int }`; `type Piece struct { Kind PieceKind; Rotation int; X, Y int }`; `func (p Piece) Cells() [4]Cell`; `func (p Piece) Rotated(delta int) Piece`. + +- [ ] **Step 1: Initialize the module** + +```bash +go mod init github.com/jessev/cosmic-tetris +go get charm.land/bubbletea/v2@latest charm.land/lipgloss/v2@latest charm.land/bubbles/v2@latest +``` + +Commit `go.mod`/`go.sum` as part of Step 6. + +- [ ] **Step 2: Write the failing tests in `internal/game/piece_test.go`** + +- `TestCellsSpawnI`: `Piece{KindI, 0, 3, 0}.Cells()` -> `{{3,1},{4,1},{5,1},{6,1}}` +- `TestCellsSpawnT`: `Piece{KindT, 0, 3, 0}.Cells()` -> `{{4,0},{3,1},{4,1},{5,1}}` +- `TestEveryRotationHasFourDistinctCells`: for all 7 kinds × 4 rotations, `Cells()` yields 4 cells, all distinct, all with `0 <= X < 4` and `0 <= Y < 4` before translation (test with `X==0, Y==0`) +- `TestORotationIsIdentical`: `Piece{KindO, r, 0, 0}.Cells()` is equal for `r` in `0..3` +- `TestRotatedWraps`: `Piece{KindT, 3, 0, 0}.Rotated(1).Rotation` -> `0`; `Piece{KindT, 0, 0, 0}.Rotated(-1).Rotation` -> `3`; `Rotated` leaves `Kind`, `X`, `Y` unchanged +- `TestStringNames`: `KindI.String()` -> `"I"`, `KindZ.String()` -> `"Z"` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `go test ./internal/game/ -run TestCells -v` +Expected: build failure, `undefined: Piece`. + +- [ ] **Step 4: Implement `internal/game/piece.go`** + +`Cells()` translates the shape offsets by `p.X, p.Y`. The shape table is data the spec does not fix, so it is pinned here — every later task's expected coordinates depend on these exact offsets. Each entry is the four occupied cells inside a 4×4 box, `X` rightward, `Y` downward, indexed `[kind][rotation]`: + +```go +var shapes = [7][4][4]Cell{ + KindI: { + {{0, 1}, {1, 1}, {2, 1}, {3, 1}}, + {{2, 0}, {2, 1}, {2, 2}, {2, 3}}, + {{0, 2}, {1, 2}, {2, 2}, {3, 2}}, + {{1, 0}, {1, 1}, {1, 2}, {1, 3}}, + }, + KindJ: { + {{0, 0}, {0, 1}, {1, 1}, {2, 1}}, + {{1, 0}, {2, 0}, {1, 1}, {1, 2}}, + {{0, 1}, {1, 1}, {2, 1}, {2, 2}}, + {{1, 0}, {1, 1}, {0, 2}, {1, 2}}, + }, + KindL: { + {{2, 0}, {0, 1}, {1, 1}, {2, 1}}, + {{1, 0}, {1, 1}, {1, 2}, {2, 2}}, + {{0, 1}, {1, 1}, {2, 1}, {0, 2}}, + {{0, 0}, {1, 0}, {1, 1}, {1, 2}}, + }, + KindO: { + {{1, 0}, {2, 0}, {1, 1}, {2, 1}}, + {{1, 0}, {2, 0}, {1, 1}, {2, 1}}, + {{1, 0}, {2, 0}, {1, 1}, {2, 1}}, + {{1, 0}, {2, 0}, {1, 1}, {2, 1}}, + }, + KindS: { + {{1, 0}, {2, 0}, {0, 1}, {1, 1}}, + {{1, 0}, {1, 1}, {2, 1}, {2, 2}}, + {{1, 1}, {2, 1}, {0, 2}, {1, 2}}, + {{0, 0}, {0, 1}, {1, 1}, {1, 2}}, + }, + KindT: { + {{1, 0}, {0, 1}, {1, 1}, {2, 1}}, + {{1, 0}, {1, 1}, {2, 1}, {1, 2}}, + {{0, 1}, {1, 1}, {2, 1}, {1, 2}}, + {{1, 0}, {0, 1}, {1, 1}, {0, 2}}, + }, + KindZ: { + {{0, 0}, {1, 0}, {1, 1}, {2, 1}}, + {{2, 0}, {1, 1}, {2, 1}, {1, 2}}, + {{0, 1}, {1, 1}, {1, 2}, {2, 2}}, + {{1, 0}, {0, 1}, {1, 1}, {0, 2}}, + }, +} +``` + +`Rotated(delta)` normalizes with `((p.Rotation+delta)%4 + 4) % 4`. + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add go.mod go.sum internal/game/piece.go internal/game/piece_test.go +git commit -m "feat(game): tetromino kinds and rotation shape table" +``` + +--- + +### Task 2: Board — bounds, collision, lock, row clearing + +**Files:** +- Create: `internal/game/board.go` +- Test: `internal/game/board_test.go` + +**Interfaces:** +- Consumes: `Piece`, `PieceKind`, `Cell` from Task 1. +- Produces: constants `Width = 10`, `Height = 22`, `HiddenRows = 2`, `VisibleRows = 20`; `type CellState struct { Filled bool; Kind PieceKind }`; `type Board struct { Cells [Height][Width]CellState }`; `func (b *Board) At(x, y int) CellState`; `func (b *Board) InBounds(x, y int) bool`; `func (b *Board) Collides(p Piece) bool`; `func (b *Board) Lock(p Piece)`; `func (b *Board) CompleteRows() []int`; `func (b *Board) ClearRows(rows []int)`. + +- [ ] **Step 1: Write the failing tests in `internal/game/board_test.go`** + +Use a helper `fillRow(b *Board, y int, gaps ...int)` in the test file that fills row `y` with `KindI` except at the listed columns. + +- `TestInBounds`: `(0,0)` -> true; `(9,21)` -> true; `(-1,0)`, `(10,0)`, `(0,22)` -> false +- `TestInBoundsRejectsNegativeY`: `InBounds(4,-1)` -> false +- `TestCollidesWithWalls`: `Piece{KindI,0,-1,0}` -> true (cell `X == -1`); `Piece{KindI,0,7,0}` -> true (cell `X == 10`); `Piece{KindI,0,6,0}` -> false +- `TestCollidesWithFloor`: `Piece{KindO,0,3,21}` -> true (cell `Y == 22`); `Piece{KindO,0,3,20}` -> false +- `TestCollidesAboveCeiling`: `Piece{KindI,1,3,-2}` -> true (cell `Y == -2`) — **Review Focus 2**; must return `true`, not panic +- `TestCollidesWithLockedCell`: fill `(4,20)`; `Piece{KindO,0,3,19}` -> true; `Piece{KindO,0,6,19}` -> false +- `TestLockWritesKind`: `Lock(Piece{KindT,0,3,0})` then `At(4,0).Filled` -> true and `At(4,0).Kind` -> `KindT`; `At(0,0).Filled` -> false +- `TestCompleteRowsEmpty`: fresh board -> `nil` or empty slice +- `TestCompleteRowsAscending`: fill rows 21 and 19 fully -> `[]int{19, 21}` +- `TestCompleteRowsIgnoresGap`: `fillRow(b, 21, 5)` -> empty +- `TestClearRowsCollapses`: fill row 21 fully; fill `(0,20)` only; `ClearRows([]int{21})`; then row 21 has exactly `(0,21)` filled and row 20 is empty +- `TestClearRowsMultipleNonAdjacent`: fill rows 21 and 19 fully, set `(3,20)`; `ClearRows([]int{19,21})`; then `(3,21)` filled, rows 0..20 empty +- `TestClearRowsPreservesKinds`: after a collapse, the surviving cell keeps its original `Kind` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/game/ -run TestBoard -v` (and the other new names) +Expected: build failure, `undefined: Board`. + +- [ ] **Step 3: Implement `internal/game/board.go`** + +`Collides` returns true if any of `p.Cells()` is `!InBounds` or already `Filled`. `ClearRows` copies rows downward from the bottom and zeroes the vacated top rows; it must tolerate the input slice in any order (sort a copy) and must not mutate the caller's slice. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/board.go internal/game/board_test.go +git commit -m "feat(game): board collision, locking, and row collapse" +``` + +--- + +### Task 3: 7-bag piece generator + +**Files:** +- Create: `internal/game/bag.go` +- Test: `internal/game/bag_test.go` + +**Interfaces:** +- Consumes: `PieceKind`, `AllKinds` from Task 1. +- Produces: `type Bag struct { ... }`; `func NewBag(rng *rand.Rand) *Bag`; `func (b *Bag) Next() PieceKind`. + +- [ ] **Step 1: Write the failing tests in `internal/game/bag_test.go`** + +- `TestEachBagContainsAllSevenOnce`: draw 70 kinds from `NewBag(rand.New(rand.NewSource(1)))`; every consecutive group of 7 contains each of the 7 kinds exactly once +- `TestSeededBagIsReproducible`: two bags from `rand.NewSource(8675309)` produce identical first 21 draws +- `TestDifferentSeedsDiffer`: bags from seeds 1 and 2 differ within the first 21 draws +- `TestBagShuffles`: across 100 bags from seed 99, the first drawn kind is not always the same value + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/game/ -run TestBag -v` / `-run TestEachBag -v` +Expected: build failure, `undefined: NewBag`. + +- [ ] **Step 3: Implement `internal/game/bag.go`** + +Hold a `[]PieceKind` remainder and an index. On exhaustion, refill with `AllKinds` and shuffle with `rng.Shuffle`. The bag never reads a clock and never creates its own RNG — the caller supplies it (§49.6). + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/bag.go internal/game/bag_test.go +git commit -m "feat(game): seeded 7-bag piece generator" +``` + +--- + +### Task 4: Rules and scoring tables + +**Files:** +- Create: `internal/game/rules.go`, `internal/game/scoring.go` +- Test: `internal/game/rules_test.go`, `internal/game/scoring_test.go` + +**Interfaces:** +- Consumes: `Cell` from Task 1. +- Produces: `const SpawnX = 3`, `SpawnY = 0`; `const BaseGravity = 800 * time.Millisecond`, `MinGravity = 60 * time.Millisecond`, `GravityFactor = 0.86`; `const LockDelay = 500 * time.Millisecond`, `MaxLockResets = 15`, `MaxGravityStepsPerAdvance = 40`, `LinesPerLevel = 10`; `var KickOffsets = [8]Cell{...}`; `func GravityInterval(level int) time.Duration`; `func LineScore(lines, level int) int`; `func ComboBonus(combo, level int) int`; `func LevelForLines(lines int) int`. + +- [ ] **Step 1: Write the failing tests in `internal/game/rules_test.go`** + +- `TestGravityIntervalLevel1`: `GravityInterval(1)` -> `800 * time.Millisecond` +- `TestGravityIntervalDecays`: `GravityInterval(2)` -> `688 * time.Millisecond` (800 × 0.86, truncated to ms); `GravityInterval(3)` is within 1ms of `591 * time.Millisecond` +- `TestGravityIntervalClampsAtFloor`: `GravityInterval(30)` -> `60 * time.Millisecond`; `GravityInterval(1000)` -> `60 * time.Millisecond` +- `TestGravityIntervalMonotonic`: for `level` 1..40, `GravityInterval(level+1) <= GravityInterval(level)` +- `TestGravityIntervalGuardsLowLevels`: `GravityInterval(0)` and `GravityInterval(-5)` -> `800 * time.Millisecond` (treat as level 1) +- `TestKickOrder`: `KickOffsets` -> `{{0,0},{-1,0},{1,0},{-2,0},{2,0},{0,-1},{-1,-1},{1,-1}}` exactly, in that order (§7) + +- [ ] **Step 2: Write the failing tests in `internal/game/scoring_test.go`** + +- `TestLineScoreValues`: `LineScore(1,1)`->100, `LineScore(2,1)`->300, `LineScore(3,1)`->500, `LineScore(4,1)`->800 +- `TestLineScoreScalesWithLevel`: `LineScore(4,7)` -> 5600; `LineScore(1,13)` -> 1300 +- `TestLineScoreZero`: `LineScore(0,5)` -> 0 +- `TestComboBonusFirstClearIsFree`: `ComboBonus(1,5)` -> 0 (§49.1) +- `TestComboBonusFormula`: `ComboBonus(2,1)` -> 50; `ComboBonus(5,3)` -> 600 +- `TestComboBonusZeroCombo`: `ComboBonus(0,9)` -> 0 +- `TestLevelForLines`: `LevelForLines(0)`->1, `LevelForLines(9)`->1, `LevelForLines(10)`->2, `LevelForLines(127)`->13 + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `go test ./internal/game/ -run 'TestGravity|TestLine|TestCombo|TestLevel|TestKick' -v` +Expected: build failure, `undefined: GravityInterval`. + +- [ ] **Step 4: Implement `internal/game/rules.go` and `internal/game/scoring.go`** + +`GravityInterval` computes `BaseGravity * math.Pow(GravityFactor, level-1)`, truncates to whole milliseconds, and clamps to `MinGravity`. `LineScore` uses a `[5]int{0,100,300,500,800}` table times level; more than 4 lines cannot occur, so index 4 is the cap. + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add internal/game/rules.go internal/game/scoring.go internal/game/rules_test.go internal/game/scoring_test.go +git commit -m "feat(game): gravity curve, kick order, and scoring tables" +``` + +--- + +### Task 5: Game state, spawning, and horizontal movement + +**Files:** +- Create: `internal/game/events.go`, `internal/game/game.go` +- Test: `internal/game/game_test.go` + +**Interfaces:** +- Consumes: everything from Tasks 1–4. +- Produces: + - `type EventKind uint8` with constants, in this order: `PieceMoved, PieceRotated, PieceHardDropped, PieceLocked, HoldUsed, LinesCleared, ComboChanged, LevelChanged, GameOver` (§14); `func (k EventKind) String() string`. + - `type Event struct { Kind EventKind; Piece Piece; Rows []int; Cells int; Value int }` — `Rows` set on `LinesCleared` (ascending cleared row indices), `Cells` set on `PieceHardDropped` (rows fallen), `Value` set on `ComboChanged` (new combo) and `LevelChanged` (new level). + - `type Game struct { Board Board; Active Piece; Hold *PieceKind; CanHold bool; Next []PieceKind; Bag *Bag; Score, Lines, Level, Combo int; GravityAccumulator, LockAccumulator time.Duration; Seed int64; ... unexported: rng *rand.Rand, lockResets int, over bool }` + - `func New(seed int64) *Game`; `func (g *Game) Over() bool`; `func (g *Game) MoveLeft() []Event`; `func (g *Game) MoveRight() []Event`. + - `const NextQueueLen = 5` (§6: keep enough future pieces to render five). + +- [ ] **Step 1: Write the failing tests in `internal/game/game_test.go`** + +- `TestNewInitialState`: `New(1)` -> `Score == 0`, `Lines == 0`, `Level == 1`, `Combo == 0`, `Hold == nil`, `CanHold == true`, `Over() == false`, `Seed == 1`, `len(Next) == 5` +- `TestNewSpawnsAtSpawnPosition`: `New(1).Active` -> `Rotation == 0`, `X == SpawnX`, `Y == SpawnY` +- `TestNewIsSeeded`: `New(42).Active.Kind == New(42).Active.Kind` and the two games' `Next` slices are equal +- `TestMoveLeftShiftsAndEmits`: from `New(1)`, `MoveLeft()` -> one event of kind `PieceMoved`, `Active.X == SpawnX-1` +- `TestMoveRightShiftsAndEmits`: `MoveRight()` -> `Active.X == SpawnX+1`, one `PieceMoved` +- `TestMoveBlockedByWallEmitsNothing`: call `MoveLeft()` until `X` stops changing; the blocked call returns zero events and leaves `X` unchanged +- `TestMoveBlockedByStack`: lock a column of cells beside the piece, assert the move toward it returns no events + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/game/ -run 'TestNew|TestMove' -v` +Expected: build failure, `undefined: New`. + +- [ ] **Step 3: Implement `internal/game/events.go` and the state/spawn/move parts of `internal/game/game.go`** + +`New(seed)` creates `rand.New(rand.NewSource(seed))`, a bag from it, refills `Next` to `NextQueueLen`, and spawns the first piece. Add an unexported `func (g *Game) spawn() []Event` that pops `Next[0]`, refills from the bag, sets `Active` to `Piece{kind, 0, SpawnX, SpawnY}`, resets `CanHold`, `LockAccumulator`, `lockResets`, and — if the spawn collides — sets `over` and returns a `GameOver` event. Movement helpers try a candidate `Piece` and commit only if `!g.Board.Collides(candidate)`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/events.go internal/game/game.go internal/game/game_test.go +git commit -m "feat(game): game state, spawning, and horizontal movement" +``` + +--- + +### Task 6: Rotation with wall kicks + +**Files:** +- Modify: `internal/game/game.go` +- Test: `internal/game/rotation_test.go` + +**Interfaces:** +- Consumes: `KickOffsets` (Task 4), `Game` (Task 5). +- Produces: `func (g *Game) RotateCW() []Event`, `func (g *Game) RotateCCW() []Event`. + +- [ ] **Step 1: Write the failing tests in `internal/game/rotation_test.go`** + +Use a helper `gameWith(kind PieceKind, rot, x, y int) *Game` in the test file that builds `New(1)` and overwrites `Active`. + +- `TestRotateCWAdvancesRotation`: T at `(3,5)` rotation 0 -> `RotateCW()` yields one `PieceRotated`, `Active.Rotation == 1` +- `TestRotateCCWWraps`: T at rotation 0 -> `RotateCCW()` -> `Rotation == 3` +- `TestRotateKicksOffLeftWall`: I at rotation 1, `X == -2`, `Y == 5` (its vertical column sits at board column 0); `RotateCW()` succeeds and the resulting cells are all in bounds +- `TestRotateKicksOffRightWall`: place a piece so that plain rotation would put a cell at `X == 10`; assert rotation succeeds and `Active.X` moved left by the first kick offset that works, following `KickOffsets` order +- `TestRotateTriesOffsetsInOrder`: place a T where offset `(0,0)` and `(-1,0)` are both blocked but `(1,0)` is free; assert `Active.X == startX+1` +- `TestRotateFailsWhenNoOffsetWorks`: fill the whole board except the 4 cells the piece occupies; `RotateCW()` returns zero events and leaves `Active` byte-identical +- `TestRotateOIsAlwaysAccepted`: O anywhere with room -> rotation succeeds and cells are unchanged +- `TestRotateNeverLeavesBoard`: for all 7 kinds at every `X` in `-2..11` and `Y` in `0..21` on an empty board, if `RotateCW()` returns an event then all resulting cells satisfy `Board.InBounds` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/game/ -run TestRotate -v` +Expected: FAIL, `undefined: RotateCW`. + +- [ ] **Step 3: Implement `RotateCW`/`RotateCCW` in `internal/game/game.go`** + +Both delegate to an unexported `rotate(delta int) []Event` that walks `KickOffsets` in order, testing `Piece{Kind, newRot, X+off.X, Y+off.Y}`, and accepts the first non-colliding candidate. On success also apply the grounded lock-timer reset from Task 7 (add that call when Task 7 lands; for now just emit `PieceRotated`). + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/game.go internal/game/rotation_test.go +git commit -m "feat(game): forgiving wall-kick rotation" +``` + +--- + +### Task 7: `Advance(dt)` — gravity, lock delay, line clears, level, game over + +**Files:** +- Modify: `internal/game/game.go` +- Test: `internal/game/advance_test.go` + +**Interfaces:** +- Consumes: `GravityInterval`, `LockDelay`, `MaxLockResets`, `MaxGravityStepsPerAdvance`, `LinesPerLevel`, `LineScore`, `ComboBonus`, `LevelForLines`. +- Produces: `func (g *Game) Advance(dt time.Duration) []Event`; unexported `func (g *Game) grounded() bool`, `func (g *Game) lockPiece() []Event`, `func (g *Game) resetLockTimer()`. + +- [ ] **Step 1: Write the failing tests in `internal/game/advance_test.go`** + +- `TestAdvanceBelowIntervalDoesNothing`: `Advance(100*time.Millisecond)` on `New(1)` -> zero events, `Active.Y` unchanged +- `TestAdvanceOneIntervalDropsOneRow`: `Advance(800*time.Millisecond)` -> one `PieceMoved`, `Active.Y == SpawnY+1` +- `TestAdvanceAccumulatesRemainder`: two calls of `500ms` -> exactly one row dropped, `GravityAccumulator == 200*time.Millisecond` +- `TestAdvanceUsesLevelInterval`: with `Level` set to 5, `Advance(GravityInterval(5))` drops exactly one row +- `TestAdvanceCapsCatchUp`: `Advance(60*time.Second)` on `New(1)` -> at most `MaxGravityStepsPerAdvance` `PieceMoved` events, `GravityAccumulator` back below one interval, no panic — **Review Focus 3** +- `TestGroundedStartsLockTimer`: drop a piece to the floor, then `Advance(100ms)` -> `LockAccumulator == 100ms`, no `PieceLocked` +- `TestLockAfterLockDelay`: grounded piece, `Advance(LockDelay)` -> events contain `PieceLocked`, the piece's cells are now `Filled` on the board, a new piece has spawned +- `TestMovementResetsLockTimer`: grounded piece, `Advance(300ms)`, `MoveLeft()` -> `LockAccumulator == 0` +- `TestRotationResetsLockTimer`: same with `RotateCW()` +- `TestLockResetsAreCapped`: grounded piece; alternate `MoveLeft`/`MoveRight` 20 times with `Advance(100ms)` between; after the 15th reset the timer no longer resets and the piece locks — **spec §12** +- `TestLockClearsCompleteRow`: fill row 21 except columns 3–4, drop an O into the gap, force lock -> events contain `LinesCleared` with `Rows == []int{21}`, `Lines == 1`, `Score == 100` +- `TestLockScoresFourLines`: build four rows with a 1-wide well at column 0, hard-lock a vertical I -> `LinesCleared` with 4 rows, `Score` includes `LineScore(4, level)` +- `TestComboIncrementsAndEmits`: two consecutive clearing locks -> `ComboChanged` with `Value 1` then `Value 2`; second lock's score includes `ComboBonus(2, level)` +- `TestComboResetsOnEmptyPlacement`: clearing lock then non-clearing lock -> `ComboChanged` with `Value 0`, and no bonus added +- `TestNoComboEventWhenAlreadyZero`: two non-clearing locks -> no `ComboChanged` events at all +- `TestLevelUpEveryTenLines`: clear lines until `Lines == 10` -> events contain `LevelChanged` with `Value 2`, `Level == 2` +- `TestGameOverOnBlockedSpawn`: fill rows 0–1 fully except one column, force a lock so the next spawn collides -> events contain `GameOver`, `Over()` -> true +- `TestAdvanceAfterGameOverIsInert`: after `GameOver`, `Advance(5*time.Second)` -> zero events, `Score` unchanged +- `TestAdvanceZeroAndNegativeDt`: `Advance(0)` and `Advance(-1*time.Second)` -> zero events, accumulator unchanged + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/game/ -run 'TestAdvance|TestLock|TestCombo|TestLevelUp|TestGameOver|TestGrounded' -v` +Expected: FAIL, `undefined: Advance`. + +- [ ] **Step 3: Implement `Advance` and locking in `internal/game/game.go`** + +Order of operations inside `Advance`, since the tests pin it and it is not derivable from the signature: + +``` +if over || dt <= 0: return nil +GravityAccumulator += dt +steps := 0 +for GravityAccumulator >= GravityInterval(Level) && steps < MaxGravityStepsPerAdvance: + GravityAccumulator -= GravityInterval(Level) + steps++ + if !grounded(): move down 1, emit PieceMoved, LockAccumulator = 0 + else: break +if GravityAccumulator >= GravityInterval(Level): GravityAccumulator = 0 // catch-up cap +if grounded(): + LockAccumulator += dt + if LockAccumulator >= LockDelay: append lockPiece()... +``` + +`lockPiece` performs §12's sequence exactly: commit, `CompleteRows`, `ClearRows`, update score/lines/combo/level, emit `PieceLocked` then `LinesCleared` then `ComboChanged` then `LevelChanged`, then `spawn()`. `resetLockTimer` zeroes `LockAccumulator` and increments `lockResets` only while `grounded()` and `lockResets < MaxLockResets`. Wire `resetLockTimer` into `MoveLeft`, `MoveRight`, `RotateCW`, `RotateCCW`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/game.go internal/game/advance_test.go +git commit -m "feat(game): elapsed-time gravity, lock delay, clears, and game over" +``` + +--- + +### Task 8: Soft drop, hard drop, and ghost landing position + +**Files:** +- Modify: `internal/game/game.go` +- Test: `internal/game/drop_test.go` + +**Interfaces:** +- Consumes: `Game`, `lockPiece` (Task 7). +- Produces: `func (g *Game) SoftDrop() []Event`, `func (g *Game) HardDrop() []Event`, `func (g *Game) GhostY() int`. + +- [ ] **Step 1: Write the failing tests in `internal/game/drop_test.go`** + +- `TestSoftDropMovesOneRowAndScores`: `New(1).SoftDrop()` -> one `PieceMoved`, `Active.Y == SpawnY+1`, `Score == 1` +- `TestSoftDropResetsGravityAccumulator`: `Advance(400ms)` then `SoftDrop()` -> `GravityAccumulator == 0` +- `TestSoftDropWhenGroundedDoesNotScore`: grounded piece -> `SoftDrop()` returns zero events, `Score` unchanged +- `TestGhostYOnEmptyBoard`: `New(1)`; `GhostY()` equals the largest `Y` for which the active piece does not collide (compute it in the test by stepping down) +- `TestGhostYRespectsStack`: fill row 21 fully; `GhostY()` for an O at `X==3` -> the row that rests on top of it +- `TestGhostYEqualsCurrentWhenGrounded`: grounded piece -> `GhostY() == Active.Y` +- `TestHardDropScoresTwoPerCell`: from `New(1)`, record `d := GhostY() - Active.Y`, then `HardDrop()` -> `Score` increased by `2*d` plus any line-clear score +- `TestHardDropEmitsEventOrder`: `HardDrop()` events start with `PieceHardDropped` (with `Cells == d`) and then contain `PieceLocked` +- `TestHardDropLocksImmediately`: after `HardDrop()`, the dropped piece's cells are `Filled` and a fresh piece has spawned, without any `Advance` call +- `TestHardDropWhenGroundedStillLocks`: grounded piece -> `HardDrop()` emits `PieceHardDropped` with `Cells == 0` and locks +- `TestHardDropAtGameOverIsInert`: after game over, `HardDrop()` -> zero events + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/game/ -run 'TestSoftDrop|TestHardDrop|TestGhost' -v` +Expected: FAIL, `undefined: SoftDrop`. + +- [ ] **Step 3: Implement the three methods in `internal/game/game.go`** + +`GhostY` walks a copy of `Active` downward until the next step collides; it must not mutate `g`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/game.go internal/game/drop_test.go +git commit -m "feat(game): soft drop, hard drop, and ghost landing position" +``` + +--- + +### Task 9: Hold + +**Files:** +- Modify: `internal/game/game.go` +- Test: `internal/game/hold_test.go` + +**Interfaces:** +- Consumes: `Game`, `spawn` (Task 5). +- Produces: `func (g *Game) HoldPiece() []Event` (named `HoldPiece` because the `Hold` field already occupies `Hold`). + +- [ ] **Step 1: Write the failing tests in `internal/game/hold_test.go`** + +- `TestFirstHoldStoresAndSpawnsNext`: `New(1)`; record `first := Active.Kind` and `queued := Next[0]`; `HoldPiece()` -> one `HoldUsed` event, `*Hold == first`, `Active.Kind == queued`, `CanHold == false` +- `TestHoldSwapsWithStoredPiece`: hold, lock a piece, then hold again -> the two kinds swap, `Active.Kind` is the previously held kind +- `TestSecondHoldBlockedBeforeLock`: `HoldPiece()` twice without locking -> second call returns zero events and leaves state unchanged +- `TestHoldRestoredAfterLock`: hold, then `HardDrop()` -> `CanHold == true` +- `TestHeldPieceReturnsToSpawnRotation`: rotate the active piece twice, hold it, lock, hold again -> `Active.Rotation == 0`, `Active.X == SpawnX`, `Active.Y == SpawnY` +- `TestHoldDoesNotConsumeExtraQueue`: after a swap-style hold, `len(Next) == NextQueueLen` +- `TestHoldAtGameOverIsInert`: after game over, `HoldPiece()` -> zero events +- `TestHoldIntoBlockedSpawnEndsGame`: fill rows 0–1 so any spawn collides, then `HoldPiece()` -> events contain `GameOver` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/game/ -run TestHold -v` +Expected: FAIL, `undefined: HoldPiece`. + +- [ ] **Step 3: Implement `HoldPiece` in `internal/game/game.go`** + +Empty hold: store `Active.Kind`, then `spawn()`. Non-empty: swap `*Hold` with `Active.Kind` and reset the active piece to `Piece{kind, 0, SpawnX, SpawnY}`, re-checking collision so a blocked swap ends the game. Set `CanHold = false` and emit `HoldUsed` in both paths. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/game.go internal/game/hold_test.go +git commit -m "feat(game): hold with one-per-piece restriction" +``` + +--- + +### Task 10: Determinism replay test + +**Files:** +- Test: `internal/game/determinism_test.go` + +**Interfaces:** +- Consumes: the whole `game` API. +- Produces: nothing (test-only). This task is the proof of §35 and §49.2. + +- [ ] **Step 1: Write the failing test in `internal/game/determinism_test.go`** + +Define a table of steps in the test file: `type step struct { key string; dt time.Duration }` where `key` is one of `"" "left" "right" "cw" "ccw" "soft" "hard" "hold"`, and a `replay(seed int64, steps []step) *Game` helper that applies the key then `Advance(dt)`. + +- `TestReplayIsReproducible`: a hand-written stream of ~200 mixed steps run twice from seed `8675309` -> identical `Score`, `Lines`, `Level`, `Combo`, `Board.Cells`, `Active`, `Hold`, `Next` +- `TestReplayDiffersBySeed`: same stream from seeds `1` and `2` -> the boards differ +- `TestReplayReachesGameOver`: a stream of 400 `{"hard", 0}` steps -> `Over()` is true and the run produced exactly one `GameOver` event +- `TestNoClockReadsInGamePackage`: walk `internal/game/*.go` with `go/parser` and assert no `time.Now` selector appears in non-test files — **§49.2 made mechanical** +- `TestGameRNGIsNotShared`: two games from the same seed, where one has 10 000 extra calls made to an unrelated `rand.Rand`, still produce identical `Next` queues + +- [ ] **Step 2: Run tests to verify they fail (or fail meaningfully)** + +Run: `go test ./internal/game/ -run 'TestReplay|TestNoClock|TestGameRNG' -v` +Expected: FAIL until the helper compiles and the assertions hold. If `TestNoClockReadsInGamePackage` fails, remove the clock read rather than relaxing the test. + +- [ ] **Step 3: Fix any determinism defect the replay exposes** + +Likely culprits: map iteration order, a `time.Now()` slipped into `game`, or a shared package-level RNG. + +- [ ] **Step 4: Run the full engine suite** + +Run: `go test ./internal/game/ -count=2 -v` +Expected: PASS twice (`-count=2` catches state leaking through package-level variables). + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/determinism_test.go +git commit -m "test(game): canned-replay determinism proof" +``` + +--- + +### Task 11: Render modes, glyphs, and palette + +**Files:** +- Create: `internal/render/palette.go` +- Test: `internal/render/palette_test.go` + +**Interfaces:** +- Consumes: `game.PieceKind`. +- Produces: + - `type Mode uint8` with `ModeFull, ModeReduced, ModeASCII`; `func ParseMode(ascii bool) Mode` (returns `ModeASCII` when `ascii` is true, else detects: `ModeFull` when `lipgloss` reports truecolor, `ModeReduced` otherwise). + - `type Glyphs struct { Block, Ghost, Empty string; BorderTL, BorderTR, BorderBL, BorderBR, BorderH, BorderV string }` + - `func GlyphsFor(m Mode) Glyphs` + - `func PieceColor(k game.PieceKind, m Mode) lipgloss.Color` + - `func LockedStyle(k game.PieceKind, m Mode) lipgloss.Style`, `func ActiveStyle(k game.PieceKind, m Mode) lipgloss.Style`, `func GhostStyle(m Mode) lipgloss.Style` + - `func BorderColor(m Mode, energy float64) lipgloss.Color` — `energy` in `[0,1]`; Plan 1 always passes `0`, Plan 2 animates it. + +- [ ] **Step 1: Write the failing tests in `internal/render/palette_test.go`** + +- `TestGlyphWidths`: for each mode, `Block`, `Ghost`, and `Empty` all have `lipgloss.Width` of exactly 2 (§5: 2 columns per cell) +- `TestFullGlyphs`: `GlyphsFor(ModeFull).Block` -> `"██"`, `.Ghost` -> `"░░"` (§49.4) +- `TestReducedGlyphsMatchFull`: `GlyphsFor(ModeReduced)` block/ghost equal `ModeFull`'s (§49.4 pins ghost `░░` for full **and** reduced) +- `TestASCIIGlyphs`: `GlyphsFor(ModeASCII).Block` -> `"[]"`, `.Ghost` -> `"··"` (§49.4, verbatim) +- `TestASCIIBorders`: `GlyphsFor(ModeASCII)` border runes are `+ - |` only +- `TestASCIIIsSevenBitExceptGhost`: in `ModeASCII`, every rune of every `Glyphs` field except `Ghost` is `< 128` +- `TestParseModeASCIIFlagWins`: `ParseMode(true)` -> `ModeASCII` +- `TestPieceColorsAreDistinct`: the 7 `PieceColor(k, ModeFull)` values are pairwise different +- `TestActiveBrighterThanLocked`: for each kind, `ActiveStyle` and `LockedStyle` produce different foreground colors (§49.4: active renders one step brighter) +- `TestBorderColorStableAtZeroEnergy`: `BorderColor(m, 0)` is equal across repeated calls (no hidden clock) + +Note for the implementer: §49.4 pins the ASCII-mode ghost as `··` (U+00B7), which is Latin-1 rather than 7-bit ASCII. §49 wins over §32's "no special Unicode assumptions", so ship `··` — it is the single non-7-bit glyph in ASCII mode, and `TestASCIIIsSevenBitExceptGhost` is what keeps it the only one. It is still 1 column wide per rune, so widths are unaffected. Mention the exception in the README. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/render/ -v` +Expected: build failure, `undefined: GlyphsFor`. + +- [ ] **Step 3: Implement `internal/render/palette.go`** + +Neon space palette per §26, as hex for `ModeFull` and the nearest ANSI-256 index for `ModeReduced`: I plasma cyan, J deep electric blue, L solar orange, O stellar gold, S alien green, T ultraviolet, Z supernova pink. Locked cells use the base color; active cells use a lighter variant. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/palette.go internal/render/palette_test.go +git commit -m "feat(render): render modes, glyph tables, and neon palette" +``` + +--- + +### Task 12: Responsive layout + +**Files:** +- Create: `internal/render/layout.go` +- Test: `internal/render/layout_test.go` + +**Interfaces:** +- Consumes: nothing. +- Produces: + - `type LayoutKind uint8` with `KindTooSmall, KindSmall, KindMedium, KindWide` + - `type Layout struct { Kind LayoutKind; Width, Height int; ShowTitleBorder, ShowMissionControl, ShowStatLabels, ShowHold bool; NextCount int }` + - `func Compute(width, height int) Layout` + - `const MinWidth = 40`, `MinHeight = 24` + +- [ ] **Step 1: Write the failing tests in `internal/render/layout_test.go`** + +The thresholds below are this plan's concrete reading of §31 and §49.3's drop order; the tests are the contract. + +- `TestComputeTooSmall`: `Compute(34,19).Kind` -> `KindTooSmall`; also `Compute(39,40)` and `Compute(80,23)` -> `KindTooSmall` +- `TestComputeZeroSize`: `Compute(0,0).Kind` -> `KindTooSmall`, and no field is negative — **Review Focus 1** +- `TestComputeNegativeSize`: `Compute(-5,-5).Kind` -> `KindTooSmall`, no panic +- `TestComputeSmall`: `Compute(40,24)` -> `KindSmall`, `NextCount == 3`, `ShowHold == false`, `ShowTitleBorder == false`, `ShowMissionControl == false`, `ShowStatLabels == false` +- `TestComputeMedium`: `Compute(50,26)` -> `KindMedium`, `NextCount == 3`, `ShowHold == false`, `ShowMissionControl == true`, `ShowTitleBorder == false` +- `TestComputeWide`: `Compute(80,30)` -> `KindWide`, `NextCount == 5`, `ShowHold == true`, `ShowTitleBorder == true`, `ShowMissionControl == true`, `ShowStatLabels == true` +- `TestDropOrderByHeight`: at `width == 80`, as height goes 30→27→26→25→24: `ShowTitleBorder` turns off first (at 26), then `ShowMissionControl` (at 25), then `ShowStatLabels` (at 24) — §49.3's exact order +- `TestNextNeverStacksVertically`: for every size from `(40,24)` to `(120,50)`, `NextCount > 0` (NEXT is never dropped; §49.3 says it moves beside the board and truncates to 3) +- `TestComputeIsMonotonic`: growing either dimension never turns a `Show*` flag from true to false and never lowers `NextCount` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/render/ -run TestCompute -v` +Expected: FAIL, `undefined: Compute`. + +- [ ] **Step 3: Implement `Compute` in `internal/render/layout.go`** + +Thresholds, pinned: + +``` +TooSmall : width < MinWidth || height < MinHeight +KindWide : width >= 60 +KindMedium : width >= 46 +KindSmall : otherwise +ShowHold, NextCount : width >= 60 -> true, 5 ; else false, 3 +ShowTitleBorder : height >= 27 +ShowMissionControl : height >= 26 +ShowStatLabels : height >= 25 +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/layout.go internal/render/layout_test.go +git commit -m "feat(render): responsive layout with pinned element drop order" +``` + +--- + +### Task 13: Board panel rendering + +**Files:** +- Create: `internal/render/board.go` +- Test: `internal/render/board_test.go` + +**Interfaces:** +- Consumes: `game.Game`, `game.Board`, `Glyphs`, styles (Task 11). +- Produces: `type BoardView struct { Game *game.Game; Mode Mode; ShowGhost bool; ShakeX, ShakeY int; BorderEnergy float64 }`; `func RenderBoard(v BoardView) string`. Returns exactly `VisibleRows + 2` lines, each `game.Width*2 + 2` display columns wide. + +- [ ] **Step 1: Write the failing tests in `internal/render/board_test.go`** + +Add a test helper `stripANSI(s string) string` (regexp `\x1b\[[0-9;]*m`) in this file; later tasks reuse it. + +- `TestBoardDimensions`: `RenderBoard` output stripped of ANSI has 22 lines, each with `lipgloss.Width == 22` +- `TestBoardOnlyShowsVisibleRows`: lock a piece into hidden row 0 only; the rendered board contains no block glyph +- `TestBoardShowsLockedCells`: lock an O at `(3,20)`; the two bottom-ish rows contain `██` at the expected column offsets +- `TestBoardShowsActivePiece`: the active piece's cells appear once it is in visible rows +- `TestGhostBelowActive`: with `ShowGhost: true` on an empty board, the ghost glyph `░░` appears at the `GhostY()` rows +- `TestGhostNeverOverwritesLockedCells`: fill row 21; assert row 21's stripped text contains no ghost glyph (§10) +- `TestGhostSuppressed`: `ShowGhost: false` -> no ghost glyph anywhere +- `TestGhostHiddenWhenGrounded`: grounded piece -> no ghost glyph (ghost and active coincide, active wins) +- `TestShakeShiftsWithoutChangingWidth`: `ShakeX: 1, ShakeY: -1` -> still 22 lines of width 22 (§44: shake never exceeds one cell and never resizes the panel) +- `TestASCIIBoardIsSevenBitExceptGhost`: with `Mode: ModeASCII`, every rune in the stripped output is `< 128` except `·` (the §49.4 ghost exception) + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/render/ -run TestBoard -v` +Expected: FAIL, `undefined: RenderBoard`. + +- [ ] **Step 3: Implement `RenderBoard` in `internal/render/board.go`** + +Build a `[VisibleRows][Width]` scratch grid of `{glyph, style}`, painted in §37's order: locked cells, then ghost (only into empty cells), then active piece. Then wrap in the border using `Glyphs` border runes styled with `BorderColor(mode, v.BorderEnergy)`. `ShakeX/ShakeY` shift the *contents* inside the fixed-size border. Must not mutate `v.Game`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/board.go internal/render/board_test.go +git commit -m "feat(render): board panel with ghost, active piece, and shake offset" +``` + +--- + +### Task 14: HUD panels + +**Files:** +- Create: `internal/render/hud.go` +- Test: `internal/render/hud_test.go` + +**Interfaces:** +- Consumes: `game.Game`, `Layout`, `Glyphs`. +- Produces: + - `func RenderHold(g *game.Game, m Mode, width int) string` + - `func RenderNext(g *game.Game, m Mode, count, width int) string` + - `func RenderStats(g *game.Game, l Layout) string` + - `func RenderMissionControl(text string, width int) string` + - `func RenderControls(l Layout) string` + - `func RenderTitle(seed int64, width int) string` + +- [ ] **Step 1: Write the failing tests in `internal/render/hud_test.go`** + +- `TestHoldEmpty`: `RenderHold` with `Hold == nil` -> contains the label `HOLD` and no block glyph +- `TestHoldShowsPiece`: after a hold, the held kind's mini-shape appears +- `TestNextShowsRequestedCount`: `RenderNext(g, ModeFull, 5, 12)` shows 5 distinct mini-pieces; `count == 3` shows 3 +- `TestNextCountClampedToQueue`: `count == 99` does not panic and shows at most `len(g.Next)` +- `TestStatsWithLabels`: `Layout{ShowStatLabels: true}` -> output contains `SCORE`, `LINES`, `LEVEL` +- `TestStatsWithoutLabels`: `ShowStatLabels: false` -> output contains the values but none of those three words (§49.3: `"042"` not `"LINES 042"`) +- `TestStatsZeroPadding`: `Score == 129340` renders as `00129340`; `Lines == 42` renders as `042`; `Level == 7` renders as `07` (§4's mockup) +- `TestStatsLargeScoreDoesNotTruncate`: `Score == 999999999` renders all 9 digits +- `TestMissionControlPrefix`: `RenderMissionControl("NOMINALISH", 60)` contains `MISSION CONTROL: NOMINALISH` +- `TestMissionControlTruncates`: a 200-character message rendered at `width == 40` -> `lipgloss.Width` of the stripped result is exactly 40, no wrap to a second line +- `TestMissionControlEmpty`: `RenderMissionControl("", 40)` -> a single blank line of width 40, not the prefix alone +- `TestControlsFitWidth`: for `Compute(w,30)` at `w` in `{40,50,60,80,120}`, `RenderControls` stripped width `<= w` and is a single line +- `TestControlsSmallIsAbbreviated`: at `KindSmall`, controls are shorter than at `KindWide` +- `TestTitleShowsSeedHex`: `RenderTitle(0x7F3A, 66)` contains `COSMIC TETRIS` and `7F3A` (§4's `LOCAL UNIVERSE 7F3A` is the seed in hex) + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/render/ -run 'TestHold|TestNext|TestStats|TestMission|TestControls|TestTitle' -v` +Expected: FAIL, `undefined: RenderHold`. + +- [ ] **Step 3: Implement `internal/render/hud.go`** + +Mini-piece rendering reuses `game.Piece{kind, 0, 0, 0}.Cells()` into a 4×2 glyph block. Control hints, exact copy: wide is `←→ move ↑ rotate ↓ descend SPACE YEET C hold ? help`; small drops to `←→ ↑ ↓ SPACE c p ?`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/hud.go internal/render/hud_test.go +git commit -m "feat(render): HOLD, NEXT, stats, mission-control, and control hints" +``` + +--- + +### Task 15: Scene composition and golden tests + +**Files:** +- Create: `internal/render/render.go`, `internal/render/overlay.go` +- Test: `internal/render/render_test.go`, `internal/render/testdata/*.golden` + +**Interfaces:** +- Consumes: Tasks 11–14. +- Produces: + - `type Overlay uint8` with `OverlayNone, OverlayPause, OverlayHelp, OverlayGameOver, OverlayBoot` + - `type Scene struct { Game *game.Game; Mode Mode; Overlay Overlay; Width, Height int; MissionControl string; FX FXView }` + - `type FXView interface { Stars() []StarCell; Particles() []ParticleCell; ShakeOffset() (int, int); BorderEnergy() float64; BorderPhase() float64; Banner() (BannerView, bool) }` + - the cell and view types this interface traffics in, all owned by `render` so that `fx` depends on `render` and never the reverse: `type StarCell struct { X, Y int; Glyph rune; Dim int }`, `type ParticleCell struct { X, Y int; Glyph rune; Bright int }`, `type BannerView struct { Kind uint8; Title, Subtitle string; Age, Life time.Duration }`. + - Plan 1 always passes a nil `FX`; Plan 2 implements this interface on `*fx.World`. Every use site must be nil-guarded. Plans 2 and 3 each **add** methods to `FXView` (trails, supernovae, shockwaves, collapse) and fields to `BoardView`; they never change the ones declared here. + - `func Render(s Scene) string` + - `func RenderTooSmall(width, height int) string` + - `func RenderPause(width, height int) string`, `func RenderHelp(width, height int) string`, `func RenderGameOver(g *game.Game, width, height int) string` + +- [ ] **Step 1: Write the failing tests in `internal/render/render_test.go`** + +Add a `-update` flag helper that rewrites goldens, and a `scene(w, h int, opts...) Scene` helper that builds a deterministic game: `game.New(0x7F3A)`, then a fixed script of moves and hard drops so the board is non-empty and identical every run. + +- `TestGoldenWide`: `Render(scene(80,30))`, ANSI-stripped, matches `testdata/wide.golden` +- `TestGoldenMedium`: `50×26` matches `testdata/medium.golden` +- `TestGoldenSmall`: `40×24` matches `testdata/small.golden` +- `TestGoldenTooSmall`: `34×19` matches `testdata/toosmall.golden` and contains `THIS UNIVERSE IS TOO SMALL`, `current: 34 × 19`, `needed: approximately 40 × 24` +- `TestGoldenPause`: `Overlay: OverlayPause` matches `testdata/pause.golden` and contains `TEMPORAL SUSPENSION` and `SPACE IS PAUSED` +- `TestGoldenHelp`: `Overlay: OverlayHelp` matches `testdata/help.golden` and contains `FLIGHT MANUAL` +- `TestGoldenGameOver`: `Overlay: OverlayGameOver` matches `testdata/gameover.golden` and contains `UNIVERSE EXPIRED`, `r REBOOT UNIVERSE`, `q ACCEPT COSMIC DEATH` +- `TestGoldenASCII`: `Mode: ModeASCII` at `80×30` matches `testdata/ascii.golden`, all runes `< 128` except the `·` ghost +- `TestRenderNeverExceedsBounds`: for every size in `40..120 × 24..50` (step 7), every line's `lipgloss.Width <= Width` and the line count is `<= Height` +- `TestRenderNeverPanicsOnAnySize`: for `w` in `0..120` and `h` in `0..50` (step 3), `Render` returns without panic — **Review Focus 1** +- `TestRenderDoesNotMutateGame`: snapshot `*Scene.Game` (deep-compare `Board.Cells`, `Active`, `Score`, `Next`) before and after `Render` -> unchanged (§37) +- `TestRenderWithNilFX`: `Scene{FX: nil}` renders normally (the Plan 1 path) +- `TestBoardIntactUnderOverlays`: with each overlay, the board's 22 rows still appear at their layout position for the rows the overlay does not cover; the overlay must not corrupt board width (§41) + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/render/ -run TestGolden -v` +Expected: FAIL, `undefined: Render`. + +- [ ] **Step 3: Implement `internal/render/render.go` and `internal/render/overlay.go`** + +`Render` follows §37's order, skipping steps 2/6/9/10 while `FX` is nil: compute layout (bail to `RenderTooSmall` on `KindTooSmall`), render board, join HUD columns with `lipgloss.JoinHorizontal`, stack title/body/mission-control/controls with `lipgloss.JoinVertical`, then `lipgloss.Place` any overlay on top. Overlay panel copy comes verbatim from §28, §30, §31, §39. + +- [ ] **Step 4: Generate the goldens, read them, then verify** + +```bash +go test ./internal/render/ -run TestGolden -update +git diff --stat internal/render/testdata +go test ./internal/render/ -v +``` + +Open each golden and confirm by eye: nothing overlaps, the board is 22×22, the HUD does not intrude into the border (§41). Fix the renderer, not the golden, if it looks wrong. + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/render.go internal/render/overlay.go internal/render/render_test.go internal/render/testdata +git commit -m "feat(render): scene composition, overlays, and golden layout tests" +``` + +--- + +### Task 16: Bubble Tea model, keys, and the frame clock + +**Files:** +- Create: `internal/app/keys.go`, `internal/app/messages.go`, `internal/app/model.go`, `internal/app/update.go` +- Test: `internal/app/update_test.go` + +**Interfaces:** +- Consumes: `game`, `render`. +- Produces: + - `type AppState uint8` with `StatePlaying, StatePaused, StateGameOver` (Plan 3 adds `StateBoot`) + - `type Options struct { Seed int64; ASCII, NoFX, ReducedMotion bool }` + - `type Model struct { Game *game.Game; Opts Options; Mode render.Mode; Width, Height int; State AppState; ShowHelp bool; MissionControl string; LastFrame time.Time; Keys KeyMap }` + - `func New(opts Options) Model`; `func (m Model) Init() (tea.Model, tea.Cmd)`; `func (m Model) Update(tea.Msg) (tea.Model, tea.Cmd)`; `func (m Model) View() string` + - `type FrameMsg struct { Now time.Time }`; `func frameCmd() tea.Cmd` at `FrameInterval = 16 * time.Millisecond` (~60Hz) + - `type KeyMap struct { Left, Right, SoftDrop, RotateCW, RotateCCW, HardDrop, Hold, Pause, Restart, Help, Quit key.Binding }`; `func DefaultKeyMap() KeyMap`; `func (k KeyMap) ShortHelp() []key.Binding`; `func (k KeyMap) FullHelp() [][]key.Binding` + + §36 lists a separate `GravityMsg`. This plan deliberately defines only `FrameMsg` and derives gravity from accumulated `dt`, which is what §36's own prose asks for ("prefer one animation clock … rather than spawning multiple timing loops") and what §49.2 requires. + +- [ ] **Step 1: Write the failing tests in `internal/app/update_test.go`** + +Drive `Update` directly with messages; no terminal needed. + +- `TestKeyBindings`: `DefaultKeyMap()` matches §8 exactly — left: `left,h,a`; right: `right,l,d`; soft drop: `down,j,s`; rotate CW: `up,k,x,w`; rotate CCW: `z`; hard drop: `space`; hold: `c`; pause: `p`; restart: `r`; help: `?`; quit: `q,esc` +- `TestFrameMsgAdvancesGame`: send `FrameMsg` with a `Now` 800ms after `LastFrame` -> the active piece dropped one row +- `TestFrameMsgSchedulesNextFrame`: `Update(FrameMsg{})` returns a non-nil `tea.Cmd` +- `TestFirstFrameDoesNotJumpTheGame`: `New` leaves `LastFrame` zero; the first `FrameMsg` must advance the game by at most one gravity step — **guards against a multi-year `dt` on startup** +- `TestKeyMovesImmediately`: a left-key `tea.KeyPressMsg` changes `Active.X` in the same `Update`, with no `FrameMsg` in between (§8: input must not wait for ticks) +- `TestHeldKeyRepeats`: three consecutive left-key messages move the piece three columns (§8: repeated movement) +- `TestWindowSizeMsgStored`: `tea.WindowSizeMsg{Width:100,Height:40}` -> `Width == 100`, `Height == 40` +- `TestResizeToTinyDoesNotPanic`: `WindowSizeMsg{0,0}` then `View()` -> the too-small notice, no panic — **Review Focus 1** +- `TestResizeSequenceNeverPanics`: replay 50 random sizes in `0..200 × 0..80` through `Update`, calling `View()` after each -> no panic (§31: never crash from resizing) +- `TestQuitReturnsQuitCmd`: `q` -> the returned cmd is `tea.Quit` +- `TestViewMatchesRenderer`: `View()` equals `render.Render` on the model's scene (assert equality against a directly built `Scene`, so the mapping stays honest) + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/app/ -v` +Expected: build failure, `undefined: New`. + +- [ ] **Step 3: Implement the four `internal/app` files** + +`Update` handles `FrameMsg` by computing `dt := msg.Now.Sub(m.LastFrame)`, clamping to `[0, 250ms]` when `LastFrame` is zero or the gap is implausible, calling `m.Game.Advance(dt)`, storing `LastFrame`, and returning `frameCmd()`. Key handling switches on `key.Matches` and calls the matching `game` mutator. Consume returned `[]game.Event` and discard them for now — Plan 2 routes them to `fx` and `flavor`; leave a single `m.handleEvents(events)` seam so Plan 2 has one place to edit. + +Bubble Tea v2 note: `Init` returns `(tea.Model, tea.Cmd)` and key presses arrive as `tea.KeyPressMsg`. If the compiler disagrees, follow the installed version's API rather than this line. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/app/ -v && go test ./... ` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/app +git commit -m "feat(app): Bubble Tea model, key map, and single 60Hz frame clock" +``` + +--- + +### Task 17: Pause, help, restart, and game-over state gating + +**Files:** +- Modify: `internal/app/update.go`, `internal/app/model.go` +- Test: `internal/app/state_test.go` + +**Interfaces:** +- Consumes: Task 16. +- Produces: `func (m *Model) Restart()`; `func (m Model) overlay() render.Overlay`. + +- [ ] **Step 1: Write the failing tests in `internal/app/state_test.go`** + +- `TestPauseTogglesState`: `p` -> `StatePaused`; `p` again -> `StatePlaying` +- `TestPausedFrameDoesNotAdvanceGame`: while paused, a `FrameMsg` with 5s of `dt` leaves `Active.Y` and `Score` unchanged (§30) +- `TestPausedStillSchedulesFrames`: while paused, `Update(FrameMsg{})` still returns a non-nil cmd (§30: background stars keep drifting) +- `TestPausedIgnoresGameplayKeys`: while paused, left/right/rotate/hard-drop/hold change nothing — **Review Focus 5** +- `TestPausedAcceptsPauseRestartQuitHelp`: while paused, `p`, `r`, `q`, `?` all still work +- `TestHelpTogglesWithoutPausing`: `?` -> `ShowHelp == true` and `State` stays `StatePlaying`; `?` again -> false +- `TestHelpOverlayWins`: with `ShowHelp` true and `State == StatePaused`, `overlay()` -> `OverlayHelp` +- `TestGameOverStateEntered`: force game over through `Advance` -> `State == StateGameOver`, `overlay()` -> `OverlayGameOver` +- `TestGameOverIgnoresGameplayKeys`: after game over, gameplay keys change nothing; `Score` frozen — **Review Focus 5** +- `TestRestartResetsEverything`: after a game with score and locked cells, `r` -> `Score == 0`, `Lines == 0`, `Level == 1`, empty board, `State == StatePlaying` +- `TestRestartReusesSeed`: `r` produces the same first piece sequence as the original run (§34: `Seed` is kept for restart) +- `TestRestartFromGameOver`: `r` after game over -> `StatePlaying` and a playable game + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/app/ -run 'TestPause|TestHelp|TestGameOver|TestRestart' -v` +Expected: FAIL. + +- [ ] **Step 3: Implement state gating in `internal/app`** + +Route every gameplay key through a single guard: gameplay input applies only when `State == StatePlaying`. `Restart` rebuilds `m.Game = game.New(m.Opts.Seed)` and resets `State`, `ShowHelp`, `MissionControl`, `LastFrame`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./... -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/app +git commit -m "feat(app): pause, help, restart, and game-over input gating" +``` + +--- + +### Task 18: CLI entrypoint and README + +**Files:** +- Create: `cmd/cosmic-tetris/main.go`, `README.md`, `LICENSE` +- Test: `cmd/cosmic-tetris/main_test.go` + +**Interfaces:** +- Consumes: `app.Options`, `app.New`. +- Produces: `func parseArgs(args []string, stderr io.Writer) (app.Options, error)` — separated from `main` so it is testable; `main` calls it, then `tea.NewProgram(app.New(opts), tea.WithAltScreen())`. + +- [ ] **Step 1: Write the failing tests in `cmd/cosmic-tetris/main_test.go`** + +- `TestParseDefaults`: `parseArgs(nil, io.Discard)` -> no error, `ASCII == false`, `NoFX == false`, `ReducedMotion == false`, and `Seed != 0` (a random seed when unspecified) +- `TestParseSeed`: `--seed 1234` -> `Seed == 1234` +- `TestParseSeedZeroIsValid`: `--seed 0` -> `Seed == 0`, no error — **Review Focus 4** +- `TestParseSeedNegative`: `--seed -9` -> `Seed == -9`, no error +- `TestParseBadSeed`: `--seed abc` -> a non-nil error; no panic — **Review Focus 4** +- `TestParseFlags`: `--ascii`, `--no-fx`, `--reduced-motion` each set their field; all three together set all three +- `TestParseUnknownFlag`: `--warp-drive` -> non-nil error +- `TestParseHelpText`: `--help` returns `flag.ErrHelp` and the written usage lists exactly the five flags of §49.5 (`--seed`, `--ascii`, `--no-fx`, `--reduced-motion`, `--help`) + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./cmd/... -v` +Expected: FAIL, `undefined: parseArgs`. + +- [ ] **Step 3: Implement `cmd/cosmic-tetris/main.go`** + +Use `flag.NewFlagSet` with `flag.ContinueOnError` and its output set to the passed `stderr`. Default seed: `time.Now().UnixNano()` — this is the one clock read in the program outside Bubble Tea, and it is outside `internal/game`. `main` exits 2 on a parse error, 0 on `--help`. + +`--no-fx` and `--reduced-motion` parse and are stored in `Options` but have no effect until Plan 2; note that in the README so the flag surface is stable from the first release (§49.5). + +- [ ] **Step 4: Write `README.md` and `LICENSE`, then verify the binary runs** + +README: one-paragraph pitch, build/run instructions, the five-flag CLI surface, the controls table from §8, the ASCII-ghost deviation noted in Task 11, and a "what's not built yet" line pointing at Plans 2 and 3. `LICENSE`: MIT, copyright Jesse Vincent. + +```bash +go build ./... && go vet ./... && gofmt -l . && go test ./... -count=1 +go run ./cmd/cosmic-tetris --seed 1 --help +``` + +Expected: build clean, `gofmt -l` prints nothing, all tests pass, `--help` prints the five flags. Then play it for a minute: `go run ./cmd/cosmic-tetris --seed 8675309`. It should already be a good game (§42 Phase 2). + +- [ ] **Step 5: Commit** + +```bash +git add cmd README.md LICENSE +git commit -m "feat(cli): flag parsing, entrypoint, and README" +``` + +--- + +## Done when + +- `go test ./... -count=2` passes; `go vet ./...` and `gofmt -l .` are clean. +- `go run ./cmd/cosmic-tetris` is playable start to game over with working move, rotate, soft/hard drop, hold, ghost, next queue, pause, help, restart, resize. +- `internal/game` contains no `time.Now()` and no `fx` import. +- Golden tests cover wide, medium, small, too-small, pause, help, game-over, and ASCII layouts. +- `render.Scene.FX` and `app.Model.handleEvents` exist as the seams Plan 2 fills. diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/T1-test-lines-cosmic-63/2026-09-18-cosmic-tetris-02-cosmic-effects.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/T1-test-lines-cosmic-63/2026-09-18-cosmic-tetris-02-cosmic-effects.md new file mode 100644 index 000000000..d27065983 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/T1-test-lines-cosmic-63/2026-09-18-cosmic-tetris-02-cosmic-effects.md @@ -0,0 +1,746 @@ +# Cosmic Tetris, Plan 2: Cosmic Effects — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add the independent FX simulation — starfield, animated border, piece trails, mission-control commentary, particle physics, hard-drop impact, supernova line clears, shockwaves, hyperdrive, and the four-line spectacle — so the terminal is visibly losing its mind without gameplay ever noticing. + +**Architecture:** `internal/fx` is a second simulation with its own RNG and its own `Advance(dt)`. It **observes** `[]game.Event` and **never** touches `game` state — the dependency is `fx → game` for the event type only, and the compiler enforces the rest because `fx` receives events by value and a read-only snapshot. `internal/flavor` owns mission-control copy and its cooldown. The renderer already accepts an `FXView` interface (Plan 1, Task 15); this plan implements it on `*fx.World` and turns on §37's steps 2, 6, 9, and 10. + +**Tech Stack:** Go 1.26, `charm.land/lipgloss/v2` for color. No new dependencies. + +**Spec:** `design.md` (this repo root). Sections 14–27, 42 Phases 3–4, 44, 49.5. + +**Prerequisite:** Plan 1 (`plans/2026-09-18-cosmic-tetris-01-engine-and-terminal.md`) complete. This plan consumes `render.FXView`, `render.StarCell`, `render.ParticleCell`, `app.Model.handleEvents`, and `app.Options.NoFX/ReducedMotion` from it. + +## Global Constraints + +- `fx` may observe game events. It may **never** modify `GameState` (§14). No method on `*fx.World` takes a `*game.Game`. +- `fx.World` holds its own `*rand.Rand`, seeded independently of the game RNG. The two never share (§49.6). Crossing them makes piece order depend on particle counts. +- Gameplay never waits for an animation, and animations never block input (§44). +- Screen shake never exceeds roughly one terminal cell (§18, §44). +- Particles never permanently alter the rendered board; effects never obscure the active piece (§44). +- No goroutine per particle or per frame; reuse slices; a few hundred particles must be trivial (§38). +- `--no-fx` must still be a good game; `--reduced-motion` suppresses screen shake, hyperdrive acceleration, and shockwaves while leaving color, trails, and particles alone (§49.5). +- FX glyph choices must have a mode-appropriate fallback: the ASCII set is verified in Plan 3. +- Board readability is sacred. Never make the background so busy the board is harder to read (§15, §21). +- Every task ends with tests passing and a commit. `go vet ./...` and `gofmt -l .` clean before each commit. + +## Review Focus + +1. **A huge `dt` reaching `fx.Advance`.** Laptop sleep means one call with 30s of elapsed time. Star wrapping must not loop per-row, particle lifetimes must not go so negative they wrap, and timers must not fire hundreds of times. → Task 1. +2. **A viewport smaller than the effects assume.** At 40×24 the FX area is tiny; particle positions rounded to cells must be culled, never written at a negative index or past the right edge. → Task 5. +3. **Several major events in one placement.** A four-line clear that also levels up and also sets a high score fires hyperdrive, banner, level notification, and shockwave at once. Banners must not stack over each other or over the board. → Task 10. +4. **Unbounded particle growth.** Sustained combos plus hard drops emit continuously; the pool must be capped and oldest-dropped rather than grown without limit. → Task 5. +5. **`--no-fx` leaving a nil `FXView`.** Every renderer FX call site must be nil-guarded, and `--no-fx` must not merely produce an empty world that still costs per-frame work. → Task 12. + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `internal/fx/world.go` | `World`, `Config`, `Advance`, the `render.FXView` implementation, border/shake timers | +| `internal/fx/starfield.go` | Three-layer star simulation and hyperdrive state | +| `internal/fx/particle.go` | `Particle`, the pool, integration step, emitters, shockwaves | +| `internal/fx/trail.go` | Ion trails and the quantum-storage hold streak | +| `internal/fx/supernova.go` | Three-phase line-clear animation | +| `internal/fx/events.go` | `Handle`: `game.Event` → FX reaction mapping; the reduced-motion policy | +| `internal/fx/banner.go` | Banner and notification queue with lifetimes | +| `internal/flavor/messages.go` | Mission-control copy tables | +| `internal/flavor/channel.go` | Cooldown/priority logic for the status line | +| `internal/render/board.go` (modify) | Composite board-local FX: trails, supernova, ion trails | +| `internal/render/render.go` (modify) | §37 steps 2, 6, 9, 10 | +| `internal/app/update.go` (modify) | Route events into `fx` and `flavor`; advance the FX clock | + +`internal/fx/banner.go` and `internal/flavor/channel.go` are two files beyond §33's listing. Both are separate responsibilities with their own lifetimes; folding them into `world.go`/`messages.go` would make those files hard to hold in context. + +--- + +### Task 1: FX world skeleton and the starfield + +**Files:** +- Create: `internal/fx/world.go`, `internal/fx/starfield.go` +- Test: `internal/fx/world_test.go`, `internal/fx/starfield_test.go` + +**Interfaces:** +- Consumes: `game.Event` (Plan 1 Task 5), `render.StarCell` (Plan 1 Task 15). +- Produces: + - `type Config struct { Seed int64; Width, Height int; ReducedMotion bool }` + - `type World struct { ... }`; `func NewWorld(cfg Config) *World` + - `func (w *World) Resize(width, height int)` + - `func (w *World) Advance(dt time.Duration)` + - `func (w *World) SetLevel(level int)` — the only game value the world tracks continuously; star velocity scales with it (§15) + - `func (w *World) Stars() []render.StarCell` + - `type Layer uint8` with `LayerFar, LayerMid, LayerNear` + - `const MaxDT = 100 * time.Millisecond` — every `Advance` clamps `dt` to this + +- [ ] **Step 1: Write the failing tests in `internal/fx/starfield_test.go` and `internal/fx/world_test.go`** + +- `TestNewWorldPopulatesStars`: `NewWorld(Config{Seed:1,Width:80,Height:30})` -> `len(Stars()) > 0`, and all cells satisfy `0 <= X < 80`, `0 <= Y < 30` +- `TestThreeDepthLayers`: the internal star slice contains stars of all three `Layer` values (§15) +- `TestLayerGlyphs`: far-layer glyphs are drawn only from `{'.'}`, mid from `{'·','˚'}`, near from `{'✦','✧','*'}` (§15) +- `TestFarLayerDimmerThanNear`: every far star's `Dim` value is greater than every near star's (higher `Dim` = dimmer) +- `TestStarsDriftDownward`: record one star's `Y`, `Advance(500*time.Millisecond)` -> its `Y` is greater or it wrapped to the top +- `TestNearLayerMovesFasterThanFar`: after `Advance(500ms)`, total near-layer displacement exceeds total far-layer displacement +- `TestStarsWrapAtBottom`: after `Advance(30*time.Second)` in 100ms slices, `len(Stars())` is unchanged and every star is still in bounds (§15: the field is conserved) +- `TestLevelIncreasesStarVelocity`: displacement over 1s at `SetLevel(15)` is greater than at `SetLevel(1)`, but less than 4× it (§15: *subtly* increases) +- `TestHugeDTIsClamped`: `Advance(60*time.Second)` returns promptly, all stars remain in bounds, and the resulting displacement equals that of `Advance(MaxDT)` — **Review Focus 1** +- `TestZeroAndNegativeDT`: `Advance(0)` and `Advance(-1*time.Second)` change nothing +- `TestResizeKeepsStarsInBounds`: `Resize(40,24)` after building at `80×30` -> every star is in the new bounds +- `TestResizeToZero`: `Resize(0,0)` then `Advance` and `Stars()` -> empty or in-bounds, no panic +- `TestFXRNGIsIndependent`: two worlds with the same `Seed` produce identical star layouts; a world seeded `1` and one seeded `2` differ +- `TestWorldNeverTouchesGame`: parse `internal/fx/*.go` (non-test) with `go/parser` and assert no `*game.Game` type appears in any signature — **§14 made mechanical** + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ -v` +Expected: build failure, `undefined: NewWorld`. + +- [ ] **Step 3: Implement `internal/fx/world.go` and `internal/fx/starfield.go`** + +Stars hold `float64` positions and a per-layer base velocity in cells/second; pin them as `LayerFar: 1.2`, `LayerMid: 3.0`, `LayerNear: 7.0`, scaled by `1 + 0.04*(level-1)` and capped at `2.5×`. Star count scales with area: `area/45`, clamped to `[0, 400]`. Wrapping is arithmetic (`y = math.Mod(y, height)`), not a loop — that is what makes Review Focus 1 cheap. `Stars()` reuses a preallocated slice. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/fx/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx +git commit -m "feat(fx): FX world skeleton and three-layer starfield" +``` + +--- + +### Task 2: Wire FX into the app and render the starfield behind the board + +**Files:** +- Modify: `internal/app/model.go`, `internal/app/update.go`, `internal/render/render.go` +- Test: `internal/app/fx_test.go`, `internal/render/fx_test.go` + +**Interfaces:** +- Consumes: `fx.NewWorld`, `fx.World.Advance/Resize/SetLevel/Stars`, `render.Scene.FX`. +- Produces: `Model.FX *fx.World` (nil when `Opts.NoFX`); `render.Render` now paints §37 step 2. + +- [ ] **Step 1: Write the failing tests** + +In `internal/app/fx_test.go`: +- `TestModelCreatesFXWorld`: `New(Options{})` -> `FX != nil` +- `TestNoFXLeavesWorldNil`: `New(Options{NoFX:true})` -> `FX == nil` +- `TestFXSeedDiffersFromGameSeed`: with `Options{Seed: 5}`, the FX world's config seed is not `5` (§49.6: independent generators) +- `TestFrameAdvancesFXWithSameDT`: a `FrameMsg` advances both game and FX; with `NoFX` it advances only the game and does not panic +- `TestFXTracksLevel`: after enough clears to reach level 2, the FX world's level is 2 +- `TestResizeResizesFXWorld`: `WindowSizeMsg{100,40}` -> the FX world reports the new bounds +- `TestPausedFreezesGameplayFXButNotStars`: while paused, a `FrameMsg` still moves stars but leaves particle and timer state untouched (§30) + +In `internal/render/fx_test.go`: +- `TestStarsRenderedBehindBoard`: with a stub `FXView` returning one star at a coordinate the board occupies, the board glyph wins at that cell (§44: never obscure the piece) +- `TestStarsRenderedOutsideBoard`: a star outside the board panel appears in the output +- `TestStarsOutOfRangeIgnored`: stub stars at `(-5,-5)`, `(9999,9999)` -> no panic, not rendered +- `TestNilFXStillRenders`: `Scene{FX:nil}` output equals the Plan 1 golden for the same scene + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/app/ ./internal/render/ -run 'TestFX|TestStars|TestNoFX|TestNil|TestPaused|TestModel|TestFrameAdvances' -v` +Expected: FAIL. + +- [ ] **Step 3: Implement the wiring** + +`app.New` seeds the FX world with a value derived from but not equal to the game seed — pin it as `opts.Seed ^ 0x5DEECE66D`. The starfield is painted into a background canvas that the board and HUD panels are then composited over, so the board always wins a contested cell. + +- [ ] **Step 4: Regenerate goldens and verify** + +```bash +go test ./internal/render/ -run TestGolden -update && go test ./... -v +``` + +Goldens change only where stars now appear outside the board. Inspect the diff before committing; the board region must be byte-identical to Plan 1. + +- [ ] **Step 5: Commit** + +```bash +git add internal/app internal/render +git commit -m "feat(fx): wire the FX world into the frame loop and render the starfield" +``` + +--- + +### Task 3: Animated board border + +**Files:** +- Modify: `internal/fx/world.go`, `internal/render/palette.go`, `internal/render/board.go` +- Test: `internal/fx/border_test.go`, `internal/render/border_test.go` + +**Interfaces:** +- Consumes: `fx.World`, `render.BorderColor`. +- Produces: `func (w *World) BorderEnergy() float64` in `[0,1]`; `func (w *World) FlashBorder(strength float64, d time.Duration)`; `func (w *World) BorderPhase() float64` in `[0,1)` — the slow hue cycle position. + +- [ ] **Step 1: Write the failing tests** + +- `TestBorderPhaseCyclesSlowly`: `BorderPhase()` after `Advance(1*time.Second)` moved by less than `0.1` (§25: the shift is subtle) +- `TestBorderPhaseWraps`: after 60s of 100ms advances, `BorderPhase()` is still in `[0,1)` +- `TestBorderEnergyRestsLow`: a fresh world -> `BorderEnergy() < 0.2` +- `TestFlashRaisesEnergy`: `FlashBorder(1.0, 200*time.Millisecond)` -> `BorderEnergy() > 0.8` +- `TestFlashDecays`: after `Advance(200ms)`, energy is back below `0.2` +- `TestFlashClamped`: `FlashBorder(50, time.Second)` -> `BorderEnergy() <= 1.0` +- `TestBorderColorVariesWithPhase`: `BorderColor(ModeFull, e)` differs for `e` of 0, 0.5, 1.0, and every returned value parses as a valid color +- `TestBorderPaletteMembers`: the full-mode border ramp contains the five §25 colors — deep violet, electric cyan, magenta, stellar blue, hot white +- `TestBorderInReducedMode`: `BorderColor(ModeReduced, 0.5)` returns an ANSI-256 color, not a hex truecolor value +- `TestBorderInASCIIModeStillColored`: ASCII mode changes glyphs, not colors (§32: ASCII means glyphs, limited colors) +- `TestBoardBorderWidthUnchangedByEnergy`: `RenderBoard` at energy 0 and 1 -> identical stripped output (color-only change; layout must not move) + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ ./internal/render/ -run TestBorder -v` +Expected: FAIL, `undefined: BorderEnergy`. + +- [ ] **Step 3: Implement** + +`BorderPhase` advances at `0.05` per second. `BorderEnergy` is `max(restEnergy, flashRemaining/flashDuration * flashStrength)` with `restEnergy = 0.1`. `BorderColor(mode, energy)` interpolates the §25 ramp by `phase` at rest and pushes toward hot white as energy rises; pass `w.BorderPhase()` into `BoardView.BorderEnergy`'s sibling field — add `BorderPhase float64` to `BoardView` rather than overloading energy. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./... -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx internal/render +git commit -m "feat(fx): animated board border as the game's energy indicator" +``` + +--- + +### Task 4: Piece trails and quantum storage + +**Files:** +- Create: `internal/fx/trail.go` +- Modify: `internal/fx/events.go` (create), `internal/render/board.go` +- Test: `internal/fx/trail_test.go`, `internal/fx/hold_test.go` + +**Interfaces:** +- Consumes: `game.Event`, `game.Cell`. +- Produces: + - `type Trail struct { Cells []game.Cell; Age, Life time.Duration; Kind game.PieceKind; Vertical bool; Streak bool }` — `Streak` marks the §9 sideways hold smear + - `func (w *World) Handle(events []game.Event)` — the single entry point for all event reactions; this task implements the `PieceMoved`, `PieceRotated`, `PieceHardDropped`, and `HoldUsed` branches + - `func (w *World) Trails() []render.TrailCell` + - `func (w *World) HoldFlash() float64` in `[0,1]` — the incoming piece's flash-into-existence brightness (§9) + - `const TrailLife = 140 * time.Millisecond` (§17: ~100–160ms) + - `const HoldStorageLife = 120 * time.Millisecond` (§9, exact) + +- [ ] **Step 1: Write the failing tests in `internal/fx/trail_test.go`** + +- `TestMoveSpawnsTrail`: `Handle([]game.Event{{Kind: game.PieceMoved, Piece: p}})` -> `len(Trails()) == 1` with `Cells` equal to `p.Cells()` +- `TestRotateSpawnsTrail`: a `PieceRotated` event also spawns one +- `TestTrailExpires`: after `Advance(TrailLife)` in 20ms slices, `Trails()` is empty +- `TestTrailShortLived`: at `Advance(50ms)` the trail is still present (§17: it must be visible for a frame or two) +- `TestTrailGlyphFades`: the `render.TrailCell.Glyph` values progress `'█'`, `'▓'`, `'▒'`, `'░'` as age crosses 1/4, 1/2, 3/4 of `Life` (§17) +- `TestHardDropTrailIsVertical` : a `PieceHardDropped` event with `Cells: 8` -> a trail with `Vertical: true` covering the crossed rows (§17: stronger vertical trail) +- `TestTrailsCapped`: 500 `PieceMoved` events in one `Handle` -> `len(Trails())` cells come from at most 32 live trails +- `TestTrailsFrozenAtZeroDT`: `Advance(0)` does not age trails +- `TestTrailsAreFXOnly`: `Handle` takes only `[]game.Event`; assert via the Task 1 parser test that no `*game.Game` appears (§17: trails are FX only) + +In `internal/fx/hold_test.go` — the §9 QUANTUM STORAGE effect: + +- `TestHoldUsedSpawnsStreak`: `Handle([]game.Event{{Kind: game.HoldUsed, Piece: p}})` -> a trail with `Streak: true` whose cells extend sideways beyond `p.Cells()` (§9: compressed → streaked sideways → disappear) +- `TestHoldStreakCompressesThenWidens`: at 30ms the streak's cell span is narrower than `p.Cells()`' span (compression), and at 80ms it is wider (the sideways streak) +- `TestHoldStreakLifeIs120ms`: present at 100ms, gone at 140ms (§9, exact) +- `TestHoldFlashPeaksEarly`: `HoldFlash()` is near `1.0` immediately after the event and back to `0` by `HoldStorageLife` (§9: the incoming piece briefly flashes into existence) +- `TestHoldFlashZeroWhenIdle`: a fresh world -> `HoldFlash() == 0` +- `TestHoldDoesNotDelayGameplay`: `Handle` returns nothing and takes no game pointer, so the swap has already happened in the engine (§9: gameplay does not wait for the animation) +- `TestRepeatedHoldRestartsTheEffect`: a second `HoldUsed` 200ms later restarts the streak rather than stacking two + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ -run 'TestTrail|TestMoveSpawns|TestHold' -v` +Expected: FAIL, `undefined: Trails`. + +- [ ] **Step 3: Implement trails and the `Handle` skeleton** + +`Handle` is a `switch e.Kind` with one case per §14 event; add the remaining cases as later tasks land. + +Do not hand `fx.Trail` values to the renderer directly — `render` must never import `fx`, or the two packages form a cycle. Instead extend the `render.FXView` interface with `Trails() []render.TrailCell` where `type TrailCell struct { X, Y int; Glyph rune; Kind uint8; Fade int }`, add a matching `Trails []TrailCell` field to `render.BoardView`, and have `fx.World` flatten its trails into that at read time. Every later effect in this plan follows the same pattern: `fx` converts to `render`'s cell types, `render` gains an `FXView` method plus a `BoardView` field, and `render` never learns that `fx` exists. + +Board compositing order: trails paint into empty cells only, never over locked cells or the active piece (§44). The hold streak paints into the HOLD panel and the columns between it and the board, not into the board interior. + +- [ ] **Step 4: Run tests to verify they pass, then look at it** + +```bash +go test ./... -v && go run ./cmd/cosmic-tetris --seed 8675309 +``` + +Move a piece: a faint ion trail follows it. Press `c`: the outgoing piece compresses, streaks sideways, and vanishes while the incoming one flashes in — and the game never stalls waiting for it (§9). + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx internal/render +git commit -m "feat(fx): ion trails and quantum-storage hold animation" +``` + +--- + +### Task 5: Particle system + +**Files:** +- Create: `internal/fx/particle.go` +- Modify: `internal/fx/world.go` +- Test: `internal/fx/particle_test.go` + +**Interfaces:** +- Consumes: `fx.World`, `render.ParticleCell`. +- Produces: + - `type Particle struct { X, Y, VX, VY, Life, MaxLife float64; Glyph rune; Brightness float64 }` (§23, verbatim) + - `func (w *World) Emit(p Particle)` + - `func (w *World) EmitBurst(x, y float64, count int, spread, speed float64, glyphs []rune)` + - `func (w *World) Particles() []render.ParticleCell` + - `func (w *World) ParticleCount() int` + - `const MaxParticles = 600`, `ParticleGravity = 14.0`, `ParticleDrag = 0.92` + +- [ ] **Step 1: Write the failing tests in `internal/fx/particle_test.go`** + +- `TestEmitAddsParticle`: `Emit(...)` -> `ParticleCount() == 1` +- `TestIntegrationStep`: a particle at `(5,5)` with `VY: 10`, after `Advance(100ms)` -> `Y` is about `6.0` and `VY` grew by roughly `ParticleGravity*0.1` then shrank by drag; assert `Y` within `0.2` of `6.0` (§23's exact update order: position += velocity·dt, then velocity += accel·dt, then velocity *= drag, then life -= dt) +- `TestParticleDiesAtZeroLife`: `Life: 0.05`, `Advance(100ms)` -> `ParticleCount() == 0` +- `TestParticleDiesOutsideViewport`: a particle at `Y: 1000` with long life -> culled (§23) +- `TestParticlesOutOfBoundsNotEmitted`: emit at `(-50,-50)` and `(9999,9999)`, then `Particles()` -> no cell with a negative or over-width coordinate — **Review Focus 2** +- `TestTinyViewport`: `Resize(1,1)` then `EmitBurst(0,0,50,...)` and `Advance(100ms)` -> no panic, all returned cells at `(0,0)` or culled — **Review Focus 2** +- `TestPoolIsCapped`: emit 5000 particles -> `ParticleCount() <= MaxParticles` — **Review Focus 4** +- `TestPoolDropsOldest`: fill to the cap, emit one more with a distinctive glyph -> the new particle is present +- `TestEmitBurstSpreadsRadially`: `EmitBurst(10,10,40,math.Pi*2,20,glyphs)` -> the 40 particles' velocity directions cover all four quadrants (§23: radial explosion force) +- `TestEmitBurstIsSeeded`: two worlds with the same seed produce identical bursts +- `TestBurstDoesNotAllocatePerFrame`: `testing.AllocsPerRun` on `Advance(16ms)` with 300 live particles -> zero allocations (§38: reusable slices) +- `TestHugeDTKillsParticlesNotMath`: 300 particles, `Advance(60*time.Second)` -> all dead, no `NaN` or `Inf` in any remaining position — **Review Focus 1** +- `TestBrightnessMapsToCell`: a particle with `Brightness: 1.0` yields a lower `Bright` index than one at `0.2` (brighter = lower index, matching `StarCell.Dim`) + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ -run 'TestEmit|TestParticle|TestPool|TestBurst|TestTiny|TestHugeDT' -v` +Expected: FAIL, `undefined: Emit`. + +- [ ] **Step 3: Implement `internal/fx/particle.go`** + +One backing `[]Particle` of length `MaxParticles`, compacted in place each step by swapping dead particles to the tail — no per-particle allocation, no goroutines (§38). `Particles()` writes into a reused `[]render.ParticleCell`. Rounding to cells uses `int(math.Round(...))` and culls anything outside `[0,Width)×[0,Height)`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/fx/ -v -count=1` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx +git commit -m "feat(fx): capped particle pool with terminal-space physics" +``` + +--- + +### Task 6: Hard-drop impact — ion trail, debris, shake, border flash + +**Files:** +- Modify: `internal/fx/events.go`, `internal/fx/world.go`, `internal/render/board.go` +- Test: `internal/fx/impact_test.go`, `internal/render/shake_test.go` + +**Interfaces:** +- Consumes: `Emit`, `EmitBurst`, `FlashBorder`, `Trail`. +- Produces: `func (w *World) ShakeOffset() (int, int)`; `func (w *World) Shake(d time.Duration)`; `const ShakeDuration = 80 * time.Millisecond`; `var ShakePattern = [5][2]int{{0,1},{-1,0},{1,0},{0,-1},{0,0}}` (§18, verbatim). + +- [ ] **Step 1: Write the failing tests** + +In `internal/fx/impact_test.go`: +- `TestHardDropEmitsDebris`: `Handle` with `PieceHardDropped` -> `ParticleCount() > 0`, and every glyph is drawn from `{'·','*','✦','+'}` (§18) +- `TestHardDropFlashesBorder`: after the event, `BorderEnergy() > 0.8` (§18) +- `TestHardDropShakes`: after the event, `ShakeOffset()` is non-zero for at least one of the first five frames +- `TestShakeNeverExceedsOneCell`: during a whole shake, `|dx| <= 1` and `|dy| <= 1` at every 16ms step (§18, §44) +- `TestShakeFollowsPattern`: stepping the shake through five equal slices yields `ShakePattern` in order — deterministic, not random (§18) +- `TestShakeEndsAtZero`: after `Advance(ShakeDuration)`, `ShakeOffset()` -> `(0,0)` +- `TestShakeDurationIs80ms`: shake is still active at 70ms and finished at 90ms +- `TestReducedMotionSuppressesShake`: `Config{ReducedMotion:true}` -> `ShakeOffset()` stays `(0,0)` after a hard drop, but `ParticleCount() > 0` (§49.5: particles stay) +- `TestDebrisScalesWithDropDistance`: `Cells: 1` emits fewer particles than `Cells: 18` +- `TestZeroCellDropStillEmits`: `Cells: 0` -> at least one particle and a border flash (§18: hard drop always feels like something) +- `TestHardDropVerticalTrailCoversCrossedRows`: the trail's rows span exactly the `Cells` rows the piece crossed + +In `internal/render/shake_test.go`: +- `TestShakeDoesNotResizeBoard`: for every offset in `ShakePattern`, `RenderBoard` -> 22 lines of width 22 +- `TestShakeDoesNotClipTheActivePiece`: with a `(0,-1)` offset and the active piece on the top visible row, the active piece's glyphs are still present (§44) + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ ./internal/render/ -run 'TestHardDrop|TestShake|TestDebris|TestReducedMotion|TestZeroCell' -v` +Expected: FAIL. + +- [ ] **Step 3: Implement** + +Debris count: `8 + 2*cells`, capped at 48. Burst origin is the center of the locked piece's bottom edge. Shake advances a phase timer and indexes `ShakePattern` by `int(elapsed/(ShakeDuration/5))`, clamped to index 4. + +- [ ] **Step 4: Run tests to verify they pass, then look at it** + +```bash +go test ./... -v +go run ./cmd/cosmic-tetris --seed 8675309 +``` + +Hard-drop a piece. It should feel like dropping a refrigerator from orbit (§18). Then `--reduced-motion` and confirm the board stops moving but debris still flies. + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx internal/render +git commit -m "feat(fx): hard-drop impact with debris, deterministic shake, and border flash" +``` + +--- + +### Task 7: Line-clear supernova + +**Files:** +- Create: `internal/fx/supernova.go` +- Modify: `internal/fx/events.go`, `internal/render/board.go` +- Test: `internal/fx/supernova_test.go` + +**Interfaces:** +- Consumes: `game.Event` with `Kind: LinesCleared` and `Rows []int`. +- Produces: + - `type Supernova struct { Rows []int; Age time.Duration }` + - `func (w *World) Supernovae() []render.RowFXCell` where `type RowFXCell struct { X, Y int; Glyph rune; Bright int }` in `render` + - `const SupernovaLife = 220 * time.Millisecond`; phase boundaries `PhaseA` 0–70ms, `PhaseB` 70–150ms, `PhaseC` 150–220ms (§19: ~220ms total, three phases) + +- [ ] **Step 1: Write the failing tests in `internal/fx/supernova_test.go`** + +- `TestLinesClearedStartsSupernova`: `Handle` with `LinesCleared{Rows: []int{21}}` -> `len(Supernovae()) > 0` on row 21 +- `TestSupernovaDoesNotDelayGameplay`: the engine has already cleared the row — assert `Handle` never returns a value and takes no game pointer (§19, §44) +- `TestPhaseAGlyphs`: at 30ms, the row's glyphs are drawn from `{'█','▓'}` (§19 Phase A: critical mass) +- `TestPhaseBExpandsFromCenter`: at 100ms, bright `✦` glyphs sit nearer the row center than the `░` glyphs (§19 Phase B: explosion moves outward) +- `TestPhaseCEmitsDebris`: at 200ms, `ParticleCount() > 0` and the row's own cells are mostly gone (§19 Phase C: fragments into debris) +- `TestDebrisInheritsHorizontalVelocity`: Phase C particles left of the row center have negative `VX`, those right of center positive (§19, explicit) +- `TestSupernovaExpires`: after `Advance(SupernovaLife + 20ms)`, `Supernovae()` is empty +- `TestFourRowsAllAnimate`: `Rows: []int{18,19,20,21}` -> all four rows produce cells +- `TestSupernovaRowsClampedToBoard`: `Rows: []int{-3, 99}` -> no panic, nothing rendered out of bounds +- `TestSupernovaNeverCoversLockedCellsAfterExpiry`: after expiry, the board renders exactly as it would with no FX (§44: particles never permanently alter the board) + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ -run 'TestSupernova|TestPhase|TestLinesCleared|TestDebrisInherits|TestFourRows' -v` +Expected: FAIL. + +- [ ] **Step 3: Implement** + +Rows are in *logical* board coordinates; the renderer maps them to visible rows and skips rows above `HiddenRows`. Because the engine collapsed the stack at lock time, the supernova paints over whatever now occupies those rows — that is acceptable for 220ms and is why the effect is short. + +- [ ] **Step 4: Run tests to verify they pass, then look at it** + +```bash +go test ./... -v && go run ./cmd/cosmic-tetris --seed 8675309 +``` + +Clear a line. §43 requires a visible supernova, debris, and a border reaction on the first completed line. + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx internal/render +git commit -m "feat(fx): three-phase supernova line-clear animation" +``` + +--- + +### Task 8: Shockwaves + +**Files:** +- Modify: `internal/fx/particle.go`, `internal/fx/events.go` +- Test: `internal/fx/shockwave_test.go` + +**Interfaces:** +- Produces: `type Shockwave struct { X, Y float64; Age time.Duration }`; `func (w *World) EmitShockwave(x, y float64)`; `func (w *World) Shockwaves() []render.ParticleCell`; `const ShockwaveLife = 300 * time.Millisecond`; `var ShockwaveGlyphs = []rune{'·','○','◌','◯'}` (§24). + +- [ ] **Step 1: Write the failing tests in `internal/fx/shockwave_test.go`** + +- `TestShockwaveExpands`: the mean distance of returned cells from the origin at 250ms exceeds that at 50ms +- `TestShockwaveGlyphsProgress`: early cells use `·`, late cells use `◯` (§24) +- `TestShockwaveIsElliptical`: at a fixed age, the horizontal radius is about twice the vertical (terminal cells are ~2:1, §24: fake the geometry) +- `TestShockwaveExpires`: after `Advance(ShockwaveLife + 20ms)` -> empty +- `TestShockwaveClippedToViewport`: an origin near a corner -> every returned cell is in bounds +- `TestReducedMotionSuppressesShockwaves`: `ReducedMotion: true` -> `Shockwaves()` stays empty after `EmitShockwave` (§49.5) +- `TestShockwaveUsedSparingly`: a sequence of 20 single-line clears emits zero shockwaves; a four-line clear emits exactly one (§24: use sparingly) + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ -run TestShockwave -v` +Expected: FAIL, `undefined: EmitShockwave`. + +- [ ] **Step 3: Implement** + +Radius grows as `28 * (age/ShockwaveLife)` horizontally and half that vertically; cells are sampled at 16 angles around the ellipse. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./... -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx +git commit -m "feat(fx): faked elliptical shockwave rings" +``` + +--- + +### Task 9: Hyperdrive + +**Files:** +- Modify: `internal/fx/starfield.go`, `internal/fx/events.go`, `internal/fx/world.go` +- Test: `internal/fx/hyperdrive_test.go` + +**Interfaces:** +- Produces: `func (w *World) TriggerHyperdrive()`; `func (w *World) HyperdriveFactor() float64`; `func (w *World) HighScoreReached()`; `const HyperdriveLife = 1100 * time.Millisecond`. + +- [ ] **Step 1: Write the failing tests in `internal/fx/hyperdrive_test.go`** + +The §16 envelope, sampled at its own keyframes: +- `TestHyperdrivePausesFirst`: at 20ms after trigger, `HyperdriveFactor() < 0.2` (§16: 0ms stars pause) +- `TestHyperdriveStretchesAt50ms`: at 60ms, factor is between `0.2` and `1.5` +- `TestHyperdriveAcceleratesAt100ms`: at 120ms, factor `> 2` +- `TestHyperdrivePeaksAt500ms`: the factor's maximum over the whole envelope occurs between 400ms and 600ms and is `>= 6` +- `TestHyperdriveDecaysAt800ms`: the factor at 800ms is less than at 500ms +- `TestHyperdriveNormalAt1100ms`: at 1100ms, factor is `1.0` within `0.05` +- `TestStarsMoveFarUnderHyperdrive`: total star displacement over the envelope exceeds 5× the same interval at rest +- `TestFourLineClearTriggersHyperdrive`: `Handle` with `LinesCleared{Rows: 4 rows}` -> factor rises (§16) +- `TestLargeComboTriggersHyperdrive`: `ComboChanged{Value: 5}` -> triggers; `Value: 2` -> does not +- `TestHighScoreTriggersHyperdrive`: `HighScoreReached()` -> triggers +- `TestSingleClearDoesNotTrigger`: `LinesCleared` with 1 row -> factor stays `1.0` +- `TestReTriggerRestartsEnvelope`: triggering at 600ms restarts from the pause phase rather than summing +- `TestReducedMotionSuppressesHyperdrive`: `ReducedMotion: true` -> factor stays `1.0`, but stars still drift at their base speed (§49.5) + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ -run 'TestHyperdrive|TestStarsMoveFar|TestFourLineClearTriggers|TestLargeCombo|TestHighScore|TestSingleClearDoes|TestReTrigger' -v` +Expected: FAIL. + +- [ ] **Step 3: Implement** + +The envelope is a piecewise-linear function over §16's five keyframes — pin it as a table so the tests and the code agree: + +```go +var hyperdriveEnvelope = []struct { + At time.Duration + Factor float64 +}{ + {0, 0.0}, // stars pause + {50 * time.Millisecond, 0.6}, // stretch + {100 * time.Millisecond, 4.0}, // violent acceleration + {500 * time.Millisecond, 9.0}, // peak + {800 * time.Millisecond, 3.0}, // decay + {1100 * time.Millisecond, 1.0}, // normal +} +``` + +`HighScoreReached` is called by `app` (Task 11), not derived inside `fx` — the world does not know the high score. + +- [ ] **Step 4: Run tests to verify they pass, then look at it** + +```bash +go test ./... -v && go run ./cmd/cosmic-tetris --seed 8675309 +``` + +Land a four-line clear. The terminal should appear to enter hyperspace for absolutely no reason. + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx +git commit -m "feat(fx): hyperdrive starfield envelope" +``` + +--- + +### Task 10: Banners, four-line spectacle, and level-up notification + +**Files:** +- Create: `internal/fx/banner.go` +- Modify: `internal/fx/events.go`, `internal/render/render.go` +- Test: `internal/fx/banner_test.go`, `internal/render/banner_test.go` + +**Interfaces:** +- Produces: + - `type BannerKind uint8` with `BannerMajor = 0, BannerNotice = 1` — the numeric values matter, because they travel through `render.BannerView.Kind` as a `uint8` + - `type banner struct { Kind BannerKind; Title, Subtitle string; Age, Life time.Duration }` — unexported; the world's internal queue entry + - `func (w *World) Banner() (render.BannerView, bool)` — the `render.FXView` method (Plan 1 Task 15); returns the single highest-priority live banner, `BannerMajor` outranking `BannerNotice` + - `const BannerLife = 700 * time.Millisecond`, `NoticeLife = 1400 * time.Millisecond` + - `var FourLineBanners = []string{...}`, `var LevelSubtitles = []string{...}` + +- [ ] **Step 1: Write the failing tests in `internal/fx/banner_test.go`** + +- `TestFourLineClearShowsBanner`: `LinesCleared` with 4 rows -> a `BannerMajor` whose `Title` is one of `FourLineBanners` +- `TestFourLineBannerCopy`: `FourLineBanners` contains exactly §20's four strings: `✦ EVENT HORIZON ✦`, `QUADRUPLE COSMIC INCIDENT`, `FOUR ROWS HAVE LEFT THE CHAT`, `SPACE-TIME HAS FILED A COMPLAINT` +- `TestBannerLife700ms`: present at 650ms, gone at 750ms (§20) +- `TestBannerSelectionIsSeeded`: same seed -> same banner choice +- `TestLevelUpShowsNotice`: `LevelChanged{Value: 8}` -> a `BannerNotice` with `Title` containing `GRAVITY ANOMALY DETECTED` and `LEVEL 08` (§22, zero-padded) +- `TestLevelSubtitleCopy`: `LevelSubtitles` contains §22's three strings: `GRAVITY TAX INCREASED`, `LOCAL PHYSICS UPDATED WITHOUT CONSENT`, `PLEASE SECURE ALL LOOSE TETROMINOES` +- `TestMajorOutranksNotice`: a level-up and a four-line clear in the same `Handle` -> `Banner()` returns a `render.BannerView` with `Kind == uint8(BannerMajor)`, and only one banner is returned — **Review Focus 3** +- `TestNoticeSurvivesMajorExpiry`: with both live, after the major expires the notice becomes visible (nothing is lost, just deferred) +- `TestFourLineFiresEverything`: `LinesCleared` with 4 rows -> hyperdrive triggered, shake active, border energy high, particles emitted, a shockwave present, and a banner live — all six §20 reactions at once +- `TestFourLineStarDensityIncreases`: star count is temporarily higher after a four-line clear and returns to baseline within 2s (§20) +- `TestSimultaneousEventsDoNotPanic`: `Handle` with all nine `EventKind`s in one slice -> no panic, at most one banner — **Review Focus 3** + +- [ ] **Step 2: Write the failing tests in `internal/render/banner_test.go`** + +- `TestBannerCenteredOverBoard`: with a stub banner, the title appears once, horizontally centered +- `TestBannerDoesNotChangeLineCount`: output line count with and without a banner is identical (§20: must not block gameplay, and must not reflow the screen) +- `TestBannerTruncatedAtSmallWidth`: at `40×24`, a long banner's rendered width is `<= 40` and it stays on one or two lines +- `TestBannerDoesNotHideActivePiece`: with the active piece in the top visible rows and a banner live, the active piece's glyphs are still in the output (§44) +- `TestNoBannerWhenNilFX`: `Scene{FX:nil}` -> no banner region + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `go test ./internal/fx/ ./internal/render/ -run 'TestBanner|TestFourLine|TestLevelUp|TestLevelSubtitle|TestMajor|TestNotice|TestSimultaneous' -v` +Expected: FAIL. + +- [ ] **Step 4: Implement** + +Banners render into the top third of the board area, above the current stack where the piece rarely is; the "does not hide the active piece" test is what makes that placement binding. The notification slides by shifting its row by `Age/Life`, fading via color only — no pause (§22). + +- [ ] **Step 5: Run tests to verify they pass, then look at it** + +```bash +go test ./... -v && go run ./cmd/cosmic-tetris --seed 8675309 +``` + +A four-line clear must produce an immediate "LOL WHAT THE FUCK" reaction. That is an actual product requirement (§43). + +- [ ] **Step 6: Commit** + +```bash +git add internal/fx internal/render +git commit -m "feat(fx): four-line spectacle, banners, and level-up notification" +``` + +--- + +### Task 11: Mission control and combo escalation + +**Files:** +- Create: `internal/flavor/messages.go`, `internal/flavor/channel.go` +- Modify: `internal/app/update.go`, `internal/fx/events.go` +- Test: `internal/flavor/channel_test.go`, `internal/fx/combo_test.go` + +**Interfaces:** +- Produces: + - `type Channel struct { ... }`; `func NewChannel(seed int64) *Channel` + - `func (c *Channel) Handle(events []game.Event)`; `func (c *Channel) Advance(dt time.Duration)`; `func (c *Channel) Current() string` + - `const MessageHold = 2500 * time.Millisecond`, `MessageCooldown = 1200 * time.Millisecond` + - `var Idle, Locked, Cleared, Combo, LevelUp []string` — the §27 copy tables + - `func ComboTaunt(combo int) string` — the §21 escalation lines + - `func (w *World) ComboIntensity() float64` in `[0,1]` + +- [ ] **Step 1: Write the failing tests in `internal/flavor/channel_test.go`** + +- `TestChannelStartsWithAMessage`: `NewChannel(1).Current()` is non-empty +- `TestEventChangesMessage`: `Handle([]game.Event{{Kind: game.LinesCleared, Rows: []int{21}}})` -> `Current()` changed +- `TestMessageHoldsBeforeChanging`: a second event 200ms later does not change `Current()` (§27: give them time to breathe) +- `TestMessageChangesAfterCooldown`: after `Advance(MessageHold + MessageCooldown)`, a new event does change it +- `TestNoConstantRotation`: over 10s of `Advance(16ms)` with no events, `Current()` changes at most twice (§27: do not rotate constantly) +- `TestCopyTableContents`: `Locked`/`Cleared`/`Idle` together contain §27's exact strings, including `TETROMINO INJECTION SUCCESSFUL`, `STRUCTURAL VIBES: QUESTIONABLE`, `LOCAL UNIVERSE STABLE*`, `MOON NOTIFIED`, `ORBITAL OSHA HAS ENTERED THE CHAT`, `WE HAVE EXCEEDED THE RECOMMENDED NUMBER OF BLOCKS`, `PHYSICS TEAM SAYS KEEP GOING`, `GRAVITY REMAINS MOSTLY LEGAL` +- `TestSeededSelection`: two channels with the same seed produce the same message sequence for the same event stream +- `TestChannelRNGIsSeparate`: the channel takes a seed, never a `*game.Game` or the game's RNG (§35) +- `TestComboMessagesEscalate`: `ComboTaunt(5)` -> `COMBO 5 // UNAUTHORIZED ORBITAL MANEUVER`; `ComboTaunt(6)` -> `COMBO 6 // STRUCTURAL REALITY FAILURE`; `ComboTaunt(7)` -> `COMBO 7 // NASA DENIES EVERYTHING` (§21, verbatim) +- `TestComboTauntBelowFive`: `ComboTaunt(2)` -> `""` (the escalating taunts start at 5) +- `TestComboTauntAboveSeven`: `ComboTaunt(12)` -> non-empty, no panic, no index error +- `TestHighPriorityEventPreempts`: a four-line `LinesCleared` changes the message even inside the hold window + +- [ ] **Step 2: Write the failing tests in `internal/fx/combo_test.go`** + +- `TestComboTwoSparks`: `ComboChanged{Value:2}` -> a small number of particles (§21 combo 2: small sparks) +- `TestComboThreeMeteors`: `Value:3` -> more particles than combo 2, including meteor glyphs +- `TestComboFourPulses`: `Value:4` -> `ComboIntensity() >= 0.5`, which the HUD uses to pulse (§21 combo 4) +- `TestComboFivePlusIsChaos`: `Value:6` -> `ComboIntensity() == 1.0`, hyperdrive triggered, shockwave present +- `TestComboResetDropsIntensity`: `ComboChanged{Value:0}` -> `ComboIntensity()` decays to 0 within 1s +- `TestComboIntensityBounded`: `Value:99` -> `ComboIntensity() <= 1.0` +- `TestBoardStaysReadableAtMaxCombo`: at combo 12, `render.Render`'s board region still shows every locked cell glyph (§21: board readability remains sacred) — assert by rendering with a known stack and counting block glyphs in the board rows + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `go test ./internal/flavor/ ./internal/fx/ -run 'TestChannel|TestMessage|TestCombo|TestCopy|TestSeeded|TestNoConstant|TestHighPriority|TestBoardStays' -v` +Expected: FAIL. + +- [ ] **Step 4: Implement** + +`app.Model` gains `Channel *flavor.Channel` and a `HighScore int`; `handleEvents` fans each `[]game.Event` out to `m.FX.Handle`, `m.Channel.Handle`, and a high-score check that calls `m.FX.HighScoreReached()` when `Score` passes the session high. `Model.MissionControl` reads `m.Channel.Current()` each frame, and the renderer already draws it (Plan 1 Task 14). + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `go test ./... -v` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add internal/flavor internal/app internal/fx +git commit -m "feat(flavor): mission-control channel and combo escalation" +``` + +--- + +### Task 12: `--no-fx`, `--reduced-motion`, and the coolness acceptance pass + +**Files:** +- Modify: `internal/app/model.go`, `internal/render/render.go`, `README.md` +- Test: `internal/app/modes_test.go`, `internal/render/nofx_test.go` + +**Interfaces:** +- Consumes: everything above. +- Produces: no new API. This task makes the two flags real and proves §43 and §44. + +- [ ] **Step 1: Write the failing tests** + +In `internal/app/modes_test.go`: +- `TestNoFXSkipsAllFXWork`: with `NoFX`, `testing.AllocsPerRun` on a `FrameMsg` is at most that of the same frame with FX disabled by an empty world — i.e. no FX simulation runs at all — **Review Focus 5** +- `TestNoFXGameStillFullyPlayable`: with `NoFX`, a scripted 200-step session produces the same `Score`, `Lines`, `Level`, and `Board` as the same script with FX on (§44: effects never modify game state, §47: fun with effects disabled) +- `TestReducedMotionGameIdentical`: same equality check for `ReducedMotion` +- `TestReducedMotionKeepsColorAndParticles`: with `ReducedMotion`, particles and trails are non-empty while shake and shockwaves stay empty and hyperdrive stays at `1.0` (§49.5, all four clauses) +- `TestFXNeverMutatesGame`: run 500 frames with heavy events, snapshotting the game before/after each `m.FX.Handle` call -> the game is unchanged every time (§14, the central invariant) + +In `internal/render/nofx_test.go`: +- `TestNilFXAtEveryCallSite`: `Render(Scene{FX:nil})` at 12 sizes across the range -> no panic — **Review Focus 5** +- `TestNoFXGoldenMatchesPlan1`: `Scene{FX:nil}` at `80×30` still matches `testdata/wide.golden` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/app/ ./internal/render/ -run 'TestNoFX|TestReducedMotion|TestNilFX|TestFXNever' -v` +Expected: FAIL. + +- [ ] **Step 3: Implement the flag behavior** + +`NoFX` leaves `Model.FX` nil and skips `handleEvents`' FX fan-out entirely — no empty world doing per-frame work. `ReducedMotion` passes through to `fx.Config` and is checked in `Shake`, `EmitShockwave`, and `TriggerHyperdrive`. + +- [ ] **Step 4: Run the coolness acceptance test by hand (§43)** + +```bash +go test ./... -count=1 +go run ./cmd/cosmic-tetris --seed 8675309 +``` + +Within the first 30 seconds of normal play, confirm you see all six: moving starfield, animated board border, piece trails, hard-drop impact, particles, mission-control commentary. On the first completed line, confirm: supernova, debris, border reaction. On a four-line clear, confirm the §20 six-way eruption. Then check the restraint rules (§44) still hold: controls feel immediate, nothing obscures the active piece, no flicker, shake stays within one cell. + +Then run `--no-fx` and confirm it is still a good game, and `--reduced-motion` and confirm nothing lurches. Update the README's flag section to describe what each flag now actually does. + +- [ ] **Step 5: Commit** + +```bash +git add internal/app internal/render README.md +git commit -m "feat(cli): make --no-fx and --reduced-motion real; coolness acceptance pass" +``` + +--- + +## Done when + +- `go test ./... -count=2` passes; `go vet ./...` and `gofmt -l .` clean. +- No signature under `internal/fx` mentions `*game.Game`, and the parser test enforces it. +- `game` still contains no `time.Now()` and does not import `fx`. +- §43's checklist is satisfied by hand, and §44's restraint rules hold. +- `--no-fx` and `--reduced-motion` change presentation only; the scripted-session equality tests prove it. diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/T1-test-lines-cosmic-63/2026-09-18-cosmic-tetris-03-theatrics-and-polish.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/T1-test-lines-cosmic-63/2026-09-18-cosmic-tetris-03-theatrics-and-polish.md new file mode 100644 index 000000000..c0b5db152 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/T1-test-lines-cosmic-63/2026-09-18-cosmic-tetris-03-theatrics-and-polish.md @@ -0,0 +1,448 @@ +# Cosmic Tetris, Plan 3: Theatrics and Polish — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add the boot sequence, the game-over black hole, terminal-capability detection, responsive FX reduction, ASCII-mode verification, and the small rare flavor details — then run the §47 definition-of-done sweep and ship it. + +**Architecture:** Two new app states (`StateBoot`, and a `StateCollapsing` sub-phase of game over) drive scripted timelines that live entirely in `internal/fx` and `internal/render`; the engine is untouched. FX intensity gains a single scalar that layout drives, so the effects thin out on small terminals through one code path rather than a dozen conditionals. + +**Tech Stack:** Go 1.26, `charm.land/bubbletea/v2`, `charm.land/lipgloss/v2`, `charm.land/bubbles/v2` (`help` and `key` for the flight manual). No new dependencies. + +**Spec:** `design.md` (this repo root). Sections 28–32, 39, 41, 42 Phase 5, 45, 47. + +**Prerequisite:** Plans 1 and 2 complete. + +## Global Constraints + +- The engine stays untouched: `internal/game` gains no new code in this plan, keeps no clock read, and gains no import. +- Any key skips the boot sequence (§29). No menu is required. +- Game over does not instantly replace the board; the collapse runs on its own timeline (§28). +- Effects automatically reduce outside the board on small terminals (§31). +- ASCII mode makes no special Unicode assumptions beyond the one §49.4 ghost exception recorded in Plan 1 (§32). +- Optional flavor details stay *occasional* — never a constant stream (§45). +- Restraint rules §44 continue to hold: nothing obscures the active piece, controls never lag, animation never delays gameplay. +- Terminal output must not visibly flicker under normal conditions (§47). +- The final CLI surface is exactly the five flags of §49.5. Nothing else is necessary (§46). +- Every task ends with tests passing and a commit. `go vet ./...` and `gofmt -l .` clean before each commit. + +## Review Focus + +1. **Resize during a scripted timeline.** A `WindowSizeMsg` mid-boot or mid-collapse must not panic, must not restart the timeline, and must not leave the board mid-shift. → Task 1 and Task 2. +2. **Keys during the collapse.** `r` and `q` must work the instant game over starts; other keys must be inert and must not restart the animation or double-register a restart. → Task 2. +3. **Terminal capability lies.** `NO_COLOR` set, `TERM=dumb`, or a terminal claiming 256 colors while the profile says truecolor — mode detection must degrade rather than emit escapes the terminal will print literally. → Task 3. +4. **Restart from a non-playing state.** `r` pressed during boot, while paused, mid-collapse, and on the game-over card must all land in the same clean playing state. → Task 2. +5. **Wide-character and width accounting in ASCII mode.** A single multi-byte glyph leaking into ASCII mode makes every border row ragged; the check must cover FX glyphs, banners, mission-control copy, and overlay panels, not just the board. → Task 4. + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `internal/fx/boot.go` | Boot timeline state and the checklist reveal | +| `internal/fx/collapse.go` | Game-over collapse: freeze, inward fall, black hole | +| `internal/render/boot.go` | Boot screen drawing | +| `internal/render/collapse.go` | Collapse and black-hole drawing | +| `internal/render/overlay.go` (modify) | Flight manual via `bubbles/help`; final game-over card | +| `internal/render/palette.go` (modify) | Capability detection for `ModeFull` vs `ModeReduced` | +| `internal/fx/world.go` (modify) | `Intensity` scalar driven by layout | +| `internal/flavor/messages.go` (modify) | §45 rare lines and their trigger conditions | +| `internal/app/update.go`, `model.go` (modify) | `StateBoot`, collapse phase, restart-from-anywhere | + +--- + +### Task 1: Boot sequence + +**Files:** +- Create: `internal/fx/boot.go`, `internal/render/boot.go` +- Modify: `internal/app/model.go`, `internal/app/update.go` +- Test: `internal/fx/boot_test.go`, `internal/render/boot_test.go`, `internal/app/boot_test.go` + +**Interfaces:** +- Consumes: `fx.World`, `render.Scene`, `app.AppState`. +- Produces: + - `type Boot struct { Age time.Duration }`; `func (w *World) StartBoot()`; `func (w *World) BootProgress() (elapsed time.Duration, done bool)` + - `const BootDuration = 1000 * time.Millisecond` (§29: approximately one second) + - `func RenderBoot(elapsed time.Duration, width, height int, m Mode, fxv FXView) string` in `render` + - `app.StateBoot` added to `AppState`; `app.New` now starts in `StateBoot` + +- [ ] **Step 1: Write the failing tests** + +In `internal/fx/boot_test.go`: +- `TestBootStartsAtZero`: after `StartBoot()`, `BootProgress()` -> `(0, false)` +- `TestBootCompletes`: after `Advance` totaling `BootDuration`, `BootProgress()` -> `done == true` +- `TestBootStarfieldRunsDuringBoot`: stars move during boot (§29's drama is over a live starfield) + +In `internal/render/boot_test.go`: +- `TestBootShowsTitle`: at 400ms, output contains `C O S M I C` and `T E T R I S` (§29's exact spacing) +- `TestBootShowsInitializing`: at 400ms, output contains `INITIALIZING LOCAL UNIVERSE...` +- `TestBootChecklistRevealsProgressively`: `gravity ........ OK` appears before `spacetime ...... OK`, which appears before `tetrominoes .... QUESTIONABLE`; at 200ms fewer lines are present than at 800ms (§29) +- `TestBootFinalLine`: at `BootDuration`, output contains `UNIVERSE ONLINE` +- `TestBootFitsSmallTerminal`: at `40×24`, every line's `lipgloss.Width <= 40` and the line count is `<= 24` +- `TestBootAtTooSmallSize`: at `20×10`, `RenderBoot` returns without panic — **Review Focus 1** +- `TestGoldenBoot`: at `80×30`, mid-boot output matches `testdata/boot.golden` + +In `internal/app/boot_test.go`: +- `TestModelStartsInBoot`: `New(Options{}).State` -> `StateBoot` +- `TestBootAdvancesToPlaying`: `FrameMsg`s totaling `BootDuration` -> `State == StatePlaying` +- `TestBootIgnoresGravity`: during boot, the game does not advance — `Active.Y == SpawnY` and `Score == 0` after 900ms of frames +- `TestAnyKeySkipsBoot`: an `x` key press during boot -> `State == StatePlaying` immediately (§29) +- `TestQuitDuringBootStillQuits`: `q` during boot -> `tea.Quit`, not a skip-into-play +- `TestBootThenImmediatePlay`: right after the skip, a left-key press moves the piece (§29: "and immediately start") +- `TestResizeDuringBootDoesNotRestartIt`: a `WindowSizeMsg` at 500ms -> `BootProgress()` elapsed is still ~500ms — **Review Focus 1** + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ ./internal/render/ ./internal/app/ -run 'TestBoot|TestAnyKey|TestModelStarts|TestQuitDuringBoot|TestResizeDuringBoot|TestGoldenBoot' -v` +Expected: FAIL, `undefined: StartBoot`. + +- [ ] **Step 3: Implement** + +Checklist reveal keyframes, pinned so the tests and the code agree: title at 0ms, `INITIALIZING` at 250ms, `gravity` at 450ms, `spacetime` at 600ms, `tetrominoes` at 750ms, `UNIVERSE ONLINE` at 950ms. `q`/`esc` are checked before the any-key skip. With `--no-fx` the boot screen still draws (it is content, not an effect) but without stars. + +- [ ] **Step 4: Run tests to verify they pass, then look at it** + +```bash +go test ./... -v && go run ./cmd/cosmic-tetris +``` + +One second of excessive drama, then play. Press a key immediately and confirm it skips cleanly. + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx internal/render internal/app +git commit -m "feat: one second of excessive boot drama" +``` + +--- + +### Task 2: Game-over collapse and black hole + +**Files:** +- Create: `internal/fx/collapse.go`, `internal/render/collapse.go` +- Modify: `internal/render/overlay.go`, `internal/app/update.go` +- Test: `internal/fx/collapse_test.go`, `internal/render/collapse_test.go`, `internal/app/gameover_test.go` + +**Interfaces:** +- Produces: + - `type CollapsePhase uint8` with `CollapseNone = 0, CollapseFreeze = 1, CollapseInward = 2, CollapseSingularity = 3, CollapseDone = 4` — the numeric values matter, because the phase travels to the renderer as a `uint8` + - `func (w *World) StartCollapse(cells []game.Cell)`; `func (w *World) CollapsePhase() CollapsePhase` + - Phase boundaries, from §28: `CollapseFreeze` 0–300ms, `CollapseInward` 300–900ms, `CollapseSingularity` 900–1300ms, then `CollapseDone` + - `render` owns the view type, so `render` still never imports `fx`: `type CollapseView struct { Phase uint8; Cells []ParticleCell; Age time.Duration }` and `func RenderCollapse(v CollapseView, width, height int, m Mode) string` + - `render.FXView` gains one method: `Collapse() (CollapseView, bool)`, implemented on `*fx.World` + - `app.AppState` gains `StateCollapsing` + +- [ ] **Step 1: Write the failing tests** + +In `internal/fx/collapse_test.go`: +- `TestCollapseStartsFrozen`: at 100ms, `CollapsePhase()` -> `CollapseFreeze` and every cell is at its original position (§28: everything freezes) +- `TestCollapsePhaseBoundaries`: phase at 250ms is `CollapseFreeze`, at 500ms `CollapseInward`, at 1000ms `CollapseSingularity`, at 1400ms `CollapseDone` (§28's exact timings) +- `TestCellsFallInward`: at 600ms, the mean distance of cells from the board center is less than at 350ms (§28: blocks fall inward toward the center) +- `TestCellsConverge`: at 1250ms, nearly all cells are within a few cells of the center +- `TestSingularityGlyphs`: during `CollapseSingularity`, the emitted glyph set includes `'●'` and the `\ | / - / | \` spokes of §28's diagram +- `TestCollapseIsSeeded`: same seed and same input cells -> identical collapse +- `TestEmptyBoardCollapse`: `StartCollapse(nil)` -> reaches `CollapseDone` without panic +- `TestReducedMotionShortensCollapse`: with `ReducedMotion`, the collapse still runs but without shake; it still reaches `CollapseDone` (§49.5 suppresses shake, not the sequence) + +In `internal/render/collapse_test.go`: +- `TestCollapseShowsSignalLost`: during the freeze phase, output contains `SIGNAL LOST` (§28) +- `TestCollapseKeepsBoardBorder`: during all phases, the board border is still 22 lines of width 22 +- `TestBlackHoleCentered`: during the singularity phase, `●` appears once, centered in the board area +- `TestFinalCardCopy`: the game-over card contains `UNIVERSE EXPIRED`, `SCORE`, `LINES`, `LEVEL`, `r REBOOT UNIVERSE`, `q ACCEPT COSMIC DEATH` (§28) +- `TestFinalCardFormatsScore`: `Score == 483200` renders as `483,200`; `Lines == 127` as `127`; `Level == 13` as `13` (§28's mockup uses thousands separators on the final card, unlike the in-game zero-padded HUD) +- `TestFinalCardSubtitle`: contains `CAUSE: EXCESSIVE GEOMETRY` +- `TestGoldenCollapse`: at `80×30`, mid-singularity output matches `testdata/collapse.golden` +- `TestCollapseAtSmallSize`: at `40×24`, the collapse and the final card both fit within the bounds + +In `internal/app/gameover_test.go`: +- `TestGameOverEntersCollapsing`: a `GameOver` event -> `State == StateCollapsing`, not `StateGameOver` +- `TestCollapseAdvancesToGameOver`: frames totaling 1300ms -> `State == StateGameOver` and the final card renders +- `TestRestartDuringCollapse`: `r` at 400ms into the collapse -> `StatePlaying`, fresh game, and the collapse is cleared — **Review Focus 2 and 4** +- `TestQuitDuringCollapse`: `q` at 400ms -> `tea.Quit` — **Review Focus 2** +- `TestOtherKeysDuringCollapseAreInert`: left/rotate/hard-drop/hold/`p` during the collapse change nothing and do not restart the timeline — **Review Focus 2** +- `TestDoubleRestartIsIdempotent`: two `r` presses in a row -> one clean playing game, score 0 — **Review Focus 4** +- `TestRestartFromEveryState`: for each of `StateBoot`, `StatePlaying`, `StatePaused`, `StateCollapsing`, `StateGameOver`, pressing `r` yields `State == StatePlaying`, `Score == 0`, an empty board, and `ShowHelp == false` — **Review Focus 4** +- `TestResizeDuringCollapseDoesNotRestartIt`: a `WindowSizeMsg` mid-collapse leaves the phase and age intact — **Review Focus 1** +- `TestNoFXGameOverGoesStraightToCard`: with `NoFX`, a `GameOver` event -> `StateGameOver` immediately, no collapse (the boring mode is still a good game, §32) + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ ./internal/render/ ./internal/app/ -run 'TestCollapse|TestGameOver|TestRestart|TestQuitDuring|TestOtherKeys|TestDoubleRestart|TestBlackHole|TestFinalCard|TestSingularity|TestCellsFall|TestCellsConverge|TestEmptyBoard' -v` +Expected: FAIL. + +- [ ] **Step 3: Implement** + +`StartCollapse` snapshots the filled cells into particles with zero velocity, then in `CollapseInward` accelerates each toward the board center with a force proportional to distance, and in `CollapseSingularity` shrinks the remaining spread while drawing the §28 spoke diagram over the convergence point. `app` calls `StartCollapse(m.Game.FilledCells())` — add that tiny read-only accessor to `game.Board` if it does not exist; it returns a fresh slice and mutates nothing. + +Restart handling moves into one place: `m.Restart()` (Plan 1 Task 17) additionally clears the FX world's collapse, boot, banner, and particle state, so `r` behaves identically from every state. + +- [ ] **Step 4: Run tests to verify they pass, then look at it** + +```bash +go test ./... -v && go run ./cmd/cosmic-tetris --seed 1 +``` + +Stack out on purpose. The universe should collapse into a black hole before the card appears (§47). + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx internal/render internal/app +git commit -m "feat: collapse the universe into a black hole on game over" +``` + +--- + +### Task 3: Terminal capability detection + +**Files:** +- Modify: `internal/render/palette.go`, `cmd/cosmic-tetris/main.go` +- Test: `internal/render/detect_test.go` + +**Interfaces:** +- Produces: `func DetectMode(ascii bool, profile lipgloss.Profile, noColor bool) Mode` — the testable core; `ParseMode(ascii bool)` (Plan 1 Task 11) becomes a thin wrapper that reads the real profile and `NO_COLOR`. + +- [ ] **Step 1: Write the failing tests in `internal/render/detect_test.go`** + +- `TestASCIIFlagAlwaysWins`: `DetectMode(true, truecolor, false)` -> `ModeASCII` +- `TestTruecolorGivesFull`: `DetectMode(false, lipgloss.TrueColor, false)` -> `ModeFull` +- `TestANSI256GivesReduced`: `DetectMode(false, lipgloss.ANSI256, false)` -> `ModeReduced` +- `TestANSI16GivesReduced`: `DetectMode(false, lipgloss.ANSI, false)` -> `ModeReduced` +- `TestNoColorGivesASCII`: `DetectMode(false, lipgloss.TrueColor, true)` -> `ModeASCII` — **Review Focus 3**; `NO_COLOR` means no color, and a colorless Unicode board reads worse than the ASCII set +- `TestAsciiProfileGivesASCII`: `DetectMode(false, lipgloss.Ascii, false)` -> `ModeASCII` (`TERM=dumb`) — **Review Focus 3** +- `TestReducedEmitsNoTruecolorEscapes`: render a scene in `ModeReduced` and assert the raw (un-stripped) output contains no `38;2;` sequence — **Review Focus 3** +- `TestASCIIEmitsNoEscapesBeyond16Color`: in `ModeASCII`, the raw output contains no `38;5;` or `38;2;` sequence +- `TestDetectIsPure`: `DetectMode` reads no environment itself — call it 100 times with fixed arguments and assert a stable result + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/render/ -run 'TestDetect|TestASCIIFlag|TestTruecolor|TestANSI|TestNoColor|TestReducedEmits' -v` +Expected: FAIL, `undefined: DetectMode`. + +- [ ] **Step 3: Implement** + +`ParseMode` reads `lipgloss.ColorProfile()` and `os.Getenv("NO_COLOR") != ""` and delegates. Verify the `ModeReduced` styles genuinely use `lipgloss.Color` values in the 0–255 range, since that is what `TestReducedEmitsNoTruecolorEscapes` checks. + +- [ ] **Step 4: Run tests to verify they pass, then check by hand** + +```bash +go test ./... -v +NO_COLOR=1 go run ./cmd/cosmic-tetris +TERM=dumb go run ./cmd/cosmic-tetris +``` + +Neither should print literal escape sequences or garbled glyphs. + +- [ ] **Step 5: Commit** + +```bash +git add internal/render cmd +git commit -m "feat(render): terminal capability detection with NO_COLOR and dumb-terminal fallbacks" +``` + +--- + +### Task 4: ASCII mode end to end + +**Files:** +- Modify: `internal/fx/particle.go`, `internal/fx/starfield.go`, `internal/fx/banner.go`, `internal/fx/collapse.go`, `internal/render/*` +- Test: `internal/render/ascii_test.go`, `internal/render/testdata/ascii-*.golden` + +**Interfaces:** +- Produces: `func ASCIIGlyph(r rune) rune` in `render` — the single mapping table from every Unicode FX glyph to an ASCII stand-in; `fx` glyph choices pass through it when `Mode == ModeASCII`. `render.Scene`/`FXView` cell types gain no fields; the substitution happens at render time so `fx` stays mode-agnostic. + +- [ ] **Step 1: Write the failing tests in `internal/render/ascii_test.go`** + +- `TestASCIIGlyphTableCoversEveryFXGlyph`: enumerate every rune used by `fx` (stars `. · ˚ ✦ ✧ *`, debris `· * ✦ +`, trails `█ ▓ ▒ ░`, shockwaves `· ○ ◌ ◯`, singularity `● \ | / -`, board `██ ░░`, borders `╔ ═ ╗ ║ ╚ ╝ ╭ ─ ╮ ╰ ╯`) and assert `ASCIIGlyph` maps each to a rune `< 128` +- `TestASCIIGlyphIdentityForASCII`: `ASCIIGlyph('*')` -> `'*'` +- `TestEveryASCIIGlyphIsSingleWidth`: every value in the table has `lipgloss.Width == 1` — **Review Focus 5** +- `TestFullSceneASCIIIsSevenBit`: render a scene at `80×30` in `ModeASCII` with a fully loaded FX world (stars, particles, a live banner, an active supernova, a shockwave, a shake) and assert every rune in the stripped output is `< 128` except the §49.4 `·` ghost — **Review Focus 5** +- `TestASCIIBannerCopyIsSevenBit`: `✦ EVENT HORIZON ✦` renders in ASCII mode without the `✦` (substituted or dropped), and its width still fits +- `TestASCIIOverlaysAreSevenBit`: pause, help, game-over, and too-small panels in `ModeASCII` contain no rune `>= 128` +- `TestASCIIBoardRowsAlignAtEverySize`: for sizes `40..120 × 24..50` (step 9) in `ModeASCII`, every rendered line's `lipgloss.Width` equals its `utf8.RuneCountInString` — i.e. nothing wide slipped in — **Review Focus 5** +- `TestGoldenASCIIWithFX`: `80×30`, FX loaded, matches `testdata/ascii-fx.golden` +- `TestGoldenASCIISmall`: `40×24` matches `testdata/ascii-small.golden` +- `TestGoldenASCIIGameOver`: the game-over card in ASCII matches `testdata/ascii-gameover.golden` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/render/ -run 'TestASCII|TestEveryASCII|TestFullScene|TestGoldenASCII' -v` +Expected: FAIL, `undefined: ASCIIGlyph`. + +- [ ] **Step 3: Implement `ASCIIGlyph` and route FX glyphs through it** + +Suggested mapping: stars `. · ˚` → `.`, `✦ ✧` → `*`; trails `█ ▓ ▒ ░` → `# = - .`; shockwaves `○ ◌ ◯` → `o`; singularity `●` → `@`; box-drawing → `+ - |`. Apply the mapping in the one place each glyph reaches a cell, not scattered per effect. + +- [ ] **Step 4: Generate goldens, inspect, verify** + +```bash +go test ./internal/render/ -run TestGolden -update && go test ./... -v +go run ./cmd/cosmic-tetris --ascii +``` + +Read the new goldens. Borders must be straight and the board exactly 22 columns on every row. Play a minute in `--ascii`: it must still be a good game. + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx internal/render +git commit -m "feat(render): ASCII glyph substitution across every effect" +``` + +--- + +### Task 5: Responsive FX reduction + +**Files:** +- Modify: `internal/fx/world.go`, `internal/app/update.go` +- Test: `internal/fx/intensity_test.go` + +**Interfaces:** +- Produces: `func (w *World) SetIntensity(v float64)` with `v` in `[0,1]`; `func (w *World) Intensity() float64`; `func IntensityFor(l render.Layout) float64` in `fx`. + +- [ ] **Step 1: Write the failing tests in `internal/fx/intensity_test.go`** + +- `TestIntensityByLayout`: `IntensityFor(render.Compute(80,30))` -> `1.0`; `Compute(50,26)` -> `0.6`; `Compute(40,24)` -> `0.35` +- `TestIntensityClamped`: `SetIntensity(-1)` -> `0`; `SetIntensity(5)` -> `1` +- `TestLowIntensityReducesStarCount`: at intensity `0.35`, `len(Stars())` is under half the count at `1.0` (§31: effects automatically reduce outside the board) +- `TestLowIntensityReducesParticleBursts`: a hard drop at `0.35` emits fewer particles than at `1.0`, but more than zero +- `TestBoardEffectsSurviveLowIntensity`: at `0.35`, the supernova and the ghost still render in full — the board is the visual center and its effects are not what gets thinned (§4, §31) +- `TestIntensityZeroStillPlayable`: `SetIntensity(0)` -> no stars, no particles, but trails and the animated border remain and the game renders +- `TestAppSetsIntensityOnResize`: a `WindowSizeMsg{40,24}` -> the FX world's intensity is `0.35`; `{80,30}` -> `1.0` +- `TestIntensityDoesNotAffectGameplay`: a scripted 200-step session at intensity `0.35` and at `1.0` produces identical `Score`, `Lines`, and `Board` (§44) + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ -run 'TestIntensity|TestLowIntensity|TestBoardEffectsSurvive|TestAppSets' -v` +Expected: FAIL, `undefined: SetIntensity`. + +- [ ] **Step 3: Implement** + +Intensity scales star count (via `Resize`'s count formula) and burst counts. Board-local effects — supernova, trails, ghost, border — are exempt, which is what `TestBoardEffectsSurviveLowIntensity` pins. + +- [ ] **Step 4: Run tests to verify they pass, then resize by hand** + +```bash +go test ./... -v && go run ./cmd/cosmic-tetris +``` + +Drag the terminal from wide to 40×24 and back while playing. No crash, no flicker, and the background visibly calms down as it shrinks. + +- [ ] **Step 5: Commit** + +```bash +git add internal/fx internal/app +git commit -m "feat(fx): scale effect intensity to terminal size" +``` + +--- + +### Task 6: Flight manual via Bubbles, and the rare flavor details + +**Files:** +- Modify: `internal/render/overlay.go`, `internal/flavor/messages.go`, `internal/flavor/channel.go`, `internal/app/keys.go` +- Test: `internal/render/help_test.go`, `internal/flavor/rare_test.go` + +**Interfaces:** +- Produces: + - `func RenderHelp(width, height int, hm help.Model, k KeyMapView) string` where `type KeyMapView interface { FullHelp() [][]key.Binding }` — `render` takes the interface so it does not import `app` (§39: use the Bubbles key/help primitives). This **widens** Plan 1 Task 15's `RenderHelp(width, height int)`; update the `Scene`-composition call site and regenerate `testdata/help.golden`. `app.KeyMap` already satisfies `KeyMapView` via Plan 1 Task 16's `FullHelp`. + - `render.Scene` gains `Keys KeyMapView` and `Help help.Model`, which `app.Model` populates; both may be zero-valued, in which case `RenderHelp` falls back to the Plan 1 static panel + - `func (c *Channel) HandleRare(ev RareEvent)`; `type RareEvent uint8` with `RareIdleNoMove, RareVerticalIDrop, RareHoldO, RareScoreRollover, RareTerminalAwareness, RareShootingStar` + - `func (w *World) MaybeShootingStar()` — called by the world's own idle timer + +- [ ] **Step 1: Write the failing tests in `internal/render/help_test.go`** + +- `TestHelpUsesBubblesHelp`: `RenderHelp` output contains every §8 binding's key and description, sourced from `FullHelp()` rather than a hand-written string +- `TestHelpCopy`: contains `FLIGHT MANUAL`, `move spacecraft`, `accelerate doom`, `rotate geometry`, `rotate other way`, `YEET`, `quantum storage`, `suspend spacetime`, `reboot universe`, `abandon mission`, `close this nonsense` (§39, verbatim) +- `TestHelpFitsSmallTerminal`: at `40×24`, every line fits and the panel is not clipped mid-border +- `TestHelpDoesNotPauseTheGame`: covered in Plan 1 Task 17; re-assert here that `RenderHelp` is pure and takes no game state + +In `internal/flavor/rare_test.go`: +- `TestVerticalIDropMessage`: `HandleRare(RareVerticalIDrop)` -> `Current()` is `KINETIC ROD DEPLOYED` (§45) +- `TestHoldOMessage`: `RareHoldO` -> `CUBE ADJACENT OBJECT SECURED` +- `TestScoreRolloverMessage`: `RareScoreRollover` -> `NUMBER BECAME BIGGER` +- `TestLongIdleMessage`: `RareIdleNoMove` -> `MISSION CONTROL: CAPTAIN?` +- `TestTerminalAwarenessIsRare`: over 10 000 seeded opportunities, `RareTerminalAwareness` fires between 1 and 50 times — extremely rare, but not never (§45) +- `TestRareMessagesRespectCooldown`: a rare event inside the hold window does not preempt a major message +- `TestRareDetectionInApp`: hard-dropping a vertical I triggers `RareVerticalIDrop`; hard-dropping a horizontal I does not; holding an O triggers `RareHoldO` +- `TestIdleTriggersAfterNoInput`: 20s of frames with no key press -> `RareIdleNoMove` fired exactly once, not repeatedly (§45: these remain occasional) +- `TestShootingStarIsOccasional`: over 60s of seeded frames, shooting stars occur between 1 and 20 times (§45) + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/render/ ./internal/flavor/ -run 'TestHelp|TestRare|TestVerticalI|TestHoldO|TestScoreRollover|TestLongIdle|TestTerminalAwareness|TestIdleTriggers|TestShootingStar' -v` +Expected: FAIL. + +- [ ] **Step 3: Implement** + +Rare-event detection lives in `app.handleEvents`, where both the event and the piece are available: a `PieceHardDropped` with `Piece.Kind == KindI` and `Rotation` odd is the kinetic rod; a `HoldUsed` with `KindO` is the cube. Score rollover fires when the score crosses a power of ten. The idle timer resets on any gameplay key. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./... -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/render internal/flavor internal/app +git commit -m "feat: flight manual via Bubbles help, plus the rare flavor lines" +``` + +--- + +### Task 7: Definition-of-done sweep + +**Files:** +- Modify: `README.md`, and whatever the sweep turns up +- Test: `internal/render/render_test.go` (extend), `internal/app/session_test.go` + +**Interfaces:** +- Consumes: everything. Produces no new API. + +- [ ] **Step 1: Write the failing tests in `internal/app/session_test.go`** + +These are the §47 checklist items no earlier task's tests cover as a whole-program property. + +- `TestFullSessionStartToGameOver`: from `New(Options{Seed: 1})`, drive boot → play → game over → final card entirely through `Update`, asserting each state transition happens exactly once +- `TestFXNeverModifiesGameOverAWholeSession`: during that session, snapshot the game before and after every FX call -> unchanged (§47) +- `TestGameAndFXRNGRemainIsolated`: two sessions with identical seeds and identical input streams but different FX intensities produce identical piece orders (§47) +- `TestOutputIsStableBetweenIdenticalFrames`: two consecutive `View()` calls with no elapsed time produce byte-identical output — the flicker precondition (§47: no visible flicker) +- `TestNoPanicOverAFuzzedSession`: 5 000 pseudo-random messages (keys, resizes from `0×0` to `300×100`, frames with `dt` from 0 to 60s) -> no panic and the model stays in a valid state +- `TestAllFiveFlagCombinations`: for the 8 combinations of `ASCII`/`NoFX`/`ReducedMotion`, a 300-step scripted session produces identical `Score`, `Lines`, and `Board` (§47: effects never modify game state) + +- [ ] **Step 2: Run tests to verify they fail, then fix what they find** + +Run: `go test ./internal/app/ -run 'TestFullSession|TestFXNever|TestGameAndFX|TestOutputIsStable|TestNoPanic|TestAllFive' -v` +Expected: FAIL initially. Fix the product, not the test. + +- [ ] **Step 3: Walk the §47 checklist by hand and record the result in the README** + +```bash +go test ./... -count=2 && go vet ./... && gofmt -l . && go build ./... +go run ./cmd/cosmic-tetris --seed 8675309 +``` + +Confirm each line of §47 in a real terminal: playable start to game over, immediate controls, resize, hold, ghost, next queue, deterministic pieces, isolated RNGs, correct clearing, rising gravity, pause, restart, ASCII fallback, no-FX mode, no flicker, animations never blocking input, four-line clears gloriously excessive, black-hole game over, fun without effects, much funnier with them. Anything that fails becomes a fix in this task, not a note. + +- [ ] **Step 4: Finish the README** + +Sections: the pitch, a screenshot or a pasted frame, install/build, the five-flag CLI surface with what each does, the §8 controls table, "how it's put together" (the four packages and the one-way dependency rule), the two recorded deviations (single `FrameMsg` instead of §36's separate `GravityMsg`; the §49.4 ASCII ghost being Latin-1), and how to run the tests. + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "feat: definition-of-done sweep and README" +``` + +--- + +## Done when + +- Every line of §47 is verified — by a test where it is testable, by hand in a real terminal where it is not. +- `go test ./... -count=2`, `go vet ./...`, `gofmt -l .`, and `go build ./...` are all clean. +- The five-flag CLI surface of §49.5 is exactly what `--help` prints. +- `internal/game` still reads no clock and imports nothing beyond the standard library. +- A four-line clear still produces the §43 reaction. diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/T1-test-lines-ledgerlite-91.plan.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/T1-test-lines-ledgerlite-91.plan.md new file mode 100644 index 000000000..a88591ea5 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/T1-test-lines-ledgerlite-91.plan.md @@ -0,0 +1,394 @@ +# ledgerlite Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build `ledgerlite`, a stdlib-only Python CLI that reads a transactions CSV, categorizes each row from a rules file, and prints per-category totals plus the closing balance. + +**Architecture:** Six small modules with one responsibility each, wired together only in `cli.py`: `model` (the `Transaction` record), `parse` (text → transactions, with `ParseError` carrying a line number), `rules` (rules text → rule list, plus `categorize`), `balance` (date ordering and closing balance), `report` (bucketing and formatting), `cli` (argparse, file reading, exit codes). Everything below `cli` is pure — it takes and returns strings and objects, never touches the filesystem, and never prints — so all of it is directly unit-testable, and `cli` owns the only I/O and the only knowledge of paths and exit codes. + +**Tech Stack:** Python 3.11+, standard library only (`csv`, `decimal`, `datetime`, `dataclasses`, `argparse`, `pathlib`, `re`, `io`), tests with `unittest`. + +**Spec:** `design.md` (in this repo root) + +## Global Constraints + +- Python 3.11+. Standard library only — no third-party dependencies. +- Money is always `decimal.Decimal`, never `float`. No arithmetic path may pass through `float`. +- Package lives at `ledgerlite/`; tests live at the repo root as `test_.py` and run with `python3 -m unittest`. +- Amounts print with exactly two fractional digits, a leading `-` for negatives, and no thousands separators (`-12.50`, `0.00`, `1200.00`). +- Error messages go to stderr and are exactly: `ledgerlite: cannot read : ` (exit 1) and `ledgerlite: :: ` (exit 2). Success prints only the report to stdout and exits 0. +- A malformed row rejects the whole file: nothing is printed to stdout. +- Work directly on `main`; this repo has no remote, so never push. + +## Review Focus + +Five input classes the spec implies but does not spell out. Each has a test in the task that owns the code; they are listed here because they are the ones most likely to bite a real user. + +1. A description containing a comma or double quotes (`"COFFEE, LARGE"`) must parse as one field, not a column-count error — real bank exports quote descriptions. (Task 2) +2. A transactions path that names a directory, or a `--rules` path that does not exist, must produce the `cannot read` message and exit 1, never a traceback. (Task 6) +3. Amount spellings `decimal.Decimal` accepts but the spec does not (`1e3`, `NaN`, `Infinity`, the empty string) must be rejected as malformed with exit 2, not silently turned into a number. (Task 2) +4. A category whose amounts cancel to negative zero must print `0.00`, not `-0.00` — `Decimal` preserves the sign. (Task 5) +5. A transactions file that is not valid UTF-8 must produce the `cannot read` message and exit 1, not a `UnicodeDecodeError` traceback. (Task 6) + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `ledgerlite/__init__.py` | Empty package marker. | +| `ledgerlite/model.py` | `Transaction` frozen dataclass. | +| `ledgerlite/parse.py` | `ParseError`, `parse_amount`, `parse_transactions`. | +| `ledgerlite/rules.py` | `parse_rules`, `categorize`. | +| `ledgerlite/balance.py` | `order_by_date`, `closing_balance`. | +| `ledgerlite/report.py` | `format_amount`, `category_totals`, `format_report`. | +| `ledgerlite/cli.py` | `main(argv) -> int`: argparse, file reads, exit codes. | +| `ledgerlite/__main__.py` | `python3 -m ledgerlite` entry point. | +| `test_model.py`, `test_parse.py`, `test_rules.py`, `test_balance.py`, `test_report.py`, `test_cli.py` | Unit tests, repo root. | + +`design.md`'s layout does not list `__main__.py`; it is added in Task 6 because without it the tool cannot be invoked at all. There is no packaging metadata, so the invocation is `python3 -m ledgerlite report ...` rather than a `ledgerlite` console script. + +--- + +### Task 1: Package skeleton and `Transaction` model + +**Files:** +- Create: `ledgerlite/__init__.py` (empty), `ledgerlite/model.py`, `.gitignore` +- Test: `test_model.py` + +**Interfaces:** +- Consumes: nothing. +- Produces: `Transaction`, a frozen dataclass with fields `date: datetime.date`, `amount: decimal.Decimal`, `description: str`, in that positional order. Every later task uses it. + +- [ ] **Step 1: Write the failing tests in `test_model.py`** + +- `test_fields_are_positional`: `Transaction(date(2026, 3, 4), Decimal("-7.50"), "Coffee")` has `.date == date(2026, 3, 4)`, `.amount == Decimal("-7.50")`, `.description == "Coffee"` +- `test_is_frozen`: assigning to `.amount` raises `dataclasses.FrozenInstanceError` +- `test_equality_by_value`: two `Transaction`s built from the same three values are `==` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python3 -m unittest test_model -v` +Expected: FAIL with `ModuleNotFoundError: No module named 'ledgerlite'` + +- [ ] **Step 3: Create the package and `Transaction` in `ledgerlite/model.py`** + +`@dataclasses.dataclass(frozen=True)`. Create `ledgerlite/__init__.py` empty, and `.gitignore` containing `__pycache__/`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python3 -m unittest test_model -v` +Expected: PASS (3 tests) + +- [ ] **Step 5: Commit** + +```bash +git add .gitignore ledgerlite/__init__.py ledgerlite/model.py test_model.py +git commit -m "feat: add ledgerlite package skeleton and Transaction model" +``` + +--- + +### Task 2: `parse.py` — CSV text to transactions + +**Files:** +- Create: `ledgerlite/parse.py` +- Test: `test_parse.py` + +**Interfaces:** +- Consumes: `Transaction` from Task 1. +- Produces: + - `class ParseError(Exception)` — constructed `ParseError(line: int, message: str)`, exposing `.line: int` and `.message: str`. `super().__init__(message)`. + - `parse_amount(text: str) -> Decimal` — raises `ValueError` on bad input. Message is exactly `amount is not a decimal number` or `amount has more than two fractional digits`. Task 6 reuses this for `--opening`. + - `parse_transactions(text: str) -> list[Transaction]` — input order preserved; raises `ParseError` on the first bad line. + +**Pinned decisions (the spec leaves these open):** +- Line numbers are 1-based file lines, so the header is line 1 and the first data row is line 2. Use `csv.reader(io.StringIO(text))` and read `reader.line_num` for the current line, which stays correct when a quoted field spans newlines. +- The first row is the header. It must equal `date`, `amount`, `description` after stripping each field and lowercasing; otherwise `ParseError(1, "expected header 'date,amount,description'")`. A file with no rows at all is `ParseError(1, "file is empty")`. +- Rows that are empty or whose every field is blank after stripping are skipped, not errors — a stray blank line should not reject a file. +- `date` and `amount` fields are stripped before parsing; `description` is kept verbatim. +- Date parsing is `datetime.date.fromisoformat`, so `2026-3-4` and `2026-02-30` are malformed. +- Amount validity is a regex, not `Decimal`'s own leniency: `re.fullmatch(r"[+-]?\d+(?:\.\d+)?", s)` must match (else `amount is not a decimal number`), and any fractional part must be 1–2 digits (else `amount has more than two fractional digits`). This rejects `1e3`, `NaN`, `Infinity`, `.5` and `""`, and accepts `+5`, `1.5`, `1.50`. +- Row-level messages append the offending raw value: e.g. `amount is not a decimal number: 'abc'`. + +- [ ] **Step 1: Write the failing tests in `test_parse.py`** + +Helper: build CSV text inline with `"\n".join([...])`. `HEADER = "date,amount,description"`. + +`parse_amount`: +- `test_amount_two_digits`: `parse_amount("-12.50")` -> `Decimal("-12.50")` +- `test_amount_one_digit_and_integer`: `parse_amount("1.5")` -> `Decimal("1.5")`; `parse_amount("+5")` -> `Decimal("5")` +- `test_amount_rejects_non_number`: each of `"abc"`, `"1e3"`, `"NaN"`, `"Infinity"`, `""`, `".5"` raises `ValueError` with `str(e) == "amount is not a decimal number"` +- `test_amount_rejects_extra_digits`: `parse_amount("1.005")` raises `ValueError` with `str(e) == "amount has more than two fractional digits"` + +`parse_transactions`: +- `test_parses_rows_in_input_order`: header plus `2026-03-05,2500.00,Salary` and `2026-03-04,-7.50,Coffee` -> two `Transaction`s in that same input order, amounts `Decimal("2500.00")` then `Decimal("-7.50")` +- `test_header_only_is_empty_list`: `HEADER` alone -> `[]` +- `test_quoted_description_with_comma`: row `2026-03-04,-7.50,"COFFEE, LARGE"` -> one transaction with `.description == "COFFEE, LARGE"` (Review Focus 1) +- `test_quoted_description_with_quotes`: row `2026-03-04,-7.50,"say ""hi"""` -> `.description == 'say "hi"'` +- `test_skips_blank_lines`: header, a blank line, one valid row -> one transaction +- `test_strips_whitespace_in_date_and_amount`: row ` 2026-03-04 , -7.50 ,Coffee` -> parses, `.amount == Decimal("-7.50")` +- `test_rejects_missing_header`: text `2026-03-04,-7.50,Coffee` (no header) raises `ParseError` with `.line == 1` and `.message == "expected header 'date,amount,description'"` +- `test_rejects_empty_file`: `parse_transactions("")` raises `ParseError` with `.line == 1`, `.message == "file is empty"` +- `test_rejects_wrong_column_count`: header plus `2026-03-04,-7.50` raises `ParseError` with `.line == 2`, `.message == "expected 3 columns, got 2"` +- `test_rejects_bad_date`: header plus `2026-3-4,-7.50,Coffee` raises `ParseError` with `.line == 2`, `.message == "date is not a valid ISO 8601 date: '2026-3-4'"` +- `test_rejects_impossible_date`: header plus `2026-02-30,-7.50,Coffee` raises `ParseError` with `.line == 2` +- `test_rejects_bad_amount`: header plus `2026-03-04,abc,Coffee` raises `ParseError` with `.line == 2`, `.message == "amount is not a decimal number: 'abc'"` (Review Focus 3) +- `test_rejects_too_many_fractional_digits`: header plus `2026-03-04,1.005,Coffee` raises `ParseError` with `.line == 2`, `.message == "amount has more than two fractional digits: '1.005'"` +- `test_reports_line_of_second_bad_row`: header, one valid row, then `2026-03-04,abc,X` raises `ParseError` with `.line == 3` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python3 -m unittest test_parse -v` +Expected: FAIL with `ImportError` / `No module named 'ledgerlite.parse'` + +- [ ] **Step 3: Implement `ParseError`, `parse_amount`, and `parse_transactions` in `ledgerlite/parse.py`** + +Follow the pinned decisions above. `parse_transactions` iterates the `csv.reader`, treats the first non-skipped row as the header, and wraps each `ValueError` from `parse_amount` / `date.fromisoformat` into `ParseError(reader.line_num, f"{message}: {raw!r}")`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python3 -m unittest test_parse -v` +Expected: PASS (all tests) + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/parse.py test_parse.py +git commit -m "feat: parse transactions CSV into Transaction records" +``` + +--- + +### Task 3: `rules.py` — rules text and categorization + +**Files:** +- Create: `ledgerlite/rules.py` +- Test: `test_rules.py` + +**Interfaces:** +- Consumes: `ParseError` from Task 2 (imported, not redefined). +- Produces: + - `parse_rules(text: str) -> list[tuple[str, str]]` — `(substring, category)` pairs in file order; raises `ParseError`. + - `categorize(description: str, rules: list[tuple[str, str]]) -> str | None` — first matching rule wins, `None` if none match. + +**Pinned decisions:** +- Blank/whitespace-only lines are skipped. Line numbers are 1-based over the rules file. +- Split on the first `=` only, so a category may contain `=`. Both halves are stripped. +- A non-blank line with no `=` is `ParseError(line, "rule line has no '='")`. An empty substring is `ParseError(line, "rule has an empty substring")`; an empty category is `ParseError(line, "rule has an empty category")`. Rejecting these is better than an empty substring silently matching every transaction. +- Matching lowercases (`str.casefold`) both the description and the substring. + +- [ ] **Step 1: Write the failing tests in `test_rules.py`** + +`parse_rules`: +- `test_parses_rules_in_order`: `"coffee=food\nrent=housing\n"` -> `[("coffee", "food"), ("rent", "housing")]` +- `test_skips_blank_lines`: `"\ncoffee=food\n \n"` -> `[("coffee", "food")]` +- `test_empty_text_is_no_rules`: `parse_rules("")` -> `[]` +- `test_strips_whitespace`: `" coffee = food "` -> `[("coffee", "food")]` +- `test_splits_on_first_equals_only`: `"a=b=c"` -> `[("a", "b=c")]` +- `test_rejects_line_without_equals`: `"coffee=food\nrent\n"` raises `ParseError` with `.line == 2`, `.message == "rule line has no '='"` +- `test_rejects_empty_substring`: `"=food"` raises `ParseError` with `.line == 1`, `.message == "rule has an empty substring"` +- `test_rejects_empty_category`: `"coffee="` raises `ParseError` with `.line == 1`, `.message == "rule has an empty category"` + +`categorize`: +- `test_matches_substring`: `categorize("Blue Bottle Coffee", [("coffee", "food")])` -> `"food"` +- `test_match_is_case_insensitive_both_ways`: `categorize("BLUE BOTTLE COFFEE", [("Coffee", "food")])` -> `"food"` +- `test_first_matching_rule_wins`: `categorize("coffee rent", [("rent", "housing"), ("coffee", "food")])` -> `"housing"` +- `test_no_match_is_none`: `categorize("ATM", [("coffee", "food")])` -> `None` +- `test_no_rules_is_none`: `categorize("Coffee", [])` -> `None` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python3 -m unittest test_rules -v` +Expected: FAIL with `No module named 'ledgerlite.rules'` + +- [ ] **Step 3: Implement `parse_rules` and `categorize` in `ledgerlite/rules.py`** + +Follow the pinned decisions above. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python3 -m unittest test_rules -v` +Expected: PASS (all tests) + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/rules.py test_rules.py +git commit -m "feat: parse rules file and categorize descriptions" +``` + +--- + +### Task 4: `balance.py` — date ordering and closing balance + +**Files:** +- Create: `ledgerlite/balance.py` +- Test: `test_balance.py` + +**Interfaces:** +- Consumes: `Transaction` from Task 1. +- Produces: + - `order_by_date(transactions: list[Transaction]) -> list[Transaction]` — sorted by `.date`, ties keeping input order. New list; input untouched. + - `closing_balance(transactions: list[Transaction], opening: Decimal) -> Decimal` — `opening` plus every amount. + +Only the closing value is ever printed, so no per-transaction running-balance sequence is exposed; `closing_balance` walks the ordered list and returns the final value. + +- [ ] **Step 1: Write the failing tests in `test_balance.py`** + +- `test_orders_by_date`: `order_by_date([mar05, mar04])` -> `[mar04, mar05]` +- `test_same_date_keeps_input_order`: two transactions both dated `2026-03-04` with descriptions `"a"` then `"b"` -> descriptions still `["a", "b"]` +- `test_does_not_mutate_input`: the list passed to `order_by_date` is unchanged afterward +- `test_empty_list_orders_to_empty`: `order_by_date([])` -> `[]` +- `test_closing_balance_adds_amounts`: opening `Decimal("100")`, amounts `-7.50`, `-900.00`, `2500.00` -> `Decimal("1692.50")` +- `test_closing_balance_of_no_transactions_is_opening`: `closing_balance([], Decimal("100"))` -> `Decimal("100")` +- `test_closing_balance_is_exact_decimal`: opening `Decimal("0")`, amounts `Decimal("0.10")` ten times -> `Decimal("1.00")` (exactly `==`, proving no float path) + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python3 -m unittest test_balance -v` +Expected: FAIL with `No module named 'ledgerlite.balance'` + +- [ ] **Step 3: Implement `order_by_date` and `closing_balance` in `ledgerlite/balance.py`** + +`sorted(transactions, key=lambda t: t.date)` is stable, which is what gives ties their input order. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python3 -m unittest test_balance -v` +Expected: PASS (all tests) + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/balance.py test_balance.py +git commit -m "feat: add date ordering and closing balance" +``` + +--- + +### Task 5: `report.py` — totals and formatting + +**Files:** +- Create: `ledgerlite/report.py` +- Test: `test_report.py` + +**Interfaces:** +- Consumes: `Transaction` (Task 1), `categorize` (Task 3). +- Produces: + - `format_amount(value: Decimal) -> str` + - `category_totals(transactions: list[Transaction], rules: list[tuple[str, str]]) -> list[tuple[str, Decimal]]` — display order. + - `format_report(totals: list[tuple[str, Decimal]], closing: Decimal) -> str` — the full report, **no** trailing newline. + +**Pinned decisions:** +- `format_amount` quantizes to `Decimal("0.01")` and formats with `f"{q:f}"`, which never inserts thousands separators. If the quantized value is zero it formats `abs(q)`, so `Decimal("-0.00")` prints `0.00`. +- A category with no transactions is not listed, including `uncategorized`. +- Buckets are keyed by `categorize(...) or "uncategorized"`, so a rule whose category literally is `uncategorized` merges into that one bucket and prints last. +- Alphabetical means `key=lambda name: (name.casefold(), name)`, so `apple` sorts before `Food` (case-insensitive, with the raw name breaking ties for determinism); `uncategorized` is then moved to the end. +- `format_report` emits the blank separator line only when there is at least one category line. + +- [ ] **Step 1: Write the failing tests in `test_report.py`** + +`format_amount`: +- `test_formats_two_fractional_digits`: `Decimal("-12.5")` -> `"-12.50"`; `Decimal("1200")` -> `"1200.00"`; `Decimal("0")` -> `"0.00"` +- `test_no_thousands_separator`: `Decimal("1234567.8")` -> `"1234567.80"` +- `test_negative_zero_prints_unsigned`: `format_amount(Decimal("-0.00"))` -> `"0.00"` (Review Focus 4) + +`category_totals`: +- `test_sums_per_category_alphabetically`: transactions Coffee `-7.50` (rule `coffee=food`), Rent `-900.00` (rule `rent=housing`) -> `[("food", Decimal("-7.50")), ("housing", Decimal("-900.00"))]` +- `test_uncategorized_is_last`: add Salary `2500.00` matching no rule -> `[("food", ...), ("housing", ...), ("uncategorized", Decimal("2500.00"))]` +- `test_uncategorized_omitted_when_empty`: every transaction matches a rule -> no `uncategorized` entry +- `test_no_transactions_is_empty_list`: `category_totals([], rules)` -> `[]` +- `test_rule_named_uncategorized_merges_and_sorts_last`: rules `[("coffee", "uncategorized")]` with a Coffee `-7.50` and an unmatched Salary `2500.00` -> `[("uncategorized", Decimal("2492.50"))]` +- `test_alphabetical_is_case_insensitive`: rules yielding categories `Food` and `apple` -> `[("apple", ...), ("Food", ...)]` +- `test_no_rules_puts_everything_in_uncategorized`: `category_totals([t1, t2], [])` -> `[("uncategorized", t1.amount + t2.amount)]` + +`format_report`: +- `test_matches_spec_example`: totals `[("food", Decimal("-7.50")), ("housing", Decimal("-900.00")), ("uncategorized", Decimal("2500.00"))]` and closing `Decimal("1692.50")` -> exactly `"food: -7.50\nhousing: -900.00\nuncategorized: 2500.00\n\nclosing balance: 1692.50"` +- `test_no_categories_prints_only_closing_balance`: `format_report([], Decimal("100"))` -> `"closing balance: 100.00"` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python3 -m unittest test_report -v` +Expected: FAIL with `No module named 'ledgerlite.report'` + +- [ ] **Step 3: Implement `format_amount`, `category_totals`, and `format_report` in `ledgerlite/report.py`** + +Follow the pinned decisions above. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python3 -m unittest test_report -v` +Expected: PASS (all tests) + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/report.py test_report.py +git commit -m "feat: add per-category totals and report formatting" +``` + +--- + +### Task 6: `cli.py` — argparse, file I/O, exit codes + +**Files:** +- Create: `ledgerlite/cli.py`, `ledgerlite/__main__.py` +- Test: `test_cli.py` + +**Interfaces:** +- Consumes: `parse_amount`, `parse_transactions`, `ParseError` (Task 2); `parse_rules` (Task 3); `order_by_date`, `closing_balance` (Task 4); `category_totals`, `format_report` (Task 5). +- Produces: `main(argv: list[str] | None = None) -> int`. `__main__.py` is `raise SystemExit(main())`. + +**Pinned decisions:** +- `argparse.ArgumentParser(prog="ledgerlite")` with subcommand `report`, positional `transactions`, options `--rules` (default `None`) and `--opening` (default `"0"`). `--opening` uses `type=` a wrapper that calls `parse_amount` and re-raises `ValueError` as `argparse.ArgumentTypeError(str(e))`, so a bad `--opening` and a missing argument both exit 2 through argparse's own path — `main` raises `SystemExit(2)` for usage errors rather than returning. +- Files are read with `pathlib.Path(path).read_text(encoding="utf-8")`. `OSError` -> reason `e.strerror or str(e)`; `UnicodeDecodeError` -> reason `not valid UTF-8`. Either prints `ledgerlite: cannot read : ` to stderr and returns 1, with `` exactly as given on the command line. +- Order of operations, so failures are predictable: read the transactions file, then the rules file (exit 1 on either, transactions first), then `parse_transactions`, then `parse_rules` (exit 2 on either, transactions first). A `ParseError` prints `ledgerlite: {path}:{e.line}: {e.message}` for the file it came from. +- The report is written with `print(text)`, so stdout ends in exactly one newline. Nothing is written to stdout on any error path. +- Diagnostics go to `sys.stderr`; tests capture with `contextlib.redirect_stdout` / `redirect_stderr` and build input files under `tempfile.TemporaryDirectory()`. + +- [ ] **Step 1: Write the failing tests in `test_cli.py`** + +Helper: `run(*args)` calls `main(list(args))` inside redirected stdout/stderr and returns `(code, out, err)`. + +- `test_spec_example_end_to_end`: transactions `2026-03-06,2500.00,Salary`, `2026-03-04,-7.50,Coffee`, `2026-03-05,-900.00,Rent`; rules `coffee=food` and `rent=housing`; `run("report", tx, "--rules", rules, "--opening", "100")` -> code `0`, stdout exactly the spec's example block (`"food: -7.50\nhousing: -900.00\nuncategorized: 2500.00\n\nclosing balance: 1692.50\n"`), stderr empty +- `test_defaults_no_rules_zero_opening`: same transactions, `run("report", tx)` -> code `0`, stdout `"uncategorized: 1592.50\n\nclosing balance: 1592.50\n"` +- `test_missing_transactions_file`: `run("report", "/no/such/file.csv")` -> code `1`, stdout empty, stderr `"ledgerlite: cannot read /no/such/file.csv: No such file or directory\n"` +- `test_transactions_path_is_a_directory`: pass the temp directory -> code `1`, stderr starts with `"ledgerlite: cannot read "` and contains `"Is a directory"` (Review Focus 2) +- `test_missing_rules_file`: valid transactions, `--rules /no/such/rules.txt` -> code `1`, stdout empty, stderr names the rules path (Review Focus 2) +- `test_non_utf8_transactions_file`: write bytes `b"date,amount,description\n2026-03-04,-7.50,caf\xe9\n"` -> code `1`, stderr `"ledgerlite: cannot read : not valid UTF-8\n"` (Review Focus 5) +- `test_malformed_row_exits_2_with_line`: header plus a valid row plus `2026-03-04,1.005,X` -> code `2`, stdout empty, stderr `"ledgerlite: :3: amount has more than two fractional digits: '1.005'\n"` +- `test_malformed_rules_file_exits_2_with_line`: valid transactions, rules `"coffee=food\nrent\n"` -> code `2`, stdout empty, stderr `"ledgerlite: :2: rule line has no '='\n"` +- `test_bad_opening_exits_2`: `main(["report", tx, "--opening", "abc"])` raises `SystemExit` with `.code == 2` +- `test_negative_opening_accepted`: `--opening -50.25` with one transaction of `100.00` -> code `0`, stdout ends `"closing balance: 49.75\n"` +- `test_no_transactions_prints_only_closing_balance`: header-only file, `--opening 100` -> code `0`, stdout `"closing balance: 100.00\n"` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python3 -m unittest test_cli -v` +Expected: FAIL with `No module named 'ledgerlite.cli'` + +- [ ] **Step 3: Implement `main` in `ledgerlite/cli.py` and the `ledgerlite/__main__.py` entry point** + +Follow the pinned decisions above; keep the module's only logic the argument wiring, the two file reads, and the error formatting — the pipeline is `parse_transactions` → `order_by_date` → `closing_balance` / `category_totals` → `format_report`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python3 -m unittest test_cli -v` +Expected: PASS (all tests) + +- [ ] **Step 5: Run the whole suite and the real command** + +Run: `python3 -m unittest -v` +Expected: PASS, every test from Tasks 1–6, no errors. + +Then, from the repo root with a scratch CSV and rules file matching the spec example: +Run: `python3 -m ledgerlite report /tmp/tx.csv --rules /tmp/rules.txt --opening 100` +Expected: the example report on stdout, `echo $?` prints `0`. + +- [ ] **Step 6: Commit** + +```bash +git add ledgerlite/cli.py ledgerlite/__main__.py test_cli.py +git commit -m "feat: add ledgerlite report command-line interface" +``` diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/T1-test-lines-ledgerlite-92.plan.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/T1-test-lines-ledgerlite-92.plan.md new file mode 100644 index 000000000..b722a6362 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/T1-test-lines-ledgerlite-92.plan.md @@ -0,0 +1,384 @@ +# ledgerlite Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A command-line tool that reads a transactions CSV, categorizes each row from a rules file, and prints per-category totals plus the closing balance. + +**Architecture:** A `ledgerlite` package of single-responsibility modules layered bottom-up: `model` (the `Transaction` record), `parse` (text → transactions, all-or-nothing), `rules` (rules text → substring/category pairs, plus lookup), `balance` (date ordering and closing balance), `report` (totals and formatting), `cli` (argparse, file reading, exit codes). Every module below `cli` is pure — it takes and returns values, never touches the filesystem or prints — so `cli` owns all I/O and all three exit codes. + +**Tech Stack:** Python 3.11+, standard library only (`csv`, `decimal`, `datetime`, `argparse`, `dataclasses`), `unittest` for tests. + +**Spec:** `design.md` (same directory as this plan) + +## Global Constraints + +- Python 3.11+. Standard library only — no third-party dependencies. +- Money is `decimal.Decimal` everywhere. Never `float`, at any point, including in tests. +- Package lives at `ledgerlite/` with exactly the modules named in the spec's Package layout: `__init__.py`, `model.py`, `parse.py`, `rules.py`, `balance.py`, `report.py`, `cli.py`. +- Tests live at the repo root as `test_.py` and run with `python3 -m unittest`. +- Amounts are rendered with exactly two fractional digits, a leading `-` for negatives, and no thousands separators: `-12.50`, `0.00`, `1200.00`. +- Work directly on `main`. Commit after every task. + +## Review Focus + +These are input classes the spec implies but does not spell out. Each one's test is assigned to the task that owns the code, and appears in that task's steps. + +1. **`Decimal` accepts more than the spec means by "a decimal number":** `nan`, `Infinity`, `-Inf` all parse successfully and would poison every total. Must be rejected as a malformed amount (Task 1). +2. **A rules file that cannot be read:** the spec pins this behavior only for TRANSACTIONS. A missing `--rules` path must not traceback; treat it exactly like an unreadable TRANSACTIONS file — `ledgerlite: cannot read : ` on stderr, exit 1 (Task 5). +3. **An empty or header-only transactions file:** no category lines at all (not a `uncategorized: 0.00` line), and the closing balance is the opening amount (Tasks 4 and 5). +4. **A rule whose category is literally `uncategorized`:** its transactions merge into the same bucket as unmatched ones, and that bucket still prints last (Task 4). +5. **A blank line in the middle of the CSV:** it is a malformed row, so the whole file is rejected with exit 2 and nothing on stdout — not silently skipped (Tasks 1 and 5). + +--- + +### Task 1: Transaction model and CSV parsing + +**Files:** +- Create: `ledgerlite/__init__.py` (empty) +- Create: `ledgerlite/model.py` +- Create: `ledgerlite/parse.py` +- Test: `test_parse.py` + +**Interfaces:** +- Consumes: nothing. +- Produces: + ```python + # ledgerlite/model.py + @dataclass(frozen=True) + class Transaction: + date: datetime.date + amount: decimal.Decimal + description: str + + # ledgerlite/parse.py + class ParseError(Exception): + line: int # 1-based line number in the file + message: str # "what is wrong", no path or line prefix + + def parse_transactions(text: str) -> list[Transaction] + ``` + `parse_transactions` returns transactions in input order (no sorting). Line 1 is the header and is skipped unconditionally — its contents are not validated. Callers supply the file's full text; `parse.py` never opens files. + +**Requirements:** +- Malformed row messages, exact strings (`` is the offending field, `repr`-quoted): + - wrong column count: `expected 3 columns, got 2` + - unparseable date: `invalid date: '2026-13-01'` + - amount not a decimal number: `invalid amount: 'abc'` + - more than two fractional digits: `amount has more than two fractional digits: '1.005'` +- A malformed row raises immediately; no partial result is returned. + +- [ ] **Step 1: Write the failing tests in `test_parse.py`** + +- `test_parses_rows_in_input_order`: text with header + rows `2026-03-05,2500.00,Salary` then `2026-03-04,-7.50,Coffee` -> `[Transaction(date(2026,3,5), Decimal("2500.00"), "Salary"), Transaction(date(2026,3,4), Decimal("-7.50"), "Coffee")]` +- `test_amount_is_decimal_not_float`: parsing `-7.50` -> `.amount == Decimal("-7.50")` and `isinstance(.amount, Decimal)` +- `test_one_and_two_fractional_digits_ok`: amounts `1.5` and `1.50` both parse, to `Decimal("1.5")` and `Decimal("1.50")` +- `test_integer_amount_ok`: amount `100` -> `Decimal("100")` +- `test_empty_text_is_no_transactions`: `parse_transactions("")` -> `[]` +- `test_header_only_is_no_transactions`: `parse_transactions("date,amount,description\n")` -> `[]` +- `test_description_may_contain_comma`: row `2026-03-04,-7.50,"Coffee, large"` -> `.description == "Coffee, large"` +- `test_rejects_too_few_columns`: row `2026-03-04,-7.50` raises `ParseError` with `.line == 2` and `.message == "expected 3 columns, got 2"` +- `test_rejects_too_many_columns`: row with 4 fields raises `ParseError`, `.message == "expected 3 columns, got 4"` +- `test_rejects_blank_line_mid_file`: header, one good row, a blank line, one good row -> raises `ParseError` with `.line == 3` and `.message == "expected 3 columns, got 0"` +- `test_rejects_unparseable_date`: `2026-13-01` raises `ParseError`, `.message == "invalid date: '2026-13-01'"` +- `test_rejects_impossible_date`: `2026-02-30` raises `ParseError`, `.message == "invalid date: '2026-02-30'"` +- `test_rejects_non_numeric_amount`: `abc` raises `ParseError`, `.message == "invalid amount: 'abc'"` +- `test_rejects_empty_amount`: `` raises `ParseError`, `.message == "invalid amount: ''"` +- `test_rejects_three_fractional_digits`: `1.005` raises `ParseError`, `.message == "amount has more than two fractional digits: '1.005'"` +- `test_rejects_nan_amount`: `nan` raises `ParseError`, `.message == "invalid amount: 'nan'"` +- `test_rejects_infinity_amount`: `Infinity` raises `ParseError`, `.message == "invalid amount: 'Infinity'"` +- `test_reports_line_number_of_later_bad_row`: header + two good rows + one bad row -> `.line == 5` +- `test_first_bad_row_wins`: two malformed rows -> `.line` is the earlier one + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_parse -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite'` + +- [ ] **Step 3: Implement `Transaction` in `ledgerlite/model.py` and `parse_transactions(text: str) -> list[Transaction]` plus `ParseError` in `ledgerlite/parse.py`** + +Feed `csv.reader(text.splitlines())` so a trailing newline does not produce an extra row; enumerate from 1 for line numbers and skip the first row. Use `datetime.date.fromisoformat` for the date. Amount validation, in this order (the order matters — `as_tuple().exponent` is a string, not an int, for NaN and Infinity): + +```python +try: + amount = Decimal(field) +except decimal.InvalidOperation: + raise ParseError(line, f"invalid amount: {field!r}") +if not amount.is_finite(): + raise ParseError(line, f"invalid amount: {field!r}") +if amount.as_tuple().exponent < -2: + raise ParseError(line, f"amount has more than two fractional digits: {field!r}") +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_parse -v` +Expected: PASS, all tests + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/__init__.py ledgerlite/model.py ledgerlite/parse.py test_parse.py +git commit -m "feat: add Transaction model and CSV parsing" +``` + +--- + +### Task 2: Rules parsing and categorization + +**Files:** +- Create: `ledgerlite/rules.py` +- Test: `test_rules.py` + +**Interfaces:** +- Consumes: nothing. +- Produces: + ```python + # ledgerlite/rules.py + def parse_rules(text: str) -> list[tuple[str, str]] # [(substring, category), ...] in file order + def categorize(description: str, rules: list[tuple[str, str]]) -> str | None + ``` + `categorize` returns the category of the first rule whose substring appears in the description, case-insensitively, or `None` when no rule matches. Rules text comes from the caller; `rules.py` never opens files. + +**Requirements:** +- One rule per line, `=`. Split on the *first* `=`, so a category may contain `=`. +- Blank lines and whitespace-only lines are ignored. Lines with no `=` are ignored. A rule with an empty substring is ignored (it would match everything). +- Leading and trailing whitespace is stripped from both substring and category. +- Matching is case-insensitive; first matching rule wins. + +- [ ] **Step 1: Write the failing tests in `test_rules.py`** + +- `test_parses_rules_in_order`: `"coffee=food\nrent=housing\n"` -> `[("coffee", "food"), ("rent", "housing")]` +- `test_strips_whitespace`: `" coffee = food \n"` -> `[("coffee", "food")]` +- `test_ignores_blank_and_whitespace_lines`: `"coffee=food\n\n \nrent=housing\n"` -> two rules +- `test_ignores_lines_without_equals`: `"coffee=food\nnonsense\n"` -> `[("coffee", "food")]` +- `test_ignores_empty_substring`: `"=food\ncoffee=food\n"` -> `[("coffee", "food")]` +- `test_splits_on_first_equals_only`: `"a=b=c\n"` -> `[("a", "b=c")]` +- `test_empty_text_is_no_rules`: `parse_rules("")` -> `[]` +- `test_categorize_matches_substring`: `categorize("Blue Bottle Coffee", [("coffee", "food")])` -> `"food"` +- `test_categorize_is_case_insensitive_both_ways`: `categorize("COFFEE run", [("Coffee", "food")])` -> `"food"` +- `test_categorize_first_matching_rule_wins`: `categorize("coffee and rent", [("rent", "housing"), ("coffee", "food")])` -> `"housing"` +- `test_categorize_no_match_is_none`: `categorize("Salary", [("coffee", "food")])` -> `None` +- `test_categorize_no_rules_is_none`: `categorize("Salary", [])` -> `None` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_rules -v` +Expected: FAIL — `ImportError: cannot import name 'parse_rules'` + +- [ ] **Step 3: Implement `parse_rules` and `categorize` in `ledgerlite/rules.py`** + +Use `str.partition("=")` for the first-`=` split. Lowercase both the description and each substring at comparison time in `categorize` (keep the substrings as written in the returned rules). + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_rules -v` +Expected: PASS, all tests + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/rules.py test_rules.py +git commit -m "feat: add rules parsing and categorization" +``` + +--- + +### Task 3: Date ordering and closing balance + +**Files:** +- Create: `ledgerlite/balance.py` +- Test: `test_balance.py` + +**Interfaces:** +- Consumes: `ledgerlite.model.Transaction` (Task 1). +- Produces: + ```python + # ledgerlite/balance.py + def order_by_date(transactions: list[Transaction]) -> list[Transaction] + def closing_balance(transactions: list[Transaction], opening: Decimal) -> Decimal + ``` + `order_by_date` returns a new list sorted by date, ties keeping input order. `closing_balance` is the running balance after the last date-ordered transaction — `opening` when the list is empty. + +**Requirements:** +- Sorting must be stable (`sorted(key=...)`, not a comparison that reorders equal dates). +- `closing_balance` must not mutate its input and must stay in `Decimal`. + +- [ ] **Step 1: Write the failing tests in `test_balance.py`** + +- `test_orders_by_date`: two transactions dated `2026-03-05` then `2026-03-04` -> the `03-04` one first +- `test_same_date_keeps_input_order`: two transactions both dated `2026-03-04`, descriptions `"a"` then `"b"` -> descriptions `["a", "b"]` +- `test_order_does_not_mutate_input`: input list's own order is unchanged after the call +- `test_order_empty`: `order_by_date([])` -> `[]` +- `test_closing_balance_sums_in_date_order`: opening `Decimal("100")`, amounts `-7.50`, `-900.00`, `2500.00` -> `Decimal("1692.50")` +- `test_closing_balance_no_transactions_is_opening`: `closing_balance([], Decimal("100"))` -> `Decimal("100")` +- `test_closing_balance_default_opening_zero`: `closing_balance([tx(-7.50)], Decimal("0"))` -> `Decimal("-7.50")` +- `test_closing_balance_is_decimal`: result `isinstance(..., Decimal)` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_balance -v` +Expected: FAIL — `ImportError: cannot import name 'order_by_date'` + +- [ ] **Step 3: Implement `order_by_date` and `closing_balance` in `ledgerlite/balance.py`** + +`closing_balance` sums the `order_by_date` result starting from `opening`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_balance -v` +Expected: PASS, all tests + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/balance.py test_balance.py +git commit -m "feat: add date ordering and closing balance" +``` + +--- + +### Task 4: Category totals and report formatting + +**Files:** +- Create: `ledgerlite/report.py` +- Test: `test_report.py` + +**Interfaces:** +- Consumes: `Transaction` (Task 1), `categorize` (Task 2), `order_by_date` and `closing_balance` (Task 3). +- Produces: + ```python + # ledgerlite/report.py + UNCATEGORIZED = "uncategorized" + + def format_amount(amount: Decimal) -> str + def category_totals( + transactions: list[Transaction], rules: list[tuple[str, str]] + ) -> list[tuple[str, Decimal]] + def format_report( + transactions: list[Transaction], + rules: list[tuple[str, str]], + opening: Decimal, + ) -> str + ``` + `category_totals` returns `(category, total)` pairs already in print order: named categories sorted alphabetically, then `uncategorized` last if and only if that bucket has at least one transaction. `format_report` returns the whole report text, ending with a single trailing newline. + +**Requirements:** +- Report body: one `: ` line per pair, then a blank line, then `closing balance: `. +- A rule whose category is the string `uncategorized` shares the one `uncategorized` bucket, which still prints last. +- No transactions -> no category lines at all: the report is a blank line then the closing balance line. +- `format_amount` renders a zero total as `0.00`, never `-0.00`. + +- [ ] **Step 1: Write the failing tests in `test_report.py`** + +- `test_format_amount_negative`: `format_amount(Decimal("-12.5"))` -> `"-12.50"` +- `test_format_amount_zero`: `format_amount(Decimal("0"))` -> `"0.00"` +- `test_format_amount_negative_zero`: `format_amount(Decimal("-0.00"))` -> `"0.00"` +- `test_format_amount_no_thousands_separator`: `format_amount(Decimal("1200"))` -> `"1200.00"` +- `test_format_amount_large`: `format_amount(Decimal("1234567.5"))` -> `"1234567.50"` +- `test_totals_sum_per_category`: two `coffee` transactions `-7.50` and `-2.50` with rules `[("coffee", "food")]` -> `[("food", Decimal("-10.00"))]` +- `test_totals_alphabetical`: categories `housing`, `food`, `travel` -> in that alphabetical order +- `test_uncategorized_last_despite_alphabet`: categories `zoo` and unmatched transactions -> `[("zoo", ...), ("uncategorized", ...)]` +- `test_uncategorized_omitted_when_empty`: every transaction matches a rule -> no `uncategorized` pair +- `test_explicit_uncategorized_rule_merges_into_bucket`: rules `[("coffee", "uncategorized")]`, one `-7.50` coffee transaction and one unmatched `2500.00` -> `[("uncategorized", Decimal("2492.50"))]` as the only pair, and it is last +- `test_totals_empty_transactions`: `category_totals([], [])` -> `[]` +- `test_report_matches_spec_example`: opening `Decimal("100")`, rules `coffee=food` and `rent=housing`, transactions `2026-03-04,-7.50,Coffee`, `2026-03-01,-900.00,Rent`, `2026-03-05,2500.00,Salary` -> exactly: + ``` + food: -7.50 + housing: -900.00 + uncategorized: 2500.00 + + closing balance: 1692.50 + ``` + (with a trailing newline) +- `test_report_no_transactions`: `format_report([], [], Decimal("100"))` -> `"\nclosing balance: 100.00\n"` +- `test_report_no_rules_is_all_uncategorized`: transactions with `rules=[]` -> single `uncategorized:` line totalling all amounts + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_report -v` +Expected: FAIL — `ImportError: cannot import name 'format_amount'` + +- [ ] **Step 3: Implement `format_amount`, `category_totals`, and `format_report` in `ledgerlite/report.py`** + +`format_amount`: quantize/format with `f"{amount:.2f}"`, and coerce an exact zero to its absolute value first so `-0.00` cannot appear. `category_totals`: accumulate into a dict keyed by `categorize(...) or UNCATEGORIZED`, then emit `sorted()` of the keys other than `UNCATEGORIZED`, appending `UNCATEGORIZED` if present. `format_report`: `category_totals` for the lines, `closing_balance` for the last line. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_report -v` +Expected: PASS, all tests + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/report.py test_report.py +git commit -m "feat: add category totals and report formatting" +``` + +--- + +### Task 5: CLI entry point + +**Files:** +- Create: `ledgerlite/cli.py` +- Test: `test_cli.py` + +**Interfaces:** +- Consumes: `parse_transactions` and `ParseError` (Task 1), `parse_rules` (Task 2), `format_report` (Task 4). +- Produces: + ```python + # ledgerlite/cli.py + def main(argv: list[str] | None = None) -> int + ``` + `main` prints to `sys.stdout` / `sys.stderr` and returns the exit code; it never calls `sys.exit` itself. `argv` excludes the program name (`["report", "tx.csv", "--rules", "r.txt"]`). Ends with `if __name__ == "__main__": sys.exit(main())`. + +**Requirements:** +- Usage: `ledgerlite report TRANSACTIONS [--rules RULES] [--opening AMOUNT]`. Use `argparse` with `prog="ledgerlite"` and a `report` subcommand. +- `--opening` defaults to `Decimal("0")` and is converted by a function that raises `argparse.ArgumentTypeError` for anything `Decimal` rejects or that is not finite, so a bad value produces an argparse usage error rather than a traceback. +- Exit 0: report on stdout, nothing on stderr. +- Exit 1: `ledgerlite: cannot read : ` on stderr, nothing on stdout. `` is the `OSError`'s `strerror` (e.g. `No such file or directory`); `` is the path as given on the command line. This applies to TRANSACTIONS **and** to `--rules`. +- Exit 2: `ledgerlite: :: ` on stderr from the `ParseError`, nothing on stdout. +- Read the rules file (if given) and parse everything before printing anything, so a failure never leaves partial output on stdout. + +- [ ] **Step 1: Write the failing tests in `test_cli.py`** + +Write a helper that creates files in a `tempfile.TemporaryDirectory` and runs `main(argv)` with `contextlib.redirect_stdout`/`redirect_stderr` into `io.StringIO`, returning `(code, out, err)`. + +- `test_report_success`: the spec's example CSV, rules `coffee=food\nrent=housing\n`, `--opening 100` -> code `0`, stdout is the spec's exact five-line report, stderr `""` +- `test_default_opening_is_zero`: same CSV, no `--opening` -> `closing balance: 1592.50` +- `test_no_rules_flag_is_all_uncategorized`: no `--rules` -> stdout has one `uncategorized:` line and no other category lines, code `0` +- `test_empty_transactions_file`: file containing only the header, `--opening 100` -> code `0`, stdout `"\nclosing balance: 100.00\n"` +- `test_missing_transactions_file`: path that does not exist -> code `1`, stderr `"ledgerlite: /no/such.csv: ..."` — assert it starts with `ledgerlite: cannot read /no/such.csv: ` and contains `No such file or directory`, and stdout is `""` +- `test_transactions_path_is_a_directory`: pass the temp directory -> code `1`, stderr starts with `ledgerlite: cannot read `, stdout `""` +- `test_missing_rules_file`: valid CSV, `--rules` pointing at a nonexistent path -> code `1`, stderr starts with `ledgerlite: cannot read ` naming the rules path, stdout `""` +- `test_malformed_row_exits_2`: CSV whose third line is `2026-03-04,abc,Coffee` -> code `2`, stderr `f"ledgerlite: {path}:3: invalid amount: 'abc'\n"`, stdout `""` +- `test_three_fractional_digits_exits_2`: amount `1.005` -> code `2`, stderr ends with `amount has more than two fractional digits: '1.005'` +- `test_wrong_column_count_exits_2`: a two-field row -> code `2`, stderr contains `expected 3 columns, got 2` +- `test_blank_line_exits_2_with_no_stdout`: blank line between two valid rows -> code `2`, stdout `""` +- `test_bad_opening_is_usage_error`: `--opening abc` -> `SystemExit` raised with code `2` (argparse), stdout `""` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_cli -v` +Expected: FAIL — `ImportError: cannot import name 'main'` + +- [ ] **Step 3: Implement `main(argv)` in `ledgerlite/cli.py`** + +Read files with `pathlib.Path(...).read_text()`, catching `OSError` for the exit-1 path. Print errors with `print(..., file=sys.stderr)`, and the report with `sys.stdout.write(...)` (it already ends in a newline). + +- [ ] **Step 4: Run the whole suite to verify everything passes** + +Run: `python3 -m unittest -v` +Expected: PASS, all tests from all five test modules + +- [ ] **Step 5: Verify the tool end to end by hand** + +Run, from the repo root, with a scratch CSV and rules file matching the spec's example: +`python3 -m ledgerlite.cli report tx.csv --rules rules.txt --opening 100` +Expected: the spec's example report on stdout, `echo $?` prints `0`. Then delete the scratch files. + +- [ ] **Step 6: Commit** + +```bash +git add ledgerlite/cli.py test_cli.py +git commit -m "feat: add ledgerlite CLI entry point" +``` diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/T1-test-lines-ledgerlite-93.plan.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/T1-test-lines-ledgerlite-93.plan.md new file mode 100644 index 000000000..547d7953b --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/T1-test-lines-ledgerlite-93.plan.md @@ -0,0 +1,352 @@ +# ledgerlite Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A `ledgerlite` command-line tool that reads a transactions CSV, categorizes each row from a rules file, and prints per-category totals plus the closing balance. + +**Architecture:** A pure-function core in a `ledgerlite/` package — parsing (`parse.py`), rule matching (`rules.py`), ordering/summing (`balance.py`), formatting (`report.py`) — with all I/O, exit codes, and error messages confined to `cli.py`. Parsers raise exceptions carrying a line number and a message; the CLI is the only place that knows the file path and turns those into `stderr` text and exit codes. Money is `decimal.Decimal` end to end. + +**Tech Stack:** Python 3.11+, standard library only (`csv`, `decimal`, `datetime`, `argparse`, `dataclasses`), `unittest` for tests. + +**Spec:** `design.md` + +## Global Constraints + +- Python 3.11+. Standard library only — no third-party dependencies. +- Amounts are `decimal.Decimal` everywhere, never `float`. No arithmetic on money outside `Decimal`. +- Package layout is fixed by the spec: `ledgerlite/{__init__.py,model.py,parse.py,rules.py,balance.py,report.py,cli.py}`. +- Tests live at the repo root as `test_.py` and run with `python3 -m unittest`. +- All amounts printed with exactly two fractional digits, leading `-` for negatives, no thousands separators (`-12.50`, `0.00`, `1200.00`). +- Exit codes: `0` success, `1` a file cannot be read, `2` a malformed row. +- Error messages go to stderr and are exactly `ledgerlite: cannot read : ` and `ledgerlite: :: `. +- One addition beyond the spec's layout: `ledgerlite/__main__.py` (a two-line `sys.exit(main())`), so the tool is runnable as `python3 -m ledgerlite report ...`. The spec names no packaging metadata, so this is the only way to invoke it. + +## Review Focus + +Input classes the spec implies but does not spell out. Each has a test placed in the task that owns the code. + +1. **`--rules` naming an unreadable file** — the spec defines a read failure only for TRANSACTIONS, but a mistyped rules path is at least as likely; it must print the same `cannot read` message and return 1, not traceback. (Task 5) +2. **Negative zero** — `Decimal("-0.00")` (from an amount field of `-0.00`, or a category whose amounts cancel) formats as `-0.00` by default, which contradicts "a leading `-` for negatives". Zero must print `0.00`. (Task 4) +3. **`NaN` / `Infinity` amounts** — `Decimal("nan")` and `Decimal("inf")` parse without error and would poison every total; both must be rejected as malformed rows. (Task 1) +4. **A missing or mismatched header row, and an empty file** — without a header check the first transaction is silently eaten as a header, and an empty file has no header at all. Both are malformed at line 1. (Task 1) +5. **A blank line in the middle of the file** — `csv` yields an empty row, which must be reported as a wrong column count at its *own* line number (the line count must not drift after it). (Task 1) + +--- + +### Task 1: Transaction model and CSV parsing + +**Files:** +- Create: `ledgerlite/__init__.py` (empty) +- Create: `ledgerlite/model.py` +- Create: `ledgerlite/parse.py` +- Test: `test_parse.py` + +**Interfaces:** +- Consumes: nothing. +- Produces: + - `ledgerlite.model.Transaction` — frozen dataclass with fields `date: datetime.date`, `amount: decimal.Decimal`, `description: str` (in that order). + - `ledgerlite.parse.ParseError(Exception)` — constructed `ParseError(line: int, message: str)`, with attributes `.line: int` and `.message: str`; `str(err) == err.message`. + - `ledgerlite.parse.parse_date(field: str) -> datetime.date` — raises `ValueError`. + - `ledgerlite.parse.parse_amount(field: str) -> decimal.Decimal` — raises `ValueError`. + - `ledgerlite.parse.parse_transactions(text: str) -> list[Transaction]` — raises `ParseError`. Preserves input row order. + +- [ ] **Step 1: Write the failing tests in `test_parse.py`** + +Header for valid fixtures is `date,amount,description`. + +- `test_parses_one_row`: `parse_transactions("date,amount,description\n2026-03-04,-7.50,Coffee\n")` -> `[Transaction(date(2026, 3, 4), Decimal("-7.50"), "Coffee")]` +- `test_amount_is_decimal_not_float`: `type(result[0].amount) is Decimal` +- `test_header_only_gives_empty_list`: `parse_transactions("date,amount,description\n")` -> `[]` +- `test_keeps_input_order_for_unsorted_rows`: rows dated `2026-03-05` then `2026-03-01` come back in that same order +- `test_accepts_one_fractional_digit`: amount field `1.5` -> `Decimal("1.5")` +- `test_accepts_two_fractional_digits`: amount field `1.50` -> `Decimal("1.50")` +- `test_accepts_integer_amount`: amount field `2500` -> `Decimal("2500")` +- `test_accepts_crlf_line_endings`: `"date,amount,description\r\n2026-03-04,-7.50,Coffee\r\n"` -> one transaction +- `test_accepts_quoted_comma_in_description`: `2026-03-04,-7.50,"Coffee, large"` -> description `"Coffee, large"` +- `test_strips_whitespace_around_date_and_amount`: `" 2026-03-04 , -7.50 ,Coffee"` -> `date(2026, 3, 4)`, `Decimal("-7.50")` +- `test_rejects_three_fractional_digits`: amount field `1.005` raises `ParseError` with `.line == 2` and `"fractional"` in `.message` +- `test_rejects_non_numeric_amount`: amount field `abc` raises `ParseError` with `.line == 2` +- `test_rejects_nan_amount`: amount field `nan` raises `ParseError` with `.line == 2` (Review Focus 3) +- `test_rejects_infinity_amount`: amount field `Infinity` raises `ParseError` with `.line == 2` (Review Focus 3) +- `test_rejects_empty_amount`: amount field `""` raises `ParseError` with `.line == 2` +- `test_rejects_unpadded_date`: date field `2026-3-4` raises `ParseError` with `.line == 2` +- `test_rejects_compact_date`: date field `20260304` raises `ParseError` with `.line == 2` (`date.fromisoformat` accepts it; the 10-character check must not) +- `test_rejects_impossible_date`: date field `2026-02-30` raises `ParseError` with `.line == 2` +- `test_rejects_too_few_columns`: `"date,amount,description\n2026-03-04,-7.50\n"` raises `ParseError` with `.line == 2` and `"columns"` in `.message` +- `test_rejects_too_many_columns`: a row with 4 fields raises `ParseError` with `.line == 2` +- `test_rejects_blank_line_midfile`: header, then a good row, then an empty line, then a good row — raises `ParseError` with `.line == 3` (Review Focus 5) +- `test_reports_line_number_of_later_bad_row`: header + good row + bad row -> `.line == 3` +- `test_rejects_missing_header`: `"2026-03-04,-7.50,Coffee\n"` raises `ParseError` with `.line == 1` (Review Focus 4) +- `test_rejects_empty_text`: `parse_transactions("")` raises `ParseError` with `.line == 1` (Review Focus 4) +- `test_parse_error_str_is_message`: `str(ParseError(2, "boom")) == "boom"` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_parse -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'ledgerlite'` + +- [ ] **Step 3: Implement `ledgerlite/model.py`** + +`@dataclass(frozen=True)` `Transaction` with the three fields from the Interfaces block, in that order. + +- [ ] **Step 4: Implement `ledgerlite/parse.py`** + +- `ParseError` stores `line` and `message` and calls `super().__init__(message)`. +- `parse_date(field)`: `field = field.strip()`; require `len(field) == 10` before `datetime.date.fromisoformat(field)`, else `raise ValueError(f"date is not an ISO 8601 date: {field!r}")`. Raise the same message when `fromisoformat` raises. +- `parse_amount(field)`: `field = field.strip()`; `Decimal(field)` catching `decimal.InvalidOperation` -> `ValueError(f"amount is not a decimal number: {field!r}")`; then reject non-finite with the same message (`value.is_finite()` is `False` for `NaN`/`Infinity`); then `if value.as_tuple().exponent < -2: raise ValueError(f"amount has more than two fractional digits: {field!r}")`. +- `parse_transactions(text)`: `reader = csv.reader(io.StringIO(text, newline=""))` — the `newline=""` keeps `\r\n` intact for `csv`, and `reader.line_num` gives the 1-based file line for error reporting. + - First row: if the reader is exhausted, or the row's fields stripped and lowercased are not `["date", "amount", "description"]`, raise `ParseError(1, "expected header row date,amount,description")`. + - Each later row: if `len(row) != 3`, raise `ParseError(reader.line_num, f"expected 3 columns, got {len(row)}")`; else build a `Transaction` from `parse_date(row[0])`, `parse_amount(row[1])`, `row[2]` (description kept verbatim), converting any `ValueError` into `ParseError(reader.line_num, str(err))`. + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `python3 -m unittest test_parse -v` +Expected: PASS (all tests) + +- [ ] **Step 6: Commit** + +```bash +git add ledgerlite/__init__.py ledgerlite/model.py ledgerlite/parse.py test_parse.py +git commit -m "feat: parse transactions CSV into Transaction records" +``` + +--- + +### Task 2: Rules parsing and categorization + +**Files:** +- Create: `ledgerlite/rules.py` +- Test: `test_rules.py` + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: + - `ledgerlite.rules.parse_rules(text: str) -> list[tuple[str, str]]` — `(substring, category)` pairs in file order. + - `ledgerlite.rules.categorize(description: str, rules: list[tuple[str, str]]) -> str | None` — first matching rule's category, or `None`. + +- [ ] **Step 1: Write the failing tests in `test_rules.py`** + +- `test_parses_one_rule`: `parse_rules("coffee=food\n")` -> `[("coffee", "food")]` +- `test_keeps_file_order`: `parse_rules("coffee=food\nrent=housing\n")` -> `[("coffee", "food"), ("rent", "housing")]` +- `test_strips_surrounding_whitespace`: `parse_rules(" coffee = food \n")` -> `[("coffee", "food")]` +- `test_splits_on_first_equals`: `parse_rules("a=b=c\n")` -> `[("a", "b=c")]` +- `test_skips_blank_and_whitespace_lines`: `parse_rules("\n \ncoffee=food\n")` -> `[("coffee", "food")]` +- `test_ignores_line_without_equals`: `parse_rules("nonsense\n")` -> `[]` +- `test_ignores_empty_substring`: `parse_rules("=food\n")` -> `[]` +- `test_ignores_empty_category`: `parse_rules("coffee=\n")` -> `[]` +- `test_parses_empty_text`: `parse_rules("")` -> `[]` +- `test_categorize_matches_substring`: `categorize("Morning coffee", [("coffee", "food")])` -> `"food"` +- `test_categorize_is_case_insensitive_on_description`: `categorize("MORNING COFFEE", [("coffee", "food")])` -> `"food"` +- `test_categorize_is_case_insensitive_on_rule`: `categorize("morning coffee", [("COFFEE", "food")])` -> `"food"` +- `test_categorize_first_match_wins`: `categorize("Coffee Shop", [("coffee", "food"), ("shop", "retail")])` -> `"food"` +- `test_categorize_first_match_wins_regardless_of_position`: `categorize("Coffee Shop", [("shop", "retail"), ("coffee", "food")])` -> `"retail"` +- `test_categorize_no_match_returns_none`: `categorize("Salary", [("coffee", "food")])` -> `None` +- `test_categorize_with_no_rules_returns_none`: `categorize("Coffee", [])` -> `None` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_rules -v` +Expected: FAIL — no module named `ledgerlite.rules` + +- [ ] **Step 3: Implement `ledgerlite/rules.py`** + +- `parse_rules`: iterate `text.splitlines()`; split each line once on `"="`; strip both sides; keep the pair only if both sides are non-empty (so blank lines, lines with no `=`, and half-empty rules are all skipped). +- `categorize`: lowercase the description once, return the category of the first rule whose lowercased substring is in it, else `None`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_rules -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/rules.py test_rules.py +git commit -m "feat: parse rules file and categorize descriptions" +``` + +--- + +### Task 3: Date ordering and closing balance + +**Files:** +- Create: `ledgerlite/balance.py` +- Test: `test_balance.py` + +**Interfaces:** +- Consumes: `ledgerlite.model.Transaction` (Task 1). +- Produces: + - `ledgerlite.balance.order_by_date(transactions: list[Transaction]) -> list[Transaction]` — new list, ordered by date, ties keeping input order. + - `ledgerlite.balance.closing_balance(opening: Decimal, transactions: list[Transaction]) -> Decimal` — the running balance after the last transaction in date order; `opening` when the list is empty. + +- [ ] **Step 1: Write the failing tests in `test_balance.py`** + +Use a small helper in the test file to build transactions, e.g. `tx("2026-03-04", "-7.50", "Coffee")`. + +- `test_orders_by_date`: input dated `03-05, 03-01, 03-03` -> descriptions in date order +- `test_same_date_keeps_input_order`: two transactions both dated `2026-03-04` with descriptions `A` then `B` -> `["A", "B"]`; the reversed input -> `["B", "A"]` +- `test_order_does_not_mutate_input`: the argument list's order is unchanged after the call +- `test_orders_empty_list`: `order_by_date([])` -> `[]` +- `test_closing_balance_matches_spec_example`: `closing_balance(Decimal("100"), [-7.50, -900.00, 2500.00 amounts])` -> `Decimal("1692.50")` +- `test_closing_balance_with_no_transactions_is_opening`: `closing_balance(Decimal("100"), [])` -> `Decimal("100")` +- `test_closing_balance_is_exact_decimal`: amounts `0.10` and `0.20` with opening `0` -> `Decimal("0.30")` (a float sum would not be equal) +- `test_closing_balance_independent_of_input_order`: same amounts shuffled -> same result +- `test_closing_balance_returns_decimal`: `type(result) is Decimal` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_balance -v` +Expected: FAIL — no module named `ledgerlite.balance` + +- [ ] **Step 3: Implement `ledgerlite/balance.py`** + +- `order_by_date`: `sorted(transactions, key=lambda t: t.date)` — `sorted` is stable, which is what "ties keeping input order" means. +- `closing_balance`: start from `opening` and add each amount of `order_by_date(transactions)` in turn. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_balance -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/balance.py test_balance.py +git commit -m "feat: order transactions by date and compute closing balance" +``` + +--- + +### Task 4: Per-category totals and report formatting + +**Files:** +- Create: `ledgerlite/report.py` +- Test: `test_report.py` + +**Interfaces:** +- Consumes: `Transaction` (Task 1), `rules.categorize` (Task 2), `balance.order_by_date` and `balance.closing_balance` (Task 3). +- Produces: + - `ledgerlite.report.UNCATEGORIZED = "uncategorized"` + - `ledgerlite.report.format_amount(amount: Decimal) -> str` + - `ledgerlite.report.category_totals(transactions: list[Transaction], rules: list[tuple[str, str]]) -> dict[str, Decimal]` — keys are category names, uncategorized transactions under `UNCATEGORIZED`; categories with no transactions are absent. + - `ledgerlite.report.format_report(transactions: list[Transaction], rules: list[tuple[str, str]], opening: Decimal) -> str` — the whole report, ending in a single newline. + +- [ ] **Step 1: Write the failing tests in `test_report.py`** + +- `test_format_amount_integer`: `format_amount(Decimal("1200"))` -> `"1200.00"` +- `test_format_amount_one_fractional_digit`: `format_amount(Decimal("-12.5"))` -> `"-12.50"` +- `test_format_amount_zero`: `format_amount(Decimal("0"))` -> `"0.00"` +- `test_format_amount_negative_zero`: `format_amount(Decimal("-0.00"))` -> `"0.00"` (Review Focus 2) +- `test_format_amount_no_thousands_separator`: `format_amount(Decimal("1234567.5"))` -> `"1234567.50"` +- `test_format_amount_large_value_not_exponential`: `format_amount(Decimal("1E+7"))` -> `"10000000.00"` +- `test_category_totals_sums_per_category`: two `coffee` transactions with rules `[("coffee", "food")]` -> `{"food": Decimal("-15.00")}` +- `test_category_totals_uses_uncategorized_key`: one unmatched transaction -> `{"uncategorized": Decimal("2500.00")}` +- `test_category_totals_omits_uncategorized_when_all_matched`: `"uncategorized" not in result` +- `test_category_totals_with_no_transactions`: `category_totals([], [])` -> `{}` +- `test_format_report_matches_spec_example`: transactions `2026-03-01 / -7.50 / "Coffee"`, `2026-03-02 / -900.00 / "Rent"`, `2026-03-03 / 2500.00 / "Salary"`, rules `[("coffee", "food"), ("rent", "housing")]`, opening `Decimal("100")` -> exactly: + ``` + food: -7.50 + housing: -900.00 + uncategorized: 2500.00 + + closing balance: 1692.50 + ``` + (with a trailing newline) +- `test_format_report_lists_categories_alphabetically`: categories produced in the order `zebra`, `apple` -> `apple` line before `zebra` line +- `test_format_report_puts_uncategorized_last`: categories `food` and `zebra` plus an unmatched transaction -> line order `food`, `zebra`, `uncategorized` (alphabetically `uncategorized` would precede `zebra`) +- `test_format_report_with_no_transactions`: `format_report([], [], Decimal("0"))` -> `"closing balance: 0.00\n"` — no category lines and no leading blank line +- `test_format_report_without_rules_is_all_uncategorized`: the spec example with `rules=[]` -> one `uncategorized: 1592.50` line and `closing balance: 1692.50` +- `test_format_report_uses_opening_when_no_transactions`: `format_report([], [], Decimal("100"))` -> `"closing balance: 100.00\n"` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_report -v` +Expected: FAIL — no module named `ledgerlite.report` + +- [ ] **Step 3: Implement `ledgerlite/report.py`** + +- `format_amount`: quantize to `Decimal("0.01")` and format with `f"{value:f}"` (no separators, never exponential). Guard negative zero first: if the amount equals zero, format its absolute value. +- `category_totals`: accumulate over `order_by_date(transactions)` (the spec's stated ordering), keying on `categorize(t.description, rules)` or `UNCATEGORIZED`. +- `format_report`: category lines are `f"{name}: {format_amount(total)}"` for the totals' keys sorted alphabetically with `UNCATEGORIZED` removed and appended last if present; then a blank line, then `f"closing balance: {format_amount(closing_balance(opening, transactions))}"`. When there are no category lines, emit only the closing-balance line (no blank line). Return the joined lines with a trailing newline. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `python3 -m unittest test_report -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add ledgerlite/report.py test_report.py +git commit -m "feat: format per-category totals and closing balance report" +``` + +--- + +### Task 5: CLI entry point + +**Files:** +- Create: `ledgerlite/cli.py` +- Create: `ledgerlite/__main__.py` +- Test: `test_cli.py` + +**Interfaces:** +- Consumes: `parse.parse_transactions`, `parse.parse_amount`, `parse.ParseError` (Task 1), `rules.parse_rules` (Task 2), `report.format_report` (Task 4). +- Produces: + - `ledgerlite.cli.build_parser() -> argparse.ArgumentParser` + - `ledgerlite.cli.main(argv: list[str] | None = None) -> int` + +- [ ] **Step 1: Write the failing tests in `test_cli.py`** + +Write fixture files into a `tempfile.TemporaryDirectory()` per test; capture output with `contextlib.redirect_stdout`/`redirect_stderr` over `io.StringIO`. The valid fixture is the spec example (three rows) with rules `coffee=food` and `rent=housing`. + +- `test_report_returns_zero_and_prints_report`: `main(["report", csv_path, "--rules", rules_path, "--opening", "100"])` -> `0`, stdout equals the spec example report +- `test_opening_defaults_to_zero`: no `--opening` -> stdout ends `"closing balance: 1592.50\n"` +- `test_rules_optional_means_all_uncategorized`: no `--rules` -> stdout has an `uncategorized: 1592.50` line and no `food:` line +- `test_negative_opening_accepted`: `--opening -50.25` -> `closing balance: 1542.25` +- `test_missing_transactions_file_returns_one`: `main(["report", "/no/such/file.csv"])` -> `1`, stderr == `"ledgerlite: cannot read /no/such/file.csv: No such file or directory\n"`, stdout empty +- `test_directory_as_transactions_file_returns_one`: passing the temp directory itself -> `1`, stderr starts with `f"ledgerlite: cannot read {dir}: "` +- `test_missing_rules_file_returns_one`: valid CSV, `--rules /no/such/rules.txt` -> `1`, stderr == `"ledgerlite: cannot read /no/such/rules.txt: No such file or directory\n"`, stdout empty (Review Focus 1) +- `test_malformed_row_returns_two`: CSV whose second line is `2026-03-04,1.005,Coffee` -> `2`, stderr == `f"ledgerlite: {csv_path}:2: amount has more than two fractional digits: '1.005'\n"` +- `test_malformed_row_prints_nothing_to_stdout`: same fixture -> stdout is `""` +- `test_malformed_row_reports_later_line_number`: bad row on file line 3 -> stderr contains `f"{csv_path}:3: "` +- `test_missing_header_returns_two`: CSV with no header -> `2`, stderr contains `f"{csv_path}:1: "` +- `test_invalid_opening_exits_two`: `main(["report", csv_path, "--opening", "abc"])` raises `SystemExit` with `.code == 2` +- `test_opening_with_three_fractional_digits_exits_two`: `--opening 1.005` raises `SystemExit` with `.code == 2` +- `test_missing_subcommand_exits_two`: `main([])` raises `SystemExit` with `.code == 2` +- `test_header_only_file_prints_opening_as_closing`: CSV with only the header, `--opening 100` -> `0`, stdout == `"closing balance: 100.00\n"` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `python3 -m unittest test_cli -v` +Expected: FAIL — no module named `ledgerlite.cli` + +- [ ] **Step 3: Implement `ledgerlite/cli.py`** + +- `build_parser`: `ArgumentParser(prog="ledgerlite")` with `add_subparsers(dest="command", required=True)`; a `report` subparser taking positional `transactions`, `--rules` (default `None`), and `--opening` with `type=parse_amount, default=Decimal("0")` — reusing `parse_amount` makes argparse reject a bad opening amount with its own exit code 2. +- A module-level helper reads a path: `open(path, encoding="utf-8", newline="")` and return the text; let `OSError` and `UnicodeDecodeError` propagate. +- `main(argv=None)`: parse args; read the transactions file, then the rules file if `--rules` was given, inside one `try` that catches `OSError` (reason `err.strerror`) and `UnicodeDecodeError` (reason `err.reason`) and prints `f"ledgerlite: cannot read {path}: {reason}"` to `sys.stderr`, returning `1`. Then `parse_transactions`, catching `ParseError` to print `f"ledgerlite: {args.transactions}:{err.line}: {err.message}"` to `sys.stderr` and return `2`. Nothing is written to stdout before parsing succeeds. Finally print `format_report(...)` (already newline-terminated, so use `end=""`) and return `0`. + +- [ ] **Step 4: Implement `ledgerlite/__main__.py`** + +`sys.exit(main())` guarded by `if __name__ == "__main__":`. + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `python3 -m unittest test_cli -v` +Expected: PASS + +- [ ] **Step 6: Run the whole suite and the tool end to end** + +Run: `python3 -m unittest -v` +Expected: PASS, all five test modules. + +Run against a hand-made fixture: `python3 -m ledgerlite report tmp.csv --rules tmp.rules --opening 100` +Expected: the report on stdout, `echo $?` -> `0`. + +- [ ] **Step 7: Commit** + +```bash +git add ledgerlite/cli.py ledgerlite/__main__.py test_cli.py +git commit -m "feat: add ledgerlite report command-line entry point" +``` diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/baseline-cosmic-1/2026-09-17-cosmic-tetris-1-engine.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/baseline-cosmic-1/2026-09-17-cosmic-tetris-1-engine.md new file mode 100644 index 000000000..7c8e8aab2 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/baseline-cosmic-1/2026-09-17-cosmic-tetris-1-engine.md @@ -0,0 +1,2971 @@ +# Cosmic Tetris — Plan 1: Deterministic Game Engine + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the headless, deterministic falling-block engine in `internal/game` — pieces, board, 7-bag, movement, rotation with kicks, gravity, locking, line clearing, hold, scoring, game over — with comprehensive unit tests and a seeded replay test. + +**Architecture:** One package, `internal/game`, with no dependencies outside the standard library. The engine never reads a clock: `Advance(dt time.Duration) []Event` is the only way time moves. Every mutator returns a slice of `Event` values describing what happened; the engine itself renders nothing and knows nothing about terminals. The board is a fixed-size array (10×22, top two rows hidden), and the game owns a single `*rand.Rand` that drives only the 7-bag. + +**Tech Stack:** Go 1.26, standard library only (`math/rand/v2`, `time`, `testing`). + +**Spec:** `design.md` (this repo root). Sections implemented here: §5, §6, §7, §11, §12, §13, §34, §35, §40 (engine portions), §42 Phase 1, §49.1, §49.2, §49.6. + +## Global Constraints + +- Language: Go. Module path: `cosmic-tetris`. Toolchain present: `go1.26.1`. +- Board: `width 10`, `height 22`, `visible rows 20`, `hidden spawn rows 2`. +- Nothing under `internal/game` may call `time.Now()` or any other clock (§49.2). `dt` is always an input. +- Game RNG drives the 7-bag and nothing else. FX randomness lives in a different generator in a different package (§49.6). +- Gravity: `interval = 800ms * 0.86^(level-1)`, clamped at `60ms`. Level increases every `10` cleared lines. +- Lock delay `500ms`; `max lock resets = 15`. +- Line scores: `1→100×level`, `2→300×level`, `3→500×level`, `4→800×level`. Soft drop `+1 point/cell`, hard drop `+2 points/cell`. +- Combo bonus: `bonus = 50 × (combo - 1) × level`; first clearing placement sets combo to 1; a placement that clears nothing resets combo to 0 (§49.1). +- Wall-kick offsets, tried in exactly this order: `(0,0) (-1,0) (1,0) (-2,0) (2,0) (0,-1) (-1,-1) (1,-1)` (§7). +- The engine is not allowed to depend on `internal/render`, `internal/fx`, `internal/app`, or `internal/flavor`. Those depend on it. + +## Review Focus + +These are the inputs the spec implies but never names. Each line's test is assigned to the task that owns the code. + +1. **A huge `dt`** (laptop sleep, terminal suspend, debugger pause) must not fast-forward through several lock cycles or spin forever — one `Advance` locks at most one piece. → Task 8. +2. **`dt <= 0`** (zero or negative elapsed time from a jittery clock) must be a no-op, not a backwards step. → Task 8. +3. **Non-contiguous cleared rows** (a clear with an occupied gap row between two full rows) must collapse correctly, not shift the gap row into the wrong place. → Task 2. +4. **Rotation near the ceiling**, where a kick offset pushes cells to `y < 0`, must not index out of bounds — above the ceiling is empty space, not a wall. → Task 7. +5. **Every input after game over** (`MoveLeft`, `RotateCW`, `HardDrop`, `UseHold`, `SoftDrop`, `Advance`) must be an inert no-op that leaves score, board, and piece untouched. → Task 12. + +--- + +### Task 1: Module scaffold, geometry, cells, and board access + +**Files:** +- Create: `go.mod` +- Create: `LICENSE` +- Create: `.gitignore` +- Create: `internal/game/board.go` +- Test: `internal/game/board_test.go` + +**Interfaces:** +- Consumes: nothing. +- Produces: `game.Width`, `game.Height`, `game.HiddenRows`, `game.VisibleRows` (int consts); `type Cell uint8`, `Empty Cell`, `func CellOf(k PieceKind) Cell`, `func (c Cell) Filled() bool`, `func (c Cell) Kind() PieceKind`; `type Point struct{ X, Y int }`; `type Board struct{ Cells [Height][Width]Cell }` with `At`, `Set`, `InBounds`, `Blocked`. + +Coordinate convention used by every later task: `y = 0` is the top row, `y = Height-1` the bottom. Rows `0` and `1` are the hidden spawn rows; rows `2..21` are the 20 visible rows. `x = 0` is the left wall. + +- [ ] **Step 1: Create the module and repo files** + +```bash +cd "$(git rev-parse --show-toplevel)" +go mod init cosmic-tetris +printf 'cosmic-tetris\ncosmic-tetris.exe\n' > .gitignore +``` + +Write `LICENSE` as the MIT license text with `Copyright (c) 2026 Jesse Vincent`. + +- [ ] **Step 2: Write the failing test** + +Create `internal/game/board_test.go`: + +```go +package game + +import "testing" + +func TestBoardGeometry(t *testing.T) { + if Width != 10 || Height != 22 || HiddenRows != 2 || VisibleRows != 20 { + t.Fatalf("geometry drifted: %d %d %d %d", Width, Height, HiddenRows, VisibleRows) + } +} + +func TestCellRoundTrip(t *testing.T) { + if Empty.Filled() { + t.Error("empty cell reports filled") + } + for k := KindI; k <= KindZ; k++ { + c := CellOf(k) + if !c.Filled() { + t.Errorf("CellOf(%v) not filled", k) + } + if got := c.Kind(); got != k { + t.Errorf("CellOf(%v).Kind() = %v", k, got) + } + } +} + +func TestBoardSetAndAt(t *testing.T) { + var b Board + b.Set(3, 5, CellOf(KindT)) + if b.At(3, 5) != CellOf(KindT) { + t.Error("Set/At round trip failed") + } + if b.At(4, 5) != Empty { + t.Error("neighbour cell was written") + } + // Out-of-range access must not panic. + b.Set(-1, 5, CellOf(KindT)) + b.Set(Width, 5, CellOf(KindT)) + b.Set(3, Height, CellOf(KindT)) + if b.At(-1, -1) != Empty || b.At(Width, Height) != Empty { + t.Error("out-of-range At returned a filled cell") + } +} + +func TestBoardBlocked(t *testing.T) { + var b Board + cases := []struct { + name string + x, y int + blocked bool + }{ + {"left wall", -1, 10, true}, + {"right wall", Width, 10, true}, + {"floor", 0, Height, true}, + {"above ceiling is open space", 4, -3, false}, + {"empty cell", 4, 10, false}, + } + for _, tc := range cases { + if got := b.Blocked(tc.x, tc.y); got != tc.blocked { + t.Errorf("%s: Blocked(%d,%d) = %v, want %v", tc.name, tc.x, tc.y, got, tc.blocked) + } + } + b.Set(4, 10, CellOf(KindO)) + if !b.Blocked(4, 10) { + t.Error("occupied cell not blocked") + } +} +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `go test ./internal/game/ -run 'TestBoard|TestCell' -v` +Expected: FAIL — the package does not compile (`undefined: Width`, `undefined: KindI`). + +- [ ] **Step 4: Write the geometry, cell, and board code** + +Create `internal/game/board.go`: + +```go +// Package game implements the Cosmic Tetris rules engine. It is headless and +// deterministic: it renders nothing, and it never reads a clock. Time enters +// only through Advance(dt). +package game + +// Board geometry. Rows 0 and 1 are the hidden spawn rows; rows 2..21 are the +// 20 visible rows. y grows downward, x grows rightward. +const ( + Width = 10 + Height = 22 + HiddenRows = 2 + VisibleRows = Height - HiddenRows +) + +// Point is an integer board coordinate. +type Point struct { + X, Y int +} + +// Cell is one board square: Empty, or a piece kind plus one. +type Cell uint8 + +// Empty is an unoccupied board square. +const Empty Cell = 0 + +// CellOf returns the cell value that records a locked block of kind k. +func CellOf(k PieceKind) Cell { return Cell(k) + 1 } + +// Filled reports whether the cell holds a block. +func (c Cell) Filled() bool { return c != Empty } + +// Kind returns the piece kind that filled the cell. Only meaningful when +// Filled reports true. +func (c Cell) Kind() PieceKind { return PieceKind(c - 1) } + +// Board holds the locked blocks. The active piece is not part of the board +// until it locks. +type Board struct { + Cells [Height][Width]Cell +} + +// InBounds reports whether (x, y) is inside the playfield array. +func (b *Board) InBounds(x, y int) bool { + return x >= 0 && x < Width && y >= 0 && y < Height +} + +// At returns the cell at (x, y), or Empty when out of range. +func (b *Board) At(x, y int) Cell { + if !b.InBounds(x, y) { + return Empty + } + return b.Cells[y][x] +} + +// Set writes a cell, ignoring out-of-range coordinates. +func (b *Board) Set(x, y int, c Cell) { + if !b.InBounds(x, y) { + return + } + b.Cells[y][x] = c +} + +// Blocked reports whether a piece cell may not occupy (x, y). Walls and the +// floor block; space above the ceiling does not, so pieces may rotate and +// spawn with cells above row 0. +func (b *Board) Blocked(x, y int) bool { + if x < 0 || x >= Width || y >= Height { + return true + } + if y < 0 { + return false + } + return b.Cells[y][x].Filled() +} +``` + +- [ ] **Step 5: Add the piece-kind stub so the package compiles** + +Create `internal/game/piece.go` with only the kind enum for now; Task 3 fills in the shapes. + +```go +package game + +// PieceKind identifies one of the seven tetromino families. +type PieceKind uint8 + +// The seven tetromino families. +const ( + KindI PieceKind = iota + KindJ + KindL + KindO + KindS + KindT + KindZ +) + +// KindCount is the number of tetromino families. +const KindCount = 7 + +// String returns the single-letter name of the piece kind. +func (k PieceKind) String() string { + if int(k) >= KindCount { + return "?" + } + return [KindCount]string{"I", "J", "L", "O", "S", "T", "Z"}[k] +} +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `go test ./internal/game/ -run 'TestBoard|TestCell' -v && go vet ./...` +Expected: PASS, vet clean. + +- [ ] **Step 7: Commit** + +```bash +git add go.mod LICENSE .gitignore internal/game/board.go internal/game/piece.go internal/game/board_test.go +git commit -m "feat(game): board geometry, cells, and collision queries" +``` + +--- + +### Task 2: Row completion, clearing, and collapse + +**Files:** +- Modify: `internal/game/board.go` (append) +- Test: `internal/game/board_clear_test.go` + +**Interfaces:** +- Consumes: `Board`, `Cell`, `CellOf`, `Width`, `Height` from Task 1. +- Produces: `func (b *Board) CompleteRows() []int` (full row indices, topmost first); `type ClearedRow struct { Y int; Cells [Width]Cell }`; `func (b *Board) Snapshot(rows []int) []ClearedRow`; `func (b *Board) ClearRows(rows []int)`. + +`Snapshot` exists so the FX layer (Plan 3) can animate a supernova on rows the engine has already removed, without ever holding a reference into live board state. + +- [ ] **Step 1: Write the failing test** + +Create `internal/game/board_clear_test.go`: + +```go +package game + +import ( + "reflect" + "testing" +) + +// fillRow fills an entire row with the given kind. +func fillRow(b *Board, y int, k PieceKind) { + for x := 0; x < Width; x++ { + b.Set(x, y, CellOf(k)) + } +} + +func TestCompleteRows(t *testing.T) { + var b Board + fillRow(&b, 21, KindI) + fillRow(&b, 19, KindT) + b.Set(0, 20, CellOf(KindO)) // partial row, must not count + + got := b.CompleteRows() + want := []int{19, 21} + if !reflect.DeepEqual(got, want) { + t.Fatalf("CompleteRows() = %v, want %v", got, want) + } +} + +func TestCompleteRowsEmptyBoard(t *testing.T) { + var b Board + if rows := b.CompleteRows(); len(rows) != 0 { + t.Fatalf("empty board reported complete rows: %v", rows) + } +} + +func TestSnapshotCapturesContentsBeforeClear(t *testing.T) { + var b Board + fillRow(&b, 21, KindS) + snap := b.Snapshot([]int{21}) + b.ClearRows([]int{21}) + + if len(snap) != 1 || snap[0].Y != 21 { + t.Fatalf("snapshot = %+v", snap) + } + if snap[0].Cells[0] != CellOf(KindS) { + t.Error("snapshot lost the row contents") + } + if b.At(0, 21) != Empty { + t.Error("row was not cleared") + } +} + +func TestClearRowsCollapsesStackDown(t *testing.T) { + var b Board + fillRow(&b, 21, KindI) // full, will clear + b.Set(3, 20, CellOf(KindT)) // lone block above it + b.ClearRows([]int{21}) + + if b.At(3, 20) != Empty { + t.Error("block did not fall out of row 20") + } + if b.At(3, 21) != CellOf(KindT) { + t.Error("block did not land in row 21") + } +} + +// Review Focus item 3: two full rows with an occupied, non-full row between +// them. The middle row must end up directly on the floor, and nothing may be +// duplicated. +func TestClearRowsNonContiguous(t *testing.T) { + var b Board + fillRow(&b, 21, KindI) // full + b.Set(5, 20, CellOf(KindT)) + b.Set(6, 20, CellOf(KindT)) // partial gap row + fillRow(&b, 19, KindZ) // full + b.Set(0, 18, CellOf(KindL)) // survivor above everything + + b.ClearRows([]int{19, 21}) + + if b.At(5, 21) != CellOf(KindT) || b.At(6, 21) != CellOf(KindT) { + t.Errorf("gap row did not settle on the floor: row21=%v", b.Cells[21]) + } + if b.At(0, 20) != CellOf(KindL) { + t.Errorf("survivor landed wrong: row20=%v", b.Cells[20]) + } + for y := 0; y <= 19; y++ { + for x := 0; x < Width; x++ { + if y == 20 && x == 0 { + continue + } + if b.At(x, y).Filled() { + t.Fatalf("stale block left at (%d,%d)", x, y) + } + } + } +} + +func TestClearRowsIgnoresOutOfRangeIndices(t *testing.T) { + var b Board + fillRow(&b, 21, KindI) + b.ClearRows([]int{-1, 21, Height + 4}) // must not panic + if b.At(0, 21).Filled() { + t.Error("row 21 should have cleared") + } +} + +func TestClearAllFourRows(t *testing.T) { + var b Board + for y := 18; y <= 21; y++ { + fillRow(&b, y, KindI) + } + b.ClearRows([]int{18, 19, 20, 21}) + for y := 0; y < Height; y++ { + for x := 0; x < Width; x++ { + if b.At(x, y).Filled() { + t.Fatalf("board not empty at (%d,%d)", x, y) + } + } + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/game/ -run 'TestCompleteRows|TestSnapshot|TestClear' -v` +Expected: FAIL — `b.CompleteRows undefined`, `b.Snapshot undefined`, `b.ClearRows undefined`. + +- [ ] **Step 3: Implement row completion and collapse** + +Append to `internal/game/board.go`: + +```go +// ClearedRow is a copy of one row taken just before it was removed. The FX +// layer animates from these copies; the engine has already moved on. +type ClearedRow struct { + Y int + Cells [Width]Cell +} + +// CompleteRows returns the indices of every fully occupied row, topmost first. +// It returns nil when no row is complete. +func (b *Board) CompleteRows() []int { + var rows []int + for y := 0; y < Height; y++ { + full := true + for x := 0; x < Width; x++ { + if !b.Cells[y][x].Filled() { + full = false + break + } + } + if full { + rows = append(rows, y) + } + } + return rows +} + +// Snapshot copies the given rows before they are cleared. +func (b *Board) Snapshot(rows []int) []ClearedRow { + out := make([]ClearedRow, 0, len(rows)) + for _, y := range rows { + if y < 0 || y >= Height { + continue + } + out = append(out, ClearedRow{Y: y, Cells: b.Cells[y]}) + } + return out +} + +// ClearRows removes the given rows and collapses everything above them +// downward. Row indices may arrive in any order and need not be contiguous; +// out-of-range indices are ignored. +func (b *Board) ClearRows(rows []int) { + var drop [Height]bool + for _, y := range rows { + if y >= 0 && y < Height { + drop[y] = true + } + } + dst := Height - 1 + for src := Height - 1; src >= 0; src-- { + if drop[src] { + continue + } + b.Cells[dst] = b.Cells[src] + dst-- + } + for ; dst >= 0; dst-- { + b.Cells[dst] = [Width]Cell{} + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS (all tests so far). + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/board.go internal/game/board_clear_test.go +git commit -m "feat(game): row completion, snapshot, and non-contiguous collapse" +``` + +--- + +### Task 3: Piece shapes, four rotations, and spawn position + +**Files:** +- Modify: `internal/game/piece.go` +- Test: `internal/game/piece_test.go` + +**Interfaces:** +- Consumes: `PieceKind`, `Point` from Tasks 1. +- Produces: `type Piece struct { Kind PieceKind; Rotation, X, Y int }`; `func (p Piece) Cells() [4]Point` (absolute board coordinates); `func (p Piece) Normalized() [4]Point` (offsets within the 4×4 box, for previews); `func SpawnPiece(k PieceKind) Piece`; consts `SpawnX = 3`, `SpawnY = 0`. + +Shapes are written as 4 rows of 4 characters per rotation so a human can read them. Rotation index 0 is the spawn orientation; index 1 is one clockwise turn from it. + +- [ ] **Step 1: Write the failing test** + +Create `internal/game/piece_test.go`: + +```go +package game + +import "testing" + +func TestEveryRotationHasFourCells(t *testing.T) { + for k := KindI; k <= KindZ; k++ { + for r := 0; r < 4; r++ { + p := Piece{Kind: k, Rotation: r} + seen := map[Point]bool{} + for _, c := range p.Normalized() { + if c.X < 0 || c.X > 3 || c.Y < 0 || c.Y > 3 { + t.Errorf("%v r%d: cell %v outside the 4x4 box", k, r, c) + } + if seen[c] { + t.Errorf("%v r%d: duplicate cell %v", k, r, c) + } + seen[c] = true + } + if len(seen) != 4 { + t.Errorf("%v r%d: %d distinct cells, want 4", k, r, len(seen)) + } + } + } +} + +func TestORotationIsIdentical(t *testing.T) { + base := Piece{Kind: KindO, Rotation: 0}.Normalized() + for r := 1; r < 4; r++ { + if Piece{Kind: KindO, Rotation: r}.Normalized() != base { + t.Errorf("O rotation %d differs from spawn rotation", r) + } + } +} + +func TestSpawnRotationShapes(t *testing.T) { + // Spot-check the spawn orientation of each family against the spec's + // standard silhouettes. + want := map[PieceKind][4]Point{ + KindI: {{0, 1}, {1, 1}, {2, 1}, {3, 1}}, + KindJ: {{0, 0}, {0, 1}, {1, 1}, {2, 1}}, + KindL: {{2, 0}, {0, 1}, {1, 1}, {2, 1}}, + KindO: {{1, 0}, {2, 0}, {1, 1}, {2, 1}}, + KindS: {{1, 0}, {2, 0}, {0, 1}, {1, 1}}, + KindT: {{1, 0}, {0, 1}, {1, 1}, {2, 1}}, + KindZ: {{0, 0}, {1, 0}, {1, 1}, {2, 1}}, + } + for k, w := range want { + if got := (Piece{Kind: k}).Normalized(); got != w { + t.Errorf("%v spawn shape = %v, want %v", k, got, w) + } + } +} + +func TestCellsAreAbsolute(t *testing.T) { + p := Piece{Kind: KindT, Rotation: 0, X: 4, Y: 7} + want := [4]Point{{5, 7}, {4, 8}, {5, 8}, {6, 8}} + if got := p.Cells(); got != want { + t.Fatalf("Cells() = %v, want %v", got, want) + } +} + +func TestSpawnPositionSitsInHiddenRows(t *testing.T) { + for k := KindI; k <= KindZ; k++ { + p := SpawnPiece(k) + if p.Rotation != 0 || p.X != SpawnX || p.Y != SpawnY { + t.Errorf("%v spawned at %+v", k, p) + } + for _, c := range p.Cells() { + if c.Y >= HiddenRows { + t.Errorf("%v spawns with cell %v in the visible area", k, c) + } + if c.X < 0 || c.X >= Width { + t.Errorf("%v spawns off the board at %v", k, c) + } + } + } +} + +func TestClockwiseRotationCycles(t *testing.T) { + // Rotating clockwise four times returns the spawn silhouette. + for k := KindI; k <= KindZ; k++ { + if (Piece{Kind: k, Rotation: 4 % 4}).Normalized() != (Piece{Kind: k}).Normalized() { + t.Errorf("%v: rotation index does not wrap", k) + } + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/game/ -run 'Rotation|Spawn|Cells' -v` +Expected: FAIL — `p.Normalized undefined`, `undefined: SpawnPiece`. + +- [ ] **Step 3: Write the shape tables and piece methods** + +Replace `internal/game/piece.go` with: + +```go +package game + +// PieceKind identifies one of the seven tetromino families. +type PieceKind uint8 + +// The seven tetromino families. +const ( + KindI PieceKind = iota + KindJ + KindL + KindO + KindS + KindT + KindZ +) + +// KindCount is the number of tetromino families. +const KindCount = 7 + +// String returns the single-letter name of the piece kind. +func (k PieceKind) String() string { + if int(k) >= KindCount { + return "?" + } + return [KindCount]string{"I", "J", "L", "O", "S", "T", "Z"}[k] +} + +// Spawn position: the top-left corner of the piece's 4x4 box. X=3 centres the +// three-wide families over columns 3..5 and I over columns 3..6; Y=0 keeps +// every spawn silhouette inside the two hidden rows. +const ( + SpawnX = 3 + SpawnY = 0 +) + +// shapeRows describes all four rotations of all seven families as 4x4 +// character grids. Rotation 0 is the spawn orientation; rotation 1 is one +// clockwise turn from it. 'X' marks an occupied cell. +var shapeRows = [KindCount][4][4]string{ + KindI: { + {"....", "XXXX", "....", "...."}, + {"..X.", "..X.", "..X.", "..X."}, + {"....", "....", "XXXX", "...."}, + {".X..", ".X..", ".X..", ".X.."}, + }, + KindJ: { + {"X...", "XXX.", "....", "...."}, + {".XX.", ".X..", ".X..", "...."}, + {"....", "XXX.", "..X.", "...."}, + {".X..", ".X..", "XX..", "...."}, + }, + KindL: { + {"..X.", "XXX.", "....", "...."}, + {".X..", ".X..", ".XX.", "...."}, + {"....", "XXX.", "X...", "...."}, + {"XX..", ".X..", ".X..", "...."}, + }, + KindO: { + {".XX.", ".XX.", "....", "...."}, + {".XX.", ".XX.", "....", "...."}, + {".XX.", ".XX.", "....", "...."}, + {".XX.", ".XX.", "....", "...."}, + }, + KindS: { + {".XX.", "XX..", "....", "...."}, + {".X..", ".XX.", "..X.", "...."}, + {"....", ".XX.", "XX..", "...."}, + {"X...", "XX..", ".X..", "...."}, + }, + KindT: { + {".X..", "XXX.", "....", "...."}, + {".X..", ".XX.", ".X..", "...."}, + {"....", "XXX.", ".X..", "...."}, + {".X..", "XX..", ".X..", "...."}, + }, + KindZ: { + {"XX..", ".XX.", "....", "...."}, + {"..X.", ".XX.", ".X..", "...."}, + {"....", "XX..", ".XX.", "...."}, + {".X..", "XX..", "X...", "...."}, + }, +} + +// shapes is shapeRows compiled into offsets, in reading order. +var shapes = func() [KindCount][4][4]Point { + var out [KindCount][4][4]Point + for k := range shapeRows { + for r := range shapeRows[k] { + n := 0 + for y, row := range shapeRows[k][r] { + for x, ch := range row { + if ch != 'X' { + continue + } + if n == 4 { + panic("cosmic-tetris: shape table has more than four cells") + } + out[k][r][n] = Point{X: x, Y: y} + n++ + } + } + if n != 4 { + panic("cosmic-tetris: shape table has fewer than four cells") + } + } + } + return out +}() + +// Piece is the active tetromino: a family, a rotation index, and the board +// position of its 4x4 box's top-left corner. +type Piece struct { + Kind PieceKind + Rotation int + X int + Y int +} + +// Normalized returns the piece's cells as offsets inside its 4x4 box. Useful +// for HOLD and NEXT previews, which have no board position. +func (p Piece) Normalized() [4]Point { + return shapes[p.Kind][((p.Rotation%4)+4)%4] +} + +// Cells returns the piece's four cells in absolute board coordinates. +func (p Piece) Cells() [4]Point { + var out [4]Point + for i, o := range p.Normalized() { + out[i] = Point{X: p.X + o.X, Y: p.Y + o.Y} + } + return out +} + +// SpawnPiece returns a piece of kind k in its spawn rotation and position. +func SpawnPiece(k PieceKind) Piece { + return Piece{Kind: k, Rotation: 0, X: SpawnX, Y: SpawnY} +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/piece.go internal/game/piece_test.go +git commit -m "feat(game): tetromino shape tables, rotations, and spawn position" +``` + +--- + +### Task 4: Seeded 7-bag + +**Files:** +- Create: `internal/game/bag.go` +- Test: `internal/game/bag_test.go` + +**Interfaces:** +- Consumes: `PieceKind`, `KindCount`. +- Produces: `type Bag struct{...}`; `func NewBag(rng *rand.Rand) *Bag`; `func (b *Bag) Next() PieceKind`; `func NewRNG(seed int64) *rand.Rand`. + +`NewRNG` centralises seeding so the engine and any test build the same generator from the same `int64`. It uses `math/rand/v2`'s PCG source, which is reproducible across platforms and Go versions. + +- [ ] **Step 1: Write the failing test** + +Create `internal/game/bag_test.go`: + +```go +package game + +import "testing" + +func TestEachBagContainsAllSevenKindsOnce(t *testing.T) { + b := NewBag(NewRNG(42)) + for bagIndex := 0; bagIndex < 20; bagIndex++ { + counts := map[PieceKind]int{} + for i := 0; i < KindCount; i++ { + counts[b.Next()]++ + } + if len(counts) != KindCount { + t.Fatalf("bag %d drew %d distinct kinds: %v", bagIndex, len(counts), counts) + } + for k, n := range counts { + if n != 1 { + t.Fatalf("bag %d drew %v %d times", bagIndex, k, n) + } + } + } +} + +func TestSeededBagIsReproducible(t *testing.T) { + draw := func(seed int64) []PieceKind { + b := NewBag(NewRNG(seed)) + out := make([]PieceKind, 0, 30) + for i := 0; i < 30; i++ { + out = append(out, b.Next()) + } + return out + } + a, b := draw(8675309), draw(8675309) + for i := range a { + if a[i] != b[i] { + t.Fatalf("same seed diverged at draw %d: %v vs %v", i, a[i], b[i]) + } + } + if c := draw(1); equalKinds(a, c) { + t.Error("different seeds produced identical sequences") + } +} + +func equalKinds(a, b []PieceKind) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func TestBagShuffles(t *testing.T) { + // Over many bags, the first draw should not always be the same kind. + b := NewBag(NewRNG(7)) + first := map[PieceKind]bool{} + for i := 0; i < 40; i++ { + first[b.Next()] = true + for j := 1; j < KindCount; j++ { + b.Next() + } + } + if len(first) < 3 { + t.Errorf("bag looks unshuffled: only %d distinct opening kinds", len(first)) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/game/ -run 'Bag' -v` +Expected: FAIL — `undefined: NewBag`, `undefined: NewRNG`. + +- [ ] **Step 3: Implement the bag** + +Create `internal/game/bag.go`: + +```go +package game + +import "math/rand/v2" + +// rngStreamB is the second PCG word. It is a fixed constant so that a given +// int64 seed always builds the same generator. +const rngStreamB = 0x9E3779B97F4A7C15 + +// NewRNG builds the game's random source from a seed. All engine randomness +// comes from here, and only the 7-bag consumes it. +func NewRNG(seed int64) *rand.Rand { + return rand.New(rand.NewPCG(uint64(seed), rngStreamB)) +} + +// Bag is a 7-bag piece generator: every family appears exactly once per bag, +// in a shuffled order. +type Bag struct { + rng *rand.Rand + queue []PieceKind +} + +// NewBag returns a bag that draws from rng. +func NewBag(rng *rand.Rand) *Bag { + return &Bag{rng: rng, queue: make([]PieceKind, 0, KindCount)} +} + +// Next returns the next piece kind, refilling and reshuffling when the bag +// empties. +func (b *Bag) Next() PieceKind { + if len(b.queue) == 0 { + b.refill() + } + k := b.queue[len(b.queue)-1] + b.queue = b.queue[:len(b.queue)-1] + return k +} + +func (b *Bag) refill() { + b.queue = b.queue[:0] + for k := KindI; k < KindCount; k++ { + b.queue = append(b.queue, k) + } + b.rng.Shuffle(len(b.queue), func(i, j int) { + b.queue[i], b.queue[j] = b.queue[j], b.queue[i] + }) +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/bag.go internal/game/bag_test.go +git commit -m "feat(game): seeded 7-bag piece generator" +``` + +--- + +### Task 5: Game construction, events, and the next queue + +**Files:** +- Create: `internal/game/events.go` +- Create: `internal/game/game.go` +- Test: `internal/game/game_test.go` + +**Interfaces:** +- Consumes: everything from Tasks 1–4. +- Produces: + - `type EventKind uint8` with `EventPieceMoved`, `EventPieceRotated`, `EventPieceHardDropped`, `EventPieceLocked`, `EventHoldUsed`, `EventLinesCleared`, `EventComboChanged`, `EventLevelChanged`, `EventPieceSpawned`, `EventGameOver`; `func (k EventKind) String() string`. + - `type Event struct { Kind EventKind; Piece Piece; Cleared []ClearedRow; Count int; Points int }`. + - `type Game struct { Board Board; Active Piece; Hold *PieceKind; CanHold bool; Next []PieceKind; Score, Lines, Level, Combo int; GravityAccumulator, LockAccumulator time.Duration; Over bool; Seed int64 }` plus unexported `bag`, `rng`, `lockResets`, `events`. + - `const NextCount = 5`; `func New(seed int64) *Game`; `func (g *Game) Collides(p Piece) bool`; `func (g *Game) CanFall() bool`. + +- [ ] **Step 1: Write the failing test** + +Create `internal/game/game_test.go`: + +```go +package game + +import "testing" + +func TestNewGameInitialState(t *testing.T) { + g := New(99) + if g.Level != 1 { + t.Errorf("Level = %d, want 1", g.Level) + } + if g.Score != 0 || g.Lines != 0 || g.Combo != 0 { + t.Errorf("counters not zeroed: %+v", g) + } + if !g.CanHold { + t.Error("CanHold should start true") + } + if g.Hold != nil { + t.Error("Hold should start empty") + } + if g.Over { + t.Error("game should not start over") + } + if g.Seed != 99 { + t.Errorf("Seed = %d, want 99", g.Seed) + } + if len(g.Next) != NextCount { + t.Fatalf("len(Next) = %d, want %d", len(g.Next), NextCount) + } + if g.Active.X != SpawnX || g.Active.Y != SpawnY || g.Active.Rotation != 0 { + t.Errorf("active piece not at spawn: %+v", g.Active) + } +} + +func TestNewGameIsSeedReproducible(t *testing.T) { + a, b := New(1234), New(1234) + if a.Active.Kind != b.Active.Kind { + t.Fatal("same seed produced different first pieces") + } + for i := range a.Next { + if a.Next[i] != b.Next[i] { + t.Fatalf("same seed produced different queues at %d", i) + } + } +} + +func TestCollidesAndCanFall(t *testing.T) { + g := New(5) + g.Active = Piece{Kind: KindO, Rotation: 0, X: 3, Y: 19} + if g.Collides(g.Active) { + t.Fatal("piece at y=19 should not collide with an empty board") + } + if !g.CanFall() { + t.Fatal("piece at y=19 should be able to fall") + } + // O occupies box rows 0-1, so at Y=20 its lowest cells are on row 21, the + // floor row: it can no longer fall. + g.Active.Y = 20 + if !g.CanFall() == false { + // keep the direct assertion below readable + } + if g.CanFall() { + t.Error("piece resting on the floor should not be able to fall") + } + // A block directly underneath also stops the fall. + g.Active.Y = 10 + g.Board.Set(4, 12, CellOf(KindI)) + if g.CanFall() { + t.Error("piece above a locked block should not be able to fall") + } +} +``` + +Remove the empty `if !g.CanFall() == false {}` block when you paste — it is a placeholder artefact; the following assertion is the real check. (Keep the test file free of no-op statements: delete those three lines.) + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/game/ -run 'TestNewGame|TestCollides' -v` +Expected: FAIL — `undefined: New`, `undefined: NextCount`. + +- [ ] **Step 3: Write the event types** + +Create `internal/game/events.go`: + +```go +package game + +// EventKind names something the engine did. Events are the only channel +// through which the effects layer learns about gameplay; the effects layer +// never writes back. +type EventKind uint8 + +// The engine's event kinds. +const ( + EventPieceMoved EventKind = iota + EventPieceRotated + EventPieceHardDropped + EventPieceLocked + EventPieceSpawned + EventHoldUsed + EventLinesCleared + EventComboChanged + EventLevelChanged + EventGameOver +) + +// String returns the event kind's name. +func (k EventKind) String() string { + names := [...]string{ + "PieceMoved", "PieceRotated", "PieceHardDropped", "PieceLocked", + "PieceSpawned", "HoldUsed", "LinesCleared", "ComboChanged", + "LevelChanged", "GameOver", + } + if int(k) >= len(names) { + return "Unknown" + } + return names[k] +} + +// Event describes one thing that happened. Which fields carry meaning depends +// on Kind: +// +// PieceMoved, PieceRotated, PieceSpawned: Piece is the piece's new state. +// Points is 1 for a soft-drop step, 0 otherwise. +// PieceHardDropped: Piece is the landing position, Count is the number of +// cells fallen, Points is the drop score. +// PieceLocked, HoldUsed: Piece is the outgoing piece. +// LinesCleared: Cleared holds copies of the removed rows, Count is how many, +// Points is the line score plus combo bonus. +// ComboChanged: Count is the new combo (0 when broken). +// LevelChanged: Count is the new level. +// GameOver: no fields. +type Event struct { + Kind EventKind + Piece Piece + Cleared []ClearedRow + Count int + Points int +} +``` + +- [ ] **Step 4: Write the game struct and construction** + +Create `internal/game/game.go`: + +```go +package game + +import ( + "math/rand/v2" + "time" +) + +// NextCount is how many upcoming pieces the engine keeps visible. +const NextCount = 5 + +// Game is the whole logical game. Every exported field may be read freely by +// the renderer; only the engine's own methods write them during play. Tests +// and fixtures may set them directly. +type Game struct { + Board Board + Active Piece + Hold *PieceKind + CanHold bool + Next []PieceKind + + Score int + Lines int + Level int + Combo int + + GravityAccumulator time.Duration + LockAccumulator time.Duration + + Over bool + + // Seed is recorded for display and restart. Replay state lives in rng. + Seed int64 + + bag *Bag + rng *rand.Rand + lockResets int + events []Event +} + +// New starts a game from a seed. The seed fully determines piece order. +func New(seed int64) *Game { + rng := NewRNG(seed) + g := &Game{ + Level: 1, + CanHold: true, + Seed: seed, + rng: rng, + bag: NewBag(rng), + Next: make([]PieceKind, 0, NextCount), + events: make([]Event, 0, 8), + } + for len(g.Next) < NextCount { + g.Next = append(g.Next, g.bag.Next()) + } + g.Active = SpawnPiece(g.shiftNext()) + return g +} + +// shiftNext pops the head of the next queue and refills the tail from the bag. +func (g *Game) shiftNext() PieceKind { + k := g.Next[0] + copy(g.Next, g.Next[1:]) + g.Next[len(g.Next)-1] = g.bag.Next() + return k +} + +// Collides reports whether p overlaps a wall, the floor, or a locked block. +func (g *Game) Collides(p Piece) bool { + for _, c := range p.Cells() { + if g.Board.Blocked(c.X, c.Y) { + return true + } + } + return false +} + +// CanFall reports whether the active piece can descend one row. +func (g *Game) CanFall() bool { + p := g.Active + p.Y++ + return !g.Collides(p) +} + +func (g *Game) emit(e Event) { g.events = append(g.events, e) } + +// drain returns the events buffered since the last call. The returned slice is +// a copy, so callers may retain it. +func (g *Game) drain() []Event { + if len(g.events) == 0 { + return nil + } + out := make([]Event, len(g.events)) + copy(out, g.events) + g.events = g.events[:0] + return out +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v && go vet ./...` +Expected: PASS, vet clean. + +- [ ] **Step 6: Commit** + +```bash +git add internal/game/events.go internal/game/game.go internal/game/game_test.go +git commit -m "feat(game): game state, event channel, and next queue" +``` + +--- + +### Task 6: Horizontal movement and soft drop + +**Files:** +- Modify: `internal/game/game.go` (append) +- Test: `internal/game/move_test.go` + +**Interfaces:** +- Consumes: `Game`, `Event`, `CanFall`, `Collides`. +- Produces: `func (g *Game) MoveLeft() []Event`, `func (g *Game) MoveRight() []Event`, `func (g *Game) SoftDrop() []Event`, `func (g *Game) touchLockTimer()` (unexported, used by Task 7 and Task 8). + +- [ ] **Step 1: Write the failing test** + +Create `internal/game/move_test.go`: + +```go +package game + +import "testing" + +// testGame returns a game with a predictable active piece and an empty board. +func testGame(k PieceKind, x, y int) *Game { + g := New(1) + g.Active = Piece{Kind: k, Rotation: 0, X: x, Y: y} + return g +} + +func TestMoveLeftAndRight(t *testing.T) { + g := testGame(KindO, 4, 10) + if evs := g.MoveLeft(); len(evs) != 1 || evs[0].Kind != EventPieceMoved { + t.Fatalf("MoveLeft events = %v", evs) + } + if g.Active.X != 3 { + t.Errorf("X = %d, want 3", g.Active.X) + } + g.MoveRight() + if g.Active.X != 4 { + t.Errorf("X = %d, want 4", g.Active.X) + } +} + +func TestMoveBlockedByWalls(t *testing.T) { + // O occupies box columns 1-2, so X=-1 puts cells on columns 0-1. + g := testGame(KindO, -1, 10) + if evs := g.MoveLeft(); evs != nil { + t.Errorf("move into the left wall emitted %v", evs) + } + if g.Active.X != -1 { + t.Errorf("piece moved through the wall to X=%d", g.Active.X) + } + g = testGame(KindO, 7, 10) // cells on columns 8-9 + if evs := g.MoveRight(); evs != nil { + t.Errorf("move into the right wall emitted %v", evs) + } +} + +func TestMoveBlockedByLockedBlocks(t *testing.T) { + g := testGame(KindO, 4, 10) + g.Board.Set(3, 10, CellOf(KindI)) + if evs := g.MoveLeft(); evs != nil { + t.Errorf("move into a locked block emitted %v", evs) + } + if g.Active.X != 4 { + t.Error("piece moved into a locked block") + } +} + +func TestSoftDropScoresOnePointPerCell(t *testing.T) { + g := testGame(KindO, 4, 5) + evs := g.SoftDrop() + if len(evs) != 1 || evs[0].Kind != EventPieceMoved || evs[0].Points != 1 { + t.Fatalf("SoftDrop events = %+v", evs) + } + if g.Active.Y != 6 { + t.Errorf("Y = %d, want 6", g.Active.Y) + } + if g.Score != 1 { + t.Errorf("Score = %d, want 1", g.Score) + } + for i := 0; i < 3; i++ { + g.SoftDrop() + } + if g.Score != 4 { + t.Errorf("Score after 4 soft drops = %d, want 4", g.Score) + } +} + +func TestSoftDropOnFloorDoesNothing(t *testing.T) { + g := testGame(KindO, 4, 20) // resting on row 21 + if evs := g.SoftDrop(); evs != nil { + t.Errorf("soft drop on the floor emitted %v", evs) + } + if g.Score != 0 { + t.Error("soft drop on the floor scored points") + } +} + +func TestSoftDropResetsGravityAccumulator(t *testing.T) { + g := testGame(KindO, 4, 5) + g.GravityAccumulator = 700 * 1000 * 1000 // 700ms + g.SoftDrop() + if g.GravityAccumulator != 0 { + t.Errorf("GravityAccumulator = %v, want 0", g.GravityAccumulator) + } +} + +func TestGroundedMoveResetsLockTimerUpToTheCap(t *testing.T) { + g := testGame(KindO, 4, 20) // grounded + for i := 0; i < MaxLockResets; i++ { + g.LockAccumulator = 400 * 1000 * 1000 // 400ms + if i%2 == 0 { + g.MoveLeft() + } else { + g.MoveRight() + } + if g.LockAccumulator != 0 { + t.Fatalf("reset %d did not clear the lock timer", i) + } + } + // The cap is spent: further grounded moves must not reset it again. + g.LockAccumulator = 400 * 1000 * 1000 + g.MoveLeft() + if g.LockAccumulator == 0 { + t.Error("lock timer reset past MaxLockResets") + } +} + +func TestAirborneMoveDoesNotSpendLockResets(t *testing.T) { + g := testGame(KindO, 4, 5) // airborne + for i := 0; i < MaxLockResets+5; i++ { + g.MoveLeft() + g.MoveRight() + } + g.Active.Y = 20 // now ground it + g.LockAccumulator = 400 * 1000 * 1000 + g.MoveLeft() + if g.LockAccumulator != 0 { + t.Error("airborne moves consumed the lock-reset budget") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/game/ -run 'TestMove|TestSoft|TestGrounded|TestAirborne' -v` +Expected: FAIL — `g.MoveLeft undefined`, `undefined: MaxLockResets`. + +- [ ] **Step 3: Implement movement** + +Append to `internal/game/game.go`: + +```go +// Timing and lock rules. +const ( + LockDelay = 500 * time.Millisecond + MaxLockResets = 15 +) + +// MoveLeft shifts the active piece one column left when the move is legal. +func (g *Game) MoveLeft() []Event { return g.shift(-1) } + +// MoveRight shifts the active piece one column right when the move is legal. +func (g *Game) MoveRight() []Event { return g.shift(1) } + +func (g *Game) shift(dx int) []Event { + if g.Over { + return nil + } + p := g.Active + p.X += dx + if g.Collides(p) { + return nil + } + g.Active = p + g.touchLockTimer() + g.emit(Event{Kind: EventPieceMoved, Piece: g.Active}) + return g.drain() +} + +// SoftDrop lowers the active piece one row and scores one point. It is a +// no-op when the piece is already grounded. +func (g *Game) SoftDrop() []Event { + if g.Over || !g.CanFall() { + return nil + } + g.Active.Y++ + g.Score++ + g.GravityAccumulator = 0 + g.emit(Event{Kind: EventPieceMoved, Piece: g.Active, Points: 1}) + return g.drain() +} + +// touchLockTimer restarts the lock delay after a successful move or rotation +// made while grounded, up to MaxLockResets times per piece. Airborne moves +// cost nothing, because Advance zeroes the timer while the piece is falling. +func (g *Game) touchLockTimer() { + if g.CanFall() { + return + } + if g.lockResets >= MaxLockResets { + return + } + g.lockResets++ + g.LockAccumulator = 0 +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/game.go internal/game/move_test.go +git commit -m "feat(game): horizontal movement, soft drop, and lock-reset budget" +``` + +--- + +### Task 7: Rotation with wall kicks + +**Files:** +- Create: `internal/game/rules.go` +- Test: `internal/game/rotate_test.go` + +**Interfaces:** +- Consumes: `Game`, `Piece`, `Collides`, `touchLockTimer`. +- Produces: `var KickOffsets [8]Point`; `func (g *Game) RotateCW() []Event`; `func (g *Game) RotateCCW() []Event`. + +- [ ] **Step 1: Write the failing test** + +Create `internal/game/rotate_test.go`: + +```go +package game + +import "testing" + +func TestKickOrderMatchesSpec(t *testing.T) { + want := [8]Point{{0, 0}, {-1, 0}, {1, 0}, {-2, 0}, {2, 0}, {0, -1}, {-1, -1}, {1, -1}} + if KickOffsets != want { + t.Fatalf("KickOffsets = %v, want %v", KickOffsets, want) + } +} + +func TestRotateClockwiseAndBack(t *testing.T) { + g := testGame(KindT, 4, 10) + if evs := g.RotateCW(); len(evs) != 1 || evs[0].Kind != EventPieceRotated { + t.Fatalf("RotateCW events = %v", evs) + } + if g.Active.Rotation != 1 { + t.Errorf("Rotation = %d, want 1", g.Active.Rotation) + } + g.RotateCCW() + if g.Active.Rotation != 0 { + t.Errorf("Rotation = %d, want 0", g.Active.Rotation) + } +} + +func TestRotateCCWWrapsToThree(t *testing.T) { + g := testGame(KindT, 4, 10) + g.RotateCCW() + if g.Active.Rotation != 3 { + t.Errorf("Rotation = %d, want 3", g.Active.Rotation) + } +} + +func TestRotationKicksOffTheRightWall(t *testing.T) { + // I in rotation 1 occupies box column 2. Place it so that rotating to the + // horizontal orientation would poke past the right wall, and check that + // the piece is nudged left instead of the rotation failing. + g := testGame(KindI, 7, 10) + g.Active.Rotation = 1 // vertical, cells on column 9 + if evs := g.RotateCW(); len(evs) == 0 { + t.Fatal("rotation next to the right wall failed instead of kicking") + } + if g.Active.Rotation != 2 { + t.Errorf("Rotation = %d, want 2", g.Active.Rotation) + } + for _, c := range g.Active.Cells() { + if c.X < 0 || c.X >= Width { + t.Errorf("kicked piece has cell %v off the board", c) + } + } + if g.Active.X >= 7 { + t.Errorf("piece was not kicked left: X = %d", g.Active.X) + } +} + +func TestRotationKicksOffTheLeftWall(t *testing.T) { + g := testGame(KindI, -2, 10) + g.Active.Rotation = 1 // vertical, cells on column 0 + if evs := g.RotateCW(); len(evs) == 0 { + t.Fatal("rotation next to the left wall failed instead of kicking") + } + for _, c := range g.Active.Cells() { + if c.X < 0 || c.X >= Width { + t.Errorf("kicked piece has cell %v off the board", c) + } + } +} + +func TestRotationFailsWhenFullyBoxedIn(t *testing.T) { + g := testGame(KindT, 4, 10) + // Bury the piece: fill every row it could kick into, leaving only the + // three cells the spawn silhouette occupies. + for y := 8; y <= 13; y++ { + for x := 0; x < Width; x++ { + g.Board.Set(x, y, CellOf(KindI)) + } + } + for _, c := range g.Active.Cells() { + g.Board.Set(c.X, c.Y, Empty) + } + before := g.Active + if evs := g.RotateCW(); evs != nil { + t.Errorf("boxed-in rotation emitted %v", evs) + } + if g.Active != before { + t.Errorf("failed rotation still moved the piece: %+v -> %+v", before, g.Active) + } +} + +// Review Focus item 4: a kick offset of (0,-1) pushes cells above the ceiling. +// Blocked() treats y<0 as open space, so this must succeed without panicking. +func TestRotationAtTheCeilingDoesNotPanic(t *testing.T) { + g := testGame(KindI, 3, 0) + g.Active.Rotation = 1 + for x := 0; x < Width; x++ { + g.Board.Set(x, 2, CellOf(KindI)) // block the row below the hidden rows + } + g.RotateCW() // must not panic, whatever the outcome + for _, c := range g.Active.Cells() { + if c.X < 0 || c.X >= Width || c.Y >= Height { + t.Errorf("rotation produced an illegal cell %v", c) + } + } +} + +func TestGroundedRotationResetsLockTimer(t *testing.T) { + g := testGame(KindT, 4, 20) // grounded + g.LockAccumulator = 400 * 1000 * 1000 + if evs := g.RotateCW(); len(evs) == 0 { + t.Fatal("grounded rotation failed") + } + if g.LockAccumulator != 0 { + t.Error("grounded rotation did not reset the lock timer") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/game/ -run 'Rotat|Kick' -v` +Expected: FAIL — `undefined: KickOffsets`, `g.RotateCW undefined`. + +- [ ] **Step 3: Implement rotation** + +Create `internal/game/rules.go`: + +```go +package game + +// KickOffsets are the positions tried when rotating, in order. The first that +// fits wins; if none fits, the rotation fails and the piece does not move. +// This is deliberately a short, forgiving table rather than a full rotation +// ruleset. +var KickOffsets = [8]Point{ + {X: 0, Y: 0}, + {X: -1, Y: 0}, + {X: 1, Y: 0}, + {X: -2, Y: 0}, + {X: 2, Y: 0}, + {X: 0, Y: -1}, + {X: -1, Y: -1}, + {X: 1, Y: -1}, +} + +// RotateCW turns the active piece one step clockwise. +func (g *Game) RotateCW() []Event { return g.rotate(1) } + +// RotateCCW turns the active piece one step counter-clockwise. +func (g *Game) RotateCCW() []Event { return g.rotate(-1) } + +func (g *Game) rotate(dir int) []Event { + if g.Over { + return nil + } + target := ((g.Active.Rotation+dir)%4 + 4) % 4 + for _, k := range KickOffsets { + p := g.Active + p.Rotation = target + p.X += k.X + p.Y += k.Y + if g.Collides(p) { + continue + } + g.Active = p + g.touchLockTimer() + g.emit(Event{Kind: EventPieceRotated, Piece: g.Active}) + return g.drain() + } + return nil +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/rules.go internal/game/rotate_test.go +git commit -m "feat(game): rotation with a forgiving wall-kick table" +``` + +--- + +### Task 8: Gravity, `Advance(dt)`, and locking + +**Files:** +- Modify: `internal/game/rules.go` (append gravity helpers) +- Modify: `internal/game/game.go` (append `Advance`, `lock`, `spawn`) +- Test: `internal/game/advance_test.go` + +**Interfaces:** +- Consumes: everything above. +- Produces: `const BaseDropInterval = 800 * time.Millisecond`, `MinDropInterval = 60 * time.Millisecond`, `DropDecay = 0.86`, `LinesPerLevel = 10`; `func DropInterval(level int) time.Duration`; `func LevelFor(lines int) int`; `func (g *Game) Advance(dt time.Duration) []Event`; unexported `func (g *Game) lock()`, `func (g *Game) spawn(k PieceKind)`. + +At this task, locking commits the piece and spawns the next one. Line clearing and scoring arrive in Task 9; the test expectations here use a board where nothing completes. + +- [ ] **Step 1: Write the failing test** + +Create `internal/game/advance_test.go`: + +```go +package game + +import ( + "testing" + "time" +) + +func TestDropIntervalCurve(t *testing.T) { + if got := DropInterval(1); got != BaseDropInterval { + t.Errorf("DropInterval(1) = %v, want %v", got, BaseDropInterval) + } + l2 := DropInterval(2) + if l2 >= DropInterval(1) { + t.Error("interval did not shrink from level 1 to 2") + } + // 800ms * 0.86 = 688ms + if l2 < 680*time.Millisecond || l2 > 695*time.Millisecond { + t.Errorf("DropInterval(2) = %v, want ~688ms", l2) + } + if got := DropInterval(3); got >= l2 { + t.Error("interval did not shrink from level 2 to 3") + } +} + +// Review Focus support: absurd levels must clamp, never reach zero, and never +// hang the loop in Advance. +func TestDropIntervalClamps(t *testing.T) { + for _, level := range []int{40, 200, 100000} { + if got := DropInterval(level); got != MinDropInterval { + t.Errorf("DropInterval(%d) = %v, want %v", level, got, MinDropInterval) + } + } + if got := DropInterval(0); got != BaseDropInterval { + t.Errorf("DropInterval(0) = %v, want %v", got, BaseDropInterval) + } +} + +func TestLevelFor(t *testing.T) { + cases := map[int]int{0: 1, 9: 1, 10: 2, 19: 2, 20: 3, 137: 14} + for lines, want := range cases { + if got := LevelFor(lines); got != want { + t.Errorf("LevelFor(%d) = %d, want %d", lines, got, want) + } + } +} + +func TestAdvanceStepsDownOncePerInterval(t *testing.T) { + g := testGame(KindO, 4, 5) + if evs := g.Advance(400 * time.Millisecond); evs != nil { + t.Fatalf("half an interval moved the piece: %v", evs) + } + if g.Active.Y != 5 { + t.Fatalf("Y = %d, want 5", g.Active.Y) + } + evs := g.Advance(400 * time.Millisecond) + if len(evs) != 1 || evs[0].Kind != EventPieceMoved { + t.Fatalf("events = %v", evs) + } + if g.Active.Y != 6 { + t.Errorf("Y = %d, want 6", g.Active.Y) + } +} + +func TestAdvanceCarriesRemainderForward(t *testing.T) { + g := testGame(KindO, 4, 5) + g.Advance(900 * time.Millisecond) // one step, 100ms left over + if g.Active.Y != 6 { + t.Fatalf("Y = %d, want 6", g.Active.Y) + } + if g.GravityAccumulator != 100*time.Millisecond { + t.Errorf("GravityAccumulator = %v, want 100ms", g.GravityAccumulator) + } +} + +// Review Focus item 1: a huge dt (laptop sleep) may cover many rows, but it +// locks at most one piece, and it must terminate. +func TestAdvanceWithHugeDtLocksExactlyOnce(t *testing.T) { + g := testGame(KindO, 4, 2) + first := g.Active + evs := g.Advance(30 * time.Second) + locks := 0 + spawns := 0 + for _, e := range evs { + switch e.Kind { + case EventPieceLocked: + locks++ + case EventPieceSpawned: + spawns++ + } + } + if locks != 1 || spawns != 1 { + t.Fatalf("huge dt produced %d locks and %d spawns, want 1 and 1", locks, spawns) + } + if g.Active == first { + t.Error("active piece was not replaced after the lock") + } + if !g.Board.At(4, 21).Filled() { + t.Error("piece did not come to rest on the floor") + } +} + +// Review Focus item 2. +func TestAdvanceIgnoresZeroAndNegativeDt(t *testing.T) { + g := testGame(KindO, 4, 5) + before := *g + if evs := g.Advance(0); evs != nil { + t.Errorf("dt=0 emitted %v", evs) + } + if evs := g.Advance(-500 * time.Millisecond); evs != nil { + t.Errorf("negative dt emitted %v", evs) + } + if g.Active != before.Active || g.GravityAccumulator != before.GravityAccumulator { + t.Error("non-positive dt changed the game state") + } +} + +func TestGroundedPieceLocksAfterLockDelay(t *testing.T) { + g := testGame(KindO, 4, 20) // resting on the floor + if evs := g.Advance(300 * time.Millisecond); evs != nil { + t.Fatalf("locked too early: %v", evs) + } + if g.LockAccumulator != 300*time.Millisecond { + t.Errorf("LockAccumulator = %v, want 300ms", g.LockAccumulator) + } + evs := g.Advance(250 * time.Millisecond) + sawLock := false + for _, e := range evs { + if e.Kind == EventPieceLocked { + sawLock = true + } + } + if !sawLock { + t.Fatalf("piece did not lock after the delay: %v", evs) + } + if g.Board.At(4, 21) != CellOf(KindO) || g.Board.At(5, 21) != CellOf(KindO) { + t.Error("locked cells were not committed to the board") + } +} + +func TestLockResetsCounterAndHoldOnSpawn(t *testing.T) { + g := testGame(KindO, 4, 20) + g.CanHold = false + g.lockResets = 9 + g.Advance(LockDelay) + if !g.CanHold { + t.Error("CanHold was not restored on spawn") + } + if g.lockResets != 0 { + t.Errorf("lockResets = %d, want 0 after spawn", g.lockResets) + } + if g.GravityAccumulator != 0 || g.LockAccumulator != 0 { + t.Error("accumulators were not reset on spawn") + } +} + +func TestFallingResetsTheLockTimer(t *testing.T) { + g := testGame(KindO, 4, 5) + g.LockAccumulator = 300 * time.Millisecond + g.Advance(10 * time.Millisecond) + if g.LockAccumulator != 0 { + t.Errorf("LockAccumulator = %v, want 0 while airborne", g.LockAccumulator) + } +} + +func TestSpawnTakesTheHeadOfTheQueue(t *testing.T) { + g := New(4242) + wantNext := g.Next[0] + g.Active = Piece{Kind: KindO, Rotation: 0, X: 4, Y: 20} + g.Advance(LockDelay) + if g.Active.Kind != wantNext { + t.Errorf("spawned %v, want %v", g.Active.Kind, wantNext) + } + if len(g.Next) != NextCount { + t.Errorf("len(Next) = %d, want %d", len(g.Next), NextCount) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/game/ -run 'Advance|DropInterval|LevelFor|Lock|Falling|Spawn' -v` +Expected: FAIL — `undefined: DropInterval`, `g.Advance undefined`. + +- [ ] **Step 3: Implement gravity curve and level mapping** + +Append to `internal/game/rules.go`: + +```go +// Gravity and level progression. +const ( + BaseDropInterval = 800 * time.Millisecond + MinDropInterval = 60 * time.Millisecond + DropDecay = 0.86 + LinesPerLevel = 10 +) + +// DropInterval is how long one row of gravity takes at the given level: +// 800ms * 0.86^(level-1), clamped at MinDropInterval. Computed by repeated +// multiplication rather than math.Pow so the sequence is bit-identical +// everywhere, which matters for replays. +func DropInterval(level int) time.Duration { + d := float64(BaseDropInterval) + min := float64(MinDropInterval) + for i := 1; i < level && d > min; i++ { + d *= DropDecay + } + if d < min { + return MinDropInterval + } + return time.Duration(d) +} + +// LevelFor maps a cleared-line total to a level, starting at 1. +func LevelFor(lines int) int { + if lines < 0 { + return 1 + } + return lines/LinesPerLevel + 1 +} +``` + +Add `"time"` to the imports of `rules.go`. + +- [ ] **Step 4: Implement `Advance`, `lock`, and `spawn`** + +Append to `internal/game/game.go`: + +```go +// Advance moves time forward by dt and returns everything that happened. This +// is the only clock the engine has: nothing in this package calls time.Now, +// so the same seed, inputs, and dt sequence always reproduce the same state. +// +// One call locks at most one piece, so an enormous dt (a suspended terminal, a +// debugger pause) drops the active piece and stops, rather than silently +// playing several pieces' worth of game. +func (g *Game) Advance(dt time.Duration) []Event { + if g.Over || dt <= 0 { + return nil + } + if g.CanFall() { + g.LockAccumulator = 0 + g.GravityAccumulator += dt + interval := DropInterval(g.Level) + for g.GravityAccumulator >= interval && g.CanFall() { + g.GravityAccumulator -= interval + g.Active.Y++ + g.emit(Event{Kind: EventPieceMoved, Piece: g.Active}) + } + } + if !g.CanFall() { + g.GravityAccumulator = 0 + g.LockAccumulator += dt + if g.LockAccumulator >= LockDelay { + g.lock() + } + } + return g.drain() +} + +// lock commits the active piece to the board, resolves any completed rows, +// and spawns the next piece. +func (g *Game) lock() { + for _, c := range g.Active.Cells() { + g.Board.Set(c.X, c.Y, CellOf(g.Active.Kind)) + } + g.emit(Event{Kind: EventPieceLocked, Piece: g.Active}) + g.resolveClears() + g.spawn(g.shiftNext()) +} + +// resolveClears is filled in by the line-clearing task. Locking works without +// it, so it starts as a no-op. +func (g *Game) resolveClears() {} + +// spawn puts a new piece at the spawn position and resets the per-piece +// timers. A spawn that immediately collides ends the game. +func (g *Game) spawn(k PieceKind) { + g.Active = SpawnPiece(k) + g.CanHold = true + g.GravityAccumulator = 0 + g.LockAccumulator = 0 + g.lockResets = 0 + g.emit(Event{Kind: EventPieceSpawned, Piece: g.Active}) + if g.Collides(g.Active) { + g.Over = true + g.emit(Event{Kind: EventGameOver}) + } +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v && go vet ./...` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add internal/game/game.go internal/game/rules.go internal/game/advance_test.go +git commit -m "feat(game): elapsed-time gravity, lock delay, and piece spawning" +``` + +--- + +### Task 9: Line clearing, combo, level-up, and scoring + +**Files:** +- Create: `internal/game/scoring.go` +- Modify: `internal/game/game.go` (replace the `resolveClears` no-op) +- Test: `internal/game/scoring_test.go` +- Test: `internal/game/clear_test.go` + +**Interfaces:** +- Consumes: `Board.CompleteRows`, `Board.Snapshot`, `Board.ClearRows`, `Event`, `LevelFor`. +- Produces: `func LineScore(lines, level int) int`; `func ComboBonus(combo, level int) int`; a working `func (g *Game) resolveClears()` that emits `EventLinesCleared`, `EventComboChanged`, and `EventLevelChanged`. + +Scoring uses the level in force *before* the clear raised it, so a clear that levels you up is paid at the old rate. That is the conventional behaviour and it keeps the numbers explainable. + +- [ ] **Step 1: Write the failing scoring test** + +Create `internal/game/scoring_test.go`: + +```go +package game + +import "testing" + +func TestLineScoreTable(t *testing.T) { + cases := []struct { + lines, level, want int + }{ + {1, 1, 100}, {2, 1, 300}, {3, 1, 500}, {4, 1, 800}, + {1, 7, 700}, {4, 7, 5600}, + {0, 5, 0}, {5, 5, 0}, {-1, 5, 0}, + } + for _, tc := range cases { + if got := LineScore(tc.lines, tc.level); got != tc.want { + t.Errorf("LineScore(%d,%d) = %d, want %d", tc.lines, tc.level, got, tc.want) + } + } +} + +func TestComboBonusStartsAtComboTwo(t *testing.T) { + cases := []struct { + combo, level, want int + }{ + {0, 3, 0}, + {1, 3, 0}, // a lone clear earns no combo bonus + {2, 1, 50}, + {2, 3, 150}, + {5, 2, 400}, // 50 * 4 * 2 + } + for _, tc := range cases { + if got := ComboBonus(tc.combo, tc.level); got != tc.want { + t.Errorf("ComboBonus(%d,%d) = %d, want %d", tc.combo, tc.level, got, tc.want) + } + } +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `go test ./internal/game/ -run 'LineScore|ComboBonus' -v` +Expected: FAIL — `undefined: LineScore`. + +- [ ] **Step 3: Implement the scoring functions** + +Create `internal/game/scoring.go`: + +```go +package game + +// lineScores is the base value of clearing n rows, before the level multiplier. +var lineScores = [5]int{0, 100, 300, 500, 800} + +// LineScore is the score for clearing n rows at the given level. +func LineScore(lines, level int) int { + if lines < 1 || lines >= len(lineScores) { + return 0 + } + return lineScores[lines] * level +} + +// ComboBonus is the extra score for a chain of clearing placements: +// 50 * (combo-1) * level. The first clear of a chain earns nothing, so the +// bonus first appears at combo 2. +func ComboBonus(combo, level int) int { + if combo < 2 { + return 0 + } + return 50 * (combo - 1) * level +} +``` + +- [ ] **Step 4: Write the failing clear-behaviour test** + +Create `internal/game/clear_test.go`: + +```go +package game + +import ( + "testing" + "time" +) + +// dropOnto locks the active piece immediately by hard-dropping it. +func mustEvent(t *testing.T, evs []Event, kind EventKind) Event { + t.Helper() + for _, e := range evs { + if e.Kind == kind { + return e + } + } + t.Fatalf("no %v event in %v", kind, kinds(evs)) + return Event{} +} + +func kinds(evs []Event) []string { + out := make([]string, 0, len(evs)) + for _, e := range evs { + out = append(out, e.Kind.String()) + } + return out +} + +func noEvent(t *testing.T, evs []Event, kind EventKind) { + t.Helper() + for _, e := range evs { + if e.Kind == kind { + t.Fatalf("unexpected %v in %v", kind, kinds(evs)) + } + } +} + +// setupSingleClear leaves row 21 needing exactly the two cells an O piece +// covers at X=4, with the O resting on the floor. +func setupSingleClear(t *testing.T) *Game { + t.Helper() + g := New(11) + for x := 0; x < Width; x++ { + if x == 4 || x == 5 { + continue + } + g.Board.Set(x, 21, CellOf(KindI)) + } + g.Active = Piece{Kind: KindO, Rotation: 0, X: 4, Y: 20} + return g +} + +func TestSingleClearScoresAndCountsLines(t *testing.T) { + g := setupSingleClear(t) + evs := g.Advance(LockDelay) + + cleared := mustEvent(t, evs, EventLinesCleared) + if cleared.Count != 1 { + t.Errorf("Count = %d, want 1", cleared.Count) + } + if len(cleared.Cleared) != 1 || cleared.Cleared[0].Y != 21 { + t.Errorf("Cleared = %+v", cleared.Cleared) + } + if !cleared.Cleared[0].Cells[0].Filled() { + t.Error("cleared-row snapshot is empty") + } + if cleared.Points != 100 { + t.Errorf("Points = %d, want 100 (1 line at level 1, no combo bonus)", cleared.Points) + } + if g.Score != 100 { + t.Errorf("Score = %d, want 100", g.Score) + } + if g.Lines != 1 { + t.Errorf("Lines = %d, want 1", g.Lines) + } + if g.Board.At(0, 21).Filled() { + t.Error("row 21 was not cleared from the board") + } + combo := mustEvent(t, evs, EventComboChanged) + if combo.Count != 1 { + t.Errorf("combo = %d, want 1", combo.Count) + } + noEvent(t, evs, EventLevelChanged) +} + +func TestFourLineClearScores800TimesLevel(t *testing.T) { + g := New(12) + for y := 18; y <= 21; y++ { + for x := 0; x < Width; x++ { + if x == 4 { + continue + } + g.Board.Set(x, y, CellOf(KindI)) + } + } + // Vertical I in column 4 fills all four rows at once. + g.Active = Piece{Kind: KindI, Rotation: 1, X: 2, Y: 18} + evs := g.HardDropForTest() + + cleared := mustEvent(t, evs, EventLinesCleared) + if cleared.Count != 4 { + t.Fatalf("Count = %d, want 4", cleared.Count) + } + if cleared.Points != 800 { + t.Errorf("Points = %d, want 800", cleared.Points) + } + if g.Lines != 4 { + t.Errorf("Lines = %d, want 4", g.Lines) + } +} + +func TestComboChainAndReset(t *testing.T) { + g := New(13) + clearOneRow := func() []Event { + for x := 0; x < Width; x++ { + if x == 4 || x == 5 { + continue + } + g.Board.Set(x, 21, CellOf(KindI)) + } + g.Active = Piece{Kind: KindO, Rotation: 0, X: 4, Y: 20} + g.LockAccumulator = 0 + return g.Advance(LockDelay) + } + + clearOneRow() + if g.Combo != 1 { + t.Fatalf("Combo = %d, want 1", g.Combo) + } + scoreAfterFirst := g.Score + + evs := clearOneRow() + if g.Combo != 2 { + t.Fatalf("Combo = %d, want 2", g.Combo) + } + cleared := mustEvent(t, evs, EventLinesCleared) + if cleared.Points != 150 { // 100 line + 50 combo bonus at level 1 + t.Errorf("second clear Points = %d, want 150", cleared.Points) + } + if g.Score != scoreAfterFirst+150 { + t.Errorf("Score = %d, want %d", g.Score, scoreAfterFirst+150) + } + + // A placement that clears nothing breaks the chain. + g.Active = Piece{Kind: KindO, Rotation: 0, X: 0, Y: 20} + g.LockAccumulator = 0 + evs = g.Advance(LockDelay) + if g.Combo != 0 { + t.Errorf("Combo = %d, want 0 after an empty placement", g.Combo) + } + broke := mustEvent(t, evs, EventComboChanged) + if broke.Count != 0 { + t.Errorf("combo event Count = %d, want 0", broke.Count) + } + noEvent(t, evs, EventLinesCleared) +} + +func TestEmptyPlacementWithNoComboEmitsNoComboEvent(t *testing.T) { + g := testGame(KindO, 4, 20) + evs := g.Advance(LockDelay) + noEvent(t, evs, EventComboChanged) +} + +func TestLevelRisesEveryTenLines(t *testing.T) { + g := New(14) + g.Lines = 9 + for x := 0; x < Width; x++ { + if x == 4 || x == 5 { + continue + } + g.Board.Set(x, 21, CellOf(KindI)) + } + g.Active = Piece{Kind: KindO, Rotation: 0, X: 4, Y: 20} + evs := g.Advance(LockDelay) + + lvl := mustEvent(t, evs, EventLevelChanged) + if lvl.Count != 2 { + t.Errorf("new level = %d, want 2", lvl.Count) + } + if g.Level != 2 { + t.Errorf("Level = %d, want 2", g.Level) + } + cleared := mustEvent(t, evs, EventLinesCleared) + if cleared.Points != 100 { + t.Errorf("Points = %d, want 100 (scored at the pre-clear level)", cleared.Points) + } + if DropInterval(g.Level) >= DropInterval(1) { + t.Error("gravity did not speed up after the level change") + } + _ = time.Second +} +``` + +`HardDropForTest` is a temporary shim so this task's four-line test can lock a piece instantly before Task 11 adds the real `HardDrop`. Add it to `internal/game/game.go` in Step 5 and delete it in Task 11, Step 5. + +- [ ] **Step 5: Implement clearing and the test shim** + +Replace the `resolveClears` no-op in `internal/game/game.go` with: + +```go +// resolveClears removes completed rows, updates the score, lines, combo, and +// level, and emits the matching events. Scoring uses the level in force +// before this clear, so a clear that levels you up is paid at the old rate. +func (g *Game) resolveClears() { + rows := g.Board.CompleteRows() + if len(rows) == 0 { + if g.Combo != 0 { + g.Combo = 0 + g.emit(Event{Kind: EventComboChanged, Count: 0}) + } + return + } + + snapshot := g.Board.Snapshot(rows) + g.Board.ClearRows(rows) + + n := len(rows) + g.Lines += n + g.Combo++ + points := LineScore(n, g.Level) + ComboBonus(g.Combo, g.Level) + g.Score += points + + g.emit(Event{Kind: EventLinesCleared, Cleared: snapshot, Count: n, Points: points}) + g.emit(Event{Kind: EventComboChanged, Count: g.Combo}) + + if lvl := LevelFor(g.Lines); lvl != g.Level { + g.Level = lvl + g.emit(Event{Kind: EventLevelChanged, Count: g.Level}) + } +} + +// HardDropForTest drops and locks the active piece immediately. It exists only +// until the real HardDrop lands; delete it then. +func (g *Game) HardDropForTest() []Event { + for g.CanFall() { + g.Active.Y++ + } + g.lock() + return g.drain() +} +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add internal/game/scoring.go internal/game/game.go internal/game/scoring_test.go internal/game/clear_test.go +git commit -m "feat(game): line clearing, combo chain, level-up, and scoring" +``` + +--- + +### Task 10: Hold + +**Files:** +- Modify: `internal/game/game.go` (append `UseHold`) +- Test: `internal/game/hold_test.go` + +**Interfaces:** +- Consumes: `Game`, `spawn`, `shiftNext`, `Event`. +- Produces: `func (g *Game) UseHold() []Event`. + +- [ ] **Step 1: Write the failing test** + +Create `internal/game/hold_test.go`: + +```go +package game + +import "testing" + +func TestFirstHoldStoresPieceAndTakesFromQueue(t *testing.T) { + g := New(21) + active := g.Active.Kind + wantNext := g.Next[0] + + evs := g.UseHold() + mustEvent(t, evs, EventHoldUsed) + mustEvent(t, evs, EventPieceSpawned) + + if g.Hold == nil || *g.Hold != active { + t.Fatalf("Hold = %v, want %v", g.Hold, active) + } + if g.Active.Kind != wantNext { + t.Errorf("active = %v, want %v from the queue", g.Active.Kind, wantNext) + } + if g.CanHold { + t.Error("CanHold should be false after holding") + } + if len(g.Next) != NextCount { + t.Errorf("len(Next) = %d, want %d", len(g.Next), NextCount) + } +} + +func TestSecondHoldIsBlockedUntilTheNextLock(t *testing.T) { + g := New(22) + g.UseHold() + held := *g.Hold + active := g.Active.Kind + + if evs := g.UseHold(); evs != nil { + t.Fatalf("second hold emitted %v", evs) + } + if *g.Hold != held || g.Active.Kind != active { + t.Error("second hold changed the game state") + } +} + +func TestHoldSwapsWithStoredPiece(t *testing.T) { + g := New(23) + g.UseHold() + held := *g.Hold + current := g.Active.Kind + queueHead := g.Next[0] + + g.CanHold = true // simulate a fresh piece + g.UseHold() + + if g.Active.Kind != held { + t.Errorf("active = %v, want the previously held %v", g.Active.Kind, held) + } + if *g.Hold != current { + t.Errorf("Hold = %v, want %v", *g.Hold, current) + } + if g.Next[0] != queueHead { + t.Error("a swap must not consume the next queue") + } +} + +func TestHeldPieceReturnsAtSpawnRotationAndPosition(t *testing.T) { + g := New(24) + g.Active.Rotation = 3 + g.Active.X = 7 + g.Active.Y = 15 + g.UseHold() + g.CanHold = true + g.UseHold() + + if g.Active.Rotation != 0 || g.Active.X != SpawnX || g.Active.Y != SpawnY { + t.Errorf("restored piece at %+v, want spawn rotation and position", g.Active) + } +} + +func TestHoldIsRestoredAfterALock(t *testing.T) { + g := New(25) + g.UseHold() + if g.CanHold { + t.Fatal("CanHold should be false right after holding") + } + g.Active = Piece{Kind: g.Active.Kind, Rotation: 0, X: 4, Y: 20} + g.Advance(LockDelay) + if !g.CanHold { + t.Error("CanHold was not restored by the lock") + } +} + +func TestHoldResetsPerPieceTimers(t *testing.T) { + g := New(26) + g.GravityAccumulator = 500 * 1000 * 1000 + g.LockAccumulator = 200 * 1000 * 1000 + g.lockResets = 5 + g.UseHold() + if g.GravityAccumulator != 0 || g.LockAccumulator != 0 || g.lockResets != 0 { + t.Error("hold did not reset the per-piece timers") + } +} + +func TestHoldIntoABlockedSpawnEndsTheGame(t *testing.T) { + g := New(27) + // Fill the spawn rows so any incoming piece collides. + for x := 0; x < Width; x++ { + g.Board.Set(x, 0, CellOf(KindI)) + g.Board.Set(x, 1, CellOf(KindI)) + } + evs := g.UseHold() + mustEvent(t, evs, EventGameOver) + if !g.Over { + t.Error("Over should be true") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/game/ -run 'Hold' -v` +Expected: FAIL — `g.UseHold undefined`. + +- [ ] **Step 3: Implement hold** + +Append to `internal/game/game.go`: + +```go +// UseHold swaps the active piece with the held piece. With an empty hold it +// stores the active piece and pulls the next one from the queue. Only one hold +// is allowed per piece; the allowance returns when a piece locks. +func (g *Game) UseHold() []Event { + if g.Over || !g.CanHold { + return nil + } + outgoing := g.Active.Kind + g.emit(Event{Kind: EventHoldUsed, Piece: g.Active}) + + var incoming PieceKind + if g.Hold == nil { + incoming = g.shiftNext() + } else { + incoming = *g.Hold + } + stored := outgoing + g.Hold = &stored + + g.spawn(incoming) + // spawn restores the hold allowance for a newly spawned piece; a held + // piece does not get another swap. + g.CanHold = false + return g.drain() +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/game.go internal/game/hold_test.go +git commit -m "feat(game): hold with one swap per piece" +``` + +--- + +### Task 11: Ghost position and hard drop + +**Files:** +- Modify: `internal/game/game.go` (append `GhostY`, `HardDrop`; delete `HardDropForTest`) +- Modify: `internal/game/clear_test.go` (swap the shim call for the real method) +- Test: `internal/game/harddrop_test.go` + +**Interfaces:** +- Consumes: `Game`, `CanFall`, `lock`. +- Produces: `func (g *Game) GhostY() int`; `func (g *Game) GhostPiece() Piece`; `func (g *Game) HardDrop() []Event`. + +- [ ] **Step 1: Write the failing test** + +Create `internal/game/harddrop_test.go`: + +```go +package game + +import "testing" + +func TestGhostYFindsTheFloor(t *testing.T) { + g := testGame(KindO, 4, 3) + if got := g.GhostY(); got != 20 { + t.Errorf("GhostY() = %d, want 20", got) + } + if g.Active.Y != 3 { + t.Error("GhostY moved the active piece") + } +} + +func TestGhostYStopsOnTheStack(t *testing.T) { + g := testGame(KindO, 4, 3) + g.Board.Set(4, 15, CellOf(KindI)) + if got := g.GhostY(); got != 13 { + t.Errorf("GhostY() = %d, want 13", got) + } +} + +func TestGhostPieceMatchesActiveApartFromY(t *testing.T) { + g := testGame(KindT, 4, 6) + g.Active.Rotation = 2 + ghost := g.GhostPiece() + if ghost.Kind != g.Active.Kind || ghost.Rotation != g.Active.Rotation || ghost.X != g.Active.X { + t.Errorf("ghost = %+v, active = %+v", ghost, g.Active) + } + if ghost.Y != g.GhostY() { + t.Errorf("ghost.Y = %d, want %d", ghost.Y, g.GhostY()) + } +} + +func TestGhostYWhenAlreadyGrounded(t *testing.T) { + g := testGame(KindO, 4, 20) + if got := g.GhostY(); got != 20 { + t.Errorf("GhostY() = %d, want 20", got) + } +} + +func TestHardDropScoresTwoPerCellAndLocks(t *testing.T) { + g := testGame(KindO, 4, 3) + evs := g.HardDrop() + + drop := mustEvent(t, evs, EventPieceHardDropped) + if drop.Count != 17 { // 20 - 3 + t.Errorf("Count = %d, want 17", drop.Count) + } + if drop.Points != 34 { + t.Errorf("Points = %d, want 34", drop.Points) + } + if g.Score != 34 { + t.Errorf("Score = %d, want 34", g.Score) + } + mustEvent(t, evs, EventPieceLocked) + mustEvent(t, evs, EventPieceSpawned) + if g.Board.At(4, 21) != CellOf(KindO) { + t.Error("hard-dropped piece was not committed at the floor") + } +} + +func TestHardDropWhenAlreadyGroundedScoresNothingAndStillLocks(t *testing.T) { + g := testGame(KindO, 4, 20) + evs := g.HardDrop() + drop := mustEvent(t, evs, EventPieceHardDropped) + if drop.Count != 0 || drop.Points != 0 { + t.Errorf("Count/Points = %d/%d, want 0/0", drop.Count, drop.Points) + } + mustEvent(t, evs, EventPieceLocked) +} + +func TestHardDropOrdersEventsDropThenLockThenSpawn(t *testing.T) { + g := testGame(KindO, 4, 3) + evs := g.HardDrop() + var order []EventKind + for _, e := range evs { + switch e.Kind { + case EventPieceHardDropped, EventPieceLocked, EventPieceSpawned: + order = append(order, e.Kind) + } + } + want := []EventKind{EventPieceHardDropped, EventPieceLocked, EventPieceSpawned} + if len(order) != len(want) { + t.Fatalf("event order = %v, want %v", order, want) + } + for i := range want { + if order[i] != want[i] { + t.Fatalf("event order = %v, want %v", order, want) + } + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/game/ -run 'Ghost|HardDrop' -v` +Expected: FAIL — `g.GhostY undefined`, `g.HardDrop undefined`. + +- [ ] **Step 3: Implement ghost and hard drop** + +Append to `internal/game/game.go`: + +```go +// GhostY is the row the active piece would come to rest on if dropped now. +func (g *Game) GhostY() int { + p := g.Active + for { + next := p + next.Y++ + if g.Collides(next) { + return p.Y + } + p = next + } +} + +// GhostPiece is the active piece translated to its landing position. The +// renderer draws it under the active piece; it is not part of the board. +func (g *Game) GhostPiece() Piece { + p := g.Active + p.Y = g.GhostY() + return p +} + +// HardDrop slams the active piece to its landing position, scores two points +// per cell fallen, and locks it immediately. +func (g *Game) HardDrop() []Event { + if g.Over { + return nil + } + target := g.GhostY() + distance := target - g.Active.Y + g.Active.Y = target + points := 2 * distance + g.Score += points + g.emit(Event{Kind: EventPieceHardDropped, Piece: g.Active, Count: distance, Points: points}) + g.lock() + return g.drain() +} +``` + +- [ ] **Step 4: Remove the test shim** + +Delete `HardDropForTest` from `internal/game/game.go`, and in `internal/game/clear_test.go` replace `g.HardDropForTest()` with `g.HardDrop()`. + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `go test ./internal/game/ -v && go vet ./...` +Expected: PASS. `TestFourLineClearScores800TimesLevel` now exercises the real hard drop — note its score also includes the drop points, so if that assertion fails on `g.Score` rather than `cleared.Points`, the test is asserting the wrong number (it asserts `cleared.Points`, which is clear score only). + +- [ ] **Step 6: Commit** + +```bash +git add internal/game/game.go internal/game/harddrop_test.go internal/game/clear_test.go +git commit -m "feat(game): ghost landing position and hard drop" +``` + +--- + +### Task 12: Game over + +**Files:** +- Test: `internal/game/gameover_test.go` + +**Interfaces:** +- Consumes: `spawn`'s existing blocked-spawn handling from Task 8. +- Produces: no new API — this task pins the behaviour with tests and fixes anything they catch. + +- [ ] **Step 1: Write the failing test** + +Create `internal/game/gameover_test.go`: + +```go +package game + +import ( + "testing" + "time" +) + +func TestBlockedSpawnEndsTheGame(t *testing.T) { + g := New(31) + for x := 0; x < Width; x++ { + for y := 0; y < 4; y++ { + g.Board.Set(x, y, CellOf(KindI)) + } + } + g.Active = Piece{Kind: KindO, Rotation: 0, X: 4, Y: 18} + evs := g.HardDrop() + + mustEvent(t, evs, EventGameOver) + if !g.Over { + t.Fatal("Over should be true") + } + // GameOver must come after the spawn that failed. + var sawSpawn bool + for _, e := range evs { + if e.Kind == EventPieceSpawned { + sawSpawn = true + } + if e.Kind == EventGameOver && !sawSpawn { + t.Error("GameOver emitted before the blocked spawn") + } + } +} + +func TestGameOverIsEmittedOnlyOnce(t *testing.T) { + g := New(32) + for x := 0; x < Width; x++ { + for y := 0; y < 4; y++ { + g.Board.Set(x, y, CellOf(KindI)) + } + } + g.Active = Piece{Kind: KindO, Rotation: 0, X: 4, Y: 18} + g.HardDrop() + + for i := 0; i < 5; i++ { + if evs := g.Advance(time.Second); evs != nil { + t.Fatalf("Advance after game over emitted %v", kinds(evs)) + } + } +} + +// Review Focus item 5: after game over, every input is inert. +func TestAllInputsAreInertAfterGameOver(t *testing.T) { + g := New(33) + g.Over = true + before := struct { + active Piece + score, lines int + level, combo int + canHold bool + gravity, lockAccu time.Duration + }{g.Active, g.Score, g.Lines, g.Level, g.Combo, g.CanHold, g.GravityAccumulator, g.LockAccumulator} + beforeBoard := g.Board + + inputs := map[string]func() []Event{ + "MoveLeft": g.MoveLeft, + "MoveRight": g.MoveRight, + "SoftDrop": g.SoftDrop, + "RotateCW": g.RotateCW, + "RotateCCW": g.RotateCCW, + "HardDrop": g.HardDrop, + "UseHold": g.UseHold, + "Advance": func() []Event { return g.Advance(2 * time.Second) }, + } + for name, fn := range inputs { + if evs := fn(); evs != nil { + t.Errorf("%s after game over emitted %v", name, kinds(evs)) + } + } + + if g.Active != before.active || g.Score != before.score || g.Lines != before.lines || + g.Level != before.level || g.Combo != before.combo || g.CanHold != before.canHold || + g.GravityAccumulator != before.gravity || g.LockAccumulator != before.lockAccu { + t.Error("an input mutated the game after game over") + } + if g.Board != beforeBoard { + t.Error("an input mutated the board after game over") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails or passes** + +Run: `go test ./internal/game/ -run 'GameOver|Blocked|Inert' -v` +Expected: PASS if Tasks 8–11 guarded every mutator with `if g.Over`. If any test fails, add the missing guard to that method and re-run until green — do not weaken the test. + +- [ ] **Step 3: Commit** + +```bash +git add internal/game/gameover_test.go +git commit -m "test(game): pin game-over transition and post-game-over inertness" +``` + +--- + +### Task 13: Seeded replay determinism test + +**Files:** +- Test: `internal/game/determinism_test.go` + +**Interfaces:** +- Consumes: the whole public engine API. +- Produces: `type Input uint8` with `InputNone`, `InputLeft`, `InputRight`, `InputSoft`, `InputCW`, `InputCCW`, `InputHard`, `InputHold` — declared in the test file, not the package, since only the replay harness needs them. + +This is the test that makes §35's promise real: same seed, same input sequence, same `dt` sequence, same final state. + +- [ ] **Step 1: Write the failing test** + +Create `internal/game/determinism_test.go`: + +```go +package game + +import ( + "fmt" + "strings" + "testing" + "time" +) + +// Input is one player action in a replay stream. +type Input uint8 + +// The replayable inputs. +const ( + InputNone Input = iota + InputLeft + InputRight + InputSoft + InputCW + InputCCW + InputHard + InputHold +) + +type replayStep struct { + in Input + dt time.Duration +} + +func apply(g *Game, in Input) { + switch in { + case InputLeft: + g.MoveLeft() + case InputRight: + g.MoveRight() + case InputSoft: + g.SoftDrop() + case InputCW: + g.RotateCW() + case InputCCW: + g.RotateCCW() + case InputHard: + g.HardDrop() + case InputHold: + g.UseHold() + } +} + +// cannedStream is a long, varied, fully deterministic input stream. +func cannedStream() []replayStep { + pattern := []Input{ + InputLeft, InputCW, InputNone, InputRight, InputSoft, InputHard, + InputHold, InputCCW, InputRight, InputRight, InputHard, InputNone, + InputLeft, InputLeft, InputCW, InputCW, InputSoft, InputSoft, + InputHard, InputHold, + } + dts := []time.Duration{ + 16 * time.Millisecond, 33 * time.Millisecond, 7 * time.Millisecond, + 120 * time.Millisecond, 250 * time.Millisecond, 16 * time.Millisecond, + } + steps := make([]replayStep, 0, 600) + for i := 0; i < 600; i++ { + steps = append(steps, replayStep{ + in: pattern[i%len(pattern)], + dt: dts[i%len(dts)], + }) + } + return steps +} + +// fingerprint is a compact, comparable description of the whole logical state. +func fingerprint(g *Game) string { + var b strings.Builder + fmt.Fprintf(&b, "score=%d lines=%d level=%d combo=%d over=%v canhold=%v\n", + g.Score, g.Lines, g.Level, g.Combo, g.Over, g.CanHold) + fmt.Fprintf(&b, "active=%v/%d@%d,%d\n", g.Active.Kind, g.Active.Rotation, g.Active.X, g.Active.Y) + if g.Hold != nil { + fmt.Fprintf(&b, "hold=%v\n", *g.Hold) + } else { + b.WriteString("hold=none\n") + } + fmt.Fprintf(&b, "next=%v\n", g.Next) + fmt.Fprintf(&b, "grav=%v lock=%v\n", g.GravityAccumulator, g.LockAccumulator) + for y := 0; y < Height; y++ { + for x := 0; x < Width; x++ { + if g.Board.At(x, y).Filled() { + b.WriteByte('#') + } else { + b.WriteByte('.') + } + } + b.WriteByte('\n') + } + return b.String() +} + +func runReplay(seed int64, steps []replayStep) *Game { + g := New(seed) + for _, s := range steps { + apply(g, s.in) + g.Advance(s.dt) + } + return g +} + +func TestReplayIsReproducible(t *testing.T) { + steps := cannedStream() + a := fingerprint(runReplay(8675309, steps)) + b := fingerprint(runReplay(8675309, steps)) + if a != b { + t.Fatalf("same seed and inputs diverged:\n--- run A ---\n%s\n--- run B ---\n%s", a, b) + } +} + +func TestReplayDependsOnSeed(t *testing.T) { + steps := cannedStream() + a := fingerprint(runReplay(1, steps)) + b := fingerprint(runReplay(2, steps)) + if a == b { + t.Fatal("different seeds produced identical final states") + } +} + +func TestReplayReachesANonTrivialState(t *testing.T) { + g := runReplay(8675309, cannedStream()) + if g.Score == 0 { + t.Error("replay scored nothing; the canned stream is not exercising the engine") + } + filled := 0 + for y := 0; y < Height; y++ { + for x := 0; x < Width; x++ { + if g.Board.At(x, y).Filled() { + filled++ + } + } + } + if filled == 0 && !g.Over { + t.Error("replay left an empty board and no game over; stream is too short") + } +} + +// Splitting a dt in two must land in the same place as one combined step, +// because gravity accumulates elapsed time rather than counting ticks. +func TestGravityIsElapsedTimeNotTickCount(t *testing.T) { + coarse := New(77) + coarse.Active = Piece{Kind: KindO, Rotation: 0, X: 4, Y: 2} + fine := New(77) + fine.Active = Piece{Kind: KindO, Rotation: 0, X: 4, Y: 2} + + for i := 0; i < 10; i++ { + coarse.Advance(100 * time.Millisecond) + } + for i := 0; i < 100; i++ { + fine.Advance(10 * time.Millisecond) + } + if coarse.Active.Y != fine.Active.Y { + t.Errorf("coarse Y = %d, fine Y = %d", coarse.Active.Y, fine.Active.Y) + } +} +``` + +- [ ] **Step 2: Run the tests** + +Run: `go test ./internal/game/ -run 'Replay|Elapsed' -v` +Expected: PASS. If `TestReplayIsReproducible` fails, something in the engine is reading a clock or a map iteration order — grep for `time.Now` and `range` over maps under `internal/game/`. + +- [ ] **Step 3: Verify the no-clock rule mechanically** + +Run: `! grep -rn 'time\.Now\|time\.Since\|time\.Tick\|time\.After' internal/game/` +Expected: exit status 0 (no matches — the `!` inverts grep's "found" status). + +- [ ] **Step 4: Run the whole suite with the race detector and shuffled order** + +Run: `go test ./... -race -shuffle=on -count=2` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/game/determinism_test.go +git commit -m "test(game): seeded replay determinism and elapsed-time gravity" +``` + +--- + +## Done when + +- `go test ./... -race -shuffle=on` is green. +- `go vet ./...` is clean. +- `grep -rn 'time.Now' internal/game/` finds nothing. +- The engine covers §40's engine checklist: board collision/bounds/completion/removal/collapse, every rotation plus kicks and failure, bag contents and reproducibility, hold (initial/swap/blocked/restored), drops and landing and lock, score values and combo and drop points and level progression, blocked-spawn game over, and a canned-replay determinism assertion. +- Nothing outside `internal/game` exists yet apart from `go.mod`, `LICENSE`, and `.gitignore`. The terminal lives in Plan 2. diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/baseline-cosmic-1/2026-09-17-cosmic-tetris-2-terminal.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/baseline-cosmic-1/2026-09-17-cosmic-tetris-2-terminal.md new file mode 100644 index 000000000..2d2d16d3b --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/baseline-cosmic-1/2026-09-17-cosmic-tetris-2-terminal.md @@ -0,0 +1,4142 @@ +# Cosmic Tetris — Plan 2: Playable Terminal + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Turn the Plan 1 engine into a genuinely good terminal game: a cell grid renderer, a neon palette with Unicode/ASCII glyph sets, an adaptive layout, board + HUD + overlays, Bubble Tea wiring with immediate input and one 60 Hz clock, a tiny CLI, and golden snapshot tests. + +**Architecture:** `internal/render` owns a `Grid` — a width×height buffer of `(rune, *Style)` cells that renders to a string with run-length-grouped ANSI. Everything draws into the grid, so composition is trivial and snapshot tests are exact. `render` never imports `app` or `fx`; the FX layer (Plan 3) plugs in through function hooks on `render.Scene`, which keeps §37's pipeline order without a dependency. `internal/app` holds the Bubble Tea model: one `FrameMsg` clock drives elapsed-time `game.Advance(dt)`, while key presses act immediately on arrival. + +**Tech Stack:** Go 1.26, `charm.land/bubbletea/v2` v2.0.9, `charm.land/lipgloss/v2` v2.0.6, `charm.land/bubbles/v2` v2.2.1, `github.com/charmbracelet/x/ansi` (for `ansi.Strip` in tests). + +**Spec:** `design.md`. Sections implemented here: §4, §5, §8, §9 (mechanics only), §10, §25 (static border), §26, §28 (the panel, not the animation), §30, §31, §32 (mode plumbing + ASCII), §33, §34, §36, §37, §38, §39, §41, §42 Phase 2, §46 (`--seed`, `--ascii`, `--help`), §49.3, §49.4, §49.7. + +**Prerequisite:** Plan 1 complete — `internal/game` exists and its tests pass. + +## Global Constraints + +- Imports are exactly `charm.land/bubbletea/v2`, `charm.land/lipgloss/v2`, `charm.land/bubbles/v2`. Do not abstract Bubble Tea behind a homegrown framework. +- One logical block occupies `2 terminal columns × 1 terminal row`. Block glyph `██`, ASCII fallback `[]`. +- Ghost glyph: `░░` in full and reduced modes, `··` in ASCII mode (§49.4). Pieces always use filled block glyphs with a bright foreground — never a foreground+background pairing. The active piece renders one step brighter than locked cells. +- Piece colour intent (§26): I plasma cyan, J deep electric blue, L solar orange, O stellar gold, S alien green, T ultraviolet, Z supernova pink/red. +- Minimum usable terminal: `~40 columns × ~24 rows`. Below that, show the too-small notice. Handle resize live; never crash from resizing. +- Small-terminal drop order (§49.3): title border first, then mission control, then stats labels (values stay). NEXT never stacks above or below the board — it moves beside the board and truncates to 3 upcoming pieces. Board and controls are the last two things standing. +- Rendering must not mutate game state (§37). `render` takes `*game.Game` and only reads it. +- Target visual updates around `60 Hz`; gravity stays elapsed-time based; input must not wait for ticks (§36). +- Keys (§8): `←/h` left, `→/l` right, `↓/j` soft drop, `↑/k/x` rotate CW, `z` rotate CCW, `space` hard drop, `c` hold, `p` pause, `r` restart, `?` help, `q`/`esc` quit. WASD aliases `a d s w`. +- Final CLI surface (§49.5): `cosmic-tetris`, `--seed 1234`, `--ascii`, `--no-fx`, `--reduced-motion`, `--help`. All six parse in this plan. `--ascii` selects the ASCII render mode; `--no-fx` and `--reduced-motion` are carried in `app.Options` and have no visible effect until Plan 3, because there are no effects yet. `--reduced-motion` never changes glyphs or colours — it suppresses shake, hyperdrive acceleration, and shockwaves. +- The §4 mockup is mood, not geometry (§49.7). The ANSI-stripped golden files are the binding layout contract. + +## Review Focus + +1. **`WindowSizeMsg` with zero or negative dimensions** (some terminals and CI harnesses report 0×0 before the first real size) must not panic or allocate a negative-size buffer. → Task 1 (`NewGrid`) and Task 3 (`Compute`). +2. **An overlay larger than the terminal** (the help panel at 40×24) must clip instead of writing outside the grid or corrupting the board. → Task 6. +3. **A `dt` spike from laptop sleep** must be clamped in the app before it reaches `game.Advance`, so a resumed session does not instantly bury the player. → Task 8. +4. **Keys arriving in the wrong state** — movement while paused or after game over, `r`/`q` while the too-small notice is showing — must be handled explicitly rather than falling through to the engine. → Task 8. +5. **Drawing near grid edges** (a piece at column 9 in a grid 1 column too narrow, a shake offset at the boundary) must silently clip; `Grid.Set` out of range is a no-op, and every draw helper relies on that rather than on its own bounds maths. → Task 1. + +--- + +### Task 1: The cell grid and style primitive + +**Files:** +- Create: `internal/render/style.go` +- Create: `internal/render/grid.go` +- Test: `internal/render/grid_test.go` +- Modify: `go.mod` (adds lipgloss and, for tests, `x/ansi`) + +**Interfaces:** +- Consumes: nothing from Plan 1. +- Produces: + - `type Style struct { Fg color.Color; Bold, Faint bool }`, `func NewStyle(fg color.Color, bold, faint bool) *Style`, `func (s *Style) Render(text string) string` (nil-safe). + - `type Cell struct { Rune rune; Style *Style }` + - `type Grid struct { W, H int }` with `func NewGrid(w, h int) *Grid`, `Resize(w, h int)`, `Clear()`, `Set(x, y int, r rune, st *Style)`, `SetString(x, y int, s string, st *Style)`, `At(x, y int) Cell`, `Render() string`. + +`Style` values are shared, immutable palette entries compared by pointer, which is what lets `Render` group runs cheaply and deterministically. + +- [ ] **Step 1: Add the dependencies** + +```bash +go get charm.land/lipgloss/v2@v2.0.6 +go get charm.land/bubbletea/v2@v2.0.9 +go get charm.land/bubbles/v2@v2.2.1 +go get github.com/charmbracelet/x/ansi@v0.11.8 +``` + +- [ ] **Step 2: Write the failing test** + +Create `internal/render/grid_test.go`: + +```go +package render + +import ( + "strings" + "testing" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" +) + +func TestNewGridStartsBlank(t *testing.T) { + g := NewGrid(4, 2) + if g.W != 4 || g.H != 2 { + t.Fatalf("size = %dx%d, want 4x2", g.W, g.H) + } + if got := g.Render(); got != "\n" { + t.Errorf("blank 4x2 grid rendered %q, want %q", got, "\n") + } +} + +// Review Focus item 1. +func TestNewGridRejectsNonPositiveSizes(t *testing.T) { + for _, tc := range [][2]int{{0, 0}, {-3, 5}, {5, -3}, {0, 10}} { + g := NewGrid(tc[0], tc[1]) + if g.W < 0 || g.H < 0 { + t.Errorf("NewGrid(%d,%d) kept a negative dimension", tc[0], tc[1]) + } + g.Set(0, 0, 'x', nil) // must not panic + if s := g.Render(); strings.Contains(s, "x") { + t.Errorf("NewGrid(%d,%d) accepted a write into an empty buffer", tc[0], tc[1]) + } + } +} + +func TestSetAndAt(t *testing.T) { + g := NewGrid(3, 1) + st := NewStyle(lipgloss.Color("#ff0000"), true, false) + g.Set(1, 0, 'A', st) + if c := g.At(1, 0); c.Rune != 'A' || c.Style != st { + t.Errorf("At(1,0) = %+v", c) + } + if c := g.At(0, 0); c.Rune != ' ' || c.Style != nil { + t.Errorf("neighbour cell = %+v, want blank", c) + } +} + +// Review Focus item 5. +func TestSetOutOfRangeIsSilentlyIgnored(t *testing.T) { + g := NewGrid(3, 2) + for _, p := range [][2]int{{-1, 0}, {3, 0}, {0, -1}, {0, 2}, {99, 99}} { + g.Set(p[0], p[1], 'X', nil) + } + if got := ansi.Strip(g.Render()); strings.Contains(got, "X") { + t.Errorf("out-of-range write landed in the grid: %q", got) + } + if c := g.At(-1, -1); c.Rune != 0 { + t.Errorf("At out of range = %+v, want zero Cell", c) + } +} + +func TestSetStringClipsAtTheRightEdge(t *testing.T) { + g := NewGrid(5, 1) + g.SetString(3, 0, "ABCDEF", nil) + if got := ansi.Strip(g.Render()); got != " AB" { + t.Errorf("Render() = %q, want %q", got, " AB") + } +} + +func TestSetStringHandlesMultibyteRunes(t *testing.T) { + g := NewGrid(4, 1) + g.SetString(0, 0, "✦˚·", nil) + if got := ansi.Strip(g.Render()); got != "✦˚·" { + t.Errorf("Render() = %q, want %q", got, "✦˚·") + } +} + +func TestRenderTrimsTrailingBlanksButKeepsRowCount(t *testing.T) { + g := NewGrid(6, 3) + g.SetString(0, 0, "AB", nil) + g.SetString(0, 2, "C", nil) + got := ansi.Strip(g.Render()) + want := "AB\n\nC" + if got != want { + t.Errorf("Render() = %q, want %q", got, want) + } + if lines := strings.Count(got, "\n") + 1; lines != 3 { + t.Errorf("rendered %d lines, want 3", lines) + } +} + +func TestRenderGroupsRunsOfEqualStyle(t *testing.T) { + red := NewStyle(lipgloss.Color("#ff0000"), false, false) + g := NewGrid(6, 1) + g.SetString(0, 0, "AAA", red) + g.SetString(3, 0, "BBB", red) + out := g.Render() + if ansi.Strip(out) != "AAABBB" { + t.Fatalf("stripped = %q", ansi.Strip(out)) + } + // One run means one style-open sequence, not six. + if n := strings.Count(out, "\x1b["); n > 2 { + t.Errorf("expected a single grouped run, saw %d escape sequences in %q", n, out) + } +} + +func TestClearResetsEveryCell(t *testing.T) { + g := NewGrid(3, 2) + g.SetString(0, 0, "XYZ", NewStyle(lipgloss.Color("#00ff00"), false, false)) + g.Clear() + if got := g.Render(); got != "\n" { + t.Errorf("after Clear, Render() = %q, want %q", got, "\n") + } +} + +func TestResizeChangesDimensionsAndClears(t *testing.T) { + g := NewGrid(3, 1) + g.SetString(0, 0, "XYZ", nil) + g.Resize(8, 2) + if g.W != 8 || g.H != 2 { + t.Fatalf("size = %dx%d, want 8x2", g.W, g.H) + } + if got := g.Render(); got != "\n" { + t.Errorf("resize did not clear: %q", got) + } + g.Resize(0, 0) // must not panic + g.Set(0, 0, 'x', nil) +} + +func TestNilStyleRenderIsPlainText(t *testing.T) { + var s *Style + if got := s.Render("hello"); got != "hello" { + t.Errorf("nil Style.Render = %q, want plain text", got) + } +} +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `go test ./internal/render/ -v` +Expected: FAIL — no non-test Go files / `undefined: NewGrid`. + +- [ ] **Step 4: Implement `Style`** + +Create `internal/render/style.go`: + +```go +// Package render draws Cosmic Tetris into a character grid and turns that grid +// into a styled string. It reads game state and never writes it. +package render + +import ( + "image/color" + + "charm.land/lipgloss/v2" +) + +// Style is one visual treatment for a run of characters. Styles are created +// once, live in the palette, and are compared by pointer, which is what lets +// Grid.Render group adjacent cells into a single escape sequence. +type Style struct { + Fg color.Color + Bold bool + Faint bool + + lg lipgloss.Style +} + +// NewStyle builds a style. A nil foreground means "terminal default". +func NewStyle(fg color.Color, bold, faint bool) *Style { + s := &Style{Fg: fg, Bold: bold, Faint: faint} + lg := lipgloss.NewStyle() + if fg != nil { + lg = lg.Foreground(fg) + } + if bold { + lg = lg.Bold(true) + } + if faint { + lg = lg.Faint(true) + } + s.lg = lg + return s +} + +// Render applies the style to text. A nil style renders plain text. +func (s *Style) Render(text string) string { + if s == nil { + return text + } + return s.lg.Render(text) +} +``` + +- [ ] **Step 5: Implement `Grid`** + +Create `internal/render/grid.go`: + +```go +package render + +import "strings" + +// Cell is one character position in the grid. +type Cell struct { + Rune rune + Style *Style +} + +// Grid is a fixed-size character buffer. Every drawing routine writes into a +// grid, so compositing layers is just writing later, and snapshot tests can +// compare exact text. +type Grid struct { + W, H int + cells []Cell +} + +// NewGrid returns a blank grid. Non-positive dimensions are clamped to zero, +// so a terminal that reports 0x0 produces an empty grid instead of a panic. +func NewGrid(w, h int) *Grid { + if w < 0 { + w = 0 + } + if h < 0 { + h = 0 + } + g := &Grid{W: w, H: h, cells: make([]Cell, w*h)} + g.Clear() + return g +} + +// Resize changes the grid's dimensions and clears it. +func (g *Grid) Resize(w, h int) { + if w < 0 { + w = 0 + } + if h < 0 { + h = 0 + } + g.W, g.H = w, h + if need := w * h; need > cap(g.cells) { + g.cells = make([]Cell, need) + } else { + g.cells = g.cells[:need] + } + g.Clear() +} + +// Clear blanks every cell. +func (g *Grid) Clear() { + for i := range g.cells { + g.cells[i] = Cell{Rune: ' '} + } +} + +// Set writes one cell. Coordinates outside the grid are ignored, which is what +// lets drawing code near the edges stay free of bounds maths. +func (g *Grid) Set(x, y int, r rune, st *Style) { + if x < 0 || y < 0 || x >= g.W || y >= g.H { + return + } + g.cells[y*g.W+x] = Cell{Rune: r, Style: st} +} + +// SetString writes s starting at (x, y), one grid cell per rune, clipping at +// the edges. +func (g *Grid) SetString(x, y int, s string, st *Style) { + i := 0 + for _, r := range s { + g.Set(x+i, y, r, st) + i++ + } +} + +// At returns the cell at (x, y), or the zero Cell when out of range. +func (g *Grid) At(x, y int) Cell { + if x < 0 || y < 0 || x >= g.W || y >= g.H { + return Cell{} + } + return g.cells[y*g.W+x] +} + +// Render turns the grid into a newline-separated styled string. Adjacent cells +// that share a style are emitted as one run. Trailing unstyled blanks are +// trimmed so the output does not depend on the grid's right margin. +func (g *Grid) Render() string { + var b strings.Builder + var run []rune + for y := 0; y < g.H; y++ { + row := g.cells[y*g.W : y*g.W+g.W] + end := len(row) + for end > 0 && row[end-1].Rune == ' ' && row[end-1].Style == nil { + end-- + } + for x := 0; x < end; { + st := row[x].Style + run = run[:0] + for x < end && row[x].Style == st { + run = append(run, row[x].Rune) + x++ + } + b.WriteString(st.Render(string(run))) + } + if y < g.H-1 { + b.WriteByte('\n') + } + } + return b.String() +} +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `go test ./internal/render/ -v && go vet ./...` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add go.mod go.sum internal/render/style.go internal/render/grid.go internal/render/grid_test.go +git commit -m "feat(render): character grid buffer with run-grouped styled output" +``` + +--- + +### Task 2: Palette, render modes, and glyph sets + +**Files:** +- Create: `internal/render/palette.go` +- Test: `internal/render/palette_test.go` + +**Interfaces:** +- Consumes: `Style`, `NewStyle`; `game.PieceKind`, `game.KindCount`. +- Produces: + - `type Mode int` with `ModeFull`, `ModeReduced`, `ModeASCII`; `func (m Mode) String() string`. + - `type Palette struct { Mode Mode; Block, Ghost string; Locked, Active [game.KindCount]*Style; GhostStyle, Border, BorderHot, Title, Label, Value, Mission, Controls, Dim, Banner *Style; Star [3]*Style }` + - `func NewPalette(mode Mode) *Palette` + - `func (p *Palette) BlockFor(k game.PieceKind, active bool) (string, *Style)` + +- [ ] **Step 1: Write the failing test** + +Create `internal/render/palette_test.go`: + +```go +package render + +import ( + "testing" + + "cosmic-tetris/internal/game" +) + +func TestGlyphsPerMode(t *testing.T) { + cases := map[Mode][2]string{ + ModeFull: {"██", "░░"}, + ModeReduced: {"██", "░░"}, + ModeASCII: {"[]", "··"}, + } + for mode, want := range cases { + p := NewPalette(mode) + if p.Block != want[0] { + t.Errorf("%v block glyph = %q, want %q", mode, p.Block, want[0]) + } + if p.Ghost != want[1] { + t.Errorf("%v ghost glyph = %q, want %q", mode, p.Ghost, want[1]) + } + } +} + +func TestGlyphsAreTwoColumnsWide(t *testing.T) { + for _, mode := range []Mode{ModeFull, ModeReduced, ModeASCII} { + p := NewPalette(mode) + if n := len([]rune(p.Block)); n != 2 { + t.Errorf("%v block glyph is %d runes, want 2", mode, n) + } + if n := len([]rune(p.Ghost)); n != 2 { + t.Errorf("%v ghost glyph is %d runes, want 2", mode, n) + } + } +} + +func TestEveryKindHasDistinctColours(t *testing.T) { + p := NewPalette(ModeFull) + seen := map[string]game.PieceKind{} + for k := game.KindI; k < game.KindCount; k++ { + st := p.Locked[k] + if st == nil || st.Fg == nil { + t.Fatalf("%v has no locked colour", k) + } + r, g, b, _ := st.Fg.RGBA() + key := string(rune(r)) + string(rune(g)) + string(rune(b)) + if other, dup := seen[key]; dup { + t.Errorf("%v and %v share a colour", k, other) + } + seen[key] = k + } +} + +func TestActivePieceIsBrighterThanLocked(t *testing.T) { + p := NewPalette(ModeFull) + for k := game.KindI; k < game.KindCount; k++ { + if !p.Active[k].Bold { + t.Errorf("%v active style is not bold", k) + } + if p.Locked[k].Bold { + t.Errorf("%v locked style should not be bold", k) + } + if p.Active[k] == p.Locked[k] { + t.Errorf("%v active and locked styles are the same object", k) + } + } +} + +func TestPiecesNeverUseABackground(t *testing.T) { + // Pinned by 49.4: filled glyphs with a bright foreground, never fg+bg. + for _, mode := range []Mode{ModeFull, ModeReduced, ModeASCII} { + p := NewPalette(mode) + for k := game.KindI; k < game.KindCount; k++ { + for _, st := range []*Style{p.Locked[k], p.Active[k]} { + if st.Fg == nil { + t.Errorf("%v/%v has no foreground", mode, k) + } + } + } + } +} + +func TestGhostStyleIsFaint(t *testing.T) { + for _, mode := range []Mode{ModeFull, ModeReduced, ModeASCII} { + if !NewPalette(mode).GhostStyle.Faint { + t.Errorf("%v ghost style is not faint", mode) + } + } +} + +func TestBlockForPicksGlyphAndStyle(t *testing.T) { + p := NewPalette(ModeASCII) + glyph, st := p.BlockFor(game.KindT, true) + if glyph != "[]" { + t.Errorf("glyph = %q, want %q", glyph, "[]") + } + if st != p.Active[game.KindT] { + t.Error("BlockFor(active) did not return the active style") + } + _, st = p.BlockFor(game.KindT, false) + if st != p.Locked[game.KindT] { + t.Error("BlockFor(locked) did not return the locked style") + } +} + +func TestStarLayersGetDimmerWithDepth(t *testing.T) { + p := NewPalette(ModeFull) + if !p.Star[0].Faint { + t.Error("far stars should be faint") + } + if p.Star[2].Faint { + t.Error("near stars should not be faint") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/render/ -run Palette -v` +Expected: FAIL — `undefined: NewPalette`. + +- [ ] **Step 3: Implement the palette** + +Create `internal/render/palette.go`: + +```go +package render + +import ( + "charm.land/lipgloss/v2" + + "cosmic-tetris/internal/game" +) + +// Mode is how much the terminal can be trusted with. +type Mode int + +// The render modes. Full assumes Unicode and truecolor; Reduced keeps Unicode +// but avoids gradients; ASCII assumes nothing beyond 7-bit characters and a +// handful of colours. +const ( + ModeFull Mode = iota + ModeReduced + ModeASCII +) + +// String names the mode. +func (m Mode) String() string { + switch m { + case ModeReduced: + return "reduced" + case ModeASCII: + return "ascii" + default: + return "full" + } +} + +// Glyph sets. A logical block is always two terminal columns wide so cells +// look square. +const ( + blockUnicode = "██" + blockASCII = "[]" + ghostUnicode = "░░" + ghostASCII = "··" +) + +// pieceColors is the neon space palette from the spec: I plasma cyan, J deep +// electric blue, L solar orange, O stellar gold, S alien green, T ultraviolet, +// Z supernova pink. +var pieceColors = [game.KindCount]string{ + game.KindI: "#22D3EE", + game.KindJ: "#3B82F6", + game.KindL: "#FB923C", + game.KindO: "#FBBF24", + game.KindS: "#4ADE80", + game.KindT: "#A855F7", + game.KindZ: "#FB7185", +} + +// asciiPieceColors uses the basic 16-colour set for terminals we cannot trust +// with hex values. +var asciiPieceColors = [game.KindCount]string{ + game.KindI: "14", // bright cyan + game.KindJ: "12", // bright blue + game.KindL: "3", // yellow/brown + game.KindO: "11", // bright yellow + game.KindS: "10", // bright green + game.KindT: "13", // bright magenta + game.KindZ: "9", // bright red +} + +// Palette holds every style and glyph the renderer uses. It is built once per +// mode and shared; styles are compared by pointer. +type Palette struct { + Mode Mode + Block string + Ghost string + + Locked [game.KindCount]*Style + Active [game.KindCount]*Style + + GhostStyle *Style + Border *Style + BorderHot *Style + Title *Style + Label *Style + Value *Style + Mission *Style + Controls *Style + Dim *Style + Banner *Style + Star [3]*Style +} + +// NewPalette builds the palette for a render mode. +func NewPalette(mode Mode) *Palette { + p := &Palette{Mode: mode} + colors := pieceColors + if mode == ModeASCII { + p.Block, p.Ghost = blockASCII, ghostASCII + colors = asciiPieceColors + } else { + p.Block, p.Ghost = blockUnicode, ghostUnicode + } + for k := game.KindI; k < game.KindCount; k++ { + c := lipgloss.Color(colors[k]) + p.Locked[k] = NewStyle(c, false, false) + p.Active[k] = NewStyle(c, true, false) + } + + if mode == ModeASCII { + p.GhostStyle = NewStyle(lipgloss.Color("8"), false, true) + p.Border = NewStyle(lipgloss.Color("6"), false, false) + p.BorderHot = NewStyle(lipgloss.Color("15"), true, false) + p.Title = NewStyle(lipgloss.Color("14"), true, false) + p.Label = NewStyle(lipgloss.Color("6"), false, false) + p.Value = NewStyle(lipgloss.Color("15"), true, false) + p.Mission = NewStyle(lipgloss.Color("13"), false, false) + p.Controls = NewStyle(lipgloss.Color("8"), false, true) + p.Dim = NewStyle(lipgloss.Color("8"), false, true) + p.Banner = NewStyle(lipgloss.Color("11"), true, false) + p.Star = [3]*Style{ + NewStyle(lipgloss.Color("8"), false, true), + NewStyle(lipgloss.Color("6"), false, false), + NewStyle(lipgloss.Color("15"), true, false), + } + return p + } + + p.GhostStyle = NewStyle(lipgloss.Color("#4B5563"), false, true) + p.Border = NewStyle(lipgloss.Color("#7C3AED"), false, false) + p.BorderHot = NewStyle(lipgloss.Color("#F8FAFC"), true, false) + p.Title = NewStyle(lipgloss.Color("#67E8F9"), true, false) + p.Label = NewStyle(lipgloss.Color("#818CF8"), false, false) + p.Value = NewStyle(lipgloss.Color("#F8FAFC"), true, false) + p.Mission = NewStyle(lipgloss.Color("#F472B6"), false, false) + p.Controls = NewStyle(lipgloss.Color("#6B7280"), false, true) + p.Dim = NewStyle(lipgloss.Color("#4B5563"), false, true) + p.Banner = NewStyle(lipgloss.Color("#FDE047"), true, false) + p.Star = [3]*Style{ + NewStyle(lipgloss.Color("#3F3F5A"), false, true), + NewStyle(lipgloss.Color("#7DD3FC"), false, false), + NewStyle(lipgloss.Color("#F8FAFC"), true, false), + } + return p +} + +// BlockFor returns the glyph and style for a block of kind k. The active piece +// renders one step brighter than locked cells. +func (p *Palette) BlockFor(k game.PieceKind, active bool) (string, *Style) { + if int(k) >= game.KindCount { + return p.Block, p.Dim + } + if active { + return p.Block, p.Active[k] + } + return p.Block, p.Locked[k] +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/palette.go internal/render/palette_test.go +git commit -m "feat(render): neon palette, render modes, and glyph sets" +``` + +--- + +### Task 3: Adaptive layout + +**Files:** +- Create: `internal/render/layout.go` +- Test: `internal/render/layout_test.go` + +**Interfaces:** +- Consumes: `game.Width`, `game.VisibleRows`. +- Produces: + - consts `CellCols = 2`, `BoardInnerW = 20`, `BoardInnerH = 20`, `BoardW = 22`, `BoardH = 22`, `MinWidth = 40`, `MinHeight = 24`, `SideColW = 10`. + - `type SizeClass int` with `SizeSmall`, `SizeMedium`, `SizeWide`. + - `type Layout struct { W, H int; TooSmall bool; Size SizeClass; ShowTitle, ShowMission, ShowStatLabels, ShowLeftColumn bool; TitleY, BoardX, BoardY, InnerX, InnerY, LeftX, RightX, HoldX, HoldY, NextX, NextY, NextCount, StatsX, StatsY, MissionY, ControlsY int }` + - `func Compute(w, h int) Layout` + +Size classes, by terminal width: `≥ 56` wide (HOLD + stats in a left column, NEXT×5 on the right), `44..55` medium (single right column, NEXT×3, labels on), `40..43` small (single right column, NEXT×3, stat values only). Height governs the §49.3 drop order: title needs `h ≥ 25`, mission control needs `h ≥ 24`, board (22 rows) and controls (1 row) always stay. + +- [ ] **Step 1: Write the failing test** + +Create `internal/render/layout_test.go`: + +```go +package render + +import "testing" + +func TestLayoutConstants(t *testing.T) { + if BoardInnerW != 20 || BoardInnerH != 20 || BoardW != 22 || BoardH != 22 { + t.Fatalf("board geometry drifted: %d %d %d %d", BoardInnerW, BoardInnerH, BoardW, BoardH) + } + if MinWidth != 40 || MinHeight != 24 { + t.Fatalf("minimum size drifted: %dx%d", MinWidth, MinHeight) + } +} + +func TestTooSmallBelowMinimum(t *testing.T) { + for _, tc := range [][2]int{{34, 19}, {39, 30}, {80, 23}, {0, 0}, {-5, -5}} { + l := Compute(tc[0], tc[1]) + if !l.TooSmall { + t.Errorf("Compute(%d,%d) should be TooSmall", tc[0], tc[1]) + } + } + if l := Compute(40, 24); l.TooSmall { + t.Error("Compute(40,24) should be usable") + } +} + +func TestSizeClasses(t *testing.T) { + cases := []struct { + w, h int + size SizeClass + nexts int + left bool + }{ + {40, 24, SizeSmall, 3, false}, + {43, 30, SizeSmall, 3, false}, + {44, 30, SizeMedium, 3, false}, + {55, 30, SizeMedium, 3, false}, + {56, 30, SizeWide, 5, true}, + {120, 40, SizeWide, 5, true}, + } + for _, tc := range cases { + l := Compute(tc.w, tc.h) + if l.Size != tc.size { + t.Errorf("Compute(%d,%d).Size = %d, want %d", tc.w, tc.h, l.Size, tc.size) + } + if l.NextCount != tc.nexts { + t.Errorf("Compute(%d,%d).NextCount = %d, want %d", tc.w, tc.h, l.NextCount, tc.nexts) + } + if l.ShowLeftColumn != tc.left { + t.Errorf("Compute(%d,%d).ShowLeftColumn = %v, want %v", tc.w, tc.h, l.ShowLeftColumn, tc.left) + } + } +} + +func TestDropOrderAsHeightShrinks(t *testing.T) { + // 49.3: title goes first, then mission control. Controls and the board + // always survive. + tall := Compute(80, 30) + if !tall.ShowTitle || !tall.ShowMission { + t.Error("a 30-row terminal should show both title and mission control") + } + mid := Compute(80, 24) + if mid.ShowTitle { + t.Error("title should be dropped at 24 rows") + } + if !mid.ShowMission { + t.Error("mission control should survive at 24 rows") + } + for _, l := range []Layout{tall, mid} { + if l.ControlsY != l.H-1 { + t.Errorf("controls line at %d, want %d", l.ControlsY, l.H-1) + } + } +} + +func TestStatLabelsDropAtSmallWidths(t *testing.T) { + if !Compute(56, 30).ShowStatLabels { + t.Error("wide layout should show stat labels") + } + if !Compute(44, 30).ShowStatLabels { + t.Error("medium layout should show stat labels") + } + if Compute(40, 24).ShowStatLabels { + t.Error("small layout should show values only") + } +} + +func TestBoardFitsInsideTheTerminal(t *testing.T) { + sizes := [][2]int{{40, 24}, {44, 24}, {56, 26}, {80, 30}, {200, 60}, {41, 25}, {57, 24}} + for _, tc := range sizes { + l := Compute(tc[0], tc[1]) + if l.TooSmall { + t.Fatalf("Compute(%d,%d) unexpectedly too small", tc[0], tc[1]) + } + if l.BoardX < 0 || l.BoardX+BoardW > l.W { + t.Errorf("Compute(%d,%d): board columns %d..%d escape the terminal", + tc[0], tc[1], l.BoardX, l.BoardX+BoardW) + } + if l.BoardY < 0 || l.BoardY+BoardH > l.H { + t.Errorf("Compute(%d,%d): board rows %d..%d escape the terminal", + tc[0], tc[1], l.BoardY, l.BoardY+BoardH) + } + if l.InnerX != l.BoardX+1 || l.InnerY != l.BoardY+1 { + t.Errorf("Compute(%d,%d): inner origin misaligned with the border", tc[0], tc[1]) + } + } +} + +func TestSideColumnsStayInsideTheTerminal(t *testing.T) { + for _, tc := range [][2]int{{40, 24}, {44, 30}, {56, 30}, {100, 40}} { + l := Compute(tc[0], tc[1]) + if l.RightX+8 > l.W { + t.Errorf("Compute(%d,%d): NEXT column at %d overflows width %d", tc[0], tc[1], l.RightX, l.W) + } + if l.ShowLeftColumn && l.LeftX < 0 { + t.Errorf("Compute(%d,%d): left column at %d", tc[0], tc[1], l.LeftX) + } + if !l.ShowLeftColumn && l.HoldX != l.RightX { + t.Errorf("Compute(%d,%d): without a left column, HOLD should sit in the right column", + tc[0], tc[1]) + } + } +} + +func TestBoardAndSideColumnsDoNotOverlap(t *testing.T) { + for _, tc := range [][2]int{{40, 24}, {44, 30}, {56, 30}, {90, 34}} { + l := Compute(tc[0], tc[1]) + if l.RightX < l.BoardX+BoardW { + t.Errorf("Compute(%d,%d): right column %d overlaps the board ending at %d", + tc[0], tc[1], l.RightX, l.BoardX+BoardW) + } + if l.ShowLeftColumn && l.LeftX+SideColW > l.BoardX { + t.Errorf("Compute(%d,%d): left column overlaps the board", tc[0], tc[1]) + } + } +} + +func TestNextNeverStacksAboveOrBelowTheBoard(t *testing.T) { + // 49.3: NEXT is always beside the board, never over or under it. + for _, tc := range [][2]int{{40, 24}, {44, 30}, {56, 30}} { + l := Compute(tc[0], tc[1]) + if l.NextX < l.BoardX+BoardW { + t.Errorf("Compute(%d,%d): NEXT is not beside the board", tc[0], tc[1]) + } + } +} + +func TestMissionAndControlsRowsAreDistinct(t *testing.T) { + l := Compute(80, 30) + if l.MissionY == l.ControlsY { + t.Error("mission control and controls share a row") + } + if l.MissionY >= l.H || l.ControlsY >= l.H { + t.Error("bottom rows fall outside the terminal") + } + if l.MissionY < l.BoardY+BoardH { + t.Error("mission control overlaps the board") + } +} + +func TestComputeIsPure(t *testing.T) { + a := Compute(80, 30) + b := Compute(80, 30) + if a != b { + t.Error("Compute is not deterministic for the same size") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/render/ -run Layout -v` and `go test ./internal/render/ -run 'TooSmall|SizeClass|DropOrder|Board|Side|Next|Mission|Compute' -v` +Expected: FAIL — `undefined: Compute`. + +- [ ] **Step 3: Implement the layout** + +Create `internal/render/layout.go`: + +```go +package render + +import "cosmic-tetris/internal/game" + +// Board geometry in terminal cells. Each logical block is two columns wide, so +// the 10x20 visible playfield is 20x20 characters, plus a one-character border +// on every side. +const ( + CellCols = 2 + BoardInnerW = game.Width * CellCols + BoardInnerH = game.VisibleRows + BoardW = BoardInnerW + 2 + BoardH = BoardInnerH + 2 + + // MinWidth and MinHeight are the smallest terminal we claim to support. + MinWidth = 40 + MinHeight = 24 + + // SideColW is the width of the HOLD/NEXT/stats columns. + SideColW = 10 + + // Width thresholds for the size classes. + wideMinWidth = 56 + mediumMinWidth = 44 +) + +// SizeClass is how generous the terminal is. +type SizeClass int + +// The size classes, smallest first. +const ( + SizeSmall SizeClass = iota + SizeMedium + SizeWide +) + +// Layout is where everything goes for one terminal size. All coordinates are +// grid cells, with (0,0) at the top left. A field set to -1 means "not drawn". +type Layout struct { + W, H int + TooSmall bool + Size SizeClass + + ShowTitle bool + ShowMission bool + ShowStatLabels bool + ShowLeftColumn bool + + TitleY int + + BoardX, BoardY int // top-left of the border box + InnerX, InnerY int // top-left of the playfield inside the border + + LeftX int + RightX int + + HoldX, HoldY int + NextX, NextY int + NextCount int + + StatsX, StatsY int + + MissionY int + ControlsY int +} + +// Compute lays out one frame for a terminal of w x h cells. +// +// Vertical budget, per 49.3: the board (22 rows) and the controls line (1 row) +// always survive. The title needs one more row, mission control one more. When +// the terminal is taller than the minimum, the spare rows go above and below +// the board so it stays visually centred. +// +// Horizontal budget: the board is 22 columns. A wide terminal gets a left +// column (HOLD and stats) and a right column (NEXT x5). Narrower terminals get +// only a right column holding NEXT x3, HOLD, and the stats; the very smallest +// drop the stat labels and show bare values. +func Compute(w, h int) Layout { + l := Layout{W: w, H: h, TitleY: -1, MissionY: -1} + if w < MinWidth || h < MinHeight { + l.TooSmall = true + return l + } + + l.ShowMission = h >= BoardH+2 // board + controls + mission + l.ShowTitle = h >= BoardH+3 // ... + title + + switch { + case w >= wideMinWidth: + l.Size = SizeWide + l.ShowLeftColumn = true + l.ShowStatLabels = true + l.NextCount = 5 + case w >= mediumMinWidth: + l.Size = SizeMedium + l.ShowStatLabels = true + l.NextCount = 3 + default: + l.Size = SizeSmall + l.ShowStatLabels = false + l.NextCount = 3 + } + + // Horizontal placement: centre the whole cluster. + clusterW := BoardW + 1 + SideColW + if l.ShowLeftColumn { + clusterW += SideColW + 1 + } + startX := (w - clusterW) / 2 + if startX < 0 { + startX = 0 + } + if l.ShowLeftColumn { + l.LeftX = startX + l.BoardX = startX + SideColW + 1 + } else { + l.LeftX = -1 + l.BoardX = startX + } + l.RightX = l.BoardX + BoardW + 1 + if l.RightX+SideColW > w { + l.RightX = w - SideColW + } + if l.RightX < l.BoardX+BoardW { + l.RightX = l.BoardX + BoardW + } + + // Vertical placement. + top := 0 + if l.ShowTitle { + l.TitleY = 0 + top = 1 + } + bottom := 1 // controls + if l.ShowMission { + bottom++ + } + spare := h - top - bottom - BoardH + if spare < 0 { + spare = 0 + } + l.BoardY = top + spare/2 + l.InnerX = l.BoardX + 1 + l.InnerY = l.BoardY + 1 + + l.ControlsY = h - 1 + if l.ShowMission { + l.MissionY = h - 2 + } + + // Side-column contents. + l.NextX = l.RightX + l.NextY = l.BoardY + 1 + if l.ShowLeftColumn { + l.HoldX = l.LeftX + l.HoldY = l.BoardY + 1 + l.StatsX = l.LeftX + l.StatsY = l.HoldY + 4 + } else { + l.HoldX = l.RightX + l.HoldY = l.NextY + 1 + 3*l.NextCount + l.StatsX = l.RightX + l.StatsY = l.HoldY + 3 + } + return l +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/layout.go internal/render/layout_test.go +git commit -m "feat(render): adaptive layout with the pinned small-terminal drop order" +``` + +--- + +### Task 4: Board, ghost, and active piece drawing + +**Files:** +- Create: `internal/render/board.go` +- Test: `internal/render/board_test.go` + +**Interfaces:** +- Consumes: `Grid`, `Palette`, `Layout`, `game.Game`, `game.Piece`. +- Produces: + - `func DrawBoardBorder(g *Grid, l Layout, p *Palette, st *Style, shakeX, shakeY int)` + - `func DrawLockedCells(g *Grid, l Layout, p *Palette, gm *game.Game, shakeX, shakeY int)` + - `func DrawGhost(g *Grid, l Layout, p *Palette, gm *game.Game, shakeX, shakeY int)` + - `func DrawActivePiece(g *Grid, l Layout, p *Palette, gm *game.Game, shakeX, shakeY int)` + - `func BoardCellXY(l Layout, bx, by, shakeX, shakeY int) (x, y int, visible bool)` — maps a board cell to grid coordinates, reporting `false` for the hidden spawn rows. + +Every drawing call takes the shake offset so Plan 3 can shift the board by one cell without any of these functions changing. + +- [ ] **Step 1: Write the failing test** + +Create `internal/render/board_test.go`: + +```go +package render + +import ( + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" + + "cosmic-tetris/internal/game" +) + +// lines renders the grid and splits it, padding each line to the grid width so +// tests can index columns directly. +func lines(g *Grid) []string { + out := strings.Split(ansi.Strip(g.Render()), "\n") + for i, s := range out { + if n := len([]rune(s)); n < g.W { + out[i] = s + strings.Repeat(" ", g.W-n) + } + } + return out +} + +func runeAt(t *testing.T, g *Grid, x, y int) rune { + t.Helper() + ls := lines(g) + if y < 0 || y >= len(ls) { + t.Fatalf("row %d out of range (%d rows)", y, len(ls)) + } + rs := []rune(ls[y]) + if x < 0 || x >= len(rs) { + t.Fatalf("column %d out of range (%d columns)", x, len(rs)) + } + return rs[x] +} + +func TestBoardCellXYMapsVisibleRows(t *testing.T) { + l := Compute(80, 30) + // Board row 2 is the first visible row. + x, y, ok := BoardCellXY(l, 0, game.HiddenRows, 0, 0) + if !ok { + t.Fatal("first visible row reported invisible") + } + if x != l.InnerX || y != l.InnerY { + t.Errorf("first visible cell at (%d,%d), want (%d,%d)", x, y, l.InnerX, l.InnerY) + } + x, _, _ = BoardCellXY(l, 1, game.HiddenRows, 0, 0) + if x != l.InnerX+CellCols { + t.Errorf("column 1 at x=%d, want %d", x, l.InnerX+CellCols) + } + if _, _, ok := BoardCellXY(l, 0, 0, 0, 0); ok { + t.Error("hidden spawn row reported visible") + } + if _, _, ok := BoardCellXY(l, 0, 1, 0, 0); ok { + t.Error("hidden spawn row reported visible") + } +} + +func TestBoardCellXYAppliesShake(t *testing.T) { + l := Compute(80, 30) + x, y, _ := BoardCellXY(l, 3, 10, 1, -1) + bx, by, _ := BoardCellXY(l, 3, 10, 0, 0) + if x != bx+1 || y != by-1 { + t.Errorf("shake not applied: (%d,%d) vs (%d,%d)", x, y, bx, by) + } +} + +func TestDrawBoardBorderShape(t *testing.T) { + l := Compute(80, 30) + g := NewGrid(80, 30) + p := NewPalette(ModeFull) + DrawBoardBorder(g, l, p, p.Border, 0, 0) + + if got := runeAt(t, g, l.BoardX, l.BoardY); got != '╔' { + t.Errorf("top-left corner = %q, want ╔", got) + } + if got := runeAt(t, g, l.BoardX+BoardW-1, l.BoardY); got != '╗' { + t.Errorf("top-right corner = %q, want ╗", got) + } + if got := runeAt(t, g, l.BoardX, l.BoardY+BoardH-1); got != '╚' { + t.Errorf("bottom-left corner = %q, want ╚", got) + } + if got := runeAt(t, g, l.BoardX+BoardW-1, l.BoardY+BoardH-1); got != '╝' { + t.Errorf("bottom-right corner = %q, want ╝", got) + } + if got := runeAt(t, g, l.BoardX+5, l.BoardY); got != '═' { + t.Errorf("top edge = %q, want ═", got) + } + if got := runeAt(t, g, l.BoardX, l.BoardY+5); got != '║' { + t.Errorf("left edge = %q, want ║", got) + } +} + +func TestDrawBoardBorderASCII(t *testing.T) { + l := Compute(80, 30) + g := NewGrid(80, 30) + p := NewPalette(ModeASCII) + DrawBoardBorder(g, l, p, p.Border, 0, 0) + for _, tc := range []struct { + x, y int + want rune + }{ + {l.BoardX, l.BoardY, '+'}, + {l.BoardX + 5, l.BoardY, '-'}, + {l.BoardX, l.BoardY + 5, '|'}, + } { + if got := runeAt(t, g, tc.x, tc.y); got != tc.want { + t.Errorf("ASCII border at (%d,%d) = %q, want %q", tc.x, tc.y, got, tc.want) + } + } + if strings.ContainsAny(ansi.Strip(g.Render()), "╔═║╝") { + t.Error("ASCII mode emitted box-drawing characters") + } +} + +func TestDrawLockedCellsUsesTwoColumnGlyphs(t *testing.T) { + l := Compute(80, 30) + g := NewGrid(80, 30) + p := NewPalette(ModeFull) + gm := game.New(1) + gm.Board.Set(0, 21, game.CellOf(game.KindI)) + gm.Board.Set(9, 21, game.CellOf(game.KindZ)) + DrawLockedCells(g, l, p, gm, 0, 0) + + bottomY := l.InnerY + game.VisibleRows - 1 + if got := runeAt(t, g, l.InnerX, bottomY); got != '█' { + t.Errorf("left block first column = %q", got) + } + if got := runeAt(t, g, l.InnerX+1, bottomY); got != '█' { + t.Errorf("left block second column = %q", got) + } + if got := runeAt(t, g, l.InnerX+18, bottomY); got != '█' { + t.Errorf("right block first column = %q", got) + } + if got := runeAt(t, g, l.InnerX+2, bottomY); got != ' ' { + t.Errorf("empty cell = %q, want blank", got) + } + if got := g.At(l.InnerX, bottomY).Style; got != p.Locked[game.KindI] { + t.Error("locked cell did not use the locked style") + } +} + +func TestHiddenRowsAreNotDrawn(t *testing.T) { + l := Compute(80, 30) + g := NewGrid(80, 30) + p := NewPalette(ModeFull) + gm := game.New(1) + gm.Board.Set(4, 0, game.CellOf(game.KindT)) + gm.Board.Set(4, 1, game.CellOf(game.KindT)) + DrawLockedCells(g, l, p, gm, 0, 0) + if strings.Contains(ansi.Strip(g.Render()), "█") { + t.Error("cells in the hidden spawn rows were drawn") + } +} + +func TestDrawActivePieceUsesTheBrightStyle(t *testing.T) { + l := Compute(80, 30) + g := NewGrid(80, 30) + p := NewPalette(ModeFull) + gm := game.New(1) + gm.Active = game.Piece{Kind: game.KindT, Rotation: 0, X: 4, Y: 10} + DrawActivePiece(g, l, p, gm, 0, 0) + + x, y, _ := BoardCellXY(l, 5, 10, 0, 0) + if got := g.At(x, y).Style; got != p.Active[game.KindT] { + t.Error("active piece did not use the active style") + } +} + +func TestGhostIsDrawnAtTheLandingRowOnly(t *testing.T) { + l := Compute(80, 30) + g := NewGrid(80, 30) + p := NewPalette(ModeFull) + gm := game.New(1) + gm.Active = game.Piece{Kind: game.KindO, Rotation: 0, X: 4, Y: 4} + DrawGhost(g, l, p, gm, 0, 0) + + ghostY := gm.GhostY() + x, y, _ := BoardCellXY(l, 4, ghostY, 0, 0) + if got := runeAt(t, g, x, y); got != '░' { + t.Errorf("ghost glyph at the landing row = %q, want ░", got) + } + // Nothing at the active piece's own position. + ax, ay, _ := BoardCellXY(l, 4, 4, 0, 0) + if got := runeAt(t, g, ax, ay); got != ' ' { + t.Errorf("ghost drew at the active piece's row: %q", got) + } +} + +func TestGhostNeverCoversLockedBlocks(t *testing.T) { + l := Compute(80, 30) + g := NewGrid(80, 30) + p := NewPalette(ModeFull) + gm := game.New(1) + gm.Active = game.Piece{Kind: game.KindO, Rotation: 0, X: 4, Y: 4} + // Fill the landing area, then draw the locked cells and the ghost in + // pipeline order. + for _, c := range gm.GhostPiece().Cells() { + gm.Board.Set(c.X, c.Y, game.CellOf(game.KindI)) + } + DrawLockedCells(g, l, p, gm, 0, 0) + DrawGhost(g, l, p, gm, 0, 0) + + for _, c := range gm.GhostPiece().Cells() { + x, y, ok := BoardCellXY(l, c.X, c.Y, 0, 0) + if !ok { + continue + } + if got := runeAt(t, g, x, y); got != '█' { + t.Errorf("ghost overwrote a locked block at (%d,%d): %q", c.X, c.Y, got) + } + } +} + +func TestGhostGlyphInASCIIMode(t *testing.T) { + l := Compute(80, 30) + g := NewGrid(80, 30) + p := NewPalette(ModeASCII) + gm := game.New(1) + gm.Active = game.Piece{Kind: game.KindO, Rotation: 0, X: 4, Y: 4} + DrawGhost(g, l, p, gm, 0, 0) + if !strings.Contains(ansi.Strip(g.Render()), "··") { + t.Error("ASCII ghost glyph not drawn") + } +} + +func TestDrawingNeverMutatesGameState(t *testing.T) { + l := Compute(80, 30) + g := NewGrid(80, 30) + p := NewPalette(ModeFull) + gm := game.New(9) + gm.Active = game.Piece{Kind: game.KindL, Rotation: 1, X: 4, Y: 8} + before := *gm + + DrawBoardBorder(g, l, p, p.Border, 0, 0) + DrawLockedCells(g, l, p, gm, 0, 0) + DrawGhost(g, l, p, gm, 0, 0) + DrawActivePiece(g, l, p, gm, 0, 0) + + if gm.Active != before.Active || gm.Board != before.Board || gm.Score != before.Score { + t.Error("drawing mutated the game state") + } +} + +// Review Focus item 5: a grid too small for the layout must clip, not panic. +func TestDrawingIntoAnUndersizedGridClips(t *testing.T) { + l := Compute(80, 30) + g := NewGrid(10, 5) // deliberately mismatched + p := NewPalette(ModeFull) + gm := game.New(1) + DrawBoardBorder(g, l, p, p.Border, 0, 0) + DrawLockedCells(g, l, p, gm, 0, 0) + DrawGhost(g, l, p, gm, 0, 0) + DrawActivePiece(g, l, p, gm, 0, 0) + // Reaching here without a panic is the assertion. +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/render/ -run 'Board|Ghost|Active|Hidden|Drawing' -v` +Expected: FAIL — `undefined: BoardCellXY`. + +- [ ] **Step 3: Implement board drawing** + +Create `internal/render/board.go`: + +```go +package render + +import "cosmic-tetris/internal/game" + +// Border glyph sets. +type borderSet struct { + tl, tr, bl, br, h, v rune +} + +var ( + borderUnicode = borderSet{tl: '╔', tr: '╗', bl: '╚', br: '╝', h: '═', v: '║'} + borderASCII = borderSet{tl: '+', tr: '+', bl: '+', br: '+', h: '-', v: '|'} +) + +func bordersFor(p *Palette) borderSet { + if p.Mode == ModeASCII { + return borderASCII + } + return borderUnicode +} + +// BoardCellXY maps a board cell to the grid position of its left-hand column, +// applying the shake offset. It reports visible=false for the hidden spawn +// rows, which are never drawn. +func BoardCellXY(l Layout, bx, by, shakeX, shakeY int) (x, y int, visible bool) { + if by < game.HiddenRows || by >= game.Height || bx < 0 || bx >= game.Width { + return 0, 0, false + } + x = l.InnerX + bx*CellCols + shakeX + y = l.InnerY + (by - game.HiddenRows) + shakeY + return x, y, true +} + +// DrawBoardBorder draws the machinery around the playfield. The style is a +// parameter so the FX layer can animate it without owning the drawing code. +func DrawBoardBorder(g *Grid, l Layout, p *Palette, st *Style, shakeX, shakeY int) { + b := bordersFor(p) + x0, y0 := l.BoardX+shakeX, l.BoardY+shakeY + x1, y1 := x0+BoardW-1, y0+BoardH-1 + + g.Set(x0, y0, b.tl, st) + g.Set(x1, y0, b.tr, st) + g.Set(x0, y1, b.bl, st) + g.Set(x1, y1, b.br, st) + for x := x0 + 1; x < x1; x++ { + g.Set(x, y0, b.h, st) + g.Set(x, y1, b.h, st) + } + for y := y0 + 1; y < y1; y++ { + g.Set(x0, y, b.v, st) + g.Set(x1, y, b.v, st) + } +} + +// drawBlock writes a two-column glyph at a board cell. +func drawBlock(g *Grid, l Layout, bx, by, shakeX, shakeY int, glyph string, st *Style) { + x, y, ok := BoardCellXY(l, bx, by, shakeX, shakeY) + if !ok { + return + } + i := 0 + for _, r := range glyph { + g.Set(x+i, y, r, st) + i++ + } +} + +// DrawLockedCells draws the settled stack. +func DrawLockedCells(g *Grid, l Layout, p *Palette, gm *game.Game, shakeX, shakeY int) { + for by := game.HiddenRows; by < game.Height; by++ { + for bx := 0; bx < game.Width; bx++ { + c := gm.Board.At(bx, by) + if !c.Filled() { + continue + } + glyph, st := p.BlockFor(c.Kind(), false) + drawBlock(g, l, bx, by, shakeX, shakeY, glyph, st) + } + } +} + +// DrawGhost draws the landing preview under the active piece. It skips cells +// that already hold a locked block, so the ghost can never obscure the stack. +func DrawGhost(g *Grid, l Layout, p *Palette, gm *game.Game, shakeX, shakeY int) { + ghost := gm.GhostPiece() + if ghost.Y == gm.Active.Y { + return // the piece is already resting; a ghost would just double it + } + active := map[game.Point]bool{} + for _, c := range gm.Active.Cells() { + active[c] = true + } + for _, c := range ghost.Cells() { + if active[c] || gm.Board.At(c.X, c.Y).Filled() { + continue + } + drawBlock(g, l, c.X, c.Y, shakeX, shakeY, p.Ghost, p.GhostStyle) + } +} + +// DrawActivePiece draws the falling piece one step brighter than the stack. +func DrawActivePiece(g *Grid, l Layout, p *Palette, gm *game.Game, shakeX, shakeY int) { + glyph, st := p.BlockFor(gm.Active.Kind, true) + for _, c := range gm.Active.Cells() { + drawBlock(g, l, c.X, c.Y, shakeX, shakeY, glyph, st) + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/board.go internal/render/board_test.go +git commit -m "feat(render): board border, stack, ghost, and active piece" +``` + +--- + +### Task 5: HUD — title, hold, next, stats, mission line, controls + +**Files:** +- Create: `internal/render/hud.go` +- Test: `internal/render/hud_test.go` + +**Interfaces:** +- Consumes: `Grid`, `Layout`, `Palette`, `game.Game`. +- Produces: + - `func UniverseLabel(seed int64) string` — `"7F3A"`-style tag from the seed. + - `func DrawTitle(g *Grid, l Layout, p *Palette, seed int64)` + - `func DrawHold(g *Grid, l Layout, p *Palette, gm *game.Game)` + - `func DrawNext(g *Grid, l Layout, p *Palette, gm *game.Game)` + - `func DrawStats(g *Grid, l Layout, p *Palette, gm *game.Game)` + - `func DrawMission(g *Grid, l Layout, p *Palette, msg string)` + - `func DrawControls(g *Grid, l Layout, p *Palette)` + - `func DrawPiecePreview(g *Grid, x, y int, k game.PieceKind, p *Palette, st *Style)` + +- [ ] **Step 1: Write the failing test** + +Create `internal/render/hud_test.go`: + +```go +package render + +import ( + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" + + "cosmic-tetris/internal/game" +) + +func TestUniverseLabelIsStableAndFourHexDigits(t *testing.T) { + a := UniverseLabel(8675309) + if len(a) != 4 { + t.Fatalf("label = %q, want four characters", a) + } + if a != UniverseLabel(8675309) { + t.Error("label is not stable for one seed") + } + if a == UniverseLabel(8675310) { + t.Error("label does not vary with the seed") + } + if strings.ToUpper(a) != a { + t.Errorf("label %q is not upper case", a) + } +} + +func TestDrawTitleShowsNameAndUniverse(t *testing.T) { + l := Compute(80, 30) + g := NewGrid(80, 30) + p := NewPalette(ModeFull) + DrawTitle(g, l, p, 8675309) + row := lines(g)[l.TitleY] + if !strings.Contains(row, "COSMIC TETRIS") { + t.Errorf("title row = %q", row) + } + if !strings.Contains(row, UniverseLabel(8675309)) { + t.Errorf("title row is missing the universe label: %q", row) + } + if len([]rune(strings.TrimRight(row, " "))) > l.W { + t.Error("title overflows the terminal width") + } +} + +func TestTitleIsSkippedWhenTheLayoutDropsIt(t *testing.T) { + l := Compute(80, 24) // ShowTitle false + g := NewGrid(80, 24) + p := NewPalette(ModeFull) + DrawTitle(g, l, p, 1) + if strings.Contains(ansi.Strip(g.Render()), "COSMIC") { + t.Error("title drawn despite ShowTitle=false") + } +} + +func TestDrawHoldEmptyAndFilled(t *testing.T) { + l := Compute(80, 30) + p := NewPalette(ModeFull) + + g := NewGrid(80, 30) + gm := game.New(1) + DrawHold(g, l, p, gm) + if !strings.Contains(lines(g)[l.HoldY-1], "HOLD") { + t.Errorf("HOLD label missing: %q", lines(g)[l.HoldY-1]) + } + + g = NewGrid(80, 30) + k := game.KindO + gm.Hold = &k + DrawHold(g, l, p, gm) + if !strings.Contains(ansi.Strip(g.Render()), "██") { + t.Error("held piece preview not drawn") + } +} + +func TestDrawNextRespectsTheLayoutCount(t *testing.T) { + p := NewPalette(ModeFull) + gm := game.New(2) + + for _, tc := range []struct{ w, h, want int }{{80, 30, 5}, {40, 24, 3}} { + l := Compute(tc.w, tc.h) + g := NewGrid(tc.w, tc.h) + DrawNext(g, l, p, gm) + out := ansi.Strip(g.Render()) + if !strings.Contains(out, "NEXT") { + t.Errorf("%dx%d: NEXT label missing", tc.w, tc.h) + } + if l.NextCount != tc.want { + t.Errorf("%dx%d: NextCount = %d, want %d", tc.w, tc.h, l.NextCount, tc.want) + } + if strings.Count(out, "██") == 0 { + t.Errorf("%dx%d: no previews drawn", tc.w, tc.h) + } + } +} + +func TestDrawNextStaysInsideTheTerminal(t *testing.T) { + for _, tc := range [][2]int{{40, 24}, {44, 30}, {56, 30}, {100, 40}} { + l := Compute(tc[0], tc[1]) + g := NewGrid(tc[0], tc[1]) + DrawNext(g, l, NewPalette(ModeFull), game.New(3)) + for _, row := range lines(g) { + if len([]rune(row)) > tc[0] { + t.Errorf("%dx%d: a row is %d columns wide", tc[0], tc[1], len([]rune(row))) + } + } + } +} + +func TestDrawStatsWithAndWithoutLabels(t *testing.T) { + p := NewPalette(ModeFull) + gm := game.New(4) + gm.Score = 129340 + gm.Lines = 42 + gm.Level = 7 + + wide := Compute(80, 30) + g := NewGrid(80, 30) + DrawStats(g, wide, p, gm) + out := ansi.Strip(g.Render()) + for _, want := range []string{"SCORE", "00129340", "LINES", "042", "LEVEL", "07"} { + if !strings.Contains(out, want) { + t.Errorf("wide stats missing %q in:\n%s", want, out) + } + } + + small := Compute(40, 24) + g = NewGrid(40, 24) + DrawStats(g, small, p, gm) + out = ansi.Strip(g.Render()) + if strings.Contains(out, "SCORE") || strings.Contains(out, "LINES") { + t.Errorf("small layout drew stat labels:\n%s", out) + } + for _, want := range []string{"00129340", "042", "07"} { + if !strings.Contains(out, want) { + t.Errorf("small stats missing value %q in:\n%s", want, out) + } + } +} + +func TestDrawMissionPrefixesTheChannel(t *testing.T) { + l := Compute(80, 30) + g := NewGrid(80, 30) + p := NewPalette(ModeFull) + DrawMission(g, l, p, "NOMINALISH") + row := lines(g)[l.MissionY] + if !strings.Contains(row, "MISSION CONTROL: NOMINALISH") { + t.Errorf("mission row = %q", row) + } +} + +func TestDrawMissionTruncatesLongMessages(t *testing.T) { + l := Compute(40, 24) + g := NewGrid(40, 24) + p := NewPalette(ModeFull) + DrawMission(g, l, p, strings.Repeat("VERY LONG STATUS ", 10)) + row := lines(g)[l.MissionY] + if len([]rune(strings.TrimRight(row, " "))) > 40 { + t.Errorf("mission row is %d columns wide", len([]rune(row))) + } +} + +func TestDrawMissionIsSkippedWhenDropped(t *testing.T) { + l := Compute(80, 23) // too short: TooSmall, so nothing is drawn + if !l.TooSmall { + t.Skip("layout thresholds changed") + } + g := NewGrid(80, 23) + DrawMission(g, l, NewPalette(ModeFull), "HELLO") + if strings.Contains(ansi.Strip(g.Render()), "HELLO") { + t.Error("mission drawn for a too-small layout") + } +} + +func TestDrawControlsFitsEveryWidth(t *testing.T) { + for _, tc := range [][2]int{{40, 24}, {44, 30}, {56, 30}, {120, 40}} { + l := Compute(tc[0], tc[1]) + g := NewGrid(tc[0], tc[1]) + DrawControls(g, l, NewPalette(ModeFull)) + row := lines(g)[l.ControlsY] + trimmed := strings.TrimRight(row, " ") + if len([]rune(trimmed)) > tc[0] { + t.Errorf("%dx%d: controls line is %d columns", tc[0], tc[1], len([]rune(trimmed))) + } + if !strings.Contains(row, "help") { + t.Errorf("%dx%d: controls line does not mention help: %q", tc[0], tc[1], trimmed) + } + } +} + +func TestDrawControlsASCIIHasNoArrows(t *testing.T) { + l := Compute(80, 30) + g := NewGrid(80, 30) + DrawControls(g, l, NewPalette(ModeASCII)) + row := lines(g)[l.ControlsY] + if strings.ContainsAny(row, "←→↑↓") { + t.Errorf("ASCII controls line contains arrows: %q", row) + } +} + +func TestPiecePreviewIsNormalizedToTheTopLeft(t *testing.T) { + g := NewGrid(12, 4) + p := NewPalette(ModeFull) + DrawPiecePreview(g, 0, 0, game.KindI, p, p.Locked[game.KindI]) + out := lines(g) + if !strings.HasPrefix(out[0], "████████") { + t.Errorf("I preview row 0 = %q, want the bar flush to the top-left", out[0]) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/render/ -run 'Universe|Title|Hold|Next|Stats|Mission|Controls|Preview' -v` +Expected: FAIL — `undefined: UniverseLabel`. + +- [ ] **Step 3: Implement the HUD** + +Create `internal/render/hud.go`: + +```go +package render + +import ( + "fmt" + "strings" + + "cosmic-tetris/internal/game" +) + +// UniverseLabel is the four-hex-digit tag shown in the title bar. It is +// derived from the seed, so the same universe always has the same name. +func UniverseLabel(seed int64) string { + return fmt.Sprintf("%04X", uint16(seed)) +} + +// DrawTitle draws the top line: game name and universe tag. +func DrawTitle(g *Grid, l Layout, p *Palette, seed int64) { + if l.TooSmall || !l.ShowTitle { + return + } + left := "✦ COSMIC TETRIS" + if p.Mode == ModeASCII { + left = "* COSMIC TETRIS" + } + right := "LOCAL UNIVERSE " + UniverseLabel(seed) + g.SetString(l.BoardX, l.TitleY, left, p.Title) + if x := l.W - len([]rune(right)); x > l.BoardX+len([]rune(left))+1 { + g.SetString(x, l.TitleY, right, p.Label) + } +} + +// DrawPiecePreview draws a piece's spawn silhouette with its top-left cell at +// (x, y), used by HOLD and NEXT. +func DrawPiecePreview(g *Grid, x, y int, k game.PieceKind, p *Palette, st *Style) { + cells := game.Piece{Kind: k}.Normalized() + minX, minY := 3, 3 + for _, c := range cells { + if c.X < minX { + minX = c.X + } + if c.Y < minY { + minY = c.Y + } + } + for _, c := range cells { + gx := x + (c.X-minX)*CellCols + gy := y + (c.Y - minY) + i := 0 + for _, r := range p.Block { + g.Set(gx+i, gy, r, st) + i++ + } + } +} + +// DrawHold draws the HOLD box. +func DrawHold(g *Grid, l Layout, p *Palette, gm *game.Game) { + if l.TooSmall { + return + } + g.SetString(l.HoldX, l.HoldY-1, "HOLD", p.Label) + if gm.Hold == nil { + g.SetString(l.HoldX, l.HoldY, "--", p.Dim) + return + } + DrawPiecePreview(g, l.HoldX, l.HoldY, *gm.Hold, p, p.Locked[*gm.Hold]) +} + +// DrawNext draws the upcoming pieces beside the board. +func DrawNext(g *Grid, l Layout, p *Palette, gm *game.Game) { + if l.TooSmall { + return + } + g.SetString(l.NextX, l.NextY-1, "NEXT", p.Label) + n := l.NextCount + if n > len(gm.Next) { + n = len(gm.Next) + } + for i := 0; i < n; i++ { + k := gm.Next[i] + st := p.Locked[k] + if i == 0 { + st = p.Active[k] + } + DrawPiecePreview(g, l.NextX, l.NextY+i*3, k, p, st) + } +} + +// DrawStats draws score, lines, and level. At small widths the labels go and +// only the values remain, per the pinned drop order. +func DrawStats(g *Grid, l Layout, p *Palette, gm *game.Game) { + if l.TooSmall { + return + } + rows := []struct { + label string + value string + }{ + {"SCORE", fmt.Sprintf("%08d", gm.Score)}, + {"LINES", fmt.Sprintf("%03d", gm.Lines)}, + {"LEVEL", fmt.Sprintf("%02d", gm.Level)}, + } + y := l.StatsY + for _, r := range rows { + if l.ShowStatLabels { + g.SetString(l.StatsX, y, r.label, p.Label) + y++ + } + g.SetString(l.StatsX, y, r.value, p.Value) + y++ + } +} + +// DrawMission draws the one-line status channel. +func DrawMission(g *Grid, l Layout, p *Palette, msg string) { + if l.TooSmall || !l.ShowMission || msg == "" { + return + } + prefix := "☄ MISSION CONTROL: " + if p.Mode == ModeASCII { + prefix = "> MISSION CONTROL: " + } + line := prefix + msg + limit := l.W - l.BoardX + if r := []rune(line); len(r) > limit && limit > 0 { + line = string(r[:limit]) + } + g.SetString(l.BoardX, l.MissionY, line, p.Mission) +} + +// controls lines, long and short. The short form is used when the long one +// would not fit. +const ( + controlsLong = "←→ move ↑ rotate ↓ descend SPACE YEET c hold ? help" + controlsShort = "←→ move ↑ rot SPACE yeet ? help" + controlsLongASCII = "<> move w rotate s descend SPACE YEET c hold ? help" + controlsShortASCII = "<> move w rot SPACE yeet ? help" +) + +// DrawControls draws the bottom control hint line, choosing the longest +// variant that fits. +func DrawControls(g *Grid, l Layout, p *Palette) { + if l.TooSmall { + return + } + long, short := controlsLong, controlsShort + if p.Mode == ModeASCII { + long, short = controlsLongASCII, controlsShortASCII + } + line := long + if len([]rune(line)) > l.W { + line = short + } + if r := []rune(line); len(r) > l.W { + line = string(r[:l.W]) + } + x := (l.W - len([]rune(line))) / 2 + if x < 0 { + x = 0 + } + g.SetString(x, l.ControlsY, line, p.Controls) + _ = strings.TrimSpace // keep the import honest if the helper is unused +} +``` + +Drop the `strings` import and the `_ = strings.TrimSpace` line if nothing else in the file needs it — `go vet` and the compiler will tell you. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/hud.go internal/render/hud_test.go +git commit -m "feat(render): title, hold, next, stats, mission line, and controls" +``` + +--- + +### Task 6: Overlays — box helper, pause, too-small notice, game over + +**Files:** +- Create: `internal/render/overlay.go` +- Test: `internal/render/overlay_test.go` + +**Interfaces:** +- Consumes: `Grid`, `Layout`, `Palette`, `game.Game`. +- Produces: + - `type Overlay struct { Title string; Lines []string; Style, TitleStyle *Style }` + - `func DrawOverlay(g *Grid, l Layout, p *Palette, o Overlay)` — centres a bordered box over the frame and clips to the grid. + - `func PauseOverlay(p *Palette) Overlay` + - `func GameOverOverlay(p *Palette, gm *game.Game) Overlay` + - `func HelpOverlay(p *Palette, body []string) Overlay` — body comes from the app's key map (Task 8), so `render` stays free of key bindings. + - `func DrawTooSmall(g *Grid, w, h int, p *Palette)` + +Copy: use the exact wording from `design.md` §30 (pause), §28 (game over), and §31 (too-small notice) wherever it is spelled out there; the strings below are the fallbacks and are what the golden files in Task 11 will record. + +- [ ] **Step 1: Write the failing test** + +Create `internal/render/overlay_test.go`: + +```go +package render + +import ( + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" + + "cosmic-tetris/internal/game" +) + +func TestDrawOverlayIsCenteredAndBordered(t *testing.T) { + l := Compute(80, 30) + g := NewGrid(80, 30) + p := NewPalette(ModeFull) + DrawOverlay(g, l, p, Overlay{Title: "PAUSED", Lines: []string{"line one", "two"}}) + + out := lines(g) + var top, bottom int = -1, -1 + for y, row := range out { + if strings.Contains(row, "╔") { + top = y + } + if strings.Contains(row, "╚") { + bottom = y + } + } + if top < 0 || bottom < 0 { + t.Fatalf("overlay box not drawn:\n%s", strings.Join(out, "\n")) + } + if bottom <= top { + t.Fatalf("box rows out of order: %d..%d", top, bottom) + } + // Roughly vertically centred: equal-ish margins. + if diff := (top) - (29 - bottom); diff > 1 || diff < -1 { + t.Errorf("box is not vertically centred: top margin %d, bottom margin %d", top, 29-bottom) + } + joined := strings.Join(out, "\n") + if !strings.Contains(joined, "PAUSED") || !strings.Contains(joined, "line one") { + t.Errorf("overlay content missing:\n%s", joined) + } +} + +func TestOverlayIsWideEnoughForItsWidestLine(t *testing.T) { + l := Compute(80, 30) + g := NewGrid(80, 30) + p := NewPalette(ModeFull) + long := "a fairly long overlay line that must fit inside the box" + DrawOverlay(g, l, p, Overlay{Title: "T", Lines: []string{long}}) + joined := ansi.Strip(g.Render()) + if !strings.Contains(joined, long) { + t.Errorf("long line was truncated:\n%s", joined) + } +} + +// Review Focus item 2. +func TestOverlayLargerThanTheTerminalClips(t *testing.T) { + l := Compute(40, 24) + g := NewGrid(40, 24) + p := NewPalette(ModeFull) + body := make([]string, 60) + for i := range body { + body[i] = strings.Repeat("X", 120) + } + DrawOverlay(g, l, p, Overlay{Title: strings.Repeat("T", 90), Lines: body}) + + rows := lines(g) + if len(rows) != 24 { + t.Fatalf("grid grew to %d rows", len(rows)) + } + for i, row := range rows { + if n := len([]rune(row)); n > 40 { + t.Fatalf("row %d is %d columns wide", i, n) + } + } +} + +func TestOverlayDrawsOverTheBoardWithoutErasingTheWholeFrame(t *testing.T) { + l := Compute(80, 30) + p := NewPalette(ModeFull) + g := NewGrid(80, 30) + DrawControls(g, l, p) + DrawOverlay(g, l, p, PauseOverlay(p)) + if !strings.Contains(lines(g)[l.ControlsY], "help") { + t.Error("overlay erased the controls line") + } +} + +func TestPauseOverlayMentionsResuming(t *testing.T) { + o := PauseOverlay(NewPalette(ModeFull)) + joined := o.Title + " " + strings.Join(o.Lines, " ") + if !strings.Contains(strings.ToUpper(joined), "PAUSE") { + t.Errorf("pause overlay does not say it is paused: %q", joined) + } + if !strings.Contains(joined, "p") { + t.Errorf("pause overlay does not say how to resume: %q", joined) + } +} + +func TestGameOverOverlayShowsFinalStats(t *testing.T) { + gm := game.New(5) + gm.Score = 45210 + gm.Lines = 37 + gm.Level = 4 + gm.Over = true + o := GameOverOverlay(NewPalette(ModeFull), gm) + joined := strings.Join(o.Lines, "\n") + for _, want := range []string{"45210", "37", "4"} { + if !strings.Contains(joined, want) { + t.Errorf("game-over overlay is missing %q:\n%s", want, joined) + } + } + if !strings.Contains(joined, "r") || !strings.Contains(joined, "q") { + t.Errorf("game-over overlay does not offer restart and quit:\n%s", joined) + } +} + +func TestHelpOverlayCarriesTheSuppliedBody(t *testing.T) { + o := HelpOverlay(NewPalette(ModeFull), []string{"← move left", "space YEET"}) + if len(o.Lines) != 2 || o.Lines[0] != "← move left" { + t.Errorf("help overlay body = %#v", o.Lines) + } +} + +func TestDrawTooSmallStatesTheRequirement(t *testing.T) { + g := NewGrid(30, 10) + DrawTooSmall(g, 30, 10, NewPalette(ModeFull)) + out := ansi.Strip(g.Render()) + if !strings.Contains(out, "40") || !strings.Contains(out, "24") { + t.Errorf("notice does not state the minimum size:\n%s", out) + } + if !strings.Contains(out, "30") || !strings.Contains(out, "10") { + t.Errorf("notice does not state the current size:\n%s", out) + } + for _, row := range strings.Split(out, "\n") { + if len([]rune(row)) > 30 { + t.Errorf("notice overflows the terminal: %q", row) + } + } +} + +func TestDrawTooSmallInAVeryTinyTerminal(t *testing.T) { + for _, tc := range [][2]int{{1, 1}, {4, 2}, {0, 0}, {12, 3}} { + g := NewGrid(tc[0], tc[1]) + DrawTooSmall(g, tc[0], tc[1], NewPalette(ModeFull)) + for _, row := range strings.Split(ansi.Strip(g.Render()), "\n") { + if len([]rune(row)) > tc[0] { + t.Errorf("%dx%d: row %q too wide", tc[0], tc[1], row) + } + } + } +} + +func TestOverlayASCIIBorders(t *testing.T) { + l := Compute(80, 30) + g := NewGrid(80, 30) + p := NewPalette(ModeASCII) + DrawOverlay(g, l, p, Overlay{Title: "PAUSED", Lines: []string{"x"}}) + out := ansi.Strip(g.Render()) + if strings.ContainsAny(out, "╔═║╝") { + t.Error("ASCII overlay used box-drawing characters") + } + if !strings.Contains(out, "+") { + t.Error("ASCII overlay drew no border") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/render/ -run 'Overlay|TooSmall|Pause|GameOver|Help' -v` +Expected: FAIL — `undefined: DrawOverlay`. + +- [ ] **Step 3: Implement overlays** + +Create `internal/render/overlay.go`: + +```go +package render + +import ( + "fmt" + + "cosmic-tetris/internal/game" +) + +// Overlay is a centred modal box: pause, help, or game over. +type Overlay struct { + Title string + Lines []string + Style *Style + TitleStyle *Style +} + +// DrawOverlay centres a bordered box over whatever is already in the grid. The +// box is sized to its content, then clamped to the grid, and every write goes +// through Grid.Set, so content larger than the terminal clips instead of +// escaping. +func DrawOverlay(g *Grid, l Layout, p *Palette, o Overlay) { + body, title := o.Style, o.TitleStyle + if body == nil { + body = p.Value + } + if title == nil { + title = p.Banner + } + + inner := len([]rune(o.Title)) + for _, s := range o.Lines { + if n := len([]rune(s)); n > inner { + inner = n + } + } + inner += 4 // two spaces of padding each side + boxW := inner + 2 + boxH := len(o.Lines) + 4 // border, title, blank, lines, border + if boxW > g.W { + boxW = g.W + } + if boxH > g.H { + boxH = g.H + } + x0 := (g.W - boxW) / 2 + y0 := (g.H - boxH) / 2 + if x0 < 0 { + x0 = 0 + } + if y0 < 0 { + y0 = 0 + } + x1, y1 := x0+boxW-1, y0+boxH-1 + + b := bordersFor(p) + // Blank the box interior so the board does not show through the modal. + for y := y0; y <= y1; y++ { + for x := x0; x <= x1; x++ { + g.Set(x, y, ' ', nil) + } + } + g.Set(x0, y0, b.tl, p.Border) + g.Set(x1, y0, b.tr, p.Border) + g.Set(x0, y1, b.bl, p.Border) + g.Set(x1, y1, b.br, p.Border) + for x := x0 + 1; x < x1; x++ { + g.Set(x, y0, b.h, p.Border) + g.Set(x, y1, b.h, p.Border) + } + for y := y0 + 1; y < y1; y++ { + g.Set(x0, y, b.v, p.Border) + g.Set(x1, y, b.v, p.Border) + } + + center := func(y int, s string, st *Style) { + x := x0 + 1 + (boxW-2-len([]rune(s)))/2 + if x < x0+1 { + x = x0 + 1 + } + // Clip to the box interior. + i := 0 + for _, r := range s { + if x+i >= x1 { + break + } + g.Set(x+i, y, r, st) + i++ + } + } + y := y0 + 1 + center(y, o.Title, title) + y += 2 + for _, line := range o.Lines { + if y >= y1 { + break + } + center(y, line, body) + y++ + } +} + +// PauseOverlay is the pause modal, with the spec's copy. +func PauseOverlay(p *Palette) Overlay { + return Overlay{ + Title: "TEMPORAL SUSPENSION", + Lines: []string{ + "SPACE IS PAUSED", + "", + "p resume", + }, + TitleStyle: p.Banner, + Style: p.Label, + } +} + +// GameOverOverlay is the end-of-run modal, with the spec's copy. +func GameOverOverlay(p *Palette, gm *game.Game) Overlay { + return Overlay{ + Title: "UNIVERSE EXPIRED", + Lines: []string{ + "CAUSE: EXCESSIVE GEOMETRY", + "", + fmt.Sprintf("SCORE %d", gm.Score), + fmt.Sprintf("LINES %d", gm.Lines), + fmt.Sprintf("LEVEL %d", gm.Level), + "", + "r REBOOT UNIVERSE", + "q ACCEPT COSMIC DEATH", + }, + TitleStyle: p.Banner, + Style: p.Value, + } +} + +// HelpOverlay wraps a body produced by the caller, which owns the key map. +func HelpOverlay(p *Palette, body []string) Overlay { + return Overlay{ + Title: "FLIGHT MANUAL", + Lines: body, + TitleStyle: p.Banner, + Style: p.Label, + } +} + +// DrawTooSmall replaces the whole frame with the minimum-size notice. It is +// written line by line through Grid.Set so it degrades gracefully in a +// terminal only a few cells across. +func DrawTooSmall(g *Grid, w, h int, p *Palette) { + msg := []string{ + "TERMINAL TOO SMALL", + "", + fmt.Sprintf("need %dx%d", MinWidth, MinHeight), + fmt.Sprintf("have %dx%d", w, h), + "", + "resize, or q to quit", + } + y := (g.H - len(msg)) / 2 + if y < 0 { + y = 0 + } + for i, line := range msg { + if r := []rune(line); len(r) > g.W { + line = string(r[:g.W]) + } + x := (g.W - len([]rune(line))) / 2 + if x < 0 { + x = 0 + } + st := p.Label + if i == 0 { + st = p.Banner + } + g.SetString(x, y+i, line, st) + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/overlay.go internal/render/overlay_test.go +git commit -m "feat(render): centred overlays for pause, help, game over, and too-small" +``` + +--- + +### Task 7: The scene — one frame, assembled in pipeline order + +**Files:** +- Create: `internal/render/scene.go` +- Test: `internal/render/scene_test.go` + +**Interfaces:** +- Consumes: everything from Tasks 1–6. +- Produces: + - `type Hook func(g *Grid, l Layout, p *Palette)` + - `type Scene struct { Game *game.Game; Palette *Palette; Seed int64; Mission string; ShakeX, ShakeY int; BorderStyle *Style; Overlay *Overlay; Background, BoardFX, GlobalFX Hook }` + - `func Frame(g *Grid, w, h int, s Scene) Layout` — resizes the grid, computes the layout, draws the frame, and returns the layout it used. + +Draw order is §37's pipeline: background → board border → locked stack → ghost → active piece → board FX → HUD → global FX → overlay. The three `Hook` fields are how Plan 3's FX layer participates without `render` importing `fx`. + +- [ ] **Step 1: Write the failing test** + +Create `internal/render/scene_test.go`: + +```go +package render + +import ( + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" + + "cosmic-tetris/internal/game" +) + +func newScene(gm *game.Game, p *Palette) Scene { + return Scene{Game: gm, Palette: p, Seed: gm.Seed, Mission: "NOMINALISH"} +} + +func TestFrameDrawsAFullScreen(t *testing.T) { + gm := game.New(1) + gm.Board.Set(0, 21, game.CellOf(game.KindI)) + g := NewGrid(0, 0) + p := NewPalette(ModeFull) + l := Frame(g, 80, 30, newScene(gm, p)) + + if g.W != 80 || g.H != 30 { + t.Fatalf("grid is %dx%d, want 80x30", g.W, g.H) + } + if l.TooSmall { + t.Fatal("80x30 reported too small") + } + out := ansi.Strip(g.Render()) + for _, want := range []string{"COSMIC TETRIS", "HOLD", "NEXT", "SCORE", "MISSION CONTROL", "help", "╔", "█"} { + if !strings.Contains(out, want) { + t.Errorf("frame is missing %q:\n%s", want, out) + } + } + if rows := strings.Count(out, "\n") + 1; rows != 30 { + t.Errorf("frame has %d rows, want 30", rows) + } + for _, row := range strings.Split(out, "\n") { + if n := len([]rune(row)); n > 80 { + t.Errorf("a frame row is %d columns wide", n) + } + } +} + +func TestFrameHooksRunInPipelineOrder(t *testing.T) { + var order []string + gm := game.New(1) + p := NewPalette(ModeFull) + s := newScene(gm, p) + s.Background = func(*Grid, Layout, *Palette) { order = append(order, "bg") } + s.BoardFX = func(*Grid, Layout, *Palette) { order = append(order, "board") } + s.GlobalFX = func(*Grid, Layout, *Palette) { order = append(order, "global") } + Frame(NewGrid(0, 0), 80, 30, s) + + want := []string{"bg", "board", "global"} + if strings.Join(order, ",") != strings.Join(want, ",") { + t.Errorf("hook order = %v, want %v", order, want) + } +} + +func TestFrameWithNilHooksIsFine(t *testing.T) { + Frame(NewGrid(0, 0), 80, 30, newScene(game.New(1), NewPalette(ModeFull))) +} + +func TestBackgroundNeverCoversTheBoard(t *testing.T) { + // Stars are drawn first, so the board's blank interior must overwrite them. + gm := game.New(1) + p := NewPalette(ModeFull) + s := newScene(gm, p) + s.Background = func(g *Grid, l Layout, p *Palette) { + for y := 0; y < g.H; y++ { + for x := 0; x < g.W; x++ { + g.Set(x, y, '#', p.Star[0]) + } + } + } + g := NewGrid(0, 0) + l := Frame(g, 80, 30, s) + for by := game.HiddenRows; by < game.Height; by++ { + for bx := 0; bx < game.Width; bx++ { + if gm.Board.At(bx, by).Filled() { + continue + } + x, y, _ := BoardCellXY(l, bx, by, 0, 0) + if got := g.At(x, y).Rune; got == '#' { + t.Fatalf("background bled into the playfield at board (%d,%d)", bx, by) + } + } + } +} + +func TestFrameShowsTheTooSmallNoticeAndNothingElse(t *testing.T) { + g := NewGrid(0, 0) + l := Frame(g, 30, 12, newScene(game.New(1), NewPalette(ModeFull))) + if !l.TooSmall { + t.Fatal("30x12 should be too small") + } + out := ansi.Strip(g.Render()) + if !strings.Contains(out, "TERMINAL TOO SMALL") { + t.Errorf("notice not shown:\n%s", out) + } + for _, unwanted := range []string{"COSMIC TETRIS", "NEXT", "╔"} { + if strings.Contains(out, unwanted) { + t.Errorf("too-small frame still drew %q", unwanted) + } + } +} + +// Review Focus item 1. +func TestFrameWithDegenerateSizes(t *testing.T) { + for _, tc := range [][2]int{{0, 0}, {-1, -1}, {1, 40}, {200, 1}} { + g := NewGrid(0, 0) + l := Frame(g, tc[0], tc[1], newScene(game.New(1), NewPalette(ModeFull))) + if !l.TooSmall { + t.Errorf("Frame(%d,%d) should be too small", tc[0], tc[1]) + } + _ = g.Render() // must not panic + } +} + +func TestFrameAppliesShakeToTheBoardOnly(t *testing.T) { + gm := game.New(1) + gm.Board.Set(0, 21, game.CellOf(game.KindI)) + p := NewPalette(ModeFull) + + plain := NewGrid(0, 0) + l := Frame(plain, 80, 30, newScene(gm, p)) + + s := newScene(gm, p) + s.ShakeX, s.ShakeY = 1, 0 + shaken := NewGrid(0, 0) + Frame(shaken, 80, 30, s) + + x, y, _ := BoardCellXY(l, 0, 21, 0, 0) + if shaken.At(x+1, y).Rune != '█' { + t.Error("board did not move with the shake offset") + } + if shaken.At(l.NextX, l.NextY-1).Rune != plain.At(l.NextX, l.NextY-1).Rune { + t.Error("shake moved the HUD as well as the board") + } +} + +func TestFrameDrawsTheOverlayLast(t *testing.T) { + gm := game.New(1) + p := NewPalette(ModeFull) + s := newScene(gm, p) + o := PauseOverlay(p) + s.Overlay = &o + g := NewGrid(0, 0) + Frame(g, 80, 30, s) + if !strings.Contains(ansi.Strip(g.Render()), "TEMPORAL SUSPENSION") { + t.Error("overlay not drawn") + } +} + +func TestFrameDoesNotMutateGameState(t *testing.T) { + gm := game.New(7) + before := *gm + Frame(NewGrid(0, 0), 80, 30, newScene(gm, NewPalette(ModeFull))) + if *gm != before { + t.Error("Frame mutated the game") + } +} + +func TestFrameIsDeterministic(t *testing.T) { + gm := game.New(11) + p := NewPalette(ModeFull) + a, b := NewGrid(0, 0), NewGrid(0, 0) + Frame(a, 80, 30, newScene(gm, p)) + Frame(b, 80, 30, newScene(gm, p)) + if a.Render() != b.Render() { + t.Error("two frames from the same state differ") + } +} + +func TestFrameAtEverySizeClass(t *testing.T) { + gm := game.New(3) + for _, mode := range []Mode{ModeFull, ModeReduced, ModeASCII} { + p := NewPalette(mode) + for _, tc := range [][2]int{{40, 24}, {44, 26}, {56, 30}, {80, 30}, {120, 45}} { + g := NewGrid(0, 0) + Frame(g, tc[0], tc[1], newScene(gm, p)) + out := ansi.Strip(g.Render()) + rows := strings.Split(out, "\n") + if len(rows) != tc[1] { + t.Errorf("%v %dx%d: %d rows", mode, tc[0], tc[1], len(rows)) + } + for _, row := range rows { + if n := len([]rune(row)); n > tc[0] { + t.Errorf("%v %dx%d: row is %d columns", mode, tc[0], tc[1], n) + } + } + } + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/render/ -run Frame -v` +Expected: FAIL — `undefined: Frame`. + +- [ ] **Step 3: Implement the scene** + +Create `internal/render/scene.go`: + +```go +package render + +import "cosmic-tetris/internal/game" + +// Hook is an optional drawing pass supplied by the caller. The FX layer plugs +// into a Scene through these, which is why this package never imports it. +type Hook func(g *Grid, l Layout, p *Palette) + +// Scene is everything needed to draw one frame. +type Scene struct { + Game *game.Game + Palette *Palette + Seed int64 + Mission string + + // ShakeX and ShakeY offset the playfield only; the HUD stays put. + ShakeX, ShakeY int + + // BorderStyle overrides the palette's border style; nil uses the palette. + BorderStyle *Style + + // Overlay, when set, is drawn last over everything. + Overlay *Overlay + + Background Hook // starfield and nebulae, behind the board + BoardFX Hook // particles and shockwaves, over the pieces + GlobalFX Hook // banners and full-screen effects, over the HUD +} + +// Frame draws one complete screen into g, resizing it to w x h first, and +// returns the layout it used. The draw order is the spec's pipeline: +// background, board, pieces, board FX, HUD, global FX, overlay. +func Frame(g *Grid, w, h int, s Scene) Layout { + l := Compute(w, h) + g.Resize(w, h) + p := s.Palette + + if l.TooSmall { + DrawTooSmall(g, w, h, p) + return l + } + + if s.Background != nil { + s.Background(g, l, p) + } + + // The board's interior is opaque: blank it so the background cannot show + // through the playfield. + for y := l.InnerY + s.ShakeY; y < l.InnerY+BoardInnerH+s.ShakeY; y++ { + for x := l.InnerX + s.ShakeX; x < l.InnerX+BoardInnerW+s.ShakeX; x++ { + g.Set(x, y, ' ', nil) + } + } + + border := s.BorderStyle + if border == nil { + border = p.Border + } + DrawBoardBorder(g, l, p, border, s.ShakeX, s.ShakeY) + DrawLockedCells(g, l, p, s.Game, s.ShakeX, s.ShakeY) + if !s.Game.Over { + DrawGhost(g, l, p, s.Game, s.ShakeX, s.ShakeY) + DrawActivePiece(g, l, p, s.Game, s.ShakeX, s.ShakeY) + } + + if s.BoardFX != nil { + s.BoardFX(g, l, p) + } + + DrawTitle(g, l, p, s.Seed) + DrawHold(g, l, p, s.Game) + DrawNext(g, l, p, s.Game) + DrawStats(g, l, p, s.Game) + DrawMission(g, l, p, s.Mission) + DrawControls(g, l, p) + + if s.GlobalFX != nil { + s.GlobalFX(g, l, p) + } + if s.Overlay != nil { + DrawOverlay(g, l, p, *s.Overlay) + } + return l +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/render/ -v && go vet ./...` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/render/scene.go internal/render/scene_test.go +git commit -m "feat(render): scene assembly in spec pipeline order with FX hooks" +``` + +--- + +### Task 8: Key map and help content + +**Files:** +- Create: `internal/app/keys.go` +- Test: `internal/app/keys_test.go` + +**Interfaces:** +- Consumes: `charm.land/bubbles/v2/key`, `charm.land/bubbles/v2/help`. +- Produces: + - `type KeyMap struct { Left, Right, Down, RotateCW, RotateCCW, Drop, Hold, Pause, Restart, Help, Quit key.Binding }` + - `func DefaultKeyMap() KeyMap` + - `func (k KeyMap) ShortHelp() []key.Binding`, `func (k KeyMap) FullHelp() [][]key.Binding` + - `func HelpLines(k KeyMap) []string` — the body handed to `render.HelpOverlay`. + +`KeyMap` implements `help.KeyMap`, so the bubbles help model can render it, and `HelpLines` reuses the same bindings for the overlay — one source of truth for what the keys are. + +- [ ] **Step 1: Write the failing test** + +Create `internal/app/keys_test.go`: + +```go +package app + +import ( + "strings" + "testing" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" +) + +// press builds the key message a terminal would send for a named key. +func press(s string) tea.KeyPressMsg { + switch s { + case "left": + return tea.KeyPressMsg{Code: tea.KeyLeft} + case "right": + return tea.KeyPressMsg{Code: tea.KeyRight} + case "up": + return tea.KeyPressMsg{Code: tea.KeyUp} + case "down": + return tea.KeyPressMsg{Code: tea.KeyDown} + case " ", "space": + return tea.KeyPressMsg{Code: tea.KeySpace, Text: " "} + case "esc": + return tea.KeyPressMsg{Code: tea.KeyEscape} + default: + r := []rune(s)[0] + return tea.KeyPressMsg{Code: r, Text: string(r)} + } +} + +func TestEveryDocumentedKeyIsBound(t *testing.T) { + k := DefaultKeyMap() + cases := []struct { + keys []string + binding key.Binding + name string + }{ + {[]string{"left", "h", "a"}, k.Left, "left"}, + {[]string{"right", "l", "d"}, k.Right, "right"}, + {[]string{"down", "j", "s"}, k.Down, "soft drop"}, + {[]string{"up", "k", "x", "w"}, k.RotateCW, "rotate CW"}, + {[]string{"z"}, k.RotateCCW, "rotate CCW"}, + {[]string{" "}, k.Drop, "hard drop"}, + {[]string{"c"}, k.Hold, "hold"}, + {[]string{"p"}, k.Pause, "pause"}, + {[]string{"r"}, k.Restart, "restart"}, + {[]string{"?"}, k.Help, "help"}, + {[]string{"q", "esc"}, k.Quit, "quit"}, + } + for _, tc := range cases { + for _, s := range tc.keys { + if !key.Matches(press(s), tc.binding) { + t.Errorf("%q does not trigger %s", s, tc.name) + } + } + } +} + +func TestBindingsDoNotOverlap(t *testing.T) { + k := DefaultKeyMap() + all := map[string]string{} + named := []struct { + name string + b key.Binding + }{ + {"left", k.Left}, {"right", k.Right}, {"down", k.Down}, + {"rotateCW", k.RotateCW}, {"rotateCCW", k.RotateCCW}, {"drop", k.Drop}, + {"hold", k.Hold}, {"pause", k.Pause}, {"restart", k.Restart}, + {"help", k.Help}, {"quit", k.Quit}, + } + for _, n := range named { + for _, s := range n.b.Keys() { + if other, dup := all[s]; dup { + t.Errorf("key %q is bound to both %s and %s", s, other, n.name) + } + all[s] = n.name + } + } +} + +func TestEveryBindingHasHelpText(t *testing.T) { + k := DefaultKeyMap() + for _, b := range append(k.ShortHelp(), flatten(k.FullHelp())...) { + h := b.Help() + if h.Key == "" || h.Desc == "" { + t.Errorf("binding %v has incomplete help text %+v", b.Keys(), h) + } + } +} + +func flatten(groups [][]key.Binding) []key.Binding { + var out []key.Binding + for _, g := range groups { + out = append(out, g...) + } + return out +} + +func TestKeyMapSatisfiesBubblesHelp(t *testing.T) { + var _ help.KeyMap = DefaultKeyMap() + m := help.New() + m.Width = 78 + if s := m.View(DefaultKeyMap()); s == "" { + t.Error("bubbles help rendered nothing") + } +} + +func TestHelpLinesCoverEveryAction(t *testing.T) { + // The wording comes from the spec's flight manual. + body := strings.ToLower(strings.Join(HelpLines(DefaultKeyMap()), "\n")) + for _, want := range []string{ + "move spacecraft", "accelerate doom", "rotate geometry", "rotate other way", + "yeet", "quantum storage", "suspend spacetime", "reboot universe", + "close this nonsense", "abandon mission", + } { + if !strings.Contains(body, want) { + t.Errorf("help body does not mention %q:\n%s", want, body) + } + } +} + +func TestHelpLinesFitTheSmallestTerminal(t *testing.T) { + // The overlay adds a border and padding; leave room inside a 40-column + // terminal. + for _, line := range HelpLines(DefaultKeyMap()) { + if n := len([]rune(line)); n > 34 { + t.Errorf("help line %q is %d columns, too wide for a 40-column terminal", line, n) + } + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/app/ -v` +Expected: FAIL — `undefined: DefaultKeyMap`. + +- [ ] **Step 3: Implement the key map** + +Create `internal/app/keys.go`: + +```go +// Package app is the Bubble Tea program: state, input, and the frame clock. +package app + +import ( + "fmt" + + "charm.land/bubbles/v2/key" +) + +// KeyMap is every binding the game responds to. It satisfies help.KeyMap so +// the bubbles help model can render it, and HelpLines reuses the same bindings +// for the in-game overlay. +type KeyMap struct { + Left key.Binding + Right key.Binding + Down key.Binding + RotateCW key.Binding + RotateCCW key.Binding + Drop key.Binding + Hold key.Binding + Pause key.Binding + Restart key.Binding + Help key.Binding + Quit key.Binding +} + +// DefaultKeyMap returns the bindings from the spec, arrows plus vim plus WASD. +func DefaultKeyMap() KeyMap { + return KeyMap{ + // Descriptions are the spec's copy from 39, verbatim. + Left: key.NewBinding(key.WithKeys("left", "h", "a"), key.WithHelp("←/h/a", "move spacecraft")), + Right: key.NewBinding(key.WithKeys("right", "l", "d"), key.WithHelp("→/l/d", "move spacecraft")), + Down: key.NewBinding(key.WithKeys("down", "j", "s"), key.WithHelp("↓/j/s", "accelerate doom")), + RotateCW: key.NewBinding(key.WithKeys("up", "k", "x", "w"), key.WithHelp("↑/k/x", "rotate geometry")), + RotateCCW: key.NewBinding(key.WithKeys("z"), key.WithHelp("z", "rotate other way")), + Drop: key.NewBinding(key.WithKeys(" "), key.WithHelp("SPACE", "YEET")), + Hold: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "quantum storage")), + Pause: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "suspend spacetime")), + Restart: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "reboot universe")), + Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "close this nonsense")), + Quit: key.NewBinding(key.WithKeys("q", "esc"), key.WithHelp("q", "abandon mission")), + } +} + +// ShortHelp is the one-line summary. +func (k KeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Left, k.Right, k.RotateCW, k.Drop, k.Hold, k.Help, k.Quit} +} + +// FullHelp is the expanded, grouped listing. +func (k KeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Left, k.Right, k.Down}, + {k.RotateCW, k.RotateCCW, k.Drop, k.Hold}, + {k.Pause, k.Restart, k.Help, k.Quit}, + } +} + +// HelpLines renders the bindings as overlay body lines, narrow enough for a +// 40-column terminal. +func HelpLines(k KeyMap) []string { + bindings := []key.Binding{ + k.Left, k.Right, k.Down, k.RotateCW, k.RotateCCW, + k.Drop, k.Hold, k.Pause, k.Restart, k.Help, k.Quit, + } + out := make([]string, 0, len(bindings)) + for _, b := range bindings { + h := b.Help() + out = append(out, fmt.Sprintf("%-9s %s", h.Key, h.Desc)) + } + return out +} +``` + +If any generated line exceeds the 34-column budget the test enforces, shorten the `WithHelp` key text (not the description) until it fits. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/app/ -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/app/keys.go internal/app/keys_test.go +git commit -m "feat(app): key map with vim and WASD aliases plus help content" +``` + +--- + +### Task 9: The Bubble Tea model + +**Files:** +- Create: `internal/app/model.go` +- Test: `internal/app/model_test.go` + +**Interfaces:** +- Consumes: `game`, `render`, `KeyMap`, `HelpLines`, `charm.land/bubbletea/v2`. +- Produces: + - `type Options struct { Seed int64; Mode render.Mode }` + - `type FrameMsg time.Time` + - `type Model struct { ... }` implementing `tea.Model`: `Init() tea.Cmd`, `Update(tea.Msg) (tea.Model, tea.Cmd)`, `View() tea.View`. + - `func New(opts Options) *Model` + - consts `FrameInterval = 16 * time.Millisecond`, `MaxFrameDelta = 100 * time.Millisecond` + - `func (m *Model) Snapshot() string` — the ANSI-stripped current frame, used by the golden tests in Task 11. + +Behaviour: one `tea.Tick` chain emits `FrameMsg`; each frame computes `dt` from the previous frame's timestamp, clamps it to `MaxFrameDelta`, and calls `game.Advance(dt)`. Key presses act immediately, without waiting for the next frame. `p` toggles pause; while paused, no `Advance` happens and movement keys are ignored. After game over, only `r` and `q` do anything. `r` rebuilds the game with a fresh seed derived from the clock unless a seed was pinned on the command line, in which case the same seed replays. + +- [ ] **Step 1: Write the failing test** + +Create `internal/app/model_test.go`: + +```go +package app + +import ( + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/colorprofile" + + "cosmic-tetris/internal/game" + "cosmic-tetris/internal/render" +) + +func newTestModel(t *testing.T) *Model { + t.Helper() + m := New(Options{Seed: 42, Mode: render.ModeFull}) + m.Update(tea.WindowSizeMsg{Width: 80, Height: 30}) + return m +} + +// send delivers a message and returns the model, keeping the pointer type. +func send(t *testing.T, m *Model, msg tea.Msg) tea.Cmd { + t.Helper() + _, cmd := m.Update(msg) + return cmd +} + +func TestInitStartsTheFrameClock(t *testing.T) { + m := New(Options{Seed: 1}) + if cmd := m.Init(); cmd == nil { + t.Fatal("Init returned no command; nothing would ever tick") + } +} + +func TestViewIsAltScreen(t *testing.T) { + m := newTestModel(t) + v := m.View() + if !v.AltScreen { + t.Error("View is not using the alternate screen") + } + if v.Content == "" { + t.Error("View content is empty") + } +} + +func TestWindowSizeDrivesTheFrame(t *testing.T) { + m := newTestModel(t) + if !strings.Contains(m.Snapshot(), "COSMIC TETRIS") { + t.Error("80x30 frame does not contain the title") + } + send(t, m, tea.WindowSizeMsg{Width: 30, Height: 10}) + if !strings.Contains(m.Snapshot(), "TERMINAL TOO SMALL") { + t.Error("shrinking to 30x10 did not show the notice") + } + send(t, m, tea.WindowSizeMsg{Width: 80, Height: 30}) + if !strings.Contains(m.Snapshot(), "COSMIC TETRIS") { + t.Error("growing back did not restore the frame") + } +} + +// Review Focus item 1. +func TestZeroAndNegativeWindowSizes(t *testing.T) { + m := New(Options{Seed: 1}) + for _, sz := range []tea.WindowSizeMsg{{Width: 0, Height: 0}, {Width: -1, Height: -1}, {Width: 80, Height: 0}} { + send(t, m, sz) + _ = m.View() // must not panic + } + send(t, m, tea.WindowSizeMsg{Width: 80, Height: 30}) + if strings.Contains(m.Snapshot(), "TERMINAL TOO SMALL") { + t.Error("model did not recover after a degenerate size") + } +} + +func TestMovementKeysActImmediately(t *testing.T) { + m := newTestModel(t) + x := m.Game.Active.X + send(t, m, press("left")) + if m.Game.Active.X != x-1 { + t.Errorf("left moved the piece to %d, want %d", m.Game.Active.X, x-1) + } + send(t, m, press("right")) + send(t, m, press("right")) + if m.Game.Active.X != x+1 { + t.Errorf("right moves landed at %d, want %d", m.Game.Active.X, x+1) + } +} + +func TestRotateAndHoldAndDropKeys(t *testing.T) { + m := newTestModel(t) + r := m.Game.Active.Rotation + send(t, m, press("x")) + if m.Game.Active.Rotation == r && m.Game.Active.Kind != game.KindO { + t.Error("rotate key did nothing") + } + send(t, m, press("c")) + if m.Game.Hold == nil { + t.Error("hold key did nothing") + } + before := m.Game.Score + send(t, m, press(" ")) + if m.Game.Score <= before { + t.Errorf("hard drop scored nothing: %d -> %d", before, m.Game.Score) + } +} + +func TestFrameMsgAdvancesGravity(t *testing.T) { + m := newTestModel(t) + y := m.Game.Active.Y + now := time.Now() + send(t, m, FrameMsg(now)) + send(t, m, FrameMsg(now.Add(900*time.Millisecond))) + if m.Game.Active.Y <= y { + t.Errorf("900ms of frames did not drop the piece: %d -> %d", y, m.Game.Active.Y) + } +} + +func TestFrameMsgAlwaysSchedulesTheNextFrame(t *testing.T) { + m := newTestModel(t) + if cmd := send(t, m, FrameMsg(time.Now())); cmd == nil { + t.Fatal("frame did not schedule the next frame; the clock would stop") + } +} + +// Review Focus item 3. +func TestLargeFrameDeltasAreClamped(t *testing.T) { + m := newTestModel(t) + now := time.Now() + send(t, m, FrameMsg(now)) + + quick := New(Options{Seed: 42, Mode: render.ModeFull}) + quick.Update(tea.WindowSizeMsg{Width: 80, Height: 30}) + quick.Update(FrameMsg(now)) + quick.Update(FrameMsg(now.Add(MaxFrameDelta))) + + // A ten-minute gap must behave exactly like one clamped frame. + send(t, m, FrameMsg(now.Add(10*time.Minute))) + if m.Game.Active.Y != quick.Game.Active.Y || m.Game.Score != quick.Game.Score { + t.Errorf("a 10-minute gap was not clamped: y=%d score=%d vs clamped y=%d score=%d", + m.Game.Active.Y, m.Game.Score, quick.Game.Active.Y, quick.Game.Score) + } + if m.Game.Over { + t.Error("a sleep-and-resume gap ended the game") + } +} + +func TestNonMonotonicFrameTimesAreIgnored(t *testing.T) { + m := newTestModel(t) + now := time.Now() + send(t, m, FrameMsg(now)) + y := m.Game.Active.Y + send(t, m, FrameMsg(now.Add(-5*time.Second))) + if m.Game.Active.Y != y { + t.Error("a backwards clock moved the piece") + } +} + +func TestPauseFreezesGravityAndIgnoresMovement(t *testing.T) { + m := newTestModel(t) + now := time.Now() + send(t, m, FrameMsg(now)) + send(t, m, press("p")) + if !m.Paused { + t.Fatal("p did not pause") + } + if !strings.Contains(m.Snapshot(), "TEMPORAL SUSPENSION") { + t.Error("pause overlay not shown") + } + y, x := m.Game.Active.Y, m.Game.Active.X + send(t, m, FrameMsg(now.Add(2*time.Second))) + send(t, m, press("left")) + send(t, m, press(" ")) + if m.Game.Active.Y != y || m.Game.Active.X != x { + t.Error("gameplay continued while paused") + } + send(t, m, press("p")) + if m.Paused { + t.Error("p did not resume") + } + // Resuming must not apply the paused interval as one giant dt. + send(t, m, FrameMsg(now.Add(2*time.Second+16*time.Millisecond))) + if m.Game.Active.Y > y+1 { + t.Errorf("resume dumped the paused time into gravity: %d -> %d", y, m.Game.Active.Y) + } +} + +func TestHelpTogglesAndPausesPlay(t *testing.T) { + m := newTestModel(t) + send(t, m, press("?")) + snap := m.Snapshot() + if !strings.Contains(snap, "FLIGHT MANUAL") { + t.Errorf("help overlay not shown:\n%s", snap) + } + now := time.Now() + send(t, m, FrameMsg(now)) + y := m.Game.Active.Y + send(t, m, FrameMsg(now.Add(2*time.Second))) + if m.Game.Active.Y != y { + t.Error("gravity ran while the help overlay was open") + } + send(t, m, press("?")) + if strings.Contains(m.Snapshot(), "FLIGHT MANUAL") { + t.Error("? did not close help") + } +} + +func TestEscapeClosesAnOverlayBeforeQuitting(t *testing.T) { + m := newTestModel(t) + send(t, m, press("?")) + if cmd := send(t, m, press("esc")); cmd != nil { + t.Error("esc quit the program instead of closing help") + } + if strings.Contains(m.Snapshot(), "FLIGHT MANUAL") { + t.Error("esc did not close help") + } + if cmd := send(t, m, press("esc")); cmd == nil { + t.Error("esc with no overlay open did not quit") + } +} + +// Review Focus item 4. +func TestKeysAfterGameOver(t *testing.T) { + m := newTestModel(t) + // Bury the board until the game ends. + for i := 0; i < 400 && !m.Game.Over; i++ { + send(t, m, press(" ")) + } + if !m.Game.Over { + t.Fatal("could not reach game over with repeated hard drops") + } + if !strings.Contains(m.Snapshot(), "UNIVERSE EXPIRED") { + t.Error("game-over overlay not shown") + } + before := *m.Game + for _, k := range []string{"left", "right", "down", "x", "z", " ", "c", "p"} { + send(t, m, press(k)) + } + if *m.Game != before { + t.Errorf("a gameplay key changed state after game over") + } + send(t, m, press("r")) + if m.Game.Over || m.Game.Score != 0 { + t.Error("r did not restart after game over") + } +} + +func TestRestartWithAPinnedSeedReplaysTheSameUniverse(t *testing.T) { + m := New(Options{Seed: 1234, Mode: render.ModeFull}) + m.Update(tea.WindowSizeMsg{Width: 80, Height: 30}) + first := append([]game.PieceKind(nil), m.Game.Next...) + send(t, m, press("r")) + if m.Game.Seed != 1234 { + t.Errorf("restart changed a pinned seed to %d", m.Game.Seed) + } + for i := range first { + if m.Game.Next[i] != first[i] { + t.Fatal("restart with a pinned seed produced a different queue") + } + } +} + +func TestRestartWithoutAPinnedSeedPicksANewUniverse(t *testing.T) { + m := New(Options{Mode: render.ModeFull}) // Seed 0 means "choose one" + m.Update(tea.WindowSizeMsg{Width: 80, Height: 30}) + first := m.Game.Seed + send(t, m, press("r")) + if m.Game.Seed == first { + t.Error("restart reused the same random seed") + } +} + +// Review Focus item 4. +func TestQuitAndRestartWorkWhileTooSmall(t *testing.T) { + m := New(Options{Seed: 7, Mode: render.ModeFull}) + send(t, m, tea.WindowSizeMsg{Width: 20, Height: 8}) + send(t, m, press("left")) // must be inert, not a panic + if cmd := send(t, m, press("r")); cmd != nil { + t.Error("r in a too-small terminal should restart, not return a command") + } + if cmd := send(t, m, press("q")); cmd == nil { + t.Error("q in a too-small terminal did not quit") + } +} + +func TestUnknownKeysAreIgnored(t *testing.T) { + m := newTestModel(t) + before := *m.Game + for _, k := range []string{"v", "8", "%"} { + send(t, m, press(k)) + } + if *m.Game != before { + t.Error("an unbound key changed the game state") + } +} + +func TestKeyReleaseMessagesAreIgnored(t *testing.T) { + m := newTestModel(t) + x := m.Game.Active.X + m.Update(tea.KeyReleaseMsg{Code: tea.KeyLeft}) + if m.Game.Active.X != x { + t.Error("a key release moved the piece") + } +} + +func TestASCIIModeFrame(t *testing.T) { + m := New(Options{Seed: 3, Mode: render.ModeASCII}) + m.Update(tea.WindowSizeMsg{Width: 80, Height: 30}) + snap := m.Snapshot() + if strings.ContainsAny(snap, "█╔═║░") { + t.Errorf("ASCII mode emitted non-ASCII glyphs:\n%s", snap) + } +} + +func TestLimitedColorProfileSwitchesToTheReducedPalette(t *testing.T) { + m := newTestModel(t) + if m.palette.Mode != render.ModeFull { + t.Fatalf("starting mode = %v, want full", m.palette.Mode) + } + m.Update(tea.ColorProfileMsg{Profile: colorprofile.ANSI}) + if m.palette.Mode != render.ModeReduced { + t.Errorf("mode after a limited profile = %v, want reduced", m.palette.Mode) + } + _ = m.Snapshot() // must still render +} + +func TestColorProfileDoesNotOverrideASCIIMode(t *testing.T) { + m := New(Options{Seed: 3, Mode: render.ModeASCII}) + m.Update(tea.WindowSizeMsg{Width: 80, Height: 30}) + m.Update(tea.ColorProfileMsg{Profile: colorprofile.ANSI}) + if m.palette.Mode != render.ModeASCII { + t.Errorf("mode = %v, want ascii to stick", m.palette.Mode) + } +} + +func TestSnapshotIsAnsiFree(t *testing.T) { + m := newTestModel(t) + if strings.Contains(m.Snapshot(), "\x1b") { + t.Error("Snapshot contains escape sequences") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/app/ -run Model -v` +Expected: FAIL — `undefined: New`. + +- [ ] **Step 3: Implement the model** + +Create `internal/app/model.go`: + +```go +package app + +import ( + "time" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/colorprofile" + "github.com/charmbracelet/x/ansi" + + "cosmic-tetris/internal/game" + "cosmic-tetris/internal/render" +) + +// FrameInterval is the visual update period, about 60 Hz. Gravity does not +// depend on it: every frame reports its own timestamp and the model advances +// the engine by the elapsed time. +const ( + FrameInterval = 16 * time.Millisecond + + // MaxFrameDelta caps how much time one frame may hand to the engine. A + // laptop waking from sleep reports an enormous gap; without this the + // player would return to a buried board. + MaxFrameDelta = 100 * time.Millisecond +) + +// FrameMsg is one visual tick, carrying the time it fired. +type FrameMsg time.Time + +// overlayKind is which modal is open, if any. +type overlayKind int + +const ( + overlayNone overlayKind = iota + overlayHelp +) + +// Options configures a new model. +type Options struct { + // Seed pins the universe. Zero means pick one from the clock, and lets + // restart pick a new one each time. + Seed int64 + + // Mode is the glyph and colour set. It is about what the terminal can + // render, not about how much spectacle the player wants. + Mode render.Mode + + // ReducedMotion suppresses screen shake, hyperdrive acceleration, and + // shockwaves. NoFX turns off cosmic weather entirely. Both are carried + // here and consumed by the FX layer in Plan 3; nothing in this plan reads + // them, because there are no effects yet. + ReducedMotion bool + NoFX bool +} + +// Model is the Bubble Tea program state. +type Model struct { + Game *game.Game + Paused bool + + keys KeyMap + palette *render.Palette + grid *render.Grid + layout render.Layout + opts Options + + seedPinned bool + width int + height int + overlay overlayKind + lastFrame time.Time + mission string +} + +// New builds a model. It does not read the terminal size; the program's first +// WindowSizeMsg supplies that. +func New(opts Options) *Model { + seed := opts.Seed + pinned := seed != 0 + if !pinned { + seed = time.Now().UnixNano() + } + return &Model{ + Game: game.New(seed), + keys: DefaultKeyMap(), + palette: render.NewPalette(opts.Mode), + grid: render.NewGrid(0, 0), + opts: opts, + seedPinned: pinned, + mission: "ALL SYSTEMS NOMINALISH", + } +} + +// Init starts the frame clock. +func (m *Model) Init() tea.Cmd { + return frameTick() +} + +func frameTick() tea.Cmd { + return tea.Tick(FrameInterval, func(t time.Time) tea.Msg { return FrameMsg(t) }) +} + +// Update handles one message. +func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + return m, nil + + case FrameMsg: + return m, m.frame(time.Time(msg)) + + case tea.KeyPressMsg: + return m, m.handleKey(msg) + + case tea.ColorProfileMsg: + // A terminal that cannot do truecolor gets the calmer palette from + // 32 "Reduced". Confirm the field name before writing this branch: + // go doc charm.land/bubbletea/v2.ColorProfileMsg + // go doc github.com/charmbracelet/colorprofile.Profile + // If Profile is an embedded field rather than a named one, the + // comparison is `msg.Profile` either way. + if m.opts.Mode == render.ModeFull && msg.Profile != colorprofile.TrueColor { + m.palette = render.NewPalette(render.ModeReduced) + } + return m, nil + } + return m, nil +} + +// frame advances the engine by the elapsed wall time since the previous frame, +// clamped, and schedules the next frame. +func (m *Model) frame(now time.Time) tea.Cmd { + dt := time.Duration(0) + if !m.lastFrame.IsZero() { + dt = now.Sub(m.lastFrame) + } + m.lastFrame = now + if dt < 0 { + dt = 0 + } + if dt > MaxFrameDelta { + dt = MaxFrameDelta + } + if m.running() && dt > 0 { + m.Game.Advance(dt) + } + return frameTick() +} + +// running reports whether gameplay time should pass. +func (m *Model) running() bool { + return !m.Paused && m.overlay == overlayNone && !m.Game.Over && !m.layout.TooSmall +} + +// handleKey applies one key press immediately, without waiting for a frame. +func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd { + switch { + case key.Matches(msg, m.keys.Help): + m.toggleHelp() + return nil + case key.Matches(msg, m.keys.Restart): + m.restart() + return nil + case key.Matches(msg, m.keys.Quit): + // Escape closes an open overlay first; only then does it quit. + if m.overlay != overlayNone && msg.Code == tea.KeyEscape { + m.overlay = overlayNone + return nil + } + return tea.Quit + } + + // Everything below is gameplay, and is inert unless the game is live. + if m.Game.Over || m.layout.TooSmall { + return nil + } + if key.Matches(msg, m.keys.Pause) { + m.Paused = !m.Paused + return nil + } + if m.Paused || m.overlay != overlayNone { + return nil + } + + switch { + case key.Matches(msg, m.keys.Left): + m.Game.MoveLeft() + case key.Matches(msg, m.keys.Right): + m.Game.MoveRight() + case key.Matches(msg, m.keys.Down): + m.Game.SoftDrop() + case key.Matches(msg, m.keys.RotateCW): + m.Game.RotateCW() + case key.Matches(msg, m.keys.RotateCCW): + m.Game.RotateCCW() + case key.Matches(msg, m.keys.Drop): + m.Game.HardDrop() + case key.Matches(msg, m.keys.Hold): + m.Game.UseHold() + } + return nil +} + +func (m *Model) toggleHelp() { + if m.overlay == overlayHelp { + m.overlay = overlayNone + return + } + m.overlay = overlayHelp +} + +// restart begins a new run, reusing a pinned seed or drawing a fresh one. +func (m *Model) restart() { + seed := m.Game.Seed + if !m.seedPinned { + seed = time.Now().UnixNano() + if seed == m.Game.Seed { + seed++ + } + } + m.Game = game.New(seed) + m.Paused = false + m.overlay = overlayNone + m.mission = "ALL SYSTEMS NOMINALISH" +} + +// scene builds the render scene for the current state. +func (m *Model) scene() render.Scene { + s := render.Scene{ + Game: m.Game, + Palette: m.palette, + Seed: m.Game.Seed, + Mission: m.mission, + } + switch { + case m.Game.Over: + o := render.GameOverOverlay(m.palette, m.Game) + s.Overlay = &o + case m.overlay == overlayHelp: + o := render.HelpOverlay(m.palette, HelpLines(m.keys)) + s.Overlay = &o + case m.Paused: + o := render.PauseOverlay(m.palette) + s.Overlay = &o + } + return s +} + +// View draws the current frame. +func (m *Model) View() tea.View { + m.layout = render.Frame(m.grid, m.width, m.height, m.scene()) + v := tea.NewView(m.grid.Render()) + v.AltScreen = true + return v +} + +// Snapshot returns the current frame with styling removed. Tests use it; the +// program does not. +func (m *Model) Snapshot() string { + return ansi.Strip(m.View().Content) +} +``` + +Note the ordering constraint: `m.layout` is set by `View`, and `handleKey` reads `m.layout.TooSmall`. Bubble Tea always calls `View` after `Update`, and the tests call `Snapshot` or `View` before asserting on too-small behaviour, so this holds. If `go vet` or a test shows a first-key-before-first-view gap, compute the layout in the `tea.WindowSizeMsg` branch as well: + +```go + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + m.layout = render.Compute(m.width, m.height) + return m, nil +``` + +Prefer that version — it removes the dependency on call order entirely. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/app/ -v` +Expected: PASS. + +- [ ] **Step 5: Run the whole suite with the race detector** + +Run: `go test ./... -race -shuffle=on` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add internal/app/model.go internal/app/model_test.go +git commit -m "feat(app): Bubble Tea model with clamped elapsed-time gravity" +``` + +--- + +### Task 10: The command + +**Files:** +- Create: `cmd/cosmic-tetris/main.go` +- Test: `cmd/cosmic-tetris/main_test.go` + +**Interfaces:** +- Consumes: `app.New`, `app.Options`, `render.Mode`. +- Produces: + - `func parseFlags(args []string, out io.Writer) (app.Options, bool, error)` — returns the options, whether to exit immediately (help was asked for), and any error. + - `func main()` + +Flags: `--seed N`, `--ascii`, `--reduced-motion`, `--no-fx`, `--help`. `--reduced-motion` and `--no-fx` parse now and select `render.ModeReduced` where that is all they can do yet; Plan 3 gives them their full meaning. + +- [ ] **Step 1: Write the failing test** + +Create `cmd/cosmic-tetris/main_test.go`: + +```go +package main + +import ( + "bytes" + "strings" + "testing" + + "cosmic-tetris/internal/render" +) + +func TestDefaultFlags(t *testing.T) { + var out bytes.Buffer + opts, exit, err := parseFlags(nil, &out) + if err != nil || exit { + t.Fatalf("parseFlags() = %v, exit=%v, err=%v", opts, exit, err) + } + if opts.Seed != 0 { + t.Errorf("default seed = %d, want 0 (choose at runtime)", opts.Seed) + } + if opts.Mode != render.ModeFull { + t.Errorf("default mode = %v, want full", opts.Mode) + } +} + +func TestSeedFlag(t *testing.T) { + var out bytes.Buffer + opts, _, err := parseFlags([]string{"--seed", "1234"}, &out) + if err != nil { + t.Fatal(err) + } + if opts.Seed != 1234 { + t.Errorf("seed = %d, want 1234", opts.Seed) + } +} + +func TestASCIIFlag(t *testing.T) { + var out bytes.Buffer + opts, _, err := parseFlags([]string{"--ascii"}, &out) + if err != nil { + t.Fatal(err) + } + if opts.Mode != render.ModeASCII { + t.Errorf("mode = %v, want ascii", opts.Mode) + } +} + +func TestReducedMotionFlagDoesNotChangeGlyphs(t *testing.T) { + // 49.5: reduced motion suppresses shake, hyperdrive, and shockwaves. It + // leaves colour and glyphs alone. + var out bytes.Buffer + opts, _, err := parseFlags([]string{"--reduced-motion"}, &out) + if err != nil { + t.Fatal(err) + } + if !opts.ReducedMotion { + t.Error("--reduced-motion did not set ReducedMotion") + } + if opts.Mode != render.ModeFull { + t.Errorf("mode = %v, want full", opts.Mode) + } + if opts.NoFX { + t.Error("--reduced-motion should not imply --no-fx") + } +} + +func TestNoFXFlag(t *testing.T) { + var out bytes.Buffer + opts, _, err := parseFlags([]string{"--no-fx"}, &out) + if err != nil { + t.Fatal(err) + } + if !opts.NoFX { + t.Error("--no-fx did not set NoFX") + } + if opts.Mode != render.ModeFull { + t.Errorf("mode = %v, want full", opts.Mode) + } +} + +func TestFlagsCombine(t *testing.T) { + var out bytes.Buffer + opts, _, err := parseFlags([]string{"--reduced-motion", "--ascii", "--seed", "9"}, &out) + if err != nil { + t.Fatal(err) + } + if opts.Mode != render.ModeASCII || !opts.ReducedMotion || opts.Seed != 9 { + t.Errorf("combined flags = %+v", opts) + } +} + +func TestHelpFlagPrintsUsageAndExits(t *testing.T) { + var out bytes.Buffer + _, exit, err := parseFlags([]string{"--help"}, &out) + if err != nil { + t.Fatal(err) + } + if !exit { + t.Error("--help should ask the caller to exit") + } + text := out.String() + for _, want := range []string{"cosmic-tetris", "--seed", "--ascii", "--no-fx", "--reduced-motion"} { + if !strings.Contains(text, want) { + t.Errorf("usage does not mention %q:\n%s", want, text) + } + } +} + +func TestUnknownFlagIsAnError(t *testing.T) { + var out bytes.Buffer + if _, _, err := parseFlags([]string{"--warp-drive"}, &out); err == nil { + t.Error("unknown flag was accepted") + } +} + +func TestBadSeedIsAnError(t *testing.T) { + var out bytes.Buffer + if _, _, err := parseFlags([]string{"--seed", "not-a-number"}, &out); err == nil { + t.Error("non-numeric seed was accepted") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./cmd/cosmic-tetris/ -v` +Expected: FAIL — `undefined: parseFlags`. + +- [ ] **Step 3: Implement the command** + +Create `cmd/cosmic-tetris/main.go`: + +```go +// Command cosmic-tetris is a falling-block game with an unreasonable amount of +// cosmic weather. +package main + +import ( + "flag" + "fmt" + "io" + "os" + + tea "charm.land/bubbletea/v2" + + "cosmic-tetris/internal/app" + "cosmic-tetris/internal/render" +) + +const usage = `cosmic-tetris — a falling-block game with delusions of astrophysics + +usage: cosmic-tetris [flags] + +flags: + --seed N pin the universe to seed N, for a repeatable game + --ascii ASCII glyphs and basic colours, for stubborn terminals + --reduced-motion calmer visuals: no shake, no flashing + --no-fx gameplay only, no cosmic weather + --help print this and stop + +keys: + ←/h/a →/l/d drift ↑/k/x/w rotate z rotate back + ↓/j/s descend space YEET c hold + p pause r restart ? help q quit +` + +// parseFlags turns command-line arguments into app options. It reports exit=true +// when the caller should print nothing further and stop, which is what --help +// wants. +func parseFlags(args []string, out io.Writer) (app.Options, bool, error) { + fs := flag.NewFlagSet("cosmic-tetris", flag.ContinueOnError) + fs.SetOutput(out) + fs.Usage = func() { fmt.Fprint(out, usage) } + + seed := fs.Int64("seed", 0, "pin the universe to this seed") + ascii := fs.Bool("ascii", false, "ASCII glyphs and basic colours") + reduced := fs.Bool("reduced-motion", false, "calmer visuals") + noFX := fs.Bool("no-fx", false, "gameplay only, no cosmic weather") + help := fs.Bool("help", false, "print usage and stop") + + if err := fs.Parse(args); err != nil { + return app.Options{}, true, err + } + if *help { + fmt.Fprint(out, usage) + return app.Options{}, true, nil + } + if n := fs.NArg(); n > 0 { + return app.Options{}, true, fmt.Errorf("unexpected argument %q", fs.Arg(0)) + } + + // 49.5: --reduced-motion is about motion, not about glyphs or colour. Only + // --ascii changes the render mode. + opts := app.Options{ + Seed: *seed, + Mode: render.ModeFull, + ReducedMotion: *reduced, + NoFX: *noFX, + } + if *ascii { + opts.Mode = render.ModeASCII + } + return opts, false, nil +} + +func main() { + opts, exit, err := parseFlags(os.Args[1:], os.Stderr) + if err != nil { + os.Exit(2) + } + if exit { + return + } + if _, err := tea.NewProgram(app.New(opts)).Run(); err != nil { + fmt.Fprintf(os.Stderr, "cosmic-tetris: %v\n", err) + os.Exit(1) + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./cmd/... -v && go build ./...` +Expected: PASS, and a successful build. + +- [ ] **Step 5: Commit** + +```bash +git add cmd/cosmic-tetris/main.go cmd/cosmic-tetris/main_test.go +git commit -m "feat(cmd): cosmic-tetris command with seed, ascii, and motion flags" +``` + +--- + +### Task 11: Golden snapshot tests + +**Files:** +- Create: `internal/app/golden_test.go` +- Create (generated): `internal/app/testdata/*.golden` + +**Interfaces:** +- Consumes: `app.New`, `Model.Snapshot`, `press` from `keys_test.go`. +- Produces: the layout contract. These files are what "the layout is right" means from here on; Plan 3 must leave them passing with FX disabled. + +- [ ] **Step 1: Write the golden test harness** + +Create `internal/app/golden_test.go`: + +```package app + +import ( + "flag" + "os" + "path/filepath" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + + "cosmic-tetris/internal/game" + "cosmic-tetris/internal/render" +) + +var update = flag.Bool("update", false, "rewrite the golden files") + +// assertGolden compares a frame against testdata/.golden. +func assertGolden(t *testing.T, name, got string) { + t.Helper() + path := filepath.Join("testdata", name+".golden") + if *update { + if err := os.MkdirAll("testdata", 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(got), 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(path) + if err != nil { + t.Fatalf("%v (run: go test ./internal/app/ -update)", err) + } + if got != string(want) { + t.Errorf("frame %s does not match the golden file.\n--- got ---\n%s\n--- want ---\n%s", + name, got, string(want)) + } +} + +// scriptedModel builds a model in a reproducible mid-game state: a fixed seed, +// a known board, a held piece, and a score. +func scriptedModel(t *testing.T, w, h int, mode render.Mode) *Model { + t.Helper() + m := New(Options{Seed: 8675309, Mode: mode}) + m.Update(tea.WindowSizeMsg{Width: w, Height: h}) + + // A jagged stack, deterministic and easy to eyeball. + pattern := []struct { + y int + cols []int + kind game.PieceKind + }{ + {21, []int{0, 1, 2, 3, 4, 5, 6, 7, 8}, game.KindI}, + {20, []int{0, 1, 2, 6, 7, 8}, game.KindZ}, + {19, []int{0, 7, 8}, game.KindL}, + {18, []int{8}, game.KindT}, + } + for _, row := range pattern { + for _, x := range row.cols { + m.Game.Board.Set(x, row.y, game.CellOf(row.kind)) + } + } + held := game.KindO + m.Game.Hold = &held + m.Game.Score = 129340 + m.Game.Lines = 42 + m.Game.Level = 5 + m.Game.Active = game.Piece{Kind: game.KindT, Rotation: 0, X: 3, Y: 8} + m.mission = "NOMINALISH" + return m +} + +func TestGoldenFrames(t *testing.T) { + cases := []struct { + name string + w, h int + mode render.Mode + setup func(*testing.T, *Model) + }{ + {name: "wide-play", w: 80, h: 30, mode: render.ModeFull}, + {name: "medium-play", w: 48, h: 26, mode: render.ModeFull}, + {name: "small-play", w: 40, h: 24, mode: render.ModeFull}, + {name: "ascii-play", w: 80, h: 30, mode: render.ModeASCII}, + { + name: "wide-paused", w: 80, h: 30, mode: render.ModeFull, + setup: func(t *testing.T, m *Model) { m.Update(press("p")) }, + }, + { + name: "wide-help", w: 80, h: 30, mode: render.ModeFull, + setup: func(t *testing.T, m *Model) { m.Update(press("?")) }, + }, + { + name: "small-help", w: 40, h: 24, mode: render.ModeFull, + setup: func(t *testing.T, m *Model) { m.Update(press("?")) }, + }, + { + name: "wide-gameover", w: 80, h: 30, mode: render.ModeFull, + setup: func(t *testing.T, m *Model) { m.Game.Over = true }, + }, + { + name: "too-small", w: 30, h: 12, mode: render.ModeFull, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + m := scriptedModel(t, tc.w, tc.h, tc.mode) + if tc.setup != nil { + tc.setup(t, m) + } + assertGolden(t, tc.name, m.Snapshot()) + }) + } +} + +// The golden files are the layout contract, so assert their shape too: a frame +// is exactly h lines, none wider than w. +func TestGoldenFramesHaveTheRightShape(t *testing.T) { + for _, tc := range []struct { + name string + w, h int + }{ + {"wide-play", 80, 30}, {"medium-play", 48, 26}, {"small-play", 40, 24}, + {"ascii-play", 80, 30}, {"wide-paused", 80, 30}, {"wide-help", 80, 30}, + {"small-help", 40, 24}, {"wide-gameover", 80, 30}, {"too-small", 30, 12}, + } { + b, err := os.ReadFile(filepath.Join("testdata", tc.name+".golden")) + if err != nil { + t.Fatalf("%v (run: go test ./internal/app/ -update)", err) + } + rows := strings.Split(string(b), "\n") + if len(rows) != tc.h { + t.Errorf("%s has %d rows, want %d", tc.name, len(rows), tc.h) + } + for i, row := range rows { + if n := len([]rune(row)); n > tc.w { + t.Errorf("%s row %d is %d columns wide, want at most %d", tc.name, i, n, tc.w) + } + } + if strings.Contains(string(b), "\x1b") { + t.Errorf("%s contains escape sequences; snapshots must be ANSI-stripped", tc.name) + } + } +} +``` + +Fix the fenced-block opener when pasting: the file starts with `package app`, not the stray backtick-run above. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/app/ -run Golden -v` +Expected: FAIL — the `testdata` files do not exist yet. + +- [ ] **Step 3: Generate the golden files, then read every one of them** + +```bash +go test ./internal/app/ -run Golden -update +ls internal/app/testdata/ +``` + +Now open each `.golden` file and actually look at it. This is the only step in the plan where the layout gets judged by a person; the test can only tell you it did not change. Check: +- The board is 22 columns wide and 22 rows tall, with an unbroken border. +- Blocks are two columns wide and never split across the border. +- The ghost sits directly beneath the active piece, on the landing row. +- HOLD, NEXT, and the stats are beside the board, not on top of it, and nothing is cut off at the right edge. +- `small-play` has no stat labels, three NEXT previews, and no title row. +- `wide-help` and `small-help` show every binding, inside the box, with nothing truncated. +- `too-small` states both the required and the actual size. + +If anything looks wrong, fix the layout or the drawing code, re-run with `-update`, and look again. Do not commit golden files you have not read. + +- [ ] **Step 4: Run the suite without `-update`** + +Run: `go test ./... -race -shuffle=on -count=2` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/app/golden_test.go internal/app/testdata/ +git commit -m "test(app): golden frame snapshots as the layout contract" +``` + +--- + +### Task 12: Play it, then write it down + +**Files:** +- Create: `README.md` +- Modify: `internal/app/model.go` (only if playing it reveals a problem) + +- [ ] **Step 1: Build and play the game** + +```bash +go build -o /tmp/cosmic-tetris ./cmd/cosmic-tetris +/tmp/cosmic-tetris --seed 1234 +``` + +Play at least two full games, then confirm each of these by hand — this is the §40 checklist for Phase 2, and the tests cannot judge feel: + +- Movement feels immediate; there is no perceptible lag between a keypress and the piece moving. +- Gravity speeds up as the level rises, and the game stays playable at level 10. +- Hard drop lands where the ghost said it would, every time. +- Hold swaps once per piece and refuses a second swap until the next lock. +- Pause stops everything and resuming does not teleport the piece downward. +- `?` shows every key you actually use. +- Resizing the terminal — including dragging it smaller than 40×24 and back — never garbles the frame and never crashes. +- `q` and `Ctrl+C` both exit cleanly and leave the terminal usable (no stuck alt screen, no lost cursor). + +Then repeat the resize and exit checks with `--ascii`. + +- [ ] **Step 2: Fix anything that felt wrong** + +If something failed, add a test that captures it in the owning task's file, fix it, and re-run `go test ./... -race`. + +- [ ] **Step 3: Write the README** + +Create `README.md`: + +```markdown +# Cosmic Tetris + +A falling-block game for the terminal, with an unreasonable amount of cosmic +weather. Built with [Bubble Tea](https://charm.land). + +## Play + +```bash +go run ./cmd/cosmic-tetris +``` + +Needs a terminal at least 40×24. Bigger is better. + +## Flags + +| Flag | Effect | +| --- | --- | +| `--seed N` | Pin the universe to seed `N`. The same seed always plays out the same way. | +| `--ascii` | ASCII glyphs and basic colours, for terminals that cannot do better. | +| `--reduced-motion` | Calmer visuals: no shake, no flashing. | +| `--no-fx` | Gameplay only, no cosmic weather. | +| `--help` | Usage and keys. | + +## Keys + +| Keys | Action | +| --- | --- | +| `←` `h` `a` / `→` `l` `d` | Drift left and right | +| `↓` `j` `s` | Descend | +| `↑` `k` `x` `w` | Rotate | +| `z` | Rotate back | +| `space` | Hard drop | +| `c` | Hold | +| `p` | Pause | +| `r` | Restart | +| `?` | Help | +| `q` `esc` | Quit | + +## Layout + +- `internal/game` — the rules. Deterministic, no clock of its own, no terminal. +- `internal/render` — a character grid, a palette, an adaptive layout, and the + drawing passes. +- `internal/app` — the Bubble Tea program: input, the frame clock, overlays. +- `cmd/cosmic-tetris` — flags and startup. + +## Development + +```bash +go test ./... -race -shuffle=on +go test ./internal/app/ -update # rewrite the golden frame snapshots +go vet ./... +``` + +The `.golden` files under `internal/app/testdata` are the layout contract: they +are ANSI-stripped frames at every supported size. Read a diff before you accept +one. +``` + +- [ ] **Step 4: Final check** + +```bash +go test ./... -race -shuffle=on +go vet ./... +gofmt -l . +``` + +Expected: tests pass, vet clean, `gofmt -l` prints nothing. + +- [ ] **Step 5: Commit** + +```bash +git add README.md +git commit -m "docs: README with flags, keys, and layout" +``` + +--- + +## Done when + +- `go test ./... -race -shuffle=on` and `go vet ./...` are clean, and `gofmt -l .` is silent. +- `go run ./cmd/cosmic-tetris` is a complete, playable game: gravity, lock delay, rotation with kicks, hold, ghost, hard drop, scoring, levels, pause, help, game over, restart, quit. +- The golden files under `internal/app/testdata/` cover wide, medium, small, ASCII, paused, help, game over, and too-small, and a human has read each one. +- `--seed`, `--ascii`, `--reduced-motion`, `--no-fx`, and `--help` all work; `--seed N` replays identically across runs, including after `r`. +- Resizing between 20×8 and full screen never crashes or garbles the frame. +- `internal/render` imports `game` and lipgloss only. It does not import `app`, and there is no `fx` package yet. +- No cosmic weather yet: no stars, no particles, no shake, no banners. That is Plan 3. diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/baseline-cosmic-1/2026-09-17-cosmic-tetris-3-cosmic-fx.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/baseline-cosmic-1/2026-09-17-cosmic-tetris-3-cosmic-fx.md new file mode 100644 index 000000000..ba7ac0653 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/baseline-cosmic-1/2026-09-17-cosmic-tetris-3-cosmic-fx.md @@ -0,0 +1,3781 @@ +# Cosmic Tetris — Plan 3: Cosmic FX + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the playable game from Plan 2 ridiculous: a drifting starfield, an animated border, ion trails, hard-drop impact with shake and debris, supernova line clears, hyperdrive, shockwaves, banners, mission-control commentary, a boot sequence, and a black-hole game over — all of it a separate simulation that observes the engine and never touches it. + +**Architecture:** `internal/fx` holds a `World`: its own RNG, its own clock advanced by `Advance(dt)`, and state for stars, particles, trails, shake, hyperdrive, clears, banners, shockwaves, and the game-over collapse. It learns what happened by `Observe([]game.Event)` and draws through the three `render.Hook` slots on `render.Scene`, so `render` still knows nothing about it. `internal/flavor` owns the mission-control text on its own RNG. `internal/app` wires them together and gates them on `--no-fx` and `--reduced-motion`. + +**Tech Stack:** Go 1.26, `charm.land/bubbletea/v2` v2.0.9, `charm.land/lipgloss/v2` v2.0.6, `math/rand/v2`. + +**Spec:** `design.md`. Sections implemented here: §14, §15, §16, §17, §18, §19, §20, §21, §22, §23, §24, §25 (animation), §27, §28 (animation), §29, §38, §43, §44, §45, §49.5, §49.6. + +**Prerequisite:** Plans 1 and 2 complete. `go test ./... -race` is green and the golden files exist. + +## Global Constraints + +- The FX simulation may observe game events. It may **never** modify game state (§14). `internal/fx` gets a `*game.Game` for reading only, and never calls a mutating method on it. +- `fx.World` holds its own `*rand.Rand`, independent of the engine's (§49.6). Crossing them would make piece order depend on particle counts. +- Restraint rules (§44), all of them testable and all of them enforced by tests in this plan: + - Never obscure the active piece. + - Never make controls lag. + - Never delay gameplay for animation. + - Never require reading flavor text. + - Never use random effects that alter gameplay. + - Never make screen shake exceed roughly one cell. + - Never allow particles to permanently alter the rendered board. + - Never let comedy overwhelm playability. +- Performance (§38): no goroutine per particle, no goroutine per frame, no filesystem access or logging during gameplay. Reuse slices. A few hundred particles must be trivial — the cap is `MaxParticles = 400`. +- Timings, pinned: shake ≈80ms on the pattern `(0,+1) (-1,0) (+1,0) (0,-1) (0,0)`; hyperdrive `0 / 50 / 100 / 500 / 800 / 1100ms`; line clear ≈220ms in phases A `0–70ms`, B `70–150ms`, C `150–220ms`; banner ≈700ms; shockwave ≈300ms; trail lifetime 100–160ms; game-over `0–300 / 300–900 / 900–1300ms`; boot ≈1s. +- Star glyphs `. · ˚ ✦ ✧ *` in three depth layers; particles `· * ✦ +`; trail shades `▓ ▒ ░`; shockwave rings `· ○ ◌ ◯`. In ASCII mode every one of these falls back to 7-bit characters. +- `--reduced-motion` suppresses screen shake, hyperdrive acceleration, and shockwaves, and leaves colour, trails, and particles alone (§49.5). +- `--no-fx` disables the FX world entirely. With `--no-fx`, the Plan 2 golden files must still pass unchanged. +- Effects need only lightweight behavioural tests (§40). Do not pixel-test particle positions across an animation. Test invariants: counts, bounds, caps, "did anything happen", "did nothing happen when it shouldn't". + +## Review Focus + +1. **Sustained tetrises and long combos** must not grow the particle slice without bound or slow the frame; the cap has to hold under repeated large events, not just one. → Task 3. +2. **A resize to the minimum size mid-animation** must not leave particles, stars, or shockwaves drawing outside the new grid or carrying stale coordinates. → Tasks 2, 3, and 15. +3. **Screen shake at the board edges** must stay within roughly one cell and must never push the board outside the terminal or over the HUD. → Task 6. +4. **The same input stream with FX on and FX off** must produce byte-identical game state — the FX RNG must not be able to reach the engine. → Tasks 1 and 15. +5. **Pause** must freeze gameplay particles and animations while background stars keep drifting slowly (§30), and resuming must not dump the paused interval into the simulation. → Task 2. + +--- + +### Task 1: The FX world, wired in and proven independent + +**Files:** +- Create: `internal/fx/fx.go` +- Test: `internal/fx/fx_test.go` +- Modify: `internal/game/game.go` (export `DrainEvents`) +- Modify: `internal/app/model.go` (own a world, feed it, draw through it) +- Test: `internal/app/fx_wiring_test.go` + +**Interfaces:** +- Consumes: `game.Event`, `game.Game`, `render.Grid`, `render.Layout`, `render.Palette`, `render.Style`. +- Produces: + - `type Intensity struct { Enabled, ReducedMotion bool; Small bool }` — `Small` is set by the app for terminals in the small size class. + - `type Rect struct { X, Y, W, H int }`, `func (r Rect) Contains(x, y int) bool` + - `const MaxParticles = 400` + - `type World struct { Paused bool }` (everything else unexported) with: + - `func New(seed int64, in Intensity) *World` + - `func (w *World) SetViewport(view, board Rect)` + - `func (w *World) SetLevel(level int)` + - `func (w *World) Observe(events []game.Event)` + - `func (w *World) Advance(dt time.Duration)` + - `func (w *World) Elapsed() time.Duration` + - `func (w *World) ShakeOffset() (dx, dy int)` + - `func (w *World) BorderStyle(p *render.Palette) *render.Style` + - `func (w *World) DrawBackground(g *render.Grid, l render.Layout, p *render.Palette)` + - `func (w *World) DrawBoardFX(g *render.Grid, l render.Layout, p *render.Palette)` + - `func (w *World) DrawGlobalFX(g *render.Grid, l render.Layout, p *render.Palette)` + - `func (w *World) ParticleCount() int` + - `func (g *game.Game) DrainEvents() []game.Event` + +Every drawing method matches `render.Hook`, so the app assigns them straight into the scene. This task ships stubs that do nothing but keep time; later tasks fill them in. That keeps the wiring reviewable on its own and keeps the Plan 2 golden files green. + +- [ ] **Step 1: Write the failing test** + +Create `internal/fx/fx_test.go`: + +```go +package fx + +import ( + "testing" + "time" + + "cosmic-tetris/internal/game" + "cosmic-tetris/internal/render" +) + +func testWorld() *World { + w := New(7, Intensity{Enabled: true}) + w.SetViewport(Rect{0, 0, 80, 30}, Rect{X: 24, Y: 5, W: 20, H: 20}) + return w +} + +func TestRectContains(t *testing.T) { + r := Rect{X: 2, Y: 3, W: 4, H: 5} + for _, tc := range []struct { + x, y int + want bool + }{{2, 3, true}, {5, 7, true}, {1, 3, false}, {6, 3, false}, {2, 8, false}} { + if got := r.Contains(tc.x, tc.y); got != tc.want { + t.Errorf("Contains(%d,%d) = %v, want %v", tc.x, tc.y, got, tc.want) + } + } +} + +func TestAdvanceAccumulatesTime(t *testing.T) { + w := testWorld() + w.Advance(20 * time.Millisecond) + w.Advance(30 * time.Millisecond) + if got := w.Elapsed(); got != 50*time.Millisecond { + t.Errorf("Elapsed = %v, want 50ms", got) + } +} + +func TestAdvanceIgnoresNonPositiveDeltas(t *testing.T) { + w := testWorld() + w.Advance(-5 * time.Second) + w.Advance(0) + if got := w.Elapsed(); got != 0 { + t.Errorf("Elapsed = %v, want 0", got) + } +} + +func TestDisabledWorldDoesNothing(t *testing.T) { + w := New(1, Intensity{Enabled: false}) + w.SetViewport(Rect{0, 0, 80, 30}, Rect{X: 24, Y: 5, W: 20, H: 20}) + w.Observe([]game.Event{{Kind: game.EventPieceHardDropped, Count: 8}}) + w.Advance(500 * time.Millisecond) + + if n := w.ParticleCount(); n != 0 { + t.Errorf("disabled world holds %d particles", n) + } + dx, dy := w.ShakeOffset() + if dx != 0 || dy != 0 { + t.Errorf("disabled world shakes by (%d,%d)", dx, dy) + } + g := render.NewGrid(80, 30) + l := render.Compute(80, 30) + p := render.NewPalette(render.ModeFull) + w.DrawBackground(g, l, p) + w.DrawBoardFX(g, l, p) + w.DrawGlobalFX(g, l, p) + if g.Render() != blankRender(80, 30) { + t.Error("disabled world drew something") + } + if st := w.BorderStyle(p); st != p.Border { + t.Error("disabled world overrode the border style") + } +} + +func blankRender(w, h int) string { + return render.NewGrid(w, h).Render() +} + +func TestObserveNeverTouchesTheGame(t *testing.T) { + w := testWorld() + gm := game.New(3) + before := *gm + evs := gm.Advance(2 * time.Second) + w.Observe(evs) + w.Advance(time.Second) + if gm.Score != before.Score+scoreDelta(before, *gm) { + t.Skip("engine changed the score itself; this test only guards the FX path") + } + after := *gm + w.Observe(evs) + w.Advance(time.Second) + if *gm != after { + t.Error("the FX world mutated the game") + } +} + +func scoreDelta(before, after game.Game) int { return after.Score - before.Score } + +// Review Focus item 4: the FX RNG must be reachable only from fx. +func TestFXRNGIsIndependentOfTheEngine(t *testing.T) { + // Two identical games, one paired with a world that consumes a great deal + // of randomness, one with no world at all. The games must agree exactly. + quiet := game.New(4242) + loud := game.New(4242) + w := testWorld() + + for i := 0; i < 300; i++ { + qe := quiet.Advance(16 * time.Millisecond) + le := loud.Advance(16 * time.Millisecond) + if len(qe) != len(le) { + t.Fatalf("step %d: event counts diverged", i) + } + w.Observe(le) + w.Advance(16 * time.Millisecond) + if i%7 == 0 { + loud.HardDrop() + quiet.HardDrop() + w.Observe(loud.DrainEvents()) + } + } + if quiet.Score != loud.Score || quiet.Lines != loud.Lines || quiet.Board != loud.Board { + t.Errorf("FX changed the game: score %d vs %d, lines %d vs %d", + quiet.Score, loud.Score, quiet.Lines, loud.Lines) + } + if quiet.Active != loud.Active { + t.Error("FX changed the active piece") + } +} + +func TestWorldsWithTheSameSeedAgree(t *testing.T) { + a, b := New(9, Intensity{Enabled: true}), New(9, Intensity{Enabled: true}) + for _, w := range []*World{a, b} { + w.SetViewport(Rect{0, 0, 80, 30}, Rect{X: 24, Y: 5, W: 20, H: 20}) + w.Observe([]game.Event{{Kind: game.EventPieceHardDropped, Count: 6}}) + w.Advance(100 * time.Millisecond) + } + if a.ParticleCount() != b.ParticleCount() { + t.Errorf("same seed produced %d and %d particles", a.ParticleCount(), b.ParticleCount()) + } +} + +func TestDrawingWithNoViewportSetIsSafe(t *testing.T) { + w := New(1, Intensity{Enabled: true}) + w.Observe([]game.Event{{Kind: game.EventPieceHardDropped, Count: 4}}) + w.Advance(50 * time.Millisecond) + g := render.NewGrid(0, 0) + l := render.Compute(0, 0) + p := render.NewPalette(render.ModeFull) + w.DrawBackground(g, l, p) + w.DrawBoardFX(g, l, p) + w.DrawGlobalFX(g, l, p) + // Reaching here without a panic is the assertion. +} +``` + +Create `internal/app/fx_wiring_test.go`: + +```go +package app + +import ( + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + + "cosmic-tetris/internal/render" +) + +func TestFXIsWiredByDefault(t *testing.T) { + m := New(Options{Seed: 5, Mode: render.ModeFull}) + if m.fx == nil { + t.Fatal("model has no FX world") + } + m.Update(tea.WindowSizeMsg{Width: 80, Height: 30}) + now := time.Now() + m.Update(FrameMsg(now)) + m.Update(FrameMsg(now.Add(32 * time.Millisecond))) + if m.fx.Elapsed() == 0 { + t.Error("frames did not advance the FX world") + } +} + +func TestNoFXLeavesTheWorldDisabled(t *testing.T) { + m := New(Options{Seed: 5, Mode: render.ModeFull, NoFX: true}) + m.Update(tea.WindowSizeMsg{Width: 80, Height: 30}) + now := time.Now() + for i := 0; i < 30; i++ { + m.Update(FrameMsg(now.Add(time.Duration(i) * 16 * time.Millisecond))) + } + m.Update(press(" ")) + if n := m.fx.ParticleCount(); n != 0 { + t.Errorf("--no-fx produced %d particles", n) + } + snap := m.Snapshot() + if !strings.Contains(snap, "COSMIC TETRIS") { + t.Error("--no-fx broke the frame") + } +} + +func TestKeyEventsReachTheFXWorldImmediately(t *testing.T) { + m := New(Options{Seed: 5, Mode: render.ModeFull}) + m.Update(tea.WindowSizeMsg{Width: 80, Height: 30}) + m.Update(press(" ")) // hard drop, before any frame has ticked + if m.fx.ParticleCount() == 0 && m.fx.Elapsed() == 0 { + t.Log("no particles yet is fine for the stub; the queue must still be drained") + } + if evs := m.Game.DrainEvents(); len(evs) != 0 { + t.Errorf("the model left %d events in the engine queue", len(evs)) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ ./internal/app/ -v` +Expected: FAIL — `no non-test Go files` in `internal/fx`, `undefined: DrainEvents`, `m.fx undefined`. + +- [ ] **Step 3: Export the engine's event queue** + +In `internal/game/game.go`, add next to the existing unexported `drain`: + +```go +// DrainEvents removes and returns the pending events. Advance returns the +// events it produced; this is how a caller collects the events produced by an +// input method such as MoveLeft or HardDrop, without waiting for the next +// Advance. +func (g *Game) DrainEvents() []Event { + return g.drain() +} +``` + +- [ ] **Step 4: Implement the FX skeleton** + +Create `internal/fx/fx.go`: + +```go +// Package fx is the cosmic effects simulation. It observes game events and +// simulates spectacle. It never modifies game state. +package fx + +import ( + "math/rand/v2" + "time" + + "cosmic-tetris/internal/game" + "cosmic-tetris/internal/render" +) + +// MaxParticles caps the particle system. A few hundred is trivial for the +// arithmetic and plenty for the eye; the terminal is the bottleneck. +const MaxParticles = 400 + +// rngStreamFX is the FX generator's second PCG parameter. It differs from the +// engine's so the two streams cannot coincide. +const rngStreamFX = 0xBF58476D1CE4E5B9 + +// Intensity is how much spectacle the player has asked for. +type Intensity struct { + // Enabled is false under --no-fx: the world keeps no state and draws + // nothing. + Enabled bool + + // ReducedMotion suppresses screen shake, hyperdrive acceleration, and + // shockwaves. Colour, trails, and particles stay. + ReducedMotion bool + + // Small is set for terminals in the small size class, where there is not + // enough room for the full display of weather. + Small bool +} + +// Rect is a region of the terminal in grid cells. +type Rect struct { + X, Y, W, H int +} + +// Contains reports whether (x, y) is inside r. +func (r Rect) Contains(x, y int) bool { + return x >= r.X && y >= r.Y && x < r.X+r.W && y < r.Y+r.H +} + +// World is the effects simulation: its own clock, its own randomness, and no +// authority over the game. +type World struct { + // Paused freezes gameplay effects. Background stars keep drifting, slowly. + Paused bool + + in Intensity + rng *rand.Rand + now time.Duration + view Rect + board Rect + level int +} + +// New builds a world. The seed is the game's, so a replayed universe looks the +// same, but the generator is entirely separate from the engine's. +func New(seed int64, in Intensity) *World { + return &World{ + in: in, + rng: rand.New(rand.NewPCG(uint64(seed), rngStreamFX)), + level: 1, + } +} + +// SetViewport records the screen and the playfield interior, in grid cells. The +// app calls it every frame, before Advance, so a resize takes effect at once. +func (w *World) SetViewport(view, board Rect) { + w.view, w.board = view, board +} + +// SetLevel records the gravity level, which scales star drift. +func (w *World) SetLevel(level int) { + if level < 1 { + level = 1 + } + w.level = level +} + +// Elapsed is the world's own clock. +func (w *World) Elapsed() time.Duration { return w.now } + +// Observe reacts to what the engine just did. It reads events and nothing else. +func (w *World) Observe(events []game.Event) { + if !w.in.Enabled { + return + } + for _, e := range events { + w.observe(e) + } +} + +// observe handles one event. Later tasks fill in the arms. +func (w *World) observe(e game.Event) { + _ = e +} + +// Advance steps the simulation. dt is the same delta the engine received, so +// effects and gameplay stay in step. +func (w *World) Advance(dt time.Duration) { + if !w.in.Enabled || dt <= 0 { + return + } + w.now += dt +} + +// ShakeOffset is the current board offset in cells, at most one cell in each +// direction. +func (w *World) ShakeOffset() (dx, dy int) { + return 0, 0 +} + +// BorderStyle is the board border's current style. It falls back to the +// palette's when there is nothing to say. +func (w *World) BorderStyle(p *render.Palette) *render.Style { + return p.Border +} + +// ParticleCount is how many particles are alive, for tests and for the cap. +func (w *World) ParticleCount() int { return 0 } + +// DrawBackground draws behind the board: stars and other weather. +func (w *World) DrawBackground(g *render.Grid, l render.Layout, p *render.Palette) {} + +// DrawBoardFX draws over the pieces: particles, trails, clears, shockwaves. +func (w *World) DrawBoardFX(g *render.Grid, l render.Layout, p *render.Palette) {} + +// DrawGlobalFX draws over the HUD: banners and full-screen events. +func (w *World) DrawGlobalFX(g *render.Grid, l render.Layout, p *render.Palette) {} +``` + +- [ ] **Step 5: Wire the world into the model** + +In `internal/app/model.go`, add the field and the plumbing. + +Add to the `Model` struct: + +```go + fx *fx.World +``` + +Add to `New`, after the game is built: + +```go + m := &Model{ /* ... existing fields ... */ } + m.fx = fx.New(seed, fx.Intensity{ + Enabled: !opts.NoFX, + ReducedMotion: opts.ReducedMotion, + }) + return m +``` + +In `frame`, feed the world after the engine: + +```go + if m.running() && dt > 0 { + m.fx.Observe(m.Game.Advance(dt)) + } + m.syncFX() + m.fx.Advance(dt) + return frameTick() +``` + +Note the ordering: the world advances even when gameplay does not, because §30 keeps the stars drifting while paused. `syncFX` tells it what has changed: + +```go +// syncFX hands the world the current viewport, level, and pause state. The +// world reads; it never reaches back. +func (m *Model) syncFX() { + l := m.layout + view := fx.Rect{X: 0, Y: 0, W: m.width, H: m.height} + board := fx.Rect{X: l.InnerX, Y: l.InnerY, W: render.BoardInnerW, H: render.BoardInnerH} + if l.TooSmall { + board = fx.Rect{} + } + m.fx.SetViewport(view, board) + m.fx.SetLevel(m.Game.Level) + m.fx.Paused = m.Paused || m.overlay != overlayNone + m.fx.Small = m.layout.Size == render.SizeSmall +} +``` + +`Small` is part of `Intensity`, not a field on `World`, so add a setter to `internal/fx/fx.go` rather than assigning it directly: + +```go +// SetSmall records whether the terminal is in the small size class, where the +// weather has to be thinner. +func (w *World) SetSmall(small bool) { + w.in.Small = small +} +``` + +and call `m.fx.SetSmall(m.layout.Size == render.SizeSmall)` in `syncFX`. + +In `handleKey`, drain the events each input produced, right after the switch that applies it: + +```go + m.fx.Observe(m.Game.DrainEvents()) + return nil +``` + +In `scene`, attach the hooks and the shake: + +```go + s.Background = m.fx.DrawBackground + s.BoardFX = m.fx.DrawBoardFX + s.GlobalFX = m.fx.DrawGlobalFX + s.BorderStyle = m.fx.BorderStyle(m.palette) + s.ShakeX, s.ShakeY = m.fx.ShakeOffset() +``` + +In `restart`, build a fresh world so a new universe starts with clean weather: + +```go + m.fx = fx.New(seed, fx.Intensity{ + Enabled: !m.opts.NoFX, + ReducedMotion: m.opts.ReducedMotion, + }) +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `go test ./... -race -shuffle=on` +Expected: PASS, including the Plan 2 golden files — the stubs draw nothing, so no frame changed. + +- [ ] **Step 7: Commit** + +```bash +git add internal/fx/ internal/game/game.go internal/app/model.go internal/app/fx_wiring_test.go +git commit -m "feat(fx): effects world skeleton, wired to the model and proven independent" +``` + +--- + +### Task 2: Starfield + +**Files:** +- Create: `internal/fx/stars.go` +- Test: `internal/fx/stars_test.go` +- Modify: `internal/fx/fx.go` (own the stars, draw them, respawn on resize) + +**Interfaces:** +- Consumes: `World`, `Rect`, `render.Grid`, `render.Palette`. +- Produces: + - `type star struct { X, Y float64; Layer int; Glyph rune }` (unexported) + - `func (w *World) seedStars()`, `func (w *World) advanceStars(dt time.Duration)`, `func (w *World) drawStars(g *render.Grid, p *render.Palette)` + - `func (w *World) StarCount() int` — exported for tests. + - `func (w *World) starSpeed(layer int) float64` + - `func (w *World) Hyperdrive() float64` — the current hyperdrive multiplier; 1.0 normally. Task 8 gives it teeth; it exists here so the star maths already reads it. + +Three depth layers (§15): far is slow, dim, mostly `.`; mid is medium, `· ˚`; near is fast and bright, `✦ ✧`. Stars drift downward, faster as the level rises, and never so busy that the board is harder to read — so the density is roughly one star per 40 cells, and stars are drawn behind the board, which `render.Frame` blanks. + +- [ ] **Step 1: Write the failing test** + +Create `internal/fx/stars_test.go`: + +```go +package fx + +import ( + "strings" + "testing" + "time" + + "github.com/charmbracelet/x/ansi" + + "cosmic-tetris/internal/render" +) + +func drawWorld(w *World, cols, rows int) string { + g := render.NewGrid(cols, rows) + l := render.Compute(cols, rows) + p := render.NewPalette(render.ModeFull) + w.DrawBackground(g, l, p) + w.DrawBoardFX(g, l, p) + w.DrawGlobalFX(g, l, p) + return ansi.Strip(g.Render()) +} + +func TestStarsExistAfterTheViewportIsSet(t *testing.T) { + w := testWorld() + if w.StarCount() == 0 { + t.Fatal("no stars") + } + if got := drawWorld(w, 80, 30); strings.TrimSpace(got) == "" { + t.Error("stars drew nothing") + } +} + +func TestStarDensityIsModest(t *testing.T) { + w := testWorld() + cells := 80 * 30 + if n := w.StarCount(); n > cells/20 { + t.Errorf("%d stars in %d cells is too busy", n, cells) + } + if n := w.StarCount(); n < cells/120 { + t.Errorf("%d stars in %d cells is too empty", n, cells) + } +} + +func TestThreeDepthLayersArePresent(t *testing.T) { + w := testWorld() + seen := map[int]bool{} + for _, s := range w.stars { + seen[s.Layer] = true + } + for layer := 0; layer < 3; layer++ { + if !seen[layer] { + t.Errorf("no stars in layer %d", layer) + } + } +} + +func TestStarsDriftDownward(t *testing.T) { + w := testWorld() + before := make([]float64, len(w.stars)) + for i, s := range w.stars { + before[i] = s.Y + } + w.Advance(500 * time.Millisecond) + moved := 0 + for i, s := range w.stars { + if s.Y > before[i] || s.Y < before[i] { // wrapped counts as moved + moved++ + } + } + if moved == 0 { + t.Error("no star moved in half a second") + } +} + +func TestNearStarsMoveFasterThanFarStars(t *testing.T) { + w := testWorld() + if !(w.starSpeed(0) < w.starSpeed(1) && w.starSpeed(1) < w.starSpeed(2)) { + t.Errorf("layer speeds are not increasing: %.3f %.3f %.3f", + w.starSpeed(0), w.starSpeed(1), w.starSpeed(2)) + } +} + +func TestStarSpeedRisesWithTheLevel(t *testing.T) { + w := testWorld() + slow := w.starSpeed(1) + w.SetLevel(12) + fast := w.starSpeed(1) + if fast <= slow { + t.Errorf("level 12 speed %.3f is not faster than level 1 speed %.3f", fast, slow) + } + if fast > slow*4 { + t.Errorf("level 12 speed %.3f is more than four times level 1 (%.3f); too busy", fast, slow) + } +} + +func TestStarsStayInsideTheViewport(t *testing.T) { + w := testWorld() + for i := 0; i < 400; i++ { + w.Advance(16 * time.Millisecond) + } + for _, s := range w.stars { + if s.X < 0 || s.Y < 0 || int(s.X) >= w.view.W || int(s.Y) >= w.view.H { + t.Fatalf("star escaped the viewport: %+v (view %+v)", s, w.view) + } + } +} + +// Review Focus item 2. +func TestResizeRebuildsTheStarfieldInBounds(t *testing.T) { + w := testWorld() + w.Advance(2 * time.Second) + w.SetViewport(Rect{0, 0, 40, 24}, Rect{X: 4, Y: 2, W: 20, H: 20}) + w.Advance(16 * time.Millisecond) + for _, s := range w.stars { + if int(s.X) >= 40 || int(s.Y) >= 24 { + t.Fatalf("star %+v is outside the shrunken viewport", s) + } + } + if got := drawWorld(w, 40, 24); strings.Count(got, "\n")+1 != 24 { + t.Error("drawing after a resize produced the wrong number of rows") + } + w.SetViewport(Rect{}, Rect{}) + w.Advance(16 * time.Millisecond) + if w.StarCount() != 0 { + t.Error("an empty viewport should hold no stars") + } +} + +// Review Focus item 5. +func TestPausedStarsKeepDriftingSlowly(t *testing.T) { + moved := func(paused bool) float64 { + w := testWorld() + w.Paused = paused + var sum float64 + before := w.stars[0].Y + w.Advance(500 * time.Millisecond) + sum = w.stars[0].Y - before + if sum < 0 { + sum += float64(w.view.H) // wrapped + } + return sum + } + running, held := moved(false), moved(true) + if held <= 0 { + t.Error("stars stopped completely while paused; 30 says they may drift") + } + if held >= running { + t.Errorf("paused drift %.3f is not slower than running drift %.3f", held, running) + } +} + +func TestStarsUseTheSpecGlyphs(t *testing.T) { + w := testWorld() + allowed := ". ·˚✦✧*" + for _, s := range w.stars { + if !strings.ContainsRune(allowed, s.Glyph) { + t.Errorf("unexpected star glyph %q", s.Glyph) + } + } +} + +func TestStarsAreASCIIInASCIIMode(t *testing.T) { + w := testWorld() + g := render.NewGrid(80, 30) + l := render.Compute(80, 30) + w.DrawBackground(g, l, render.NewPalette(render.ModeASCII)) + out := ansi.Strip(g.Render()) + for _, r := range out { + if r > 127 && r != '\n' { + t.Fatalf("ASCII mode drew a non-ASCII star %q", r) + } + } +} + +func TestStarsNeverDrawInsideTheBoard(t *testing.T) { + // render.Frame blanks the playfield after the background, but the FX layer + // should not waste writes there either, and the invariant is worth pinning. + w := testWorld() + w.Advance(time.Second) + g := render.NewGrid(80, 30) + l := render.Compute(80, 30) + w.SetViewport(Rect{0, 0, 80, 30}, Rect{X: l.InnerX, Y: l.InnerY, W: render.BoardInnerW, H: render.BoardInnerH}) + w.DrawBackground(g, l, render.NewPalette(render.ModeFull)) + for y := l.InnerY; y < l.InnerY+render.BoardInnerH; y++ { + for x := l.InnerX; x < l.InnerX+render.BoardInnerW; x++ { + if c := g.At(x, y); c.Rune != ' ' { + t.Fatalf("star %q drawn inside the playfield at (%d,%d)", c.Rune, x, y) + } + } + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ -run Star -v` +Expected: FAIL — `w.stars undefined`, `undefined: StarCount`. + +- [ ] **Step 3: Implement the starfield** + +Create `internal/fx/stars.go`: + +```go +package fx + +import ( + "time" + + "cosmic-tetris/internal/render" +) + +// starDensity is one star per this many cells. Enough to feel like space, +// sparse enough that the board stays the most legible thing on screen. +const starDensity = 40 + +// Glyphs per depth layer: far is mostly dust, near is bright and pointy. +var starGlyphs = [3][]rune{ + {'.', '.', '·'}, + {'·', '˚'}, + {'✦', '✧', '*'}, +} + +var starGlyphsASCII = [3][]rune{ + {'.'}, + {'.', ':'}, + {'+', '*'}, +} + +// baseStarSpeed is cells per second per layer at level 1. +var baseStarSpeed = [3]float64{0.6, 1.6, 3.4} + +// pausedStarFactor is how much of the drift survives a pause. 30 lets the +// background keep moving very slowly while everything else holds still. +const pausedStarFactor = 0.15 + +// star is one background speck. +type star struct { + X, Y float64 + Layer int + Glyph rune +} + +// StarCount is how many stars exist. +func (w *World) StarCount() int { return len(w.stars) } + +// seedStars fills the viewport with a fresh starfield. It is called whenever +// the viewport changes size, so stars can never hold coordinates from a larger +// terminal. +func (w *World) seedStars() { + w.stars = w.stars[:0] + if w.view.W <= 0 || w.view.H <= 0 { + return + } + n := w.view.W * w.view.H / starDensity + for i := 0; i < n; i++ { + layer := i % 3 + w.stars = append(w.stars, star{ + X: w.rng.Float64() * float64(w.view.W), + Y: w.rng.Float64() * float64(w.view.H), + Layer: layer, + Glyph: starGlyphs[layer][w.rng.IntN(len(starGlyphs[layer]))], + }) + } +} + +// starSpeed is a layer's drift in cells per second, scaled by the gravity level +// and by hyperdrive. The level term is deliberately gentle: at level 20 the +// field is livelier, not a blizzard. +func (w *World) starSpeed(layer int) float64 { + if layer < 0 || layer > 2 { + return 0 + } + levelScale := 1 + float64(w.level-1)*0.08 + if levelScale > 2.6 { + levelScale = 2.6 + } + return baseStarSpeed[layer] * levelScale * w.Hyperdrive() +} + +// advanceStars drifts the field downward, wrapping at the bottom. +func (w *World) advanceStars(dt time.Duration) { + if len(w.stars) == 0 { + return + } + secs := dt.Seconds() + if w.Paused { + secs *= pausedStarFactor + } + h := float64(w.view.H) + for i := range w.stars { + s := &w.stars[i] + s.Y += w.starSpeed(s.Layer) * secs + for s.Y >= h { + s.Y -= h + s.X = w.rng.Float64() * float64(w.view.W) + } + } +} + +// drawStars paints the field, skipping the playfield, which is opaque. +func (w *World) drawStars(g *render.Grid, p *render.Palette) { + ascii := p.Mode == render.ModeASCII + for _, s := range w.stars { + x, y := int(s.X), int(s.Y) + if w.board.Contains(x, y) { + continue + } + glyph := s.Glyph + if ascii { + set := starGlyphsASCII[s.Layer] + glyph = set[(x+y)%len(set)] + } + g.Set(x, y, glyph, p.Star[s.Layer]) + } +} +``` + +- [ ] **Step 4: Hook the stars into the world** + +In `internal/fx/fx.go`, add the field: + +```go + stars []star +``` + +Reseed on a size change in `SetViewport`: + +```go +func (w *World) SetViewport(view, board Rect) { + resized := view.W != w.view.W || view.H != w.view.H + w.view, w.board = view, board + if !w.in.Enabled { + return + } + if resized || (len(w.stars) == 0 && view.W > 0 && view.H > 0) { + w.seedStars() + } +} +``` + +Advance them: + +```go + w.now += dt + w.advanceStars(dt) +``` + +Draw them: + +```go +func (w *World) DrawBackground(g *render.Grid, l render.Layout, p *render.Palette) { + if !w.in.Enabled { + return + } + w.drawStars(g, p) +} +``` + +Add the hyperdrive accessor, which Task 8 replaces with the real timeline: + +```go +// Hyperdrive is the current starfield speed multiplier. Task 8 gives it a +// timeline; until then space behaves itself. +func (w *World) Hyperdrive() float64 { return 1 } +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `go test ./internal/fx/ -v && go test ./... -race` +Expected: PASS. The Plan 2 golden files are generated with FX enabled through the app, so if `TestGoldenFrames` now fails, regenerate deliberately: the frames legitimately contain stars from here on. Run `go test ./internal/app/ -update`, read the diff, confirm the stars are outside the board and the HUD is intact, and commit the new files with this task. + +- [ ] **Step 6: Commit** + +```bash +git add internal/fx/stars.go internal/fx/stars_test.go internal/fx/fx.go internal/app/testdata/ +git commit -m "feat(fx): three-layer starfield with level-scaled drift" +``` + +--- + +### Task 3: Particles + +**Files:** +- Create: `internal/fx/particles.go` +- Test: `internal/fx/particles_test.go` +- Modify: `internal/fx/fx.go` (own the pool, advance it, draw it) + +**Interfaces:** +- Consumes: `World`, `Rect`, `render`. +- Produces: + - `type Particle struct { X, Y, VX, VY, Life, MaxLife float64; Glyph rune; Brightness float64; Gameplay bool }` + - `func (w *World) Emit(p Particle)` — respects the cap. + - `func (w *World) EmitBurst(x, y float64, n int, speed float64, glyphs []rune)` + - `func (w *World) advanceParticles(dt time.Duration)` + - `func (w *World) drawParticles(g *render.Grid, p *render.Palette)` + - `func (w *World) ParticleCount() int` (replaces the stub) + - consts `particleGravity = 14.0`, `particleDrag = 0.90` + +`Gameplay` marks a particle as belonging to gameplay rather than the background, so pause can freeze it (§30). Positions are floats in grid cells and are converted at draw time (§23). There is no collision detection. + +- [ ] **Step 1: Write the failing test** + +Create `internal/fx/particles_test.go`: + +```go +package fx + +import ( + "math" + "testing" + "time" + + "cosmic-tetris/internal/game" + "cosmic-tetris/internal/render" +) + +func TestEmitAndCount(t *testing.T) { + w := testWorld() + w.Emit(Particle{X: 10, Y: 10, Life: 1, MaxLife: 1, Glyph: '*'}) + if w.ParticleCount() != 1 { + t.Fatalf("count = %d, want 1", w.ParticleCount()) + } +} + +func TestParticlesMoveAndDie(t *testing.T) { + w := testWorld() + w.Emit(Particle{X: 10, Y: 10, VX: 4, VY: -2, Life: 0.2, MaxLife: 0.2, Glyph: '*', Brightness: 1}) + w.Advance(50 * time.Millisecond) + if w.ParticleCount() != 1 { + t.Fatalf("particle died too early") + } + if w.particles[0].X == 10 { + t.Error("particle did not move") + } + w.Advance(300 * time.Millisecond) + if w.ParticleCount() != 0 { + t.Errorf("particle outlived its life: %d left", w.ParticleCount()) + } +} + +func TestParticlesFallAndSlow(t *testing.T) { + w := testWorld() + w.Emit(Particle{X: 10, Y: 10, VX: 10, VY: 0, Life: 5, MaxLife: 5, Glyph: '*'}) + w.Advance(100 * time.Millisecond) + p := w.particles[0] + if p.VY <= 0 { + t.Errorf("gravity did not pull the particle down: VY = %.3f", p.VY) + } + if p.VX >= 10 { + t.Errorf("drag did not slow the particle: VX = %.3f", p.VX) + } +} + +func TestParticlesOutsideTheViewportDie(t *testing.T) { + w := testWorld() + w.Emit(Particle{X: 5, Y: 5, VX: -400, Life: 10, MaxLife: 10, Glyph: '*'}) + w.Advance(100 * time.Millisecond) + if w.ParticleCount() != 0 { + t.Error("a particle that left the viewport survived") + } +} + +// Review Focus item 1. +func TestParticleCapHoldsUnderSustainedEvents(t *testing.T) { + w := testWorld() + for round := 0; round < 50; round++ { + w.EmitBurst(30, 10, 200, 12, []rune{'*'}) + w.Advance(16 * time.Millisecond) + if n := w.ParticleCount(); n > MaxParticles { + t.Fatalf("round %d: %d particles exceeds the cap of %d", round, n, MaxParticles) + } + } + if cap(w.particles) > 4*MaxParticles { + t.Errorf("particle slice grew to capacity %d; it should be reused", cap(w.particles)) + } +} + +func TestEmitAtTheCapDropsTheNewestNotTheOldest(t *testing.T) { + w := testWorld() + for i := 0; i < MaxParticles; i++ { + w.Emit(Particle{X: 10, Y: 10, Life: 1, MaxLife: 1, Glyph: 'a'}) + } + w.Emit(Particle{X: 10, Y: 10, Life: 1, MaxLife: 1, Glyph: 'z'}) + if w.ParticleCount() != MaxParticles { + t.Fatalf("count = %d, want the cap %d", w.ParticleCount(), MaxParticles) + } + for _, p := range w.particles { + if p.Glyph == 'z' { + t.Error("emitting at the cap displaced a live particle") + } + } +} + +func TestEmitBurstSpreadsVelocity(t *testing.T) { + w := testWorld() + w.EmitBurst(30, 10, 24, 10, []rune{'·', '*', '✦', '+'}) + if n := w.ParticleCount(); n != 24 { + t.Fatalf("burst produced %d particles, want 24", n) + } + var left, right, up bool + for _, p := range w.particles { + if p.VX < -0.5 { + left = true + } + if p.VX > 0.5 { + right = true + } + if p.VY < -0.5 { + up = true + } + if math.IsNaN(p.VX) || math.IsNaN(p.VY) { + t.Fatal("burst produced a NaN velocity") + } + } + if !left || !right || !up { + t.Error("burst did not spread in every direction") + } +} + +func TestBurstParticlesAreMarkedGameplay(t *testing.T) { + w := testWorld() + w.EmitBurst(30, 10, 6, 8, []rune{'*'}) + for _, p := range w.particles { + if !p.Gameplay { + t.Error("burst particle is not marked as gameplay") + } + } +} + +// Review Focus item 5. +func TestPauseFreezesGameplayParticles(t *testing.T) { + w := testWorld() + w.EmitBurst(30, 10, 8, 8, []rune{'*'}) + before := append([]Particle(nil), w.particles...) + w.Paused = true + w.Advance(500 * time.Millisecond) + if w.ParticleCount() != len(before) { + t.Fatalf("paused particles died: %d -> %d", len(before), w.ParticleCount()) + } + for i, p := range w.particles { + if p.X != before[i].X || p.Y != before[i].Y || p.Life != before[i].Life { + t.Fatalf("particle %d moved or aged while paused", i) + } + } + w.Paused = false + w.Advance(16 * time.Millisecond) + if w.particles[0].X == before[0].X && w.particles[0].Y == before[0].Y { + t.Error("particles did not resume") + } +} + +func TestDrawParticlesStaysInsideTheGrid(t *testing.T) { + w := testWorld() + w.EmitBurst(30, 10, 100, 30, []rune{'*'}) + g := render.NewGrid(80, 30) + l := render.Compute(80, 30) + w.drawParticles(g, render.NewPalette(render.ModeFull)) + for y := 0; y < g.H; y++ { + for x := 0; x < g.W; x++ { + _ = g.At(x, y) + } + } + _ = l + // Reaching here without a panic is the assertion; Grid.Set clips. +} + +// Review Focus item 2. +func TestParticlesSurviveAResizeToTiny(t *testing.T) { + w := testWorld() + w.EmitBurst(60, 20, 200, 20, []rune{'*'}) + w.SetViewport(Rect{0, 0, 40, 24}, Rect{X: 4, Y: 2, W: 20, H: 20}) + w.Advance(16 * time.Millisecond) + for _, p := range w.particles { + if math.IsNaN(p.X) || math.IsNaN(p.Y) { + t.Fatal("resize produced a NaN particle position") + } + if p.X < -1 || p.Y < -1 || p.X > 41 || p.Y > 25 { + t.Fatalf("particle at (%.1f,%.1f) survived outside the new viewport", p.X, p.Y) + } + } + _ = drawWorld(w, 40, 24) // must not panic +} + +// Restraint rule: particles must never permanently alter the rendered board. +func TestParticlesDoNotAlterTheBoard(t *testing.T) { + w := testWorld() + gm := game.New(2) + gm.Board.Set(0, 21, game.CellOf(game.KindI)) + before := gm.Board + w.EmitBurst(30, 20, 100, 10, []rune{'*'}) + for i := 0; i < 40; i++ { + w.Advance(16 * time.Millisecond) + } + if gm.Board != before { + t.Error("particles changed the board") + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ -run Particle -v` +Expected: FAIL — `undefined: Particle`. + +- [ ] **Step 3: Implement the particle system** + +Create `internal/fx/particles.go`: + +```go +package fx + +import ( + "math" + "time" + + "cosmic-tetris/internal/render" +) + +// Particle physics constants, in cells and seconds. +const ( + particleGravity = 14.0 + particleDrag = 0.90 +) + +// Particle is one speck of debris. Positions are grid cells as floats and are +// rounded at draw time. +type Particle struct { + X, Y float64 + VX, VY float64 + Life float64 + MaxLife float64 + Glyph rune + Brightness float64 + + // Gameplay marks debris thrown by the game rather than the background, so + // pause can freeze it. + Gameplay bool +} + +// ParticleCount is how many particles are alive. +func (w *World) ParticleCount() int { return len(w.particles) } + +// Emit adds a particle, unless the pool is full. At the cap the newcomer is +// dropped rather than displacing something the player is already watching. +func (w *World) Emit(p Particle) { + if !w.in.Enabled || len(w.particles) >= MaxParticles { + return + } + if p.MaxLife <= 0 { + p.MaxLife = p.Life + } + if p.Brightness == 0 { + p.Brightness = 1 + } + w.particles = append(w.particles, p) +} + +// EmitBurst throws n particles outward from (x, y) at roughly the given speed. +func (w *World) EmitBurst(x, y float64, n int, speed float64, glyphs []rune) { + if !w.in.Enabled || n <= 0 || len(glyphs) == 0 { + return + } + for i := 0; i < n; i++ { + // Spread over a full circle, biased upward so debris arcs. + angle := w.rng.Float64() * 2 * math.Pi + mag := speed * (0.4 + 0.6*w.rng.Float64()) + life := 0.25 + 0.55*w.rng.Float64() + w.Emit(Particle{ + X: x, + Y: y, + VX: math.Cos(angle) * mag, + VY: math.Sin(angle)*mag*0.6 - speed*0.25, + Life: life, + MaxLife: life, + Glyph: glyphs[w.rng.IntN(len(glyphs))], + Brightness: 1, + Gameplay: true, + }) + } +} + +// advanceParticles steps the simulation and compacts the slice in place, so the +// backing array is reused rather than reallocated every frame. +func (w *World) advanceParticles(dt time.Duration) { + if len(w.particles) == 0 { + return + } + secs := dt.Seconds() + keep := w.particles[:0] + for _, p := range w.particles { + if p.Gameplay && w.Paused { + keep = append(keep, p) + continue + } + p.X += p.VX * secs + p.Y += p.VY * secs + p.VY += particleGravity * secs + damp := math.Pow(particleDrag, secs*60) + p.VX *= damp + p.VY *= damp + p.Life -= secs + if p.Life <= 0 || math.IsNaN(p.X) || math.IsNaN(p.Y) { + continue + } + if p.X < -1 || p.Y < -1 || p.X > float64(w.view.W)+1 || p.Y > float64(w.view.H)+1 { + continue + } + keep = append(keep, p) + } + w.particles = keep +} + +// drawParticles paints the pool. Brightness picks a style; the grid clips. +func (w *World) drawParticles(g *render.Grid, p *render.Palette) { + for _, pt := range w.particles { + frac := 0.0 + if pt.MaxLife > 0 { + frac = pt.Life / pt.MaxLife + } + st := p.Star[0] + switch { + case frac > 0.66: + st = p.Star[2] + case frac > 0.33: + st = p.Star[1] + } + glyph := pt.Glyph + if p.Mode == render.ModeASCII { + glyph = asciiParticle(glyph) + } + g.Set(int(pt.X), int(pt.Y), glyph, st) + } +} + +// asciiParticle maps the fancy debris glyphs onto 7-bit characters. +func asciiParticle(r rune) rune { + switch r { + case '✦', '✧', '˚': + return '*' + case '·': + return '.' + case '▓': + return '#' + case '▒': + return '=' + case '░': + return '-' + case '○', '◌', '◯', '●': + return 'o' + default: + if r > 127 { + return '*' + } + return r + } +} +``` + +- [ ] **Step 4: Hook particles into the world** + +In `internal/fx/fx.go`: add the field `particles []Particle`, delete the `ParticleCount` stub, add `w.advanceParticles(dt)` to `Advance` after `advanceStars`, and draw them in `DrawBoardFX`: + +```go +func (w *World) DrawBoardFX(g *render.Grid, l render.Layout, p *render.Palette) { + if !w.in.Enabled { + return + } + w.drawParticles(g, p) +} +``` + +Clamp positions after a resize so a shrunken viewport cannot leave stale coordinates behind — add to `SetViewport`, after the reseed: + +```go + if resized { + w.clampParticles() + } +``` + +and in `particles.go`: + +```go +// clampParticles pulls stray debris back into a shrunken viewport, so a resize +// mid-explosion cannot leave particles drawing off-screen forever. +func (w *World) clampParticles() { + maxX, maxY := float64(w.view.W), float64(w.view.H) + keep := w.particles[:0] + for _, p := range w.particles { + if p.X > maxX || p.Y > maxY { + continue + } + keep = append(keep, p) + } + w.particles = keep +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `go test ./internal/fx/ -v && go test ./... -race` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add internal/fx/particles.go internal/fx/particles_test.go internal/fx/fx.go +git commit -m "feat(fx): capped particle system with terminal-space physics" +``` + +--- + +### Task 4: Animated board border + +**Files:** +- Create: `internal/fx/border.go` +- Test: `internal/fx/border_test.go` +- Modify: `internal/fx/fx.go` (real `BorderStyle`) + +**Interfaces:** +- Consumes: `World`, `render.Palette`, `render.Style`, `lipgloss.Blend1D`. +- Produces: + - `func (w *World) BorderStyle(p *render.Palette) *render.Style` (replaces the stub) + - `func (w *World) FlashBorder(d time.Duration)` — a bright pulse for major events. + - `func (w *World) borderPhase() float64` — 0..1 position in the slow colour cycle. + - consts `borderCycle = 12 * time.Second`, `borderFlash = 120 * time.Millisecond` + +The border is the game's energy-state indicator (§25): deep violet → electric cyan → magenta → stellar blue → hot white, cycling slowly, flashing bright on impact, and cycling fast during major events. + +- [ ] **Step 1: Write the failing test** + +Create `internal/fx/border_test.go`: + +```go +package fx + +import ( + "testing" + "time" + + "cosmic-tetris/internal/render" +) + +func TestBorderStyleShiftsOverTime(t *testing.T) { + w := testWorld() + p := render.NewPalette(render.ModeFull) + first := w.BorderStyle(p) + w.Advance(borderCycle / 4) + second := w.BorderStyle(p) + r1, g1, b1, _ := first.Fg.RGBA() + r2, g2, b2, _ := second.Fg.RGBA() + if r1 == r2 && g1 == g2 && b1 == b2 { + t.Error("border colour did not shift over a quarter cycle") + } +} + +func TestBorderShiftIsSubtleFrameToFrame(t *testing.T) { + w := testWorld() + p := render.NewPalette(render.ModeFull) + a := w.BorderStyle(p) + w.Advance(16 * time.Millisecond) + b := w.BorderStyle(p) + r1, g1, b1, _ := a.Fg.RGBA() + r2, g2, b2, _ := b.Fg.RGBA() + diff := abs(int(r1)-int(r2)) + abs(int(g1)-int(g2)) + abs(int(b1)-int(b2)) + if diff > 3000 { + t.Errorf("border jumped by %d in one frame; the shift should be subtle", diff) + } +} + +func abs(n int) int { + if n < 0 { + return -n + } + return n +} + +func TestBorderPhaseWrapsAroundTheCycle(t *testing.T) { + w := testWorld() + if got := w.borderPhase(); got != 0 { + t.Errorf("initial phase = %.3f, want 0", got) + } + w.Advance(borderCycle) + if got := w.borderPhase(); got > 0.001 { + t.Errorf("phase after a full cycle = %.3f, want ~0", got) + } + w.Advance(borderCycle / 2) + if got := w.borderPhase(); got < 0.4 || got > 0.6 { + t.Errorf("phase after half a cycle = %.3f, want ~0.5", got) + } +} + +func TestFlashBorderGoesBrightThenBack(t *testing.T) { + w := testWorld() + p := render.NewPalette(render.ModeFull) + w.FlashBorder(borderFlash) + if got := w.BorderStyle(p); got != p.BorderHot { + t.Error("flash did not use the hot border style") + } + w.Advance(borderFlash + 10*time.Millisecond) + if got := w.BorderStyle(p); got == p.BorderHot { + t.Error("flash did not decay") + } +} + +func TestFlashIsIgnoredWhenDisabled(t *testing.T) { + w := New(1, Intensity{Enabled: false}) + p := render.NewPalette(render.ModeFull) + w.FlashBorder(time.Second) + if got := w.BorderStyle(p); got != p.Border { + t.Error("a disabled world flashed") + } +} + +func TestReducedMotionStillAnimatesColour(t *testing.T) { + // 49.5 suppresses motion, not colour. + w := New(1, Intensity{Enabled: true, ReducedMotion: true}) + w.SetViewport(Rect{0, 0, 80, 30}, Rect{X: 24, Y: 5, W: 20, H: 20}) + p := render.NewPalette(render.ModeFull) + a := w.BorderStyle(p) + w.Advance(borderCycle / 3) + b := w.BorderStyle(p) + r1, _, _, _ := a.Fg.RGBA() + r2, _, _, _ := b.Fg.RGBA() + if r1 == r2 { + t.Error("reduced motion also froze the border colour") + } +} + +func TestASCIIBorderUsesThePaletteStyle(t *testing.T) { + w := testWorld() + p := render.NewPalette(render.ModeASCII) + w.Advance(borderCycle / 3) + if got := w.BorderStyle(p); got != p.Border { + t.Error("ASCII mode should keep the palette's border style, not a blended colour") + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ -run Border -v` +Expected: FAIL — `undefined: borderCycle`. + +- [ ] **Step 3: Implement the border** + +Create `internal/fx/border.go`: + +```go +package fx + +import ( + "time" + + "charm.land/lipgloss/v2" + + "cosmic-tetris/internal/render" +) + +// The border is the game's energy-state indicator: a slow drift through a +// sci-fi palette, a bright flash on impact. +const ( + borderCycle = 12 * time.Second + borderFlash = 120 * time.Millisecond + + // borderFastCycle is the cycle during a major event. + borderFastCycle = 900 * time.Millisecond +) + +// borderPalette is the shift from 25. +var borderPalette = []string{ + "#7C3AED", // deep violet + "#22D3EE", // electric cyan + "#D946EF", // magenta + "#3B82F6", // stellar blue + "#F8FAFC", // hot white + "#7C3AED", // back to violet, so the cycle closes +} + +// borderPhase is where the colour cycle stands, 0..1. +func (w *World) borderPhase() float64 { + cycle := borderCycle + if w.borderFast > 0 { + cycle = borderFastCycle + } + return float64(w.now%cycle) / float64(cycle) +} + +// FlashBorder makes the border extremely bright for d. +func (w *World) FlashBorder(d time.Duration) { + if !w.in.Enabled || d <= 0 { + return + } + w.borderFlashLeft = d +} + +// PulseBorder speeds the gradient up for d, for major events. +func (w *World) PulseBorder(d time.Duration) { + if !w.in.Enabled || d <= 0 { + return + } + w.borderFast = d +} + +// advanceBorder ages the flash and the fast-cycle timers. +func (w *World) advanceBorder(dt time.Duration) { + if w.borderFlashLeft > 0 { + w.borderFlashLeft -= dt + if w.borderFlashLeft < 0 { + w.borderFlashLeft = 0 + } + } + if w.borderFast > 0 { + w.borderFast -= dt + if w.borderFast < 0 { + w.borderFast = 0 + } + } +} + +// BorderStyle is the border's current style. +func (w *World) BorderStyle(p *render.Palette) *render.Style { + if !w.in.Enabled { + return p.Border + } + if w.borderFlashLeft > 0 { + return p.BorderHot + } + // A terminal on the basic colour set has nothing to blend with. + if p.Mode == render.ModeASCII { + return p.Border + } + return render.NewStyle(w.borderColor(), false, false) +} + +// borderColor interpolates the palette at the current phase. +func (w *World) borderColor() lipgloss.Color { + phase := w.borderPhase() + // Blend1D gives us an evenly spaced ramp through the palette; sampling it + // is cheaper to reason about than hand-rolled channel maths. + stops := make([]lipgloss.Color, 0, len(borderPalette)) + for _, hex := range borderPalette { + stops = append(stops, lipgloss.Color(hex)) + } + ramp := lipgloss.Blend1D(borderRampSteps, stops...) + i := int(phase * float64(len(ramp))) + if i >= len(ramp) { + i = len(ramp) - 1 + } + if i < 0 { + i = 0 + } + return lipgloss.Color(ramp[i]) +} + +// borderRampSteps is how finely the palette is sampled. Sixty steps over a +// twelve-second cycle is a change every fifth of a second: visible, not busy. +const borderRampSteps = 60 +``` + +Check `Blend1D`'s exact signature and return type before writing this: + +```bash +go doc charm.land/lipgloss/v2 Blend1D +``` + +If it returns `[]color.Color` rather than `[]lipgloss.Color`, change `borderColor` to return `color.Color` and drop the conversion — `render.NewStyle` takes `color.Color` either way. Build the ramp once in `New` and store it on the world rather than rebuilding it per frame if the profiler ever cares; correctness first. + +- [ ] **Step 4: Hook the border into the world** + +In `internal/fx/fx.go`: add fields `borderFlashLeft, borderFast time.Duration`, delete the `BorderStyle` stub, and call `w.advanceBorder(dt)` from `Advance`. + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `go test ./internal/fx/ -v && go test ./... -race` +Expected: PASS. The border colour now changes with FX time, which the golden files do not see — they compare ANSI-stripped output, so the glyphs are unchanged. + +- [ ] **Step 6: Commit** + +```bash +git add internal/fx/border.go internal/fx/border_test.go internal/fx/fx.go +git commit -m "feat(fx): animated board border with flash and event pulse" +``` + +--- + +### Task 5: Piece trails + +**Files:** +- Create: `internal/fx/trails.go` +- Test: `internal/fx/trails_test.go` +- Modify: `internal/fx/fx.go` (own trails, observe movement, draw them) + +**Interfaces:** +- Consumes: `World`, `game.Event`, `game.Piece`, `render`. +- Produces: + - `type trail struct { X, Y int; Life, MaxLife float64 }` + - `func (w *World) addTrail(p game.Piece)`, `func (w *World) addColumnTrail(p game.Piece, cells int)` + - `func (w *World) advanceTrails(dt time.Duration)` + - `func (w *World) drawTrails(g *render.Grid, l render.Layout, pal *render.Palette)` + - `func (w *World) TrailCount() int` + - const `trailLife = 140 * time.Millisecond` + +Trails live 100–160ms and fade `▓ → ▒ → ░` (§17). They are drawn in board space through `render.BoardCellXY`, so they shake with the board and clip at its edges. Trails never draw over the active piece — the pieces are drawn before `BoardFX`, and a trail cell that coincides with the active piece is skipped, which is the §44 rule "never obscure the active piece" made mechanical. + +- [ ] **Step 1: Write the failing test** + +Create `internal/fx/trails_test.go`: + +```go +package fx + +import ( + "strings" + "testing" + "time" + + "github.com/charmbracelet/x/ansi" + + "cosmic-tetris/internal/game" + "cosmic-tetris/internal/render" +) + +func TestMovementLeavesATrail(t *testing.T) { + w := testWorld() + w.Observe([]game.Event{{ + Kind: game.EventPieceMoved, + Piece: game.Piece{Kind: game.KindT, X: 4, Y: 10}, + }}) + if w.TrailCount() == 0 { + t.Fatal("a move left no trail") + } +} + +func TestTrailsExpireQuickly(t *testing.T) { + w := testWorld() + w.Observe([]game.Event{{Kind: game.EventPieceMoved, Piece: game.Piece{Kind: game.KindT, X: 4, Y: 10}}}) + w.Advance(80 * time.Millisecond) + if w.TrailCount() == 0 { + t.Error("trail vanished before 100ms") + } + w.Advance(120 * time.Millisecond) + if w.TrailCount() != 0 { + t.Errorf("trail outlived 200ms: %d cells left", w.TrailCount()) + } +} + +func TestRotationAlsoLeavesATrail(t *testing.T) { + w := testWorld() + w.Observe([]game.Event{{Kind: game.EventPieceRotated, Piece: game.Piece{Kind: game.KindS, X: 4, Y: 6}}}) + if w.TrailCount() == 0 { + t.Error("a rotation left no trail") + } +} + +func TestHardDropLeavesAStrongerVerticalTrail(t *testing.T) { + short := testWorld() + short.Observe([]game.Event{{ + Kind: game.EventPieceHardDropped, + Piece: game.Piece{Kind: game.KindI, Rotation: 1, X: 4, Y: 20}, + Count: 2, + }}) + long := testWorld() + long.Observe([]game.Event{{ + Kind: game.EventPieceHardDropped, + Piece: game.Piece{Kind: game.KindI, Rotation: 1, X: 4, Y: 20}, + Count: 14, + }}) + if long.TrailCount() <= short.TrailCount() { + t.Errorf("a 14-cell drop left %d trail cells, a 2-cell drop left %d", + long.TrailCount(), short.TrailCount()) + } +} + +func TestTrailGlyphsFade(t *testing.T) { + w := testWorld() + w.Observe([]game.Event{{Kind: game.EventPieceMoved, Piece: game.Piece{Kind: game.KindT, X: 4, Y: 10}}}) + seen := map[rune]bool{} + for i := 0; i < 10; i++ { + g := render.NewGrid(80, 30) + l := render.Compute(80, 30) + w.drawTrails(g, l, render.NewPalette(render.ModeFull)) + for _, r := range ansi.Strip(g.Render()) { + if strings.ContainsRune("▓▒░", r) { + seen[r] = true + } + } + w.Advance(20 * time.Millisecond) + } + if len(seen) < 2 { + t.Errorf("trail used only %d shades: %v", len(seen), seen) + } +} + +// Restraint rule: never obscure the active piece. +func TestTrailsNeverCoverTheActivePiece(t *testing.T) { + w := testWorld() + active := game.Piece{Kind: game.KindT, X: 4, Y: 10} + w.SetActive(active) + w.Observe([]game.Event{{Kind: game.EventPieceMoved, Piece: active}}) + + g := render.NewGrid(80, 30) + l := render.Compute(80, 30) + w.SetViewport(Rect{0, 0, 80, 30}, Rect{X: l.InnerX, Y: l.InnerY, W: render.BoardInnerW, H: render.BoardInnerH}) + w.drawTrails(g, l, render.NewPalette(render.ModeFull)) + for _, c := range active.Cells() { + x, y, ok := render.BoardCellXY(l, c.X, c.Y, 0, 0) + if !ok { + continue + } + if got := g.At(x, y).Rune; got != ' ' { + t.Errorf("trail drew %q over the active piece at (%d,%d)", got, c.X, c.Y) + } + } +} + +func TestTrailsStayInsideTheBoard(t *testing.T) { + w := testWorld() + // A piece hugging both walls in turn. + for _, x := range []int{0, 8} { + w.Observe([]game.Event{{Kind: game.EventPieceMoved, Piece: game.Piece{Kind: game.KindI, X: x, Y: 10}}}) + } + g := render.NewGrid(80, 30) + l := render.Compute(80, 30) + w.drawTrails(g, l, render.NewPalette(render.ModeFull)) + for y := 0; y < g.H; y++ { + for x := 0; x < g.W; x++ { + if g.At(x, y).Rune == ' ' { + continue + } + if x < l.InnerX || x >= l.InnerX+render.BoardInnerW || y < l.InnerY || y >= l.InnerY+render.BoardInnerH { + t.Fatalf("trail drew outside the playfield at (%d,%d)", x, y) + } + } + } +} + +func TestTrailsFreezeWhilePaused(t *testing.T) { + w := testWorld() + w.Observe([]game.Event{{Kind: game.EventPieceMoved, Piece: game.Piece{Kind: game.KindT, X: 4, Y: 10}}}) + n := w.TrailCount() + w.Paused = true + w.Advance(time.Second) + if w.TrailCount() != n { + t.Errorf("trails aged while paused: %d -> %d", n, w.TrailCount()) + } +} + +func TestTrailsAreASCIISafe(t *testing.T) { + w := testWorld() + w.Observe([]game.Event{{Kind: game.EventPieceMoved, Piece: game.Piece{Kind: game.KindT, X: 4, Y: 10}}}) + g := render.NewGrid(80, 30) + l := render.Compute(80, 30) + w.drawTrails(g, l, render.NewPalette(render.ModeASCII)) + for _, r := range ansi.Strip(g.Render()) { + if r > 127 && r != '\n' { + t.Fatalf("ASCII mode drew a non-ASCII trail glyph %q", r) + } + } +} + +func TestDisabledWorldHasNoTrails(t *testing.T) { + w := New(1, Intensity{Enabled: false}) + w.Observe([]game.Event{{Kind: game.EventPieceMoved, Piece: game.Piece{Kind: game.KindT, X: 4, Y: 10}}}) + if w.TrailCount() != 0 { + t.Error("a disabled world made trails") + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ -run Trail -v` +Expected: FAIL — `undefined: TrailCount`. + +- [ ] **Step 3: Implement trails** + +Create `internal/fx/trails.go`: + +```go +package fx + +import ( + "time" + + "cosmic-tetris/internal/game" + "cosmic-tetris/internal/render" +) + +// trailLife is how long an ion trail lasts: long enough to see, short enough +// that it never reads as part of the board. +const trailLife = 140 * time.Millisecond + +// maxTrails caps the trail buffer. A hard drop from the ceiling with an I piece +// lying flat is the worst case, and it is far below this. +const maxTrails = 240 + +// trailGlyphs fade from freshest to faintest. +var trailGlyphs = [3]rune{'▓', '▒', '░'} + +// trail is one fading cell in board coordinates. +type trail struct { + X, Y int + Life float64 + MaxLife float64 +} + +// TrailCount is how many trail cells are alive. +func (w *World) TrailCount() int { return len(w.trails) } + +// SetActive records where the falling piece is, so effects can avoid drawing +// over it. The world reads this; it never writes to the game. +func (w *World) SetActive(p game.Piece) { + w.active = p +} + +// addTrail marks the cells a piece just left. +func (w *World) addTrail(p game.Piece) { + if !w.in.Enabled { + return + } + life := trailLife.Seconds() + for _, c := range p.Cells() { + if len(w.trails) >= maxTrails { + return + } + w.trails = append(w.trails, trail{X: c.X, Y: c.Y, Life: life, MaxLife: life}) + } +} + +// addColumnTrail draws the vertical streak a hard drop tore through, one cell +// per row crossed. Longer drops leave longer streaks. +func (w *World) addColumnTrail(p game.Piece, cells int) { + if !w.in.Enabled || cells <= 0 { + return + } + life := trailLife.Seconds() * 1.4 + for _, c := range p.Cells() { + for dy := 1; dy <= cells; dy++ { + if len(w.trails) >= maxTrails { + return + } + y := c.Y - dy + if y < 0 { + break + } + w.trails = append(w.trails, trail{X: c.X, Y: y, Life: life, MaxLife: life}) + } + } +} + +// advanceTrails ages the buffer, compacting in place. +func (w *World) advanceTrails(dt time.Duration) { + if len(w.trails) == 0 || w.Paused { + return + } + secs := dt.Seconds() + keep := w.trails[:0] + for _, t := range w.trails { + t.Life -= secs + if t.Life <= 0 { + continue + } + keep = append(keep, t) + } + w.trails = keep +} + +// drawTrails paints the streaks in board space, skipping the active piece. +func (w *World) drawTrails(g *render.Grid, l render.Layout, pal *render.Palette) { + if len(w.trails) == 0 { + return + } + occupied := map[game.Point]bool{} + for _, c := range w.active.Cells() { + occupied[c] = true + } + dx, dy := w.ShakeOffset() + for _, t := range w.trails { + if occupied[game.Point{X: t.X, Y: t.Y}] { + continue + } + frac := 0.0 + if t.MaxLife > 0 { + frac = t.Life / t.MaxLife + } + idx := 2 + switch { + case frac > 0.66: + idx = 0 + case frac > 0.33: + idx = 1 + } + glyph := trailGlyphs[idx] + if pal.Mode == render.ModeASCII { + glyph = asciiParticle(glyph) + } + x, y, ok := render.BoardCellXY(l, t.X, t.Y, dx, dy) + if !ok { + continue + } + g.Set(x, y, glyph, pal.Star[2-idx]) + g.Set(x+1, y, glyph, pal.Star[2-idx]) + } +} +``` + +- [ ] **Step 4: Hook trails into the world** + +In `internal/fx/fx.go`: add fields `trails []trail` and `active game.Piece`, call `w.advanceTrails(dt)` from `Advance`, draw them in `DrawBoardFX` **before** the particles so debris sits on top: + +```go + w.drawTrails(g, l, p) + w.drawParticles(g, p) +``` + +and fill in the movement arms of `observe`: + +```go + switch e.Kind { + case game.EventPieceMoved, game.EventPieceRotated: + w.addTrail(e.Piece) + case game.EventPieceHardDropped: + w.addColumnTrail(e.Piece, e.Count) + } +``` + +In `internal/app/model.go`, tell the world where the piece is, in `syncFX`: + +```go + m.fx.SetActive(m.Game.Active) +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `go test ./internal/fx/ -v && go test ./... -race` +Expected: PASS. If the app golden files change — a trail can be alive in a snapshot only if a snapshot follows a move, and the Plan 2 fixtures set state directly rather than moving — regenerate only after confirming why. + +- [ ] **Step 6: Commit** + +```bash +git add internal/fx/trails.go internal/fx/trails_test.go internal/fx/fx.go internal/app/model.go +git commit -m "feat(fx): short-lived ion trails for moves, rotations, and drops" +``` + +--- + +### Task 6: Hard-drop impact — shake and debris + +**Files:** +- Create: `internal/fx/impact.go` +- Test: `internal/fx/impact_test.go` +- Modify: `internal/fx/fx.go` (real `ShakeOffset`, impact on hard drop) +- Test: `internal/app/shake_test.go` + +**Interfaces:** +- Consumes: `World`, `game.Event`, `render.Layout`. +- Produces: + - `func (w *World) Impact(p game.Piece, cells int)` + - `func (w *World) Shake(d time.Duration)` + - `func (w *World) ShakeOffset() (dx, dy int)` (replaces the stub) + - `func (w *World) Shaking() bool` + - consts `shakeDuration = 80 * time.Millisecond`, `shakeFrames = 5` + - `var shakePattern = [shakeFrames][2]int{{0, 1}, {-1, 0}, {1, 0}, {0, -1}, {0, 0}}` + +§18: a hard drop shakes the board by exactly one cell on the pinned pattern for ~80ms, throws debris sideways from the impact row, and flashes the border. The shake offset is applied by `render.Frame` through `Scene.ShakeX/ShakeY`, which already clips at the grid edge (Plan 2, Task 7), so the shake can never push the board over the HUD — this task pins that with a test at the smallest supported terminal. + +`--reduced-motion` suppresses the shake entirely and keeps the debris and the flash (§49.5). + +- [ ] **Step 1: Write the failing test** + +Create `internal/fx/impact_test.go`: + +```go +package fx + +import ( + "testing" + "time" + + "cosmic-tetris/internal/game" +) + +func TestHardDropShakesAndThrowsDebris(t *testing.T) { + w := testWorld() + w.Observe([]game.Event{{ + Kind: game.EventPieceHardDropped, + Piece: game.Piece{Kind: game.KindO, X: 4, Y: 20}, + Count: 12, + }}) + if !w.Shaking() { + t.Error("a hard drop did not shake the board") + } + if w.ParticleCount() == 0 { + t.Error("a hard drop threw no debris") + } +} + +// Restraint rule and Review Focus item 3: shake never exceeds one cell. +func TestShakeNeverExceedsOneCell(t *testing.T) { + w := testWorld() + w.Shake(shakeDuration) + for i := 0; i < 40; i++ { + dx, dy := w.ShakeOffset() + if dx < -1 || dx > 1 || dy < -1 || dy > 1 { + t.Fatalf("frame %d: shake offset (%d,%d) exceeds one cell", i, dx, dy) + } + w.Advance(5 * time.Millisecond) + } +} + +func TestShakeFollowsThePinnedPattern(t *testing.T) { + w := testWorld() + w.Shake(shakeDuration) + step := shakeDuration / shakeFrames + for i, want := range shakePattern { + dx, dy := w.ShakeOffset() + if dx != want[0] || dy != want[1] { + t.Errorf("frame %d: offset (%d,%d), want (%d,%d)", i, dx, dy, want[0], want[1]) + } + w.Advance(step) + } +} + +func TestShakeEndsAtRest(t *testing.T) { + w := testWorld() + w.Shake(shakeDuration) + w.Advance(shakeDuration + 20*time.Millisecond) + if w.Shaking() { + t.Error("shake outlived its duration") + } + if dx, dy := w.ShakeOffset(); dx != 0 || dy != 0 { + t.Errorf("shake settled at (%d,%d), want (0,0)", dx, dy) + } +} + +func TestBiggerDropsShakeLongerButNoFurther(t *testing.T) { + small, big := testWorld(), testWorld() + small.Impact(game.Piece{Kind: game.KindO, X: 4, Y: 20}, 1) + big.Impact(game.Piece{Kind: game.KindO, X: 4, Y: 20}, 18) + if big.shakeLeft <= small.shakeLeft { + t.Errorf("an 18-cell drop shook for %v, a 1-cell drop for %v", big.shakeLeft, small.shakeLeft) + } + for i := 0; i < 40; i++ { + dx, dy := big.ShakeOffset() + if dx < -1 || dx > 1 || dy < -1 || dy > 1 { + t.Fatalf("a long drop shook by (%d,%d)", dx, dy) + } + big.Advance(5 * time.Millisecond) + } +} + +func TestTinyDropsDoNotShake(t *testing.T) { + // Tapping a piece down one cell should not rattle the screen. + w := testWorld() + w.Impact(game.Piece{Kind: game.KindO, X: 4, Y: 20}, 0) + if w.Shaking() { + t.Error("a zero-cell drop shook the board") + } +} + +func TestImpactFlashesTheBorder(t *testing.T) { + w := testWorld() + w.Impact(game.Piece{Kind: game.KindO, X: 4, Y: 20}, 10) + if w.borderFlashLeft == 0 { + t.Error("impact did not flash the border") + } +} + +// 49.5: reduced motion drops the shake and keeps the debris. +func TestReducedMotionSuppressesShakeNotDebris(t *testing.T) { + w := New(11, Intensity{Enabled: true, ReducedMotion: true}) + w.SetViewport(Rect{0, 0, 80, 30}, Rect{X: 24, Y: 5, W: 20, H: 20}) + w.Impact(game.Piece{Kind: game.KindO, X: 4, Y: 20}, 12) + if w.Shaking() { + t.Error("reduced motion still shook the screen") + } + if dx, dy := w.ShakeOffset(); dx != 0 || dy != 0 { + t.Errorf("reduced motion offset (%d,%d), want (0,0)", dx, dy) + } + if w.ParticleCount() == 0 { + t.Error("reduced motion also removed the debris; 49.5 keeps particles") + } +} + +func TestDebrisSpreadsSideways(t *testing.T) { + w := testWorld() + w.Impact(game.Piece{Kind: game.KindI, X: 3, Y: 20}, 14) + var left, right bool + for _, p := range w.particles { + if p.VX < -1 { + left = true + } + if p.VX > 1 { + right = true + } + } + if !left || !right { + t.Error("debris did not spread to both sides of the impact") + } +} + +func TestPausedShakeDoesNotAdvance(t *testing.T) { + w := testWorld() + w.Shake(shakeDuration) + w.Paused = true + before := w.shakeLeft + w.Advance(200 * time.Millisecond) + if w.shakeLeft != before { + t.Errorf("shake advanced while paused: %v -> %v", before, w.shakeLeft) + } +} +``` + +Create `internal/app/shake_test.go`: + +```go +package app + +import ( + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + + "cosmic-tetris/internal/render" +) + +// Review Focus item 3: at the smallest supported terminal, a shake must not +// push anything off the screen or over the HUD. +func TestShakeAtMinimumSizeStaysOnScreen(t *testing.T) { + m := New(Options{Seed: 3, Mode: render.ModeFull}) + m.Update(tea.WindowSizeMsg{Width: render.MinWidth, Height: render.MinHeight}) + now := time.Now() + m.Update(FrameMsg(now)) + m.Update(press(" ")) // hard drop: shake begins + + for i := 1; i <= 12; i++ { + m.Update(FrameMsg(now.Add(time.Duration(i) * 16 * time.Millisecond))) + snap := m.Snapshot() + lines := strings.Split(snap, "\n") + if len(lines) != render.MinHeight { + t.Fatalf("frame %d has %d rows, want %d", i, len(lines), render.MinHeight) + } + for j, line := range lines { + if w := len([]rune(line)); w > render.MinWidth { + t.Fatalf("frame %d row %d is %d cells wide, want at most %d", i, j, w, render.MinWidth) + } + } + } +} + +func TestShakeDoesNotDelayInput(t *testing.T) { + // Restraint rule: never make controls lag. A key pressed mid-shake takes + // effect in the same Update. + m := New(Options{Seed: 3, Mode: render.ModeFull}) + m.Update(tea.WindowSizeMsg{Width: 80, Height: 30}) + m.Update(press(" ")) + x := m.Game.Active.X + m.Update(press("left")) + if m.Game.Active.X == x { + t.Error("a move during a shake did not take effect immediately") + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ ./internal/app/ -run 'Shake|Impact|Debris' -v` +Expected: FAIL — `undefined: shakeDuration`, `undefined: Impact`. + +- [ ] **Step 3: Implement impact** + +Create `internal/fx/impact.go`: + +```go +package fx + +import ( + "time" + + "cosmic-tetris/internal/game" +) + +// Screen shake is exactly one cell, on a fixed five-frame pattern, for about a +// twelfth of a second. Anything more is nausea, not spectacle. +const ( + shakeDuration = 80 * time.Millisecond + shakeFrames = 5 + + // maxShake caps the duration a long drop can ask for. + maxShake = 140 * time.Millisecond +) + +// shakePattern is the pinned offset sequence, in cells. +var shakePattern = [shakeFrames][2]int{{0, 1}, {-1, 0}, {1, 0}, {0, -1}, {0, 0}} + +// debrisGlyphs is what a landing throws up. +var debrisGlyphs = []rune{'·', '*', '✦', '+'} + +// Shake starts a screen shake of duration d, clamped. Reduced motion ignores it. +func (w *World) Shake(d time.Duration) { + if !w.in.Enabled || w.in.ReducedMotion || d <= 0 { + return + } + if d > maxShake { + d = maxShake + } + if d > w.shakeLeft { + w.shakeLeft = d + w.shakeTotal = d + } +} + +// Shaking reports whether a shake is in progress. +func (w *World) Shaking() bool { return w.shakeLeft > 0 } + +// ShakeOffset is the board's current offset in cells: at most one cell in each +// direction, and zero when nothing is shaking. +func (w *World) ShakeOffset() (dx, dy int) { + if w.shakeLeft <= 0 || w.shakeTotal <= 0 { + return 0, 0 + } + // Walk the pattern across the shake's duration. + done := w.shakeTotal - w.shakeLeft + step := w.shakeTotal / shakeFrames + if step <= 0 { + return 0, 0 + } + i := int(done / step) + if i >= shakeFrames { + i = shakeFrames - 1 + } + if i < 0 { + i = 0 + } + return shakePattern[i][0], shakePattern[i][1] +} + +// advanceShake ages the shake. A pause holds it where it is. +func (w *World) advanceShake(dt time.Duration) { + if w.shakeLeft <= 0 || w.Paused { + return + } + w.shakeLeft -= dt + if w.shakeLeft <= 0 { + w.shakeLeft, w.shakeTotal = 0, 0 + } +} + +// Impact is a piece hitting the stack after falling cells rows: shake scaled by +// the fall, debris thrown sideways along the landing row, and a border flash. +func (w *World) Impact(p game.Piece, cells int) { + if !w.in.Enabled || cells <= 0 { + return + } + // A long fall lands harder, but the pattern is the same one cell. + scale := float64(cells) / float64(game.VisibleRows) + if scale > 1 { + scale = 1 + } + w.Shake(shakeDuration + time.Duration(scale*float64(maxShake-shakeDuration))) + w.FlashBorder(borderFlash) + + // Debris along the bottom edge of the landed piece, in board coordinates. + speed := 6 + 14*scale + for _, c := range bottomCells(p) { + w.EmitBurst(float64(c.X), float64(c.Y), 3+int(6*scale), speed, debrisGlyphs) + } +} + +// bottomCells is the lowest cell in each column the piece occupies — the cells +// that actually hit something. +func bottomCells(p game.Piece) []game.Point { + lowest := map[int]int{} + for _, c := range p.Cells() { + if y, ok := lowest[c.X]; !ok || c.Y > y { + lowest[c.X] = c.Y + } + } + out := make([]game.Point, 0, len(lowest)) + for x, y := range lowest { + out = append(out, game.Point{X: x, Y: y}) + } + return out +} +``` + +`bottomCells` iterates a map, so its order varies between runs. That is fine — every cell gets a burst either way, and the burst velocities come from the world's own RNG in whatever order the map hands them over. If a future test needs deterministic burst order across runs, sort by `X` first. + +- [ ] **Step 4: Hook impact into the world** + +In `internal/fx/fx.go`: add fields `shakeLeft, shakeTotal time.Duration`, delete the `ShakeOffset` stub, call `w.advanceShake(dt)` from `Advance`, and extend the hard-drop arm of `observe`: + +```go + case game.EventPieceHardDropped: + w.addColumnTrail(e.Piece, e.Count) + w.Impact(e.Piece, e.Count) +``` + +Note that the particle draw and the trail draw both need the shake offset so debris moves with the board. `drawTrails` already calls `ShakeOffset`. Particles are in screen space, not board space, so they do not shift — that is deliberate: debris belongs to the room, not to the board. + +Wait — debris is emitted in *board* coordinates by `Impact`, so it must be drawn in board space too. Convert at emit time instead, so the particle pool stays screen-space throughout. Replace the debris loop in `Impact` with a deferred emission that the world converts on the first draw... no: simpler and correct is for `Impact` to store board coordinates and for `EmitBurst` callers to pass screen coordinates. Change `Impact` to take the layout-independent board cell and convert in `observe`, where the layout is not available either. + +Resolve it this way, and implement exactly this: keep the particle pool in **board cell coordinates scaled to screen columns**, i.e. emit at `float64(w.board.X + c.X*render.CellCols)` and `float64(w.board.Y + c.Y - game.HiddenRows)`. `w.board` is set every frame by `SetViewport`, so the conversion is available at emit time and the pool stays screen-space. Write the debris loop as: + +```go + for _, c := range bottomCells(p) { + sx, sy, ok := w.boardToScreen(c.X, c.Y) + if !ok { + continue + } + w.EmitBurst(sx, sy, 3+int(6*scale), speed, debrisGlyphs) + } +``` + +and add to `internal/fx/fx.go`: + +```go +// boardToScreen converts a board cell to screen columns and rows, reporting +// false for cells in the hidden rows or outside a board that has no room. +func (w *World) boardToScreen(bx, by int) (x, y float64, ok bool) { + if w.board.W <= 0 || w.board.H <= 0 { + return 0, 0, false + } + row := by - game.HiddenRows + if row < 0 || row >= render.BoardInnerH { + return 0, 0, false + } + if bx < 0 || bx >= game.Width { + return 0, 0, false + } + return float64(w.board.X + bx*render.CellCols), float64(w.board.Y + row), true +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `go test ./internal/fx/ ./internal/app/ -v && go test ./... -race` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add internal/fx/impact.go internal/fx/impact_test.go internal/fx/fx.go internal/app/shake_test.go +git commit -m "feat(fx): hard-drop impact with one-cell shake and debris" +``` + +--- + +### Task 7: Line-clear supernova + +**Files:** +- Create: `internal/fx/clear.go` +- Test: `internal/fx/clear_test.go` +- Modify: `internal/fx/fx.go` (observe clears, draw them) + +**Interfaces:** +- Consumes: `World`, `game.Event`, `game.ClearedRow`, `game.Cell`, `render`. +- Produces: + - `type clearAnim struct { Rows []game.ClearedRow; Elapsed time.Duration; Count int }` + - `func (w *World) startClear(rows []game.ClearedRow, count int)` + - `func (w *World) advanceClears(dt time.Duration)` + - `func (w *World) drawClears(g *render.Grid, l render.Layout, p *render.Palette)` + - `func (w *World) ClearsActive() int` + - consts `clearPhaseA = 70ms`, `clearPhaseB = 150ms`, `clearTotal = 220ms` + +§19, three phases over ~220ms: **A** the cleared cells flash white; **B** they blow apart into particles travelling outward; **C** the gap collapses with a downward shimmer. The engine has already removed the rows — the animation replays `game.ClearedRow` snapshots at their original y, over a board that has moved on. That is why `Board.Snapshot` exists in Plan 1. + +- [ ] **Step 1: Write the failing test** + +Create `internal/fx/clear_test.go`: + +```go +package fx + +import ( + "strings" + "testing" + "time" + + "github.com/charmbracelet/x/ansi" + + "cosmic-tetris/internal/game" + "cosmic-tetris/internal/render" +) + +func clearedRow(y int) game.ClearedRow { + r := game.ClearedRow{Y: y} + for x := 0; x < game.Width; x++ { + r.Cells[x] = game.CellOf(game.KindT) + } + return r +} + +func clearEvent(count int, ys ...int) game.Event { + e := game.Event{Kind: game.EventLinesCleared, Count: count} + for _, y := range ys { + e.Cleared = append(e.Cleared, clearedRow(y)) + } + return e +} + +func TestLineClearStartsAnAnimation(t *testing.T) { + w := testWorld() + w.Observe([]game.Event{clearEvent(1, 21)}) + if w.ClearsActive() != 1 { + t.Fatalf("ClearsActive = %d, want 1", w.ClearsActive()) + } +} + +func TestPhaseAFlashesTheClearedCells(t *testing.T) { + w := testWorld() + w.Observe([]game.Event{clearEvent(1, 21)}) + g := render.NewGrid(80, 30) + l := render.Compute(80, 30) + w.SetViewport(Rect{0, 0, 80, 30}, Rect{X: l.InnerX, Y: l.InnerY, W: render.BoardInnerW, H: render.BoardInnerH}) + w.drawClears(g, l, render.NewPalette(render.ModeFull)) + out := ansi.Strip(g.Render()) + if !strings.Contains(out, "██") { + t.Error("phase A did not fill the cleared row") + } +} + +func TestPhaseBEmitsParticles(t *testing.T) { + w := testWorld() + w.Observe([]game.Event{clearEvent(1, 21)}) + before := w.ParticleCount() + w.Advance(clearPhaseA + 10*time.Millisecond) + if w.ParticleCount() <= before { + t.Error("phase B threw no particles") + } +} + +func TestParticlesAreEmittedOnceNotEveryFrame(t *testing.T) { + w := testWorld() + w.Observe([]game.Event{clearEvent(1, 21)}) + w.Advance(clearPhaseA + 10*time.Millisecond) + n := w.ParticleCount() + w.Advance(10 * time.Millisecond) + if w.ParticleCount() > n { + t.Errorf("phase B emitted again: %d -> %d", n, w.ParticleCount()) + } +} + +func TestClearEndsAfterTheFullDuration(t *testing.T) { + w := testWorld() + w.Observe([]game.Event{clearEvent(1, 21)}) + w.Advance(clearTotal - 10*time.Millisecond) + if w.ClearsActive() != 1 { + t.Error("clear ended early") + } + w.Advance(20 * time.Millisecond) + if w.ClearsActive() != 0 { + t.Errorf("clear outlived %v", clearTotal) + } +} + +// Restraint rule: never delay gameplay for animation. +func TestClearAnimationDoesNotBlockGameplay(t *testing.T) { + w := testWorld() + gm := game.New(21) + w.Observe([]game.Event{clearEvent(4, 18, 19, 20, 21)}) + x := gm.Active.X + gm.MoveLeft() + w.Observe(gm.DrainEvents()) + if gm.Active.X == x { + t.Error("the engine refused a move during a clear animation") + } + if w.ClearsActive() == 0 { + t.Error("the animation stopped because the player moved") + } +} + +func TestMoreRowsMeansMoreParticlesButStillCapped(t *testing.T) { + single, quad := testWorld(), testWorld() + single.Observe([]game.Event{clearEvent(1, 21)}) + quad.Observe([]game.Event{clearEvent(4, 18, 19, 20, 21)}) + single.Advance(clearPhaseA + 10*time.Millisecond) + quad.Advance(clearPhaseA + 10*time.Millisecond) + if quad.ParticleCount() <= single.ParticleCount() { + t.Errorf("a tetris threw %d particles, a single threw %d", + quad.ParticleCount(), single.ParticleCount()) + } + if quad.ParticleCount() > MaxParticles { + t.Errorf("a tetris exceeded the cap: %d", quad.ParticleCount()) + } +} + +func TestClearsDrawInsideTheBoardOnly(t *testing.T) { + w := testWorld() + g := render.NewGrid(80, 30) + l := render.Compute(80, 30) + w.SetViewport(Rect{0, 0, 80, 30}, Rect{X: l.InnerX, Y: l.InnerY, W: render.BoardInnerW, H: render.BoardInnerH}) + w.Observe([]game.Event{clearEvent(4, 18, 19, 20, 21)}) + for i := 0; i < 14; i++ { + w.drawClears(g, l, render.NewPalette(render.ModeFull)) + for y := 0; y < g.H; y++ { + for x := 0; x < g.W; x++ { + if g.At(x, y).Rune == ' ' { + continue + } + if x < l.InnerX || x >= l.InnerX+render.BoardInnerW || + y < l.InnerY || y >= l.InnerY+render.BoardInnerH { + t.Fatalf("clear drew outside the playfield at (%d,%d)", x, y) + } + } + } + w.Advance(16 * time.Millisecond) + } +} + +func TestHiddenRowClearsAreIgnored(t *testing.T) { + w := testWorld() + g := render.NewGrid(80, 30) + l := render.Compute(80, 30) + w.SetViewport(Rect{0, 0, 80, 30}, Rect{X: l.InnerX, Y: l.InnerY, W: render.BoardInnerW, H: render.BoardInnerH}) + w.Observe([]game.Event{clearEvent(1, 0)}) // a hidden row + w.drawClears(g, l, render.NewPalette(render.ModeFull)) + if strings.TrimSpace(ansi.Strip(g.Render())) != "" { + t.Error("a clear in the hidden rows drew on screen") + } +} + +func TestClearIsASCIISafe(t *testing.T) { + w := testWorld() + g := render.NewGrid(80, 30) + l := render.Compute(80, 30) + w.SetViewport(Rect{0, 0, 80, 30}, Rect{X: l.InnerX, Y: l.InnerY, W: render.BoardInnerW, H: render.BoardInnerH}) + w.Observe([]game.Event{clearEvent(2, 20, 21)}) + for i := 0; i < 14; i++ { + w.drawClears(g, l, render.NewPalette(render.ModeASCII)) + for _, r := range ansi.Strip(g.Render()) { + if r > 127 && r != '\n' { + t.Fatalf("ASCII mode drew %q during a clear", r) + } + } + w.Advance(16 * time.Millisecond) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ -run Clear -v` +Expected: FAIL — `undefined: clearPhaseA`. + +- [ ] **Step 3: Implement the supernova** + +Create `internal/fx/clear.go`: + +```go +package fx + +import ( + "time" + + "cosmic-tetris/internal/game" + "cosmic-tetris/internal/render" +) + +// A clear runs in three phases: flash, shatter, collapse. Two hundred and +// twenty milliseconds total, which is long enough to see and short enough that +// it is over before the next piece matters. +const ( + clearPhaseA = 70 * time.Millisecond + clearPhaseB = 150 * time.Millisecond + clearTotal = 220 * time.Millisecond +) + +// clearGlyphs is what the shattered cells become. +var clearGlyphs = []rune{'✦', '*', '·', '+'} + +// clearAnim is one line clear replaying over a board that has already +// collapsed. The rows are snapshots taken before the engine removed them. +type clearAnim struct { + Rows []game.ClearedRow + Elapsed time.Duration + Count int + + // shattered records that phase B has already thrown its particles, so it + // throws them once rather than every frame. + shattered bool +} + +// ClearsActive is how many clear animations are running. +func (w *World) ClearsActive() int { return len(w.clears) } + +// startClear begins the animation for a set of cleared rows. +func (w *World) startClear(rows []game.ClearedRow, count int) { + if !w.in.Enabled || len(rows) == 0 { + return + } + w.clears = append(w.clears, clearAnim{ + Rows: append([]game.ClearedRow(nil), rows...), + Count: count, + }) + w.FlashBorder(clearPhaseA) +} + +// advanceClears ages the animations and fires phase B once each. +func (w *World) advanceClears(dt time.Duration) { + if len(w.clears) == 0 || w.Paused { + return + } + keep := w.clears[:0] + for _, c := range w.clears { + c.Elapsed += dt + if c.Elapsed >= clearPhaseA && !c.shattered { + w.shatter(c) + c.shattered = true + } + if c.Elapsed >= clearTotal { + continue + } + keep = append(keep, c) + } + w.clears = keep +} + +// shatter throws particles from every cell of every cleared row. Bigger clears +// throw more per cell, and the pool cap keeps a tetris honest. +func (w *World) shatter(c clearAnim) { + per := 2 + c.Count + speed := 10 + 4*float64(c.Count) + for _, row := range c.Rows { + for x := 0; x < game.Width; x++ { + if !row.Cells[x].Filled() { + continue + } + sx, sy, ok := w.boardToScreen(x, row.Y) + if !ok { + continue + } + w.EmitBurst(sx, sy, per, speed, clearGlyphs) + } + } +} + +// drawClears paints the current phase of each animation. +func (w *World) drawClears(g *render.Grid, l render.Layout, p *render.Palette) { + dx, dy := w.ShakeOffset() + for _, c := range w.clears { + for _, row := range c.Rows { + for x := 0; x < game.Width; x++ { + if !row.Cells[x].Filled() { + continue + } + sx, sy, ok := render.BoardCellXY(l, x, row.Y, dx, dy) + if !ok { + continue + } + glyph, style := clearCell(c, x, row.Y, p) + if glyph == 0 { + continue + } + g.Set(sx, sy, glyph, style) + g.Set(sx+1, sy, glyph, style) + } + } + } +} + +// clearCell picks the glyph and style for one cell at the animation's current +// phase. A zero rune means draw nothing. +func clearCell(c clearAnim, x, y int, p *render.Palette) (rune, *render.Style) { + block := []rune(p.BlockFor(false)) + solid := block[0] + switch { + case c.Elapsed < clearPhaseA: + // Phase A: pure white flash. + return solid, p.Flash + case c.Elapsed < clearPhaseB: + // Phase B: breaking up. Alternate cells vanish so the row reads as + // disintegrating rather than merely recoloured. + if (x+y)%2 == 0 { + return 0, nil + } + glyph := '▒' + if p.Mode == render.ModeASCII { + glyph = asciiParticle(glyph) + } + return glyph, p.Star[2] + default: + // Phase C: the collapse shimmer, fading out. + glyph := '░' + if p.Mode == render.ModeASCII { + glyph = asciiParticle(glyph) + } + if (x+y)%3 != 0 { + return 0, nil + } + return glyph, p.Star[1] + } +} +``` + +`p.Flash` is a new palette entry: a hot-white style for flashes. Add it to `internal/render/palette.go` alongside `BorderHot`, in every mode: + +```go + Flash: NewStyle(lipgloss.Color("#FFFFFF"), true, false), +``` + +- [ ] **Step 4: Hook clears into the world** + +In `internal/fx/fx.go`: add field `clears []clearAnim`, call `w.advanceClears(dt)` from `Advance`, draw them in `DrawBoardFX` after the trails and before the particles, and add the arm to `observe`: + +```go + case game.EventLinesCleared: + w.startClear(e.Cleared, e.Count) +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `go test ./internal/fx/ ./internal/render/ -v && go test ./... -race` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add internal/fx/clear.go internal/fx/clear_test.go internal/fx/fx.go internal/render/palette.go +git commit -m "feat(fx): three-phase supernova line clear over row snapshots" +``` + +--- + +### Task 8: Hyperdrive + +**Files:** +- Create: `internal/fx/hyperdrive.go` +- Test: `internal/fx/hyperdrive_test.go` +- Modify: `internal/fx/fx.go` (real `Hyperdrive`) + +**Interfaces:** +- Consumes: `World`. +- Produces: + - `func (w *World) StartHyperdrive()` + - `func (w *World) Hyperdrive() float64` (replaces the stub) + - `func (w *World) HyperdriveActive() bool` + - `var hyperdriveKeyframes = []struct{ At time.Duration; Speed float64 }{...}` at `0/50/100/500/800/1100ms` + +§16: on a four-line clear the starfield accelerates — a fast ramp to a wild multiplier, a hold, then a decay back to normal, over 1100ms on the pinned keyframes. `--reduced-motion` suppresses it (§49.5), which means `Hyperdrive()` returns `1` throughout and the starfield never accelerates. + +- [ ] **Step 1: Write the failing test** + +Create `internal/fx/hyperdrive_test.go`: + +```go +package fx + +import ( + "testing" + "time" +) + +func TestHyperdriveIsIdleByDefault(t *testing.T) { + w := testWorld() + if w.HyperdriveActive() { + t.Error("hyperdrive active with nothing happening") + } + if got := w.Hyperdrive(); got != 1 { + t.Errorf("idle multiplier = %.2f, want 1", got) + } +} + +func TestHyperdriveRampsHoldsAndDecays(t *testing.T) { + w := testWorld() + w.StartHyperdrive() + + at := func(d time.Duration) float64 { + w2 := testWorld() + w2.StartHyperdrive() + w2.Advance(d) + return w2.Hyperdrive() + } + ramp, peak, hold, end := at(50*time.Millisecond), at(100*time.Millisecond), at(500*time.Millisecond), at(1100*time.Millisecond) + + if !(ramp > 1) { + t.Errorf("50ms multiplier %.2f did not rise above 1", ramp) + } + if !(peak > ramp) { + t.Errorf("100ms multiplier %.2f is not the peak (50ms was %.2f)", peak, ramp) + } + if !(hold < peak && hold > 1) { + t.Errorf("500ms multiplier %.2f should be past the peak and still fast", hold) + } + if end != 1 { + t.Errorf("1100ms multiplier = %.2f, want back to 1", end) + } +} + +func TestHyperdriveEndsAndStaysEnded(t *testing.T) { + w := testWorld() + w.StartHyperdrive() + w.Advance(2 * time.Second) + if w.HyperdriveActive() { + t.Error("hyperdrive outlived its timeline") + } + if got := w.Hyperdrive(); got != 1 { + t.Errorf("multiplier after the end = %.2f, want 1", got) + } +} + +func TestHyperdriveActuallySpeedsTheStars(t *testing.T) { + slow := testWorld() + fast := testWorld() + fast.StartHyperdrive() + fast.Advance(100 * time.Millisecond) + if fast.starSpeed(2) <= slow.starSpeed(2) { + t.Errorf("hyperdrive star speed %.2f is not faster than %.2f", + fast.starSpeed(2), slow.starSpeed(2)) + } +} + +func TestHyperdriveRestartsFromTheBeginning(t *testing.T) { + w := testWorld() + w.StartHyperdrive() + w.Advance(900 * time.Millisecond) + late := w.Hyperdrive() + w.StartHyperdrive() + if got := w.Hyperdrive(); got >= late && late > 1 { + // A restart resets to the start of the ramp, which is slower than the + // tail of a decay only if the decay had not finished; either way the + // clock must have been reset. + } + if w.hyperElapsed != 0 { + t.Errorf("restart left the clock at %v", w.hyperElapsed) + } +} + +// 49.5. +func TestReducedMotionDisablesHyperdrive(t *testing.T) { + w := New(5, Intensity{Enabled: true, ReducedMotion: true}) + w.SetViewport(Rect{0, 0, 80, 30}, Rect{X: 24, Y: 5, W: 20, H: 20}) + w.StartHyperdrive() + for i := 0; i < 20; i++ { + if got := w.Hyperdrive(); got != 1 { + t.Fatalf("reduced motion accelerated to %.2f", got) + } + w.Advance(50 * time.Millisecond) + } + if w.HyperdriveActive() { + t.Error("reduced motion started hyperdrive") + } +} + +func TestPauseHoldsHyperdrive(t *testing.T) { + w := testWorld() + w.StartHyperdrive() + w.Advance(100 * time.Millisecond) + peak := w.Hyperdrive() + w.Paused = true + w.Advance(time.Second) + if got := w.Hyperdrive(); got != peak { + t.Errorf("hyperdrive advanced while paused: %.2f -> %.2f", peak, got) + } +} + +func TestHyperdriveMultiplierIsBounded(t *testing.T) { + w := testWorld() + w.StartHyperdrive() + for i := 0; i < 100; i++ { + got := w.Hyperdrive() + if got < 1 || got > 20 { + t.Fatalf("multiplier %.2f is out of range at %v", got, w.hyperElapsed) + } + w.Advance(16 * time.Millisecond) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ -run Hyperdrive -v` +Expected: FAIL — `undefined: StartHyperdrive`. + +- [ ] **Step 3: Implement hyperdrive** + +Create `internal/fx/hyperdrive.go`: + +```go +package fx + +import "time" + +// The hyperdrive timeline is pinned: a hard kick, a peak, a long fast hold, and +// a decay back to ordinary space over one and a tenth seconds. +var hyperdriveKeyframes = []struct { + At time.Duration + Speed float64 +}{ + {0, 1.0}, + {50 * time.Millisecond, 4.0}, + {100 * time.Millisecond, 12.0}, + {500 * time.Millisecond, 8.0}, + {800 * time.Millisecond, 3.0}, + {1100 * time.Millisecond, 1.0}, +} + +// hyperdriveTotal is the end of the timeline. +var hyperdriveTotal = hyperdriveKeyframes[len(hyperdriveKeyframes)-1].At + +// StartHyperdrive kicks the starfield into overdrive from the top of the +// timeline. Reduced motion declines. +func (w *World) StartHyperdrive() { + if !w.in.Enabled || w.in.ReducedMotion { + return + } + w.hyperElapsed = 0 + w.hyperOn = true +} + +// HyperdriveActive reports whether the timeline is running. +func (w *World) HyperdriveActive() bool { return w.hyperOn } + +// Hyperdrive is the current starfield speed multiplier, interpolated linearly +// between keyframes. It is 1 when nothing is happening. +func (w *World) Hyperdrive() float64 { + if !w.hyperOn { + return 1 + } + t := w.hyperElapsed + for i := 1; i < len(hyperdriveKeyframes); i++ { + prev, next := hyperdriveKeyframes[i-1], hyperdriveKeyframes[i] + if t > next.At { + continue + } + span := next.At - prev.At + if span <= 0 { + return next.Speed + } + frac := float64(t-prev.At) / float64(span) + return prev.Speed + (next.Speed-prev.Speed)*frac + } + return 1 +} + +// advanceHyperdrive runs the timeline. A pause holds it. +func (w *World) advanceHyperdrive(dt time.Duration) { + if !w.hyperOn || w.Paused { + return + } + w.hyperElapsed += dt + if w.hyperElapsed >= hyperdriveTotal { + w.hyperOn = false + w.hyperElapsed = 0 + } +} +``` + +- [ ] **Step 4: Hook hyperdrive into the world** + +In `internal/fx/fx.go`: add fields `hyperElapsed time.Duration` and `hyperOn bool`, delete the `Hyperdrive` stub from `stars.go`'s task, and call `w.advanceHyperdrive(dt)` from `Advance` **before** `advanceStars`, so the frame's star motion uses the frame's multiplier. + +Task 10 calls `StartHyperdrive` on a four-line clear; the clear arm stays as it is for now. + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `go test ./internal/fx/ -v && go test ./... -race` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add internal/fx/hyperdrive.go internal/fx/hyperdrive_test.go internal/fx/fx.go +git commit -m "feat(fx): hyperdrive starfield acceleration on the pinned timeline" +``` + +--- + +### Task 9: Shockwaves + +**Files:** +- Create: `internal/fx/shockwave.go` +- Test: `internal/fx/shockwave_test.go` +- Modify: `internal/fx/fx.go` (own waves, draw them) + +**Interfaces:** +- Consumes: `World`, `render`. +- Produces: + - `type shockwave struct { X, Y float64; Elapsed time.Duration; MaxRadius float64 }` + - `func (w *World) Shockwave(x, y float64, maxRadius float64)` + - `func (w *World) advanceShockwaves(dt time.Duration)` + - `func (w *World) drawShockwaves(g *render.Grid, p *render.Palette)` + - `func (w *World) ShockwaveCount() int` + - const `shockwaveLife = 300 * time.Millisecond` + +§24: an expanding ring of `· ○ ◌ ◯` that grows and fades over ~300ms, used for big impacts and tetrises. Rings are drawn in screen space and clipped by the grid. Terminal cells are about twice as tall as they are wide, so the ring is drawn with a 2:1 horizontal stretch or it looks like a vertical ellipse. `--reduced-motion` suppresses shockwaves (§49.5). + +- [ ] **Step 1: Write the failing test** + +Create `internal/fx/shockwave_test.go`: + +```go +package fx + +import ( + "strings" + "testing" + "time" + + "github.com/charmbracelet/x/ansi" + + "cosmic-tetris/internal/render" +) + +func TestShockwaveStartsAndExpires(t *testing.T) { + w := testWorld() + w.Shockwave(40, 15, 10) + if w.ShockwaveCount() != 1 { + t.Fatalf("ShockwaveCount = %d, want 1", w.ShockwaveCount()) + } + w.Advance(shockwaveLife + 20*time.Millisecond) + if w.ShockwaveCount() != 0 { + t.Error("shockwave outlived its life") + } +} + +func TestShockwaveExpands(t *testing.T) { + radiusDrawn := func(after time.Duration) int { + w := testWorld() + w.Shockwave(40, 15, 12) + w.Advance(after) + g := render.NewGrid(80, 30) + w.drawShockwaves(g, render.NewPalette(render.ModeFull)) + out := ansi.Strip(g.Render()) + widest := 0 + for _, line := range strings.Split(out, "\n") { + l, r := strings.IndexFunc(line, isMark), strings.LastIndexFunc(line, isMark) + if l >= 0 && r-l > widest { + widest = r - l + } + } + return widest + } + early, late := radiusDrawn(30*time.Millisecond), radiusDrawn(200*time.Millisecond) + if late <= early { + t.Errorf("ring did not expand: %d cells at 30ms, %d at 200ms", early, late) + } +} + +func isMark(r rune) bool { return r != ' ' && r != '\n' } + +func TestShockwaveStaysOnScreen(t *testing.T) { + w := testWorld() + // Centred at the corner, with a radius larger than the terminal. + w.Shockwave(0, 0, 60) + for i := 0; i < 20; i++ { + g := render.NewGrid(80, 30) + w.drawShockwaves(g, render.NewPalette(render.ModeFull)) + lines := strings.Split(ansi.Strip(g.Render()), "\n") + if len(lines) != 30 { + t.Fatalf("frame %d has %d rows, want 30", i, len(lines)) + } + for _, line := range lines { + if n := len([]rune(line)); n > 80 { + t.Fatalf("frame %d produced a %d-cell row", i, n) + } + } + w.Advance(16 * time.Millisecond) + } +} + +func TestShockwaveIsWiderThanTall(t *testing.T) { + // Terminal cells are roughly 1:2, so a circle needs a 2:1 stretch. + w := testWorld() + w.Shockwave(40, 15, 12) + w.Advance(150 * time.Millisecond) + g := render.NewGrid(80, 30) + w.drawShockwaves(g, render.NewPalette(render.ModeFull)) + lines := strings.Split(ansi.Strip(g.Render()), "\n") + var width, height int + for _, line := range lines { + l, r := strings.IndexFunc(line, isMark), strings.LastIndexFunc(line, isMark) + if l < 0 { + continue + } + height++ + if r-l+1 > width { + width = r - l + 1 + } + } + if height == 0 { + t.Fatal("the ring drew nothing") + } + if width <= height { + t.Errorf("ring is %d wide and %d tall; it should be about twice as wide", width, height) + } +} + +func TestShockwaveGlyphsAreFromTheSpec(t *testing.T) { + w := testWorld() + w.Shockwave(40, 15, 10) + seen := map[rune]bool{} + for i := 0; i < 20; i++ { + g := render.NewGrid(80, 30) + w.drawShockwaves(g, render.NewPalette(render.ModeFull)) + for _, r := range ansi.Strip(g.Render()) { + if r != ' ' && r != '\n' { + seen[r] = true + } + } + w.Advance(16 * time.Millisecond) + } + for r := range seen { + if !strings.ContainsRune("·○◌◯", r) { + t.Errorf("unexpected shockwave glyph %q", r) + } + } + if len(seen) < 2 { + t.Errorf("the ring used only %d glyphs; it should change as it expands", len(seen)) + } +} + +func TestShockwaveIsASCIISafe(t *testing.T) { + w := testWorld() + w.Shockwave(40, 15, 10) + for i := 0; i < 20; i++ { + g := render.NewGrid(80, 30) + w.drawShockwaves(g, render.NewPalette(render.ModeASCII)) + for _, r := range ansi.Strip(g.Render()) { + if r > 127 && r != '\n' { + t.Fatalf("ASCII mode drew %q", r) + } + } + w.Advance(16 * time.Millisecond) + } +} + +// 49.5. +func TestReducedMotionSuppressesShockwaves(t *testing.T) { + w := New(6, Intensity{Enabled: true, ReducedMotion: true}) + w.SetViewport(Rect{0, 0, 80, 30}, Rect{X: 24, Y: 5, W: 20, H: 20}) + w.Shockwave(40, 15, 10) + if w.ShockwaveCount() != 0 { + t.Error("reduced motion produced a shockwave") + } +} + +func TestPauseHoldsShockwaves(t *testing.T) { + w := testWorld() + w.Shockwave(40, 15, 10) + before := w.shockwaves[0].Elapsed + w.Paused = true + w.Advance(200 * time.Millisecond) + if w.shockwaves[0].Elapsed != before { + t.Error("a shockwave expanded while paused") + } +} + +// Review Focus item 2. +func TestShockwavesAfterAResizeStayInBounds(t *testing.T) { + w := testWorld() + w.Shockwave(70, 25, 20) + w.SetViewport(Rect{0, 0, 40, 24}, Rect{X: 4, Y: 2, W: 20, H: 20}) + for i := 0; i < 20; i++ { + out := drawWorld(w, 40, 24) + for _, line := range strings.Split(out, "\n") { + if n := len([]rune(line)); n > 40 { + t.Fatalf("row of %d cells after a resize", n) + } + } + w.Advance(16 * time.Millisecond) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ -run Shockwave -v` +Expected: FAIL — `undefined: shockwaveLife`. + +- [ ] **Step 3: Implement shockwaves** + +Create `internal/fx/shockwave.go`: + +```go +package fx + +import ( + "math" + "time" + + "cosmic-tetris/internal/render" +) + +// shockwaveLife is how long a ring takes to expand and fade. +const shockwaveLife = 300 * time.Millisecond + +// cellAspect is roughly how much taller a terminal cell is than it is wide. +// Rings are stretched by it so they read as circles. +const cellAspect = 2.0 + +// shockwaveGlyphs run from a tight new ring to a wide faint one. +var shockwaveGlyphs = []rune{'·', '○', '◌', '◯'} + +// shockwave is an expanding ring in screen coordinates. +type shockwave struct { + X, Y float64 + Elapsed time.Duration + MaxRadius float64 +} + +// ShockwaveCount is how many rings are expanding. +func (w *World) ShockwaveCount() int { return len(w.shockwaves) } + +// Shockwave starts a ring at (x, y) that grows to maxRadius rows. +func (w *World) Shockwave(x, y, maxRadius float64) { + if !w.in.Enabled || w.in.ReducedMotion || maxRadius <= 0 { + return + } + w.shockwaves = append(w.shockwaves, shockwave{X: x, Y: y, MaxRadius: maxRadius}) +} + +// advanceShockwaves expands the rings and retires the finished ones. +func (w *World) advanceShockwaves(dt time.Duration) { + if len(w.shockwaves) == 0 || w.Paused { + return + } + keep := w.shockwaves[:0] + for _, s := range w.shockwaves { + s.Elapsed += dt + if s.Elapsed >= shockwaveLife { + continue + } + keep = append(keep, s) + } + w.shockwaves = keep +} + +// drawShockwaves paints each ring at its current radius. The ring is walked by +// angle rather than by scanning the grid, so cost is proportional to the +// circumference, not the screen. +func (w *World) drawShockwaves(g *render.Grid, p *render.Palette) { + ascii := p.Mode == render.ModeASCII + for _, s := range w.shockwaves { + frac := float64(s.Elapsed) / float64(shockwaveLife) + if frac < 0 { + frac = 0 + } + if frac > 1 { + frac = 1 + } + radius := s.MaxRadius * frac + if radius < 0.5 { + radius = 0.5 + } + + idx := int(frac * float64(len(shockwaveGlyphs))) + if idx >= len(shockwaveGlyphs) { + idx = len(shockwaveGlyphs) - 1 + } + glyph := shockwaveGlyphs[idx] + if ascii { + glyph = asciiParticle(glyph) + } + + // Fade as it expands: bright, then dim. + st := p.Star[2] + switch { + case frac > 0.66: + st = p.Star[0] + case frac > 0.33: + st = p.Star[1] + } + + steps := int(math.Max(8, radius*cellAspect*4)) + for i := 0; i < steps; i++ { + angle := 2 * math.Pi * float64(i) / float64(steps) + x := int(math.Round(s.X + math.Cos(angle)*radius*cellAspect)) + y := int(math.Round(s.Y + math.Sin(angle)*radius)) + g.Set(x, y, glyph, st) // Grid.Set clips + } + } +} +``` + +- [ ] **Step 4: Hook shockwaves into the world** + +In `internal/fx/fx.go`: add field `shockwaves []shockwave`, call `w.advanceShockwaves(dt)` from `Advance`, and draw them in `DrawGlobalFX` — a ring is a room-scale event and may cross the HUD, but never the overlay, which `render.Frame` draws last: + +```go +func (w *World) DrawGlobalFX(g *render.Grid, l render.Layout, p *render.Palette) { + if !w.in.Enabled { + return + } + w.drawShockwaves(g, p) +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `go test ./internal/fx/ -v && go test ./... -race` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add internal/fx/shockwave.go internal/fx/shockwave_test.go internal/fx/fx.go +git commit -m "feat(fx): expanding shockwave rings with terminal aspect correction" +``` + +--- + +### Task 10: The four-line sequence and the banner system + +**Files:** +- Create: `internal/fx/banner.go` +- Test: `internal/fx/banner_test.go` +- Modify: `internal/fx/fx.go` (four-line arm, draw banners) + +**Interfaces:** +- Consumes: `World`, `game.Event`, `render.Layout`, `render.Grid`. +- Produces: + - `type banner struct { Text, Sub string; Elapsed, Life time.Duration; Hot bool }` + - `func (w *World) ShowBanner(text, sub string, life time.Duration, hot bool)` + - `func (w *World) advanceBanners(dt time.Duration)` + - `func (w *World) drawBanner(g *render.Grid, l render.Layout, p *render.Palette)` + - `func (w *World) BannerText() (text, sub string)` + - `func (w *World) fourLineSequence(rows []game.ClearedRow)` + - const `bannerLife = 700 * time.Millisecond` + - `var fourLineBanners = []string{...}` — the four §20 lines. + +§20: a four-line clear runs everything at once — the supernova, hyperdrive, a full-width shockwave, a heavy shake, and a banner from the pinned list, for ~700ms. + +**Banner placement is the §44 "never obscure the active piece" rule made structural:** banners draw as a single centred line in the row directly *above* the board's top border, falling back to the title row and then to the mission line. They are never drawn inside the playfield rect. A test pins that invariant, and every later banner (level-up, combo) inherits it because they all go through `drawBanner`. + +- [ ] **Step 1: Write the failing test** + +Create `internal/fx/banner_test.go`: + +```go +package fx + +import ( + "strings" + "testing" + "time" + + "github.com/charmbracelet/x/ansi" + + "cosmic-tetris/internal/game" + "cosmic-tetris/internal/render" +) + +func TestBannerAppearsAndExpires(t *testing.T) { + w := testWorld() + w.ShowBanner("✦ EVENT HORIZON ✦", "", bannerLife, true) + text, _ := w.BannerText() + if text != "✦ EVENT HORIZON ✦" { + t.Fatalf("BannerText = %q", text) + } + w.Advance(bannerLife + 20*time.Millisecond) + if text, _ := w.BannerText(); text != "" { + t.Errorf("banner outlived its life: %q", text) + } +} + +func TestFourLineClearRunsTheWholeSequence(t *testing.T) { + w := testWorld() + w.Observe([]game.Event{clearEvent(4, 18, 19, 20, 21)}) + + if w.ClearsActive() == 0 { + t.Error("no supernova") + } + if !w.HyperdriveActive() { + t.Error("no hyperdrive") + } + if w.ShockwaveCount() == 0 { + t.Error("no shockwave") + } + if !w.Shaking() { + t.Error("no shake") + } + text, _ := w.BannerText() + if text == "" { + t.Error("no banner") + } +} + +func TestFourLineBannerComesFromTheSpecList(t *testing.T) { + seen := map[string]bool{} + for seed := int64(0); seed < 60; seed++ { + w := New(seed, Intensity{Enabled: true}) + w.SetViewport(Rect{0, 0, 80, 30}, Rect{X: 24, Y: 5, W: 20, H: 20}) + w.Observe([]game.Event{clearEvent(4, 18, 19, 20, 21)}) + text, _ := w.BannerText() + var ok bool + for _, want := range fourLineBanners { + if text == want { + ok = true + } + } + if !ok { + t.Fatalf("seed %d produced an off-spec banner %q", seed, text) + } + seen[text] = true + } + if len(seen) < 2 { + t.Errorf("sixty seeds produced only %d distinct banners", len(seen)) + } +} + +func TestSingleClearDoesNotRunTheFourLineSequence(t *testing.T) { + w := testWorld() + w.Observe([]game.Event{clearEvent(1, 21)}) + if w.HyperdriveActive() { + t.Error("a single line engaged hyperdrive") + } + if text, _ := w.BannerText(); text != "" { + t.Errorf("a single line raised the banner %q", text) + } +} + +// Restraint rule: never obscure the active piece. This is the structural test. +func TestBannersNeverDrawInsideThePlayfield(t *testing.T) { + for _, size := range [][2]int{{80, 30}, {48, 26}, {40, 24}} { + cols, rows := size[0], size[1] + w := New(3, Intensity{Enabled: true}) + l := render.Compute(cols, rows) + w.SetViewport(Rect{0, 0, cols, rows}, + Rect{X: l.InnerX, Y: l.InnerY, W: render.BoardInnerW, H: render.BoardInnerH}) + w.ShowBanner("QUADRUPLE COSMIC INCIDENT", "SPACE-TIME HAS FILED A COMPLAINT", bannerLife, true) + + for i := 0; i < 45; i++ { + g := render.NewGrid(cols, rows) + w.drawBanner(g, l, render.NewPalette(render.ModeFull)) + for y := l.InnerY; y < l.InnerY+render.BoardInnerH; y++ { + for x := l.InnerX; x < l.InnerX+render.BoardInnerW; x++ { + if got := g.At(x, y).Rune; got != ' ' { + t.Fatalf("%dx%d: banner drew %q inside the playfield at (%d,%d)", + cols, rows, got, x, y) + } + } + } + w.Advance(16 * time.Millisecond) + } + } +} + +func TestBannerIsCentredAndFitsTheTerminal(t *testing.T) { + for _, size := range [][2]int{{80, 30}, {48, 26}, {40, 24}} { + cols, rows := size[0], size[1] + w := New(3, Intensity{Enabled: true}) + l := render.Compute(cols, rows) + w.SetViewport(Rect{0, 0, cols, rows}, + Rect{X: l.InnerX, Y: l.InnerY, W: render.BoardInnerW, H: render.BoardInnerH}) + w.ShowBanner("FOUR ROWS HAVE LEFT THE CHAT", "", bannerLife, true) + g := render.NewGrid(cols, rows) + w.drawBanner(g, l, render.NewPalette(render.ModeFull)) + for _, line := range strings.Split(ansi.Strip(g.Render()), "\n") { + if n := len([]rune(line)); n > cols { + t.Fatalf("%dx%d: banner produced a %d-cell row", cols, rows, n) + } + } + } +} + +func TestOverlongBannerIsTruncatedNotWrapped(t *testing.T) { + w := New(3, Intensity{Enabled: true}) + l := render.Compute(40, 24) + w.SetViewport(Rect{0, 0, 40, 24}, Rect{X: l.InnerX, Y: l.InnerY, W: render.BoardInnerW, H: render.BoardInnerH}) + w.ShowBanner(strings.Repeat("COSMIC ", 20), "", bannerLife, true) + g := render.NewGrid(40, 24) + w.drawBanner(g, l, render.NewPalette(render.ModeFull)) + out := ansi.Strip(g.Render()) + if n := strings.Count(out, "\n") + 1; n != 24 { + t.Errorf("an overlong banner changed the row count to %d", n) + } +} + +func TestANewBannerReplacesTheOld(t *testing.T) { + w := testWorld() + w.ShowBanner("FIRST", "", bannerLife, true) + w.ShowBanner("SECOND", "", bannerLife, true) + if text, _ := w.BannerText(); text != "SECOND" { + t.Errorf("BannerText = %q, want SECOND", text) + } +} + +func TestBannerIsASCIISafeInASCIIMode(t *testing.T) { + w := testWorld() + l := render.Compute(80, 30) + w.ShowBanner("✦ EVENT HORIZON ✦", "", bannerLife, true) + g := render.NewGrid(80, 30) + w.drawBanner(g, l, render.NewPalette(render.ModeASCII)) + for _, r := range ansi.Strip(g.Render()) { + if r > 127 && r != '\n' { + t.Fatalf("ASCII mode drew %q in a banner", r) + } + } +} + +func TestPauseHoldsBanners(t *testing.T) { + w := testWorld() + w.ShowBanner("HELD", "", bannerLife, true) + w.Paused = true + w.Advance(2 * time.Second) + if text, _ := w.BannerText(); text != "HELD" { + t.Error("a banner expired while paused") + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/fx/ -run 'Banner|FourLine' -v` +Expected: FAIL — `undefined: bannerLife`. + +- [ ] **Step 3: Implement banners and the four-line sequence** + +Create `internal/fx/banner.go`: + +```go +package fx + +import ( + "time" + + "github.com/charmbracelet/x/ansi" + + "cosmic-tetris/internal/game" + "cosmic-tetris/internal/render" +) + +// bannerLife is how long a major-event banner holds the screen. +const bannerLife = 700 * time.Millisecond + +// fourLineBanners are the lines a tetris may announce, verbatim from 20. +var fourLineBanners = []string{ + "✦ EVENT HORIZON ✦", + "QUADRUPLE COSMIC INCIDENT", + "FOUR ROWS HAVE LEFT THE CHAT", + "SPACE-TIME HAS FILED A COMPLAINT", +} + +// banner is one announcement above the board. +type banner struct { + Text string + Sub string + Elapsed time.Duration + Life time.Duration + Hot bool +} + +// ShowBanner raises an announcement, replacing any current one. +func (w *World) ShowBanner(text, sub string, life time.Duration, hot bool) { + if !w.in.Enabled || text == "" || life <= 0 { + return + } + w.banner = &banner{Text: text, Sub: sub, Life: life, Hot: hot} +} + +// BannerText is the current announcement, empty when there is none. +func (w *World) BannerText() (text, sub string) { + if w.banner == nil { + return "", "" + } + return w.banner.Text, w.banner.Sub +} + +// advanceBanners ages the announcement. A pause holds it. +func (w *World) advanceBanners(dt time.Duration) { + if w.banner == nil || w.Paused { + return + } + w.banner.Elapsed += dt + if w.banner.Elapsed >= w.banner.Life { + w.banner = nil + } +} + +// bannerRows are the rows a banner may use, in order of preference: directly +// above the board's top border, then the title row, then the mission line. +// Never inside the playfield — 44 forbids obscuring the active piece, and the +// cheapest way to obey a rule is to make breaking it impossible. +func bannerRows(l render.Layout) []int { + rows := []int{} + if above := l.BoardY - 1; above >= 0 && above != l.TitleY { + rows = append(rows, above) + } + if l.TitleY >= 0 { + rows = append(rows, l.TitleY) + } + if l.MissionY >= 0 { + rows = append(rows, l.MissionY) + } + return rows +} + +// drawBanner paints the announcement centred on the first available row, and +// the subtitle on the next one if there is a second row to spare. +func (w *World) drawBanner(g *render.Grid, l render.Layout, p *render.Palette) { + if w.banner == nil { + return + } + rows := bannerRows(l) + if len(rows) == 0 { + return + } + + // Blink twice over the banner's life so it reads as an alarm, not a label. + frac := float64(w.banner.Elapsed) / float64(w.banner.Life) + st := p.Flash + if !w.banner.Hot || int(frac*6)%2 == 1 { + st = p.Accent + } + + w.drawCentred(g, rows[0], w.banner.Text, st, p) + if w.banner.Sub != "" && len(rows) > 1 { + w.drawCentred(g, rows[1], w.banner.Sub, p.Dim, p) + } +} + +// drawCentred writes s centred on row y, truncating rather than wrapping so a +// long line can never change the frame's shape. +func (w *World) drawCentred(g *render.Grid, y int, s string, st *render.Style, p *render.Palette) { + if y < 0 || s == "" { + return + } + if p.Mode == render.ModeASCII { + s = toASCII(s) + } + if width := ansi.StringWidth(s); width > g.W { + s = ansi.Truncate(s, g.W, "") + } + x := (g.W - ansi.StringWidth(s)) / 2 + if x < 0 { + x = 0 + } + g.SetString(x, y, s, st) +} + +// toASCII strips the decorative runes out of copy so ASCII mode stays 7-bit. +func toASCII(s string) string { + out := make([]rune, 0, len(s)) + for _, r := range s { + if r < 128 { + out = append(out, r) + continue + } + switch r { + case '✦', '✧', '☄', '★': + out = append(out, '*') + case '·', '˚': + out = append(out, '.') + default: + out = append(out, '?') + } + } + return string(out) +} + +// fourLineSequence is everything a tetris sets off at once: the supernova is +// already running, so this adds the hyperdrive, the room-scale shockwave, a +// heavy shake, a fast border cycle, and one of the banners. +func (w *World) fourLineSequence(rows []game.ClearedRow) { + w.StartHyperdrive() + w.PulseBorder(bannerLife) + w.Shake(maxShake) + + // Centre the ring on the middle of the cleared block. + cx := float64(w.board.X + render.BoardInnerW/2) + cy := float64(w.board.Y + render.BoardInnerH/2) + if len(rows) > 0 { + if _, y, ok := w.boardToScreen(game.Width/2, rows[len(rows)/2].Y); ok { + cy = y + } + } + w.Shockwave(cx, cy, float64(render.BoardInnerH)) + + w.ShowBanner(fourLineBanners[w.rng.IntN(len(fourLineBanners))], "", bannerLife, true) +} +``` + +`drawBanner` needs three layout rows that Plan 2's `Layout` may not expose: `BoardY`, `TitleY`, and `MissionY`. Plan 2's `Compute` already positions the title, the board, and the mission line, so add whichever of those three fields is missing to `render.Layout` and set it in `Compute`, using `-1` for "no room for this row" (which the small size class needs for the mission line). Add a `render` test that `Compute` returns `BoardY > TitleY` at every size and that `MissionY` is `-1` exactly when the class is `SizeSmall`. + +`p.Accent` and `p.Dim` are existing palette styles from Plan 2. `p.Flash` was added in Task 7. + +- [ ] **Step 4: Hook the sequence into the world** + +In `internal/fx/fx.go`: add field `banner *banner`, call `w.advanceBanners(dt)` from `Advance`, draw it in `DrawGlobalFX` after the shockwaves, and extend the clear arm of `observe`: + +```go + case game.EventLinesCleared: + w.startClear(e.Cleared, e.Count) + if e.Count >= 4 { + w.fourLineSequence(e.Cleared) + } +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `go test ./internal/fx/ ./internal/render/ -v && go test ./... -race` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add internal/fx/banner.go internal/fx/banner_test.go internal/fx/fx.go internal/render/ +git commit -m "feat(fx): four-line sequence and banners that never cover the board" +``` diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/baseline-cosmic-2/2026-09-17-cosmic-tetris-1-game-engine.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/baseline-cosmic-2/2026-09-17-cosmic-tetris-1-game-engine.md new file mode 100644 index 000000000..6989cd689 --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/baseline-cosmic-2/2026-09-17-cosmic-tetris-1-game-engine.md @@ -0,0 +1,2852 @@ +# Cosmic Tetris — Plan 1: Headless Game Engine Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the complete, deterministic, headless falling-block game engine in `internal/game`, with comprehensive unit tests and a seeded replay test, so that Plan 2 can put a terminal on top of a game that already works. + +**Architecture:** A single package `internal/game` owns board, pieces, bag, rules, scoring and the `Game` aggregate. The engine never reads a clock: all time enters through `Game.Advance(dt time.Duration) []Event`. All mutating methods return `[]Event`, which is the only channel the effects system will ever be given (Plan 3) — events carry every payload FX needs, so FX never receives a pointer into game state. Rendering will read a value-copy `Snapshot`, so it structurally cannot mutate the game. + +**Tech Stack:** Go 1.26, standard library only for this plan (`math/rand/v2` for the seeded RNG). No Bubble Tea yet. + +**Spec:** `design.md` + +## Global Constraints + +These apply to every task in this plan (and to Plans 2–4). + +- Language: Go. Module path `cosmic-tetris` (local scratch repo, no remote). Go directive `go 1.26`. +- Repository layout is §33 of the spec, exactly. Deviations from §33 permitted by this plan set, and only these: `internal/game/events.go` (§36 names the type `game.Event`, so the game package must own it), `internal/render/canvas.go` (Plan 2, cell compositor), `internal/render/overlays.go` (Plan 2, pause/help/game-over/boot panels). No other new files or packages. +- Board geometry: `width: 10`, `height: 22`, `visible rows: 20`, `hidden spawn rows: 2` (§5). +- Nothing under `internal/game` may call `time.Now()` or otherwise read a clock (§49.2). `time.Duration` values as parameters are fine. +- `Game` owns its own `*rand.Rand`, used only to drive the 7-bag. `Seed int64` is recorded for display and restart (§49.6). The FX RNG (Plan 3) is a separate generator; the two never share. +- Do not build: networking, profiles, achievements, a plugin system, a database (§2). +- Do not abstract Bubble Tea away behind a homegrown framework (§3). Not relevant to this plan; do not pre-build for it either. +- Every commit must leave `gofmt -l .` empty, `go vet ./...` clean, and `go test ./...` passing. +- Combo indexing and bonus follow §49.1 exactly: first clearing placement sets combo to 1; a non-clearing placement resets combo to 0; `bonus = 50 × (combo - 1) × level`. + +## Review Focus + +Input classes the spec implies but no task's own happy-path tests exercise. Each has a test pinned into the task that owns the code. + +1. **`Advance` with a `dt` far larger than one gravity interval** (terminal suspended, machine slept, laggy SSH): must apply gravity a bounded number of times, never loop unboundedly, never teleport the piece through the stack. Pinned in Task 7. +2. **A wall kick that pushes the piece above row 0** (`(0,-1)` offsets exist in §7, spawn is at row 0): negative `y` is empty air, not a collision and not an index panic. Pinned in Task 2 (`Occupied`) and Task 6 (kick). +3. **Input actions arriving after game over** (player mashing keys as they die, or holding a key through the transition): every mutating method must be a no-op returning no events, not a panic. Pinned in Task 9. +4. **Hold pressed twice in a row, and hold pressed on the very first piece**: second hold blocked, first hold pulls from the next queue and refills it. Pinned in Task 9. +5. **A lock that tops out the board**: `EventGameOver` is emitted exactly once, and subsequent `Advance` calls return no events and change nothing. Pinned in Task 9. + +--- + +### Task 1: Module bootstrap and tetromino geometry + +**Files:** +- Create: `go.mod` +- Create: `internal/game/piece.go` +- Test: `internal/game/piece_test.go` +- Create: `.gitignore` + +**Interfaces:** +- Consumes: nothing. +- Produces: `type PieceKind uint8`; constants `KindI, KindJ, KindL, KindO, KindS, KindT, KindZ PieceKind`; `const KindCount = 7`; `func (k PieceKind) String() string`; `type Offset struct{ X, Y int }`; `type Piece struct{ Kind PieceKind; Rotation, X, Y int }`; `func (p Piece) Cells() [4]Offset` (absolute board coordinates); `func parseArt(art [4]string) [4]Offset`; `const SpawnX = 3`, `const SpawnY = 0`. + +Coordinate system for the whole engine: `x` increases rightward `0..9`, `y` increases **downward** `0..21`. Rows 0 and 1 are the hidden spawn rows; rows 2..21 are the 20 visible rows. A piece's `X, Y` is the top-left corner of its 4×4 shape box. + +- [ ] **Step 1: Initialise the module** + +```bash +cd "$(git rev-parse --show-toplevel)" +go mod init cosmic-tetris +printf '/cosmic-tetris\n' > .gitignore +``` + +- [ ] **Step 2: Write the failing test** + +Create `internal/game/piece_test.go`: + +```go +package game + +import "testing" + +func TestParseArtReturnsCellsInReadingOrder(t *testing.T) { + got := parseArt([4]string{".#..", "###.", "....", "...."}) + want := [4]Offset{{1, 0}, {0, 1}, {1, 1}, {2, 1}} + if got != want { + t.Fatalf("parseArt = %v, want %v", got, want) + } +} + +func TestEveryRotationHasFourCellsInsideTheBox(t *testing.T) { + for k := PieceKind(0); k < KindCount; k++ { + for r := 0; r < 4; r++ { + cells := Piece{Kind: k, Rotation: r}.Cells() + seen := map[Offset]bool{} + for _, c := range cells { + if c.X < 0 || c.X > 3 || c.Y < 0 || c.Y > 3 { + t.Errorf("%s rot %d: cell %v outside 4x4 box", k, r, c) + } + if seen[c] { + t.Errorf("%s rot %d: duplicate cell %v", k, r, c) + } + seen[c] = true + } + } + } +} + +func TestCellsAreAbsoluteToPiecePosition(t *testing.T) { + p := Piece{Kind: KindT, Rotation: 0, X: SpawnX, Y: SpawnY} + want := [4]Offset{{4, 0}, {3, 1}, {4, 1}, {5, 1}} + if got := p.Cells(); got != want { + t.Fatalf("T at spawn = %v, want %v", got, want) + } +} + +func TestOIsIdenticalThroughRotation(t *testing.T) { + first := Piece{Kind: KindO, Rotation: 0}.Cells() + for r := 1; r < 4; r++ { + if got := (Piece{Kind: KindO, Rotation: r}).Cells(); got != first { + t.Errorf("O rot %d = %v, want %v", r, got, first) + } + } +} + +func TestNonOPiecesChangeShapeWhenRotated(t *testing.T) { + for _, k := range []PieceKind{KindI, KindJ, KindL, KindS, KindT, KindZ} { + a := Piece{Kind: k, Rotation: 0}.Cells() + b := Piece{Kind: k, Rotation: 1}.Cells() + if a == b { + t.Errorf("%s rotation 0 and 1 are identical: %v", k, a) + } + } +} + +func TestKindString(t *testing.T) { + want := "IJLOSTZ" + for k := PieceKind(0); k < KindCount; k++ { + if got := k.String(); got != string(want[k]) { + t.Errorf("kind %d String() = %q, want %q", k, got, string(want[k])) + } + } +} +``` + +- [ ] **Step 3: Run the test to verify it fails** + +Run: `go test ./internal/game/ -run TestParseArt -v` +Expected: FAIL — `undefined: parseArt`. + +- [ ] **Step 4: Write the implementation** + +Create `internal/game/piece.go`: + +```go +// Package game implements the Cosmic Tetris rules engine. It is headless and +// deterministic: it never reads a clock and never touches the terminal. All +// elapsed time enters through Game.Advance. +package game + +// PieceKind identifies one of the seven tetromino families. +type PieceKind uint8 + +// The seven tetromino families (§6). +const ( + KindI PieceKind = iota + KindJ + KindL + KindO + KindS + KindT + KindZ +) + +// KindCount is the number of tetromino families. +const KindCount = 7 + +const kindNames = "IJLOSTZ" + +func (k PieceKind) String() string { + if k >= KindCount { + return "?" + } + return string(kindNames[k]) +} + +// Offset is a cell coordinate or a delta. X grows rightward, Y grows downward. +type Offset struct{ X, Y int } + +// Piece is a tetromino in play. X, Y is the top-left corner of its 4x4 box. +type Piece struct { + Kind PieceKind + Rotation int + X int + Y int +} + +// Spawn position: X=3 centres the 4-wide box on a 10-wide board, Y=0 puts the +// piece in the hidden rows (§5). +const ( + SpawnX = 3 + SpawnY = 0 +) + +// shapeArt holds the four predefined rotations of every piece (§6) as 4x4 art. +// '#' is a filled cell. Rotation 0 is the spawn orientation; rotation N+1 is +// one clockwise step from rotation N. +var shapeArt = [KindCount][4][4]string{ + KindI: { + {"....", "####", "....", "...."}, + {"..#.", "..#.", "..#.", "..#."}, + {"....", "....", "####", "...."}, + {".#..", ".#..", ".#..", ".#.."}, + }, + KindJ: { + {"#...", "###.", "....", "...."}, + {".##.", ".#..", ".#..", "...."}, + {"....", "###.", "..#.", "...."}, + {".#..", ".#..", "##..", "...."}, + }, + KindL: { + {"..#.", "###.", "....", "...."}, + {".#..", ".#..", ".##.", "...."}, + {"....", "###.", "#...", "...."}, + {"##..", ".#..", ".#..", "...."}, + }, + KindO: { + {".##.", ".##.", "....", "...."}, + {".##.", ".##.", "....", "...."}, + {".##.", ".##.", "....", "...."}, + {".##.", ".##.", "....", "...."}, + }, + KindS: { + {".##.", "##..", "....", "...."}, + {".#..", ".##.", "..#.", "...."}, + {"....", ".##.", "##..", "...."}, + {"#...", "##..", ".#..", "...."}, + }, + KindT: { + {".#..", "###.", "....", "...."}, + {".#..", ".##.", ".#..", "...."}, + {"....", "###.", ".#..", "...."}, + {".#..", "##..", ".#..", "...."}, + }, + KindZ: { + {"##..", ".##.", "....", "...."}, + {"..#.", ".##.", ".#..", "...."}, + {"....", "##..", ".##.", "...."}, + {".#..", "##..", "#...", "...."}, + }, +} + +// shapes is shapeArt resolved to cell offsets once at startup. +var shapes = func() (out [KindCount][4][4]Offset) { + for k := range shapeArt { + for r := range shapeArt[k] { + out[k][r] = parseArt(shapeArt[k][r]) + } + } + return out +}() + +// parseArt converts 4x4 shape art to exactly four offsets, in reading order +// (top row first, left to right). It panics on malformed art, which can only +// happen if the tables above are edited wrongly. +func parseArt(art [4]string) [4]Offset { + var out [4]Offset + n := 0 + for y, row := range art { + if len(row) != 4 { + panic("game: shape art row must be 4 characters: " + row) + } + for x := 0; x < 4; x++ { + if row[x] != '#' { + continue + } + if n == 4 { + panic("game: shape art has more than four cells") + } + out[n] = Offset{X: x, Y: y} + n++ + } + } + if n != 4 { + panic("game: shape art must have exactly four cells") + } + return out +} + +// Cells returns the piece's four cells in absolute board coordinates. +func (p Piece) Cells() [4]Offset { + local := shapes[p.Kind][((p.Rotation%4)+4)%4] + var out [4]Offset + for i, o := range local { + out[i] = Offset{X: p.X + o.X, Y: p.Y + o.Y} + } + return out +} +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS — all six tests. + +- [ ] **Step 6: Format, vet, commit** + +```bash +gofmt -l . && go vet ./... +git add go.mod .gitignore internal/game/piece.go internal/game/piece_test.go +git commit -m "feat(game): tetromino kinds, rotation tables and cell geometry" +``` + +--- + +### Task 2: Board — bounds, collision, locking, row clearing + +**Files:** +- Create: `internal/game/board.go` +- Test: `internal/game/board_test.go` + +**Interfaces:** +- Consumes: `Piece`, `PieceKind`, `Offset` from Task 1. +- Produces: `const Width = 10`, `const Height = 22`, `const HiddenRows = 2`, `const VisibleRows = 20`; `type Cell struct{ Filled bool; Kind PieceKind }`; `type Board struct{ Cells [Height][Width]Cell }`; `func (b *Board) InBounds(x, y int) bool`; `func (b *Board) Occupied(x, y int) bool`; `func (b *Board) Collides(p Piece) bool`; `func (b *Board) Lock(p Piece)`; `func (b *Board) CompleteRows() []int`; `func (b *Board) ClearRows(rows []int)`. + +The decision that matters here, and that later tasks depend on: `Occupied` treats `y < 0` as **empty air**, so a piece may legally sit above the board. `x` outside `0..Width-1` and `y >= Height` are occupied (walls and floor). This is what makes the §7 upward wall kicks work at spawn height instead of panicking. + +- [ ] **Step 1: Write the failing test** + +Create `internal/game/board_test.go`: + +```go +package game + +import ( + "reflect" + "testing" +) + +// fillRow marks every cell of row y as filled with kind. +func fillRow(b *Board, y int, kind PieceKind) { + for x := 0; x < Width; x++ { + b.Cells[y][x] = Cell{Filled: true, Kind: kind} + } +} + +func TestBoardDimensionsMatchSpec(t *testing.T) { + if Width != 10 || Height != 22 || HiddenRows != 2 || VisibleRows != 20 { + t.Fatalf("geometry = %d x %d (%d hidden, %d visible), want 10 x 22 (2 hidden, 20 visible)", + Width, Height, HiddenRows, VisibleRows) + } +} + +func TestOccupiedTreatsWallsAndFloorAsSolidAndAboveBoardAsAir(t *testing.T) { + var b Board + cases := []struct { + name string + x, y int + want bool + }{ + {"left of board", -1, 5, true}, + {"right of board", Width, 5, true}, + {"below floor", 3, Height, true}, + {"above board is air", 3, -1, false}, + {"above board far", 3, -4, false}, + {"empty interior", 3, 5, false}, + } + for _, c := range cases { + if got := b.Occupied(c.x, c.y); got != c.want { + t.Errorf("%s: Occupied(%d,%d) = %v, want %v", c.name, c.x, c.y, got, c.want) + } + } +} + +func TestOccupiedSeesLockedCells(t *testing.T) { + var b Board + b.Cells[7][4] = Cell{Filled: true, Kind: KindZ} + if !b.Occupied(4, 7) { + t.Error("locked cell reported empty") + } + if b.Occupied(5, 7) { + t.Error("neighbouring empty cell reported occupied") + } +} + +func TestCollidesWithFloorWallsAndStack(t *testing.T) { + var b Board + fillRow(&b, Height-1, KindI) + + // O piece resting on the filled bottom row: box rows 0-1 are the piece, so + // Y = Height-3 puts its lower cells directly above the stack. + resting := Piece{Kind: KindO, X: 4, Y: Height - 3} + if b.Collides(resting) { + t.Error("piece just above the stack should not collide") + } + if !b.Collides(Piece{Kind: KindO, X: 4, Y: Height - 2}) { + t.Error("piece overlapping the stack should collide") + } + if !b.Collides(Piece{Kind: KindO, X: -2, Y: 5}) { + t.Error("piece pushed off the left wall should collide") + } + if !b.Collides(Piece{Kind: KindO, X: Width - 1, Y: 5}) { + t.Error("piece pushed off the right wall should collide") + } +} + +func TestCollidesAllowsPieceAboveTheBoard(t *testing.T) { + var b Board + if b.Collides(Piece{Kind: KindI, Rotation: 1, X: 3, Y: -3}) { + t.Error("piece above row 0 should not collide") + } +} + +func TestLockWritesPieceCellsWithItsKind(t *testing.T) { + var b Board + p := Piece{Kind: KindT, Rotation: 0, X: 3, Y: 10} + b.Lock(p) + for _, c := range p.Cells() { + got := b.Cells[c.Y][c.X] + if !got.Filled || got.Kind != KindT { + t.Errorf("cell %v = %+v, want filled T", c, got) + } + } + if b.Cells[10][0].Filled { + t.Error("Lock touched a cell outside the piece") + } +} + +func TestCompleteRowsReturnsFullRowsAscending(t *testing.T) { + var b Board + fillRow(&b, 19, KindI) + fillRow(&b, 21, KindI) + b.Cells[20][0] = Cell{Filled: true, Kind: KindI} // partial row + if got, want := b.CompleteRows(), []int{19, 21}; !reflect.DeepEqual(got, want) { + t.Fatalf("CompleteRows = %v, want %v", got, want) + } +} + +func TestCompleteRowsIgnoresAlmostFullRow(t *testing.T) { + var b Board + fillRow(&b, 21, KindI) + b.Cells[21][5] = Cell{} + if got := b.CompleteRows(); len(got) != 0 { + t.Fatalf("CompleteRows = %v, want none", got) + } +} + +func TestClearRowsCollapsesStackDownward(t *testing.T) { + var b Board + // Marker above, two complete rows below it. + b.Cells[18][2] = Cell{Filled: true, Kind: KindT} + fillRow(&b, 20, KindI) + fillRow(&b, 21, KindJ) + + b.ClearRows([]int{20, 21}) + + if !b.Cells[20][2].Filled || b.Cells[20][2].Kind != KindT { + t.Errorf("marker should have fallen two rows to (2,20), got %+v", b.Cells[20][2]) + } + if b.Cells[18][2].Filled { + t.Error("marker left behind at its old row") + } + for _, y := range []int{0, 1} { + for x := 0; x < Width; x++ { + if b.Cells[y][x].Filled { + t.Fatalf("row %d should be empty after collapse", y) + } + } + } + if got := b.CompleteRows(); len(got) != 0 { + t.Fatalf("rows still complete after clear: %v", got) + } +} + +func TestClearRowsHandlesNonAdjacentRows(t *testing.T) { + var b Board + b.Cells[17][0] = Cell{Filled: true, Kind: KindT} // above both clears + fillRow(&b, 18, KindI) + b.Cells[19][1] = Cell{Filled: true, Kind: KindS} // between the clears + fillRow(&b, 20, KindJ) + + b.ClearRows([]int{18, 20}) + + if !b.Cells[19][0].Filled || b.Cells[19][0].Kind != KindT { + t.Errorf("T should sit at (0,19), got %+v", b.Cells[19][0]) + } + if !b.Cells[20][1].Filled || b.Cells[20][1].Kind != KindS { + t.Errorf("S should sit at (1,20), got %+v", b.Cells[20][1]) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/game/ -run TestBoard -v` +Expected: FAIL — `undefined: Board`. + +- [ ] **Step 3: Write the implementation** + +Create `internal/game/board.go`: + +```go +package game + +// Board geometry (§5). Rows 0..HiddenRows-1 are the hidden spawn rows; the +// remaining rows are what the player sees. +const ( + Width = 10 + Height = 22 + HiddenRows = 2 + VisibleRows = Height - HiddenRows +) + +// Cell is one board square. Kind is only meaningful when Filled is true; it is +// what gives locked blocks their colour. +type Cell struct { + Filled bool + Kind PieceKind +} + +// Board is the locked stack. The active piece is not part of it. +type Board struct { + Cells [Height][Width]Cell +} + +// InBounds reports whether x, y addresses a real cell. +func (b *Board) InBounds(x, y int) bool { + return x >= 0 && x < Width && y >= 0 && y < Height +} + +// Occupied reports whether a piece cell at x, y would be blocked. Walls and the +// floor are blocked; everything above row 0 is empty air, which is what lets +// the §7 upward wall kicks work at spawn height. +func (b *Board) Occupied(x, y int) bool { + if x < 0 || x >= Width || y >= Height { + return true + } + if y < 0 { + return false + } + return b.Cells[y][x].Filled +} + +// Collides reports whether the piece overlaps a wall, the floor, or a locked cell. +func (b *Board) Collides(p Piece) bool { + for _, c := range p.Cells() { + if b.Occupied(c.X, c.Y) { + return true + } + } + return false +} + +// Lock commits the piece's cells to the stack. Cells above row 0 are dropped, +// which can only happen on a top-out. +func (b *Board) Lock(p Piece) { + for _, c := range p.Cells() { + if b.InBounds(c.X, c.Y) { + b.Cells[c.Y][c.X] = Cell{Filled: true, Kind: p.Kind} + } + } +} + +// CompleteRows returns the indices of fully filled rows, ascending. The result +// is a fresh slice, safe to hand to the effects system. +func (b *Board) CompleteRows() []int { + var rows []int + for y := 0; y < Height; y++ { + full := true + for x := 0; x < Width; x++ { + if !b.Cells[y][x].Filled { + full = false + break + } + } + if full { + rows = append(rows, y) + } + } + return rows +} + +// ClearRows removes the given rows and collapses everything above them downward. +// Row indices that are out of range are ignored. +func (b *Board) ClearRows(rows []int) { + if len(rows) == 0 { + return + } + var doomed [Height]bool + for _, y := range rows { + if y >= 0 && y < Height { + doomed[y] = true + } + } + var out [Height][Width]Cell + dst := Height - 1 + for y := Height - 1; y >= 0; y-- { + if doomed[y] { + continue + } + out[dst] = b.Cells[y] + dst-- + } + b.Cells = out +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Format, vet, commit** + +```bash +gofmt -l . && go vet ./... +git add internal/game/board.go internal/game/board_test.go +git commit -m "feat(game): board collision, locking and row clearing" +``` + +--- + +### Task 3: Seven-bag piece generator + +**Files:** +- Create: `internal/game/bag.go` +- Test: `internal/game/bag_test.go` + +**Interfaces:** +- Consumes: `PieceKind`, `KindCount`. +- Produces: `type Bag struct{ ... }`; `func NewBag(rng *rand.Rand) *Bag`; `func (b *Bag) Next() PieceKind`. + +`rand` here and everywhere in this plan is `math/rand/v2`. `rand.New(rand.NewPCG(a, b))` is a specified, version-stable generator, which is what makes §35's reproducibility promise real. + +- [ ] **Step 1: Write the failing test** + +Create `internal/game/bag_test.go`: + +```go +package game + +import ( + "math/rand/v2" + "testing" +) + +func newTestRNG(seed int64) *rand.Rand { + return rand.New(rand.NewPCG(uint64(seed), 0)) +} + +func TestEveryBagContainsAllSevenKindsExactlyOnce(t *testing.T) { + b := NewBag(newTestRNG(1)) + for group := 0; group < 10; group++ { + var count [KindCount]int + for i := 0; i < KindCount; i++ { + count[b.Next()]++ + } + for k := PieceKind(0); k < KindCount; k++ { + if count[k] != 1 { + t.Fatalf("bag %d: kind %s appeared %d times, want 1", group, k, count[k]) + } + } + } +} + +func TestSeededBagsAreReproducible(t *testing.T) { + a, b := NewBag(newTestRNG(8675309)), NewBag(newTestRNG(8675309)) + for i := 0; i < 70; i++ { + x, y := a.Next(), b.Next() + if x != y { + t.Fatalf("draw %d diverged: %s vs %s", i, x, y) + } + } +} + +func TestDifferentSeedsProduceDifferentOrder(t *testing.T) { + a, b := NewBag(newTestRNG(1)), NewBag(newTestRNG(2)) + same := true + for i := 0; i < 70; i++ { + if a.Next() != b.Next() { + same = false + } + } + if same { + t.Error("two different seeds produced identical 70-piece sequences") + } +} + +func TestBagIsShuffledNotSorted(t *testing.T) { + b := NewBag(newTestRNG(42)) + sorted := 0 + for group := 0; group < 20; group++ { + inOrder := true + for i := 0; i < KindCount; i++ { + if b.Next() != PieceKind(i) { + inOrder = false + } + } + if inOrder { + sorted++ + } + } + if sorted > 1 { + t.Errorf("%d of 20 bags came out in I,J,L,O,S,T,Z order; shuffle is not working", sorted) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/game/ -run TestEveryBag -v` +Expected: FAIL — `undefined: NewBag`. + +- [ ] **Step 3: Write the implementation** + +Create `internal/game/bag.go`: + +```go +package game + +import "math/rand/v2" + +// Bag is the 7-bag piece generator (§6): one of every piece goes in, the bag is +// shuffled, drained, and refilled. +type Bag struct { + rng *rand.Rand + queue []PieceKind +} + +// NewBag returns a bag driven by rng. The bag never uses any other source of +// randomness, so piece order depends on the game RNG alone (§49.6). +func NewBag(rng *rand.Rand) *Bag { + return &Bag{rng: rng} +} + +// Next draws the next piece, refilling the bag when it runs dry. +func (b *Bag) Next() PieceKind { + if len(b.queue) == 0 { + b.refill() + } + k := b.queue[0] + b.queue = b.queue[1:] + return k +} + +func (b *Bag) refill() { + bag := []PieceKind{KindI, KindJ, KindL, KindO, KindS, KindT, KindZ} + b.rng.Shuffle(len(bag), func(i, j int) { bag[i], bag[j] = bag[j], bag[i] }) + b.queue = bag +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Format, vet, commit** + +```bash +gofmt -l . && go vet ./... +git add internal/game/bag.go internal/game/bag_test.go +git commit -m "feat(game): seeded 7-bag piece generator" +``` + +--- + +### Task 4: Rules constants, gravity curve, and scoring + +**Files:** +- Create: `internal/game/rules.go` +- Create: `internal/game/scoring.go` +- Test: `internal/game/rules_test.go` +- Test: `internal/game/scoring_test.go` + +**Interfaces:** +- Consumes: `Offset`. +- Produces: `const BaseGravity = 800 * time.Millisecond`, `const GravityFactor = 0.86`, `const MinGravity = 60 * time.Millisecond`, `const LockDelay = 500 * time.Millisecond`, `const MaxLockResets = 15`, `const LinesPerLevel = 10`, `const NextQueueLen = 5`, `const MaxGravityStepsPerAdvance = 32`; `var KickOffsets [8]Offset`; `func GravityInterval(level int) time.Duration`; `func LineScore(lines, level int) int`; `func ComboBonus(combo, level int) int`; `func LevelFor(lines int) int`. + +- [ ] **Step 1: Write the failing tests** + +Create `internal/game/rules_test.go`: + +```go +package game + +import ( + "testing" + "time" +) + +func TestGravityIntervalFollowsTheCurve(t *testing.T) { + cases := []struct { + level int + want time.Duration + }{ + {1, 800 * time.Millisecond}, + {2, 688 * time.Millisecond}, + {5, 437600 * time.Microsecond}, + } + const tolerance = time.Millisecond + for _, c := range cases { + got := GravityInterval(c.level) + diff := got - c.want + if diff < 0 { + diff = -diff + } + if diff > tolerance { + t.Errorf("GravityInterval(%d) = %v, want ~%v", c.level, got, c.want) + } + } +} + +func TestGravityIntervalIsMonotonicAndClamped(t *testing.T) { + prev := time.Duration(1 << 62) + for level := 1; level <= 40; level++ { + got := GravityInterval(level) + if got > prev { + t.Fatalf("GravityInterval(%d) = %v is slower than level %d (%v)", level, got, level-1, prev) + } + if got < MinGravity { + t.Fatalf("GravityInterval(%d) = %v is below the %v clamp", level, got, MinGravity) + } + prev = got + } + if GravityInterval(40) != MinGravity { + t.Errorf("GravityInterval(40) = %v, want the clamp %v", GravityInterval(40), MinGravity) + } +} + +func TestGravityIntervalToleratesNonsenseLevels(t *testing.T) { + for _, level := range []int{0, -1, -1000} { + if got := GravityInterval(level); got != BaseGravity { + t.Errorf("GravityInterval(%d) = %v, want level-1 value %v", level, got, BaseGravity) + } + } +} + +func TestKickOffsetsAreTheSpecOrder(t *testing.T) { + want := [8]Offset{{0, 0}, {-1, 0}, {1, 0}, {-2, 0}, {2, 0}, {0, -1}, {-1, -1}, {1, -1}} + if KickOffsets != want { + t.Fatalf("KickOffsets = %v, want %v", KickOffsets, want) + } +} + +func TestTimingConstantsMatchSpec(t *testing.T) { + if LockDelay != 500*time.Millisecond { + t.Errorf("LockDelay = %v, want 500ms", LockDelay) + } + if MaxLockResets != 15 { + t.Errorf("MaxLockResets = %d, want 15", MaxLockResets) + } + if LinesPerLevel != 10 { + t.Errorf("LinesPerLevel = %d, want 10", LinesPerLevel) + } + if NextQueueLen != 5 { + t.Errorf("NextQueueLen = %d, want 5", NextQueueLen) + } +} +``` + +Create `internal/game/scoring_test.go`: + +```go +package game + +import "testing" + +func TestLineScoreBaseValues(t *testing.T) { + cases := []struct { + lines, level, want int + }{ + {0, 1, 0}, + {1, 1, 100}, + {2, 1, 300}, + {3, 1, 500}, + {4, 1, 800}, + {1, 7, 700}, + {4, 7, 5600}, + {5, 1, 0}, // impossible clear count scores nothing rather than panicking + } + for _, c := range cases { + if got := LineScore(c.lines, c.level); got != c.want { + t.Errorf("LineScore(%d, %d) = %d, want %d", c.lines, c.level, got, c.want) + } + } +} + +func TestComboBonusFirstAppearsAtComboTwo(t *testing.T) { + cases := []struct { + combo, level, want int + }{ + {0, 5, 0}, + {1, 5, 0}, // a lone clear earns no combo bonus (§49.1) + {2, 1, 50}, + {3, 1, 100}, + {5, 3, 600}, + } + for _, c := range cases { + if got := ComboBonus(c.combo, c.level); got != c.want { + t.Errorf("ComboBonus(%d, %d) = %d, want %d", c.combo, c.level, got, c.want) + } + } +} + +func TestLevelForLinesCleared(t *testing.T) { + cases := []struct{ lines, want int }{ + {0, 1}, {9, 1}, {10, 2}, {19, 2}, {20, 3}, {127, 13}, + } + for _, c := range cases { + if got := LevelFor(c.lines); got != c.want { + t.Errorf("LevelFor(%d) = %d, want %d", c.lines, got, c.want) + } + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/game/ -run 'TestGravity|TestLineScore' -v` +Expected: FAIL — `undefined: GravityInterval`, `undefined: LineScore`. + +- [ ] **Step 3: Write the implementations** + +Create `internal/game/rules.go`: + +```go +package game + +import ( + "math" + "time" +) + +// Timing and rule constants (§11, §12, §6). +const ( + // BaseGravity is the level-1 drop interval. + BaseGravity = 800 * time.Millisecond + // GravityFactor is the per-level multiplier: interval = 800ms * 0.86^(level-1). + GravityFactor = 0.86 + // MinGravity is the floor the curve clamps to. + MinGravity = 60 * time.Millisecond + // LockDelay is how long a grounded piece waits before locking. + LockDelay = 500 * time.Millisecond + // MaxLockResets bounds how many times movement may postpone a lock. + MaxLockResets = 15 + // LinesPerLevel is how many cleared lines advance the level. + LinesPerLevel = 10 + // NextQueueLen is how many upcoming pieces are kept visible (§6). + NextQueueLen = 5 + // MaxGravityStepsPerAdvance bounds gravity work in a single Advance call so + // that a huge dt (suspended terminal, sleeping machine) cannot stall the + // engine or teleport a piece through the stack. + MaxGravityStepsPerAdvance = 32 +) + +// KickOffsets are the rotation kick candidates, tried in order (§7). +var KickOffsets = [8]Offset{ + {0, 0}, + {-1, 0}, + {1, 0}, + {-2, 0}, + {2, 0}, + {0, -1}, + {-1, -1}, + {1, -1}, +} + +// GravityInterval returns the drop interval for a level, clamped at MinGravity. +func GravityInterval(level int) time.Duration { + if level < 1 { + level = 1 + } + d := time.Duration(float64(BaseGravity) * math.Pow(GravityFactor, float64(level-1))) + if d < MinGravity { + return MinGravity + } + return d +} +``` + +Create `internal/game/scoring.go`: + +```go +package game + +// LineScore is the base value of clearing n lines at a level (§13). +func LineScore(lines, level int) int { + switch lines { + case 1: + return 100 * level + case 2: + return 300 * level + case 3: + return 500 * level + case 4: + return 800 * level + default: + return 0 + } +} + +// ComboBonus is the combo reward (§49.1): 50 x (combo-1) x level. A lone clear +// (combo 1) earns nothing, so the bonus first appears at combo 2 — exactly +// where the effects start escalating. +func ComboBonus(combo, level int) int { + if combo < 2 { + return 0 + } + return 50 * (combo - 1) * level +} + +// LevelFor is the level reached after clearing n lines (§11). +func LevelFor(lines int) int { + if lines < 0 { + lines = 0 + } + return lines/LinesPerLevel + 1 +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Format, vet, commit** + +```bash +gofmt -l . && go vet ./... +git add internal/game/rules.go internal/game/scoring.go internal/game/rules_test.go internal/game/scoring_test.go +git commit -m "feat(game): gravity curve, kick table and scoring rules" +``` + +--- + +### Task 5: Events + +**Files:** +- Create: `internal/game/events.go` +- Test: `internal/game/events_test.go` + +**Interfaces:** +- Consumes: `Piece`. +- Produces: `type EventKind uint8` with constants `EventPieceMoved, EventPieceRotated, EventPieceHardDropped, EventPieceLocked, EventHoldUsed, EventLinesCleared, EventComboChanged, EventLevelChanged, EventGameOver`; `func (k EventKind) String() string`; `type Event struct{ Kind EventKind; Piece Piece; Rows []int; Distance, Combo, Level, Score int }`. + +Why one struct with optional fields instead of an interface per event: the effects system must be able to react without holding a pointer into game state (§14 — FX may never modify `GameState`). Making `Event` a self-contained value means `fx.World.Observe([]game.Event)` is the *entire* FX input, and the no-mutation rule is structural rather than a convention someone has to remember. + +- [ ] **Step 1: Write the failing test** + +Create `internal/game/events_test.go`: + +```go +package game + +import "testing" + +func TestEventKindStringsAreDistinctAndNamed(t *testing.T) { + kinds := []EventKind{ + EventPieceMoved, EventPieceRotated, EventPieceHardDropped, EventPieceLocked, + EventHoldUsed, EventLinesCleared, EventComboChanged, EventLevelChanged, EventGameOver, + } + seen := map[string]bool{} + for _, k := range kinds { + s := k.String() + if s == "" || s == "?" { + t.Errorf("event kind %d has no name", k) + } + if seen[s] { + t.Errorf("duplicate event name %q", s) + } + seen[s] = true + } + if len(seen) != 9 { + t.Fatalf("got %d named event kinds, want 9", len(seen)) + } +} + +func TestEventIsSelfContainedValue(t *testing.T) { + // An Event must carry its payload by value so the FX system can react + // without a handle on game state (§14). + e := Event{Kind: EventLinesCleared, Rows: []int{20, 21}, Combo: 3, Level: 4, Score: 900} + if e.Rows[0] != 20 || e.Combo != 3 || e.Level != 4 || e.Score != 900 { + t.Fatalf("event payload not preserved: %+v", e) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/game/ -run TestEventKind -v` +Expected: FAIL — `undefined: EventKind`. + +- [ ] **Step 3: Write the implementation** + +Create `internal/game/events.go`: + +```go +package game + +// EventKind identifies something the engine did (§14). +type EventKind uint8 + +const ( + EventPieceMoved EventKind = iota + EventPieceRotated + EventPieceHardDropped + EventPieceLocked + EventHoldUsed + EventLinesCleared + EventComboChanged + EventLevelChanged + EventGameOver +) + +var eventNames = [...]string{ + EventPieceMoved: "PieceMoved", + EventPieceRotated: "PieceRotated", + EventPieceHardDropped: "PieceHardDropped", + EventPieceLocked: "PieceLocked", + EventHoldUsed: "HoldUsed", + EventLinesCleared: "LinesCleared", + EventComboChanged: "ComboChanged", + EventLevelChanged: "LevelChanged", + EventGameOver: "GameOver", +} + +func (k EventKind) String() string { + if int(k) >= len(eventNames) { + return "?" + } + return eventNames[k] +} + +// Event is a self-contained record of an engine action. Every field the effects +// system might want is copied in, so observers never need a pointer into game +// state (§14). Unused fields are zero. +type Event struct { + Kind EventKind + + // Piece is the piece involved, in the position it occupied at the time. + Piece Piece + // Rows are the cleared row indices for EventLinesCleared, in pre-clear + // board coordinates. + Rows []int + // Distance is cells fallen, for EventPieceHardDropped. + Distance int + // Combo, Level and Score are the values after the event. + Combo int + Level int + Score int +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Format, vet, commit** + +```bash +gofmt -l . && go vet ./... +git add internal/game/events.go internal/game/events_test.go +git commit -m "feat(game): self-contained engine event values" +``` + +--- + +### Task 6: Game core — spawn, next queue, horizontal movement, soft drop, ghost + +**Files:** +- Create: `internal/game/game.go` +- Test: `internal/game/game_test.go` + +**Interfaces:** +- Consumes: everything from Tasks 1–5. +- Produces: `type Phase uint8` with `PhasePlaying`, `PhaseGameOver`; `type Game struct{ Board Board; Active Piece; Hold *PieceKind; CanHold bool; Next []PieceKind; Bag *Bag; Score, Lines, Level, Combo int; Phase Phase; GravityAccumulator, LockAccumulator time.Duration; Grounded bool; LockResets int; Seed int64; rng *rand.Rand }`; `func New(seed int64) *Game`; `func (g *Game) MoveLeft() []Event`; `func (g *Game) MoveRight() []Event`; `func (g *Game) SoftDrop() []Event`; `func (g *Game) GhostY() int`; unexported `canMoveDown`, `resetLock`, `spawnPiece`, `spawnNext`. + +Rotation (Task 7), gravity and locking (Task 8), clears (Task 9) and hard drop/hold/game over (Task 10) add methods to this same file. + +- [ ] **Step 1: Write the failing test** + +Create `internal/game/game_test.go`: + +```go +package game + +import "testing" + +// clearBoard empties the stack, for tests that want a known-clean field. +func clearBoard(g *Game) { g.Board = Board{} } + +// stackTo fills every row from y down to the floor, leaving column gap empty. +func stackTo(g *Game, y, gap PieceKind) {} + +func TestNewStartsPlayingAtLevelOneWithAFullQueue(t *testing.T) { + g := New(8675309) + if g.Phase != PhasePlaying { + t.Errorf("Phase = %v, want PhasePlaying", g.Phase) + } + if g.Level != 1 { + t.Errorf("Level = %d, want 1", g.Level) + } + if g.Score != 0 || g.Lines != 0 || g.Combo != 0 { + t.Errorf("Score/Lines/Combo = %d/%d/%d, want zeroes", g.Score, g.Lines, g.Combo) + } + if len(g.Next) != NextQueueLen { + t.Fatalf("len(Next) = %d, want %d", len(g.Next), NextQueueLen) + } + if g.Hold != nil { + t.Error("Hold should start empty") + } + if !g.CanHold { + t.Error("CanHold should start true") + } + if g.Seed != 8675309 { + t.Errorf("Seed = %d, want 8675309", g.Seed) + } +} + +func TestNewSpawnsAtSpawnPosition(t *testing.T) { + g := New(1) + if g.Active.X != SpawnX || g.Active.Y != SpawnY || g.Active.Rotation != 0 { + t.Fatalf("Active = %+v, want X=%d Y=%d Rotation=0", g.Active, SpawnX, SpawnY) + } +} + +func TestSameSeedSpawnsSameFirstPieceAndQueue(t *testing.T) { + a, b := New(4242), New(4242) + if a.Active.Kind != b.Active.Kind { + t.Errorf("first piece differs: %s vs %s", a.Active.Kind, b.Active.Kind) + } + for i := range a.Next { + if a.Next[i] != b.Next[i] { + t.Errorf("queue slot %d differs: %s vs %s", i, a.Next[i], b.Next[i]) + } + } +} + +func TestMoveLeftAndRightEmitPieceMoved(t *testing.T) { + g := New(1) + startX := g.Active.X + + evs := g.MoveLeft() + if g.Active.X != startX-1 { + t.Errorf("after MoveLeft X = %d, want %d", g.Active.X, startX-1) + } + if len(evs) != 1 || evs[0].Kind != EventPieceMoved { + t.Fatalf("MoveLeft events = %v, want one PieceMoved", evs) + } + + evs = g.MoveRight() + if g.Active.X != startX { + t.Errorf("after MoveRight X = %d, want %d", g.Active.X, startX) + } + if len(evs) != 1 || evs[0].Kind != EventPieceMoved { + t.Fatalf("MoveRight events = %v, want one PieceMoved", evs) + } +} + +func TestMovementIntoAWallIsRefusedSilently(t *testing.T) { + g := New(1) + for i := 0; i < 20; i++ { + g.MoveLeft() + } + x := g.Active.X + if evs := g.MoveLeft(); len(evs) != 0 { + t.Errorf("blocked MoveLeft emitted %v, want no events", evs) + } + if g.Active.X != x { + t.Errorf("blocked MoveLeft moved the piece to %d", g.Active.X) + } + for _, c := range g.Active.Cells() { + if c.X < 0 || c.X >= Width { + t.Fatalf("piece cell %v escaped the board", c) + } + } +} + +func TestSoftDropMovesOneRowAndScoresOnePoint(t *testing.T) { + g := New(1) + y := g.Active.Y + evs := g.SoftDrop() + if g.Active.Y != y+1 { + t.Errorf("Y = %d, want %d", g.Active.Y, y+1) + } + if g.Score != 1 { + t.Errorf("Score = %d, want 1 point per soft-dropped cell", g.Score) + } + if len(evs) != 1 || evs[0].Kind != EventPieceMoved { + t.Fatalf("SoftDrop events = %v, want one PieceMoved", evs) + } +} + +func TestSoftDropOnTheFloorScoresNothing(t *testing.T) { + g := New(1) + for g.canMoveDown() { + g.SoftDrop() + } + score := g.Score + if evs := g.SoftDrop(); len(evs) != 0 { + t.Errorf("grounded SoftDrop emitted %v, want no events", evs) + } + if g.Score != score { + t.Errorf("grounded SoftDrop scored %d extra points", g.Score-score) + } +} + +func TestGhostYIsTheLandingRow(t *testing.T) { + g := New(1) + ghost := g.GhostY() + if ghost < g.Active.Y { + t.Fatalf("GhostY = %d is above the piece at %d", ghost, g.Active.Y) + } + landed := g.Active + landed.Y = ghost + if g.Board.Collides(landed) { + t.Error("ghost position collides") + } + below := landed + below.Y++ + if !g.Board.Collides(below) { + t.Error("ghost is not resting on anything") + } +} + +func TestGhostYDoesNotMoveThePiece(t *testing.T) { + g := New(1) + before := g.Active + g.GhostY() + if g.Active != before { + t.Fatalf("GhostY mutated Active: %+v, want %+v", g.Active, before) + } +} + +func TestQueueRefillsAsPiecesSpawn(t *testing.T) { + g := New(7) + for i := 0; i < 30; i++ { + g.spawnNext() + if len(g.Next) != NextQueueLen { + t.Fatalf("after %d spawns len(Next) = %d, want %d", i+1, len(g.Next), NextQueueLen) + } + } +} +``` + +Delete the unused `stackTo` and `clearBoard` stubs if the compiler complains; later tasks add their own helpers. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/game/ -run TestNewStarts -v` +Expected: FAIL — `undefined: New`. + +- [ ] **Step 3: Write the implementation** + +Create `internal/game/game.go`: + +```go +package game + +import ( + "math/rand/v2" + "time" +) + +// Phase is the engine's lifecycle state. Pausing is an application concern, not +// an engine one: the app simply stops calling Advance. +type Phase uint8 + +const ( + PhasePlaying Phase = iota + PhaseGameOver +) + +func (p Phase) String() string { + if p == PhaseGameOver { + return "GameOver" + } + return "Playing" +} + +// Game is the whole logical game (§34). It is deterministic: identical seeds, +// identical method calls and identical dt values produce identical states. +type Game struct { + Board Board + Active Piece + Hold *PieceKind + CanHold bool + + Next []PieceKind + Bag *Bag + + Score int + Lines int + Level int + Combo int + + Phase Phase + + GravityAccumulator time.Duration + LockAccumulator time.Duration + Grounded bool + LockResets int + + Seed int64 // recorded for display and restart (§49.6) + rng *rand.Rand // game RNG: drives the 7-bag, nothing else +} + +// New starts a game from a seed. +func New(seed int64) *Game { + rng := rand.New(rand.NewPCG(uint64(seed), 0x9E3779B97F4A7C15)) + g := &Game{ + Level: 1, + CanHold: true, + Seed: seed, + rng: rng, + } + g.Bag = NewBag(rng) + g.Next = make([]PieceKind, 0, NextQueueLen+1) + for i := 0; i < NextQueueLen; i++ { + g.Next = append(g.Next, g.Bag.Next()) + } + g.spawnNext() + return g +} + +// canMoveDown reports whether the active piece has room below it. +func (g *Game) canMoveDown() bool { + p := g.Active + p.Y++ + return !g.Board.Collides(p) +} + +// resetLock restarts the lock timer after a successful move or rotation. While +// grounded this counts against MaxLockResets, which is what stops a player +// stalling forever (§12). +func (g *Game) resetLock() { + if !g.Grounded { + g.LockAccumulator = 0 + return + } + if g.LockResets >= MaxLockResets { + return + } + g.LockResets++ + g.LockAccumulator = 0 +} + +// spawnPiece puts a specific kind at the spawn position and resets the +// per-piece timers. It returns a GameOver event if the spawn is blocked. +func (g *Game) spawnPiece(kind PieceKind) []Event { + g.Active = Piece{Kind: kind, Rotation: 0, X: SpawnX, Y: SpawnY} + g.CanHold = true + g.Grounded = false + g.GravityAccumulator = 0 + g.LockAccumulator = 0 + g.LockResets = 0 + if g.Board.Collides(g.Active) { + g.Phase = PhaseGameOver + return []Event{{Kind: EventGameOver, Score: g.Score, Level: g.Level, Piece: g.Active}} + } + return nil +} + +// spawnNext pops the queue, refills it from the bag, and spawns. +func (g *Game) spawnNext() []Event { + kind := g.Next[0] + g.Next = append(g.Next[:0], g.Next[1:]...) + g.Next = append(g.Next, g.Bag.Next()) + return g.spawnPiece(kind) +} + +// shift moves the active piece horizontally by dx if there is room. +func (g *Game) shift(dx int) []Event { + if g.Phase != PhasePlaying { + return nil + } + p := g.Active + p.X += dx + if g.Board.Collides(p) { + return nil + } + g.Active = p + g.resetLock() + return []Event{{Kind: EventPieceMoved, Piece: p}} +} + +// MoveLeft nudges the active piece one column left. +func (g *Game) MoveLeft() []Event { return g.shift(-1) } + +// MoveRight nudges the active piece one column right. +func (g *Game) MoveRight() []Event { return g.shift(1) } + +// SoftDrop drops one row and scores a point (§11). It does nothing when the +// piece is already resting. +func (g *Game) SoftDrop() []Event { + if g.Phase != PhasePlaying || !g.canMoveDown() { + return nil + } + g.Active.Y++ + g.Score++ + g.GravityAccumulator = 0 + return []Event{{Kind: EventPieceMoved, Piece: g.Active}} +} + +// GhostY is the row the active piece would land on (§10). It does not mutate +// the game. +func (g *Game) GhostY() int { + p := g.Active + for { + next := p + next.Y++ + if g.Board.Collides(next) { + return p.Y + } + p = next + } +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Format, vet, commit** + +```bash +gofmt -l . && go vet ./... +git add internal/game/game.go internal/game/game_test.go +git commit -m "feat(game): game core with spawning, movement, soft drop and ghost" +``` + +--- + +### Task 7: Rotation with wall kicks + +**Files:** +- Modify: `internal/game/game.go` (append rotation methods) +- Test: `internal/game/rotation_test.go` + +**Interfaces:** +- Consumes: `KickOffsets`, `Game`, `Board.Collides`. +- Produces: `func (g *Game) RotateCW() []Event`; `func (g *Game) RotateCCW() []Event`. + +- [ ] **Step 1: Write the failing test** + +Create `internal/game/rotation_test.go`: + +```go +package game + +import "testing" + +func TestRotateCWAdvancesRotationAndWraps(t *testing.T) { + g := New(1) + clearBoard(g) + g.Active = Piece{Kind: KindT, Rotation: 0, X: 3, Y: 5} + for want := 1; want <= 4; want++ { + evs := g.RotateCW() + if len(evs) != 1 || evs[0].Kind != EventPieceRotated { + t.Fatalf("RotateCW events = %v, want one PieceRotated", evs) + } + if got := g.Active.Rotation; got != want%4 { + t.Fatalf("rotation = %d, want %d", got, want%4) + } + } +} + +func TestRotateCCWGoesBackwardsAndWraps(t *testing.T) { + g := New(1) + clearBoard(g) + g.Active = Piece{Kind: KindT, Rotation: 0, X: 3, Y: 5} + if g.RotateCCW(); g.Active.Rotation != 3 { + t.Fatalf("rotation after CCW from 0 = %d, want 3", g.Active.Rotation) + } + if g.RotateCCW(); g.Active.Rotation != 2 { + t.Fatalf("rotation = %d, want 2", g.Active.Rotation) + } +} + +func TestRotationKicksOffTheRightWall(t *testing.T) { + g := New(1) + clearBoard(g) + // Vertical I hugging the right wall: rotating to horizontal needs a shove left. + g.Active = Piece{Kind: KindI, Rotation: 1, X: 6, Y: 5} + if !g.RotateCW().IsRotated() { + t.Fatal("rotation against the right wall should have been kicked into place") + } + for _, c := range g.Active.Cells() { + if c.X < 0 || c.X >= Width { + t.Fatalf("kicked piece cell %v is outside the board", c) + } + } + if g.Board.Collides(g.Active) { + t.Fatal("kicked piece collides") + } +} + +func TestRotationKicksOffTheLeftWall(t *testing.T) { + g := New(1) + clearBoard(g) + g.Active = Piece{Kind: KindI, Rotation: 1, X: -2, Y: 5} + if len(g.RotateCW()) == 0 { + t.Fatal("rotation against the left wall should have been kicked into place") + } + if g.Board.Collides(g.Active) { + t.Fatal("kicked piece collides") + } +} + +func TestRotationPrefersTheFirstValidKick(t *testing.T) { + g := New(1) + clearBoard(g) + // Open field: the (0,0) candidate is valid, so nothing should shift. + g.Active = Piece{Kind: KindT, Rotation: 0, X: 4, Y: 10} + g.RotateCW() + if g.Active.X != 4 || g.Active.Y != 10 { + t.Fatalf("free rotation displaced the piece to (%d,%d)", g.Active.X, g.Active.Y) + } +} + +func TestRotationCanKickUpwardAboveTheBoard(t *testing.T) { + // Review Focus 2: §7 includes (0,-1) kicks and spawn is row 0, so a kick can + // legally lift the piece above row 0. That must not panic or be refused. + g := New(1) + clearBoard(g) + for y := 1; y < Height; y++ { + fillRow(&g.Board, y, KindJ) + } + g.Active = Piece{Kind: KindT, Rotation: 0, X: 3, Y: 0} + evs := g.RotateCW() + if len(evs) == 0 { + t.Skip("this stack refuses every kick, which is also legal; the point is it did not panic") + } + if g.Board.Collides(g.Active) { + t.Fatalf("piece at %+v collides after upward kick", g.Active) + } +} + +func TestRotationFailsWhenEveryKickIsBlocked(t *testing.T) { + g := New(1) + clearBoard(g) + for y := 0; y < Height; y++ { + fillRow(&g.Board, y, KindJ) + } + // Carve out exactly the horizontal I slot and nothing else. + g.Board.Cells[10] = [Width]Cell{} + g.Active = Piece{Kind: KindI, Rotation: 0, X: 3, Y: 9} + before := g.Active + if evs := g.RotateCW(); len(evs) != 0 { + t.Errorf("blocked rotation emitted %v, want no events", evs) + } + if g.Active != before { + t.Errorf("blocked rotation changed the piece to %+v, want %+v", g.Active, before) + } +} +``` + +`IsRotated()` in the first wall-kick test is a typo trap — replace that line with `if len(g.RotateCW()) == 0 {`. Do not add a helper method for it. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/game/ -run TestRotate -v` +Expected: FAIL — `g.RotateCW undefined`. + +- [ ] **Step 3: Write the implementation** + +Append to `internal/game/game.go`: + +```go +// rotate turns the active piece by dir quarter-turns (+1 clockwise), trying the +// §7 kick offsets in order and accepting the first that fits. If none fit, the +// rotation fails and nothing changes. +func (g *Game) rotate(dir int) []Event { + if g.Phase != PhasePlaying { + return nil + } + target := ((g.Active.Rotation+dir)%4 + 4) % 4 + for _, k := range KickOffsets { + cand := g.Active + cand.Rotation = target + cand.X += k.X + cand.Y += k.Y + if g.Board.Collides(cand) { + continue + } + g.Active = cand + g.resetLock() + return []Event{{Kind: EventPieceRotated, Piece: cand}} + } + return nil +} + +// RotateCW rotates the active piece clockwise. +func (g *Game) RotateCW() []Event { return g.rotate(1) } + +// RotateCCW rotates the active piece counter-clockwise. +func (g *Game) RotateCCW() []Event { return g.rotate(-1) } +``` + +Also add the `clearBoard` helper to `internal/game/game_test.go` if Task 6's version was removed: + +```go +func clearBoard(g *Game) { g.Board = Board{} } +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Format, vet, commit** + +```bash +gofmt -l . && go vet ./... +git add internal/game/game.go internal/game/game_test.go internal/game/rotation_test.go +git commit -m "feat(game): rotation with forgiving wall kicks" +``` + +--- + +### Task 8: Gravity and locking through Advance(dt) + +**Files:** +- Modify: `internal/game/game.go` (append `Advance`) +- Test: `internal/game/advance_test.go` + +**Interfaces:** +- Consumes: `GravityInterval`, `LockDelay`, `MaxLockResets`, `MaxGravityStepsPerAdvance`. +- Produces: `func (g *Game) Advance(dt time.Duration) []Event`. This is the only way time enters the engine (§49.2). + +For this task, locking commits the piece to the board and spawns the next one. Task 9 extends the same helper with line clearing and scoring, so implement `lockActive` here with a `TODO`-free minimal body and grow it there. + +- [ ] **Step 1: Write the failing test** + +Create `internal/game/advance_test.go`: + +```go +package game + +import ( + "testing" + "time" +) + +func countEvents(evs []Event, kind EventKind) int { + n := 0 + for _, e := range evs { + if e.Kind == kind { + n++ + } + } + return n +} + +func TestAdvanceDropsThePieceOncePerInterval(t *testing.T) { + g := New(1) + clearBoard(g) + g.Active = Piece{Kind: KindO, X: 4, Y: 2} + + if evs := g.Advance(799 * time.Millisecond); len(evs) != 0 { + t.Fatalf("before one interval, events = %v, want none", evs) + } + if g.Active.Y != 2 { + t.Fatalf("piece moved early to Y=%d", g.Active.Y) + } + + evs := g.Advance(2 * time.Millisecond) + if g.Active.Y != 3 { + t.Fatalf("Y = %d after one interval, want 3", g.Active.Y) + } + if countEvents(evs, EventPieceMoved) != 1 { + t.Fatalf("events = %v, want one PieceMoved", evs) + } +} + +func TestAdvanceIgnoresZeroAndNegativeDt(t *testing.T) { + g := New(1) + before := *g + if evs := g.Advance(0); len(evs) != 0 { + t.Errorf("Advance(0) = %v, want no events", evs) + } + if evs := g.Advance(-time.Second); len(evs) != 0 { + t.Errorf("Advance(-1s) = %v, want no events", evs) + } + if g.Active != before.Active || g.Score != before.Score { + t.Error("Advance with non-positive dt changed the game") + } +} + +func TestGravityIsFasterAtHigherLevels(t *testing.T) { + g := New(1) + clearBoard(g) + g.Level = 10 + g.Active = Piece{Kind: KindO, X: 4, Y: 2} + g.Advance(GravityInterval(10)) + if g.Active.Y != 3 { + t.Fatalf("Y = %d, want 3 after one level-10 interval", g.Active.Y) + } +} + +func TestGroundedPieceLocksAfterLockDelay(t *testing.T) { + g := New(1) + clearBoard(g) + g.Active = Piece{Kind: KindO, X: 4, Y: Height - 2} + if g.canMoveDown() { + t.Fatal("test setup: piece should be resting on the floor") + } + + if evs := g.Advance(499 * time.Millisecond); countEvents(evs, EventPieceLocked) != 0 { + t.Fatalf("locked early: %v", evs) + } + if !g.Grounded { + t.Error("Grounded should be true while resting") + } + + evs := g.Advance(2 * time.Millisecond) + if countEvents(evs, EventPieceLocked) != 1 { + t.Fatalf("events = %v, want one PieceLocked", evs) + } + if !g.Board.Cells[Height-1][4].Filled { + t.Error("piece was not committed to the board") + } + if g.Active.Y != SpawnY { + t.Errorf("next piece did not spawn: Active = %+v", g.Active) + } +} + +func TestMovementWhileGroundedResetsTheLockTimer(t *testing.T) { + g := New(1) + clearBoard(g) + g.Active = Piece{Kind: KindO, X: 4, Y: Height - 2} + g.Advance(400 * time.Millisecond) + g.MoveLeft() + if g.LockAccumulator != 0 { + t.Fatalf("LockAccumulator = %v after a grounded move, want 0", g.LockAccumulator) + } + if evs := g.Advance(400 * time.Millisecond); countEvents(evs, EventPieceLocked) != 0 { + t.Fatalf("locked despite the reset: %v", evs) + } +} + +func TestLockResetsAreCappedAtFifteen(t *testing.T) { + g := New(1) + clearBoard(g) + g.Active = Piece{Kind: KindO, X: 4, Y: Height - 2} + g.Advance(10 * time.Millisecond) // become grounded + + for i := 0; i < MaxLockResets; i++ { + if i%2 == 0 { + g.MoveLeft() + } else { + g.MoveRight() + } + g.Advance(400 * time.Millisecond) + } + if g.LockResets != MaxLockResets { + t.Fatalf("LockResets = %d, want %d", g.LockResets, MaxLockResets) + } + + // The sixteenth move must no longer postpone the lock. + g.MoveLeft() + if g.LockAccumulator == 0 { + t.Fatal("move past the reset cap still reset the lock timer") + } + if evs := g.Advance(LockDelay); countEvents(evs, EventPieceLocked) != 1 { + t.Fatalf("events = %v, want the piece to finally lock", evs) + } +} + +func TestSlidingOffALedgeCountsAgainstTheResetCap(t *testing.T) { + g := New(1) + clearBoard(g) + // A one-column pillar on the right: the O rests on it, then slides left into air. + g.Board.Cells[Height-1][6] = Cell{Filled: true, Kind: KindI} + g.Board.Cells[Height-1][7] = Cell{Filled: true, Kind: KindI} + g.Active = Piece{Kind: KindO, X: 5, Y: Height - 3} + g.Advance(10 * time.Millisecond) + if !g.Grounded { + t.Fatal("test setup: piece should be resting on the pillar") + } + before := g.LockResets + g.MoveLeft() + g.Advance(10 * time.Millisecond) + if g.LockResets <= before { + t.Errorf("LockResets = %d, want more than %d: sliding off a ledge must count", g.LockResets, before) + } +} + +func TestAdvanceWithAHugeDtIsBounded(t *testing.T) { + // Review Focus 1: a suspended terminal or sleeping machine hands us an + // enormous dt. Gravity work must be bounded and the piece must not tunnel. + g := New(1) + clearBoard(g) + g.Active = Piece{Kind: KindO, X: 4, Y: 0} + g.Board.Cells[10][4] = Cell{Filled: true, Kind: KindI} + g.Board.Cells[10][5] = Cell{Filled: true, Kind: KindI} + + evs := g.Advance(10 * time.Minute) + + if g.Active.Y > 8 && g.Phase == PhasePlaying { + t.Fatalf("piece tunnelled to Y=%d through the obstruction at row 10", g.Active.Y) + } + if countEvents(evs, EventPieceMoved) > MaxGravityStepsPerAdvance { + t.Fatalf("%d gravity steps in one Advance, want at most %d", + countEvents(evs, EventPieceMoved), MaxGravityStepsPerAdvance) + } + if g.GravityAccumulator > GravityInterval(g.Level) { + t.Fatalf("GravityAccumulator = %v, want a bounded backlog", g.GravityAccumulator) + } +} + +func TestAdvanceAtMaxLevelDoesNotSpinForever(t *testing.T) { + // The interval clamps to 60ms; a one-second dt must still terminate quickly + // and produce a bounded number of steps. + g := New(1) + clearBoard(g) + g.Level = 99 + g.Active = Piece{Kind: KindO, X: 4, Y: 0} + done := make(chan []Event, 1) + go func() { done <- g.Advance(time.Second) }() + select { + case evs := <-done: + if countEvents(evs, EventPieceMoved) > MaxGravityStepsPerAdvance { + t.Fatalf("too many steps: %d", countEvents(evs, EventPieceMoved)) + } + case <-time.After(2 * time.Second): + t.Fatal("Advance did not return: gravity loop is unbounded") + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/game/ -run TestAdvance -v` +Expected: FAIL — `g.Advance undefined`. + +- [ ] **Step 3: Write the implementation** + +Append to `internal/game/game.go`: + +```go +// Advance moves elapsed time into the game (§49.2). It is the only clock the +// engine has; nothing in this package calls time.Now(). Returned events are in +// the order they happened. +func (g *Game) Advance(dt time.Duration) []Event { + if g.Phase != PhasePlaying || dt <= 0 { + return nil + } + var evs []Event + if g.canMoveDown() { + if g.Grounded { + // The piece slid off a ledge. Restarting the lock timer here counts + // against MaxLockResets so that ledge-sliding cannot stall forever. + g.resetLock() + g.Grounded = false + } + interval := GravityInterval(g.Level) + g.GravityAccumulator += dt + steps := 0 + for g.GravityAccumulator >= interval { + if steps >= MaxGravityStepsPerAdvance { + // A dt this large means we lost wall-clock time (suspend, sleep, + // a stalled terminal). Drop the backlog rather than catching up. + g.GravityAccumulator = 0 + break + } + g.GravityAccumulator -= interval + if !g.canMoveDown() { + break + } + g.Active.Y++ + steps++ + evs = append(evs, Event{Kind: EventPieceMoved, Piece: g.Active}) + } + } + if !g.canMoveDown() { + g.Grounded = true + g.LockAccumulator += dt + if g.LockAccumulator >= LockDelay { + evs = append(evs, g.lockActive()...) + } + } + return evs +} + +// lockActive commits the active piece and spawns the next one. Task 9 extends +// this with row clearing and scoring. +func (g *Game) lockActive() []Event { + g.Board.Lock(g.Active) + evs := []Event{{Kind: EventPieceLocked, Piece: g.Active}} + return append(evs, g.spawnNext()...) +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Format, vet, commit** + +```bash +gofmt -l . && go vet ./... +git add internal/game/game.go internal/game/advance_test.go +git commit -m "feat(game): elapsed-time gravity, lock delay and reset cap" +``` + +--- + +### Task 9: Line clearing, combo, level-up, hard drop, hold and game over + +**Files:** +- Modify: `internal/game/game.go` (extend `lockActive`, append `HardDrop`, `UseHold`) +- Test: `internal/game/clear_test.go` +- Test: `internal/game/hold_test.go` +- Test: `internal/game/gameover_test.go` + +**Interfaces:** +- Consumes: `LineScore`, `ComboBonus`, `LevelFor`, `Board.CompleteRows`, `Board.ClearRows`. +- Produces: `func (g *Game) HardDrop() []Event`; `func (g *Game) UseHold() []Event`; extended `lockActive` emitting `EventLinesCleared`, `EventComboChanged`, `EventLevelChanged`. + +Rules pinned here, because the tests depend on them: +- Line and combo scoring use the level **before** the level-up caused by that same clear. +- Hard drop locks **immediately**; the §12 lock delay applies to gravity landings only. §18's impact fires on contact, so waiting 500ms would feel wrong. +- `EventLinesCleared` carries pre-clear row indices, which is what the §19 supernova animation draws on. + +- [ ] **Step 1: Write the failing tests** + +Create `internal/game/clear_test.go`: + +```go +package game + +import ( + "reflect" + "testing" + "time" +) + +// fillRowExcept fills row y except for column gap. +func fillRowExcept(b *Board, y, gap int, kind PieceKind) { + for x := 0; x < Width; x++ { + if x == gap { + continue + } + b.Cells[y][x] = Cell{Filled: true, Kind: kind} + } +} + +// dropVerticalIInto places a vertical I above column x and hard drops it. +func dropVerticalIInto(g *Game, x int) []Event { + g.Active = Piece{Kind: KindI, Rotation: 1, X: x - 2, Y: 0} + return g.HardDrop() +} + +func TestSingleClearScoresAndReportsRows(t *testing.T) { + g := New(1) + clearBoard(g) + fillRowExcept(&g.Board, Height-1, 4, KindJ) + g.Active = Piece{Kind: KindO, X: 3, Y: Height - 2} + // The O occupies columns 4 and 5; only column 4 of the bottom row is missing. + g.Active = Piece{Kind: KindI, Rotation: 1, X: 2, Y: Height - 4} + + evs := g.HardDrop() + + if g.Lines != 1 { + t.Fatalf("Lines = %d, want 1", g.Lines) + } + var cleared *Event + for i := range evs { + if evs[i].Kind == EventLinesCleared { + cleared = &evs[i] + } + } + if cleared == nil { + t.Fatalf("events = %v, want a LinesCleared", evs) + } + if want := []int{Height - 1}; !reflect.DeepEqual(cleared.Rows, want) { + t.Errorf("cleared rows = %v, want %v", cleared.Rows, want) + } + if cleared.Combo != 1 { + t.Errorf("combo on first clear = %d, want 1", cleared.Combo) + } +} + +func TestFourLineClearScoresEightHundredTimesLevel(t *testing.T) { + g := New(1) + clearBoard(g) + for y := Height - 4; y < Height; y++ { + fillRowExcept(&g.Board, y, 0, KindJ) + } + g.Level = 3 + g.Score = 0 + g.Active = Piece{Kind: KindI, Rotation: 1, X: -2, Y: 0} + + evs := g.HardDrop() + + if g.Lines != 4 { + t.Fatalf("Lines = %d, want 4", g.Lines) + } + // 800*3 base, no combo bonus at combo 1, plus 2 points per hard-dropped cell. + const base = 800 * 3 + if g.Score < base { + t.Errorf("Score = %d, want at least the %d base clear value", g.Score, base) + } + if countEvents(evs, EventLinesCleared) != 1 { + t.Errorf("events = %v, want exactly one LinesCleared", evs) + } +} + +func TestComboRisesOnConsecutiveClearsAndResetsOnADryPlacement(t *testing.T) { + g := New(1) + clearBoard(g) + for _, y := range []int{Height - 1, Height - 2} { + fillRowExcept(&g.Board, y, 0, KindJ) + } + + g.Active = Piece{Kind: KindO, Rotation: 0, X: -1, Y: 0} + g.HardDrop() + if g.Combo != 1 { + t.Fatalf("combo after first clear = %d, want 1", g.Combo) + } + + fillRowExcept(&g.Board, Height-1, 0, KindJ) + g.Active = Piece{Kind: KindI, Rotation: 1, X: -2, Y: 0} + g.HardDrop() + if g.Combo != 2 { + t.Fatalf("combo after second consecutive clear = %d, want 2", g.Combo) + } + + // A placement into open space clears nothing. + g.Active = Piece{Kind: KindO, X: 4, Y: 0} + evs := g.HardDrop() + if g.Combo != 0 { + t.Errorf("combo after a dry placement = %d, want 0", g.Combo) + } + found := false + for _, e := range evs { + if e.Kind == EventComboChanged && e.Combo == 0 { + found = true + } + } + if !found { + t.Errorf("events = %v, want a ComboChanged to 0", evs) + } +} + +func TestComboBonusIsAppliedAtComboTwo(t *testing.T) { + g := New(1) + clearBoard(g) + g.Level = 2 + g.Combo = 1 // pretend the previous placement cleared + fillRowExcept(&g.Board, Height-1, 0, KindJ) + g.Score = 0 + g.Active = Piece{Kind: KindI, Rotation: 1, X: -2, Y: 0} + + g.HardDrop() + + // 100*2 single + 50*(2-1)*2 combo = 300, plus hard-drop points. + if g.Score < 300 { + t.Errorf("Score = %d, want at least 300 (single + combo bonus at level 2)", g.Score) + } +} + +func TestDryPlacementFromComboZeroEmitsNoComboEvent(t *testing.T) { + g := New(1) + clearBoard(g) + g.Active = Piece{Kind: KindO, X: 4, Y: 0} + evs := g.HardDrop() + if countEvents(evs, EventComboChanged) != 0 { + t.Errorf("events = %v, want no ComboChanged when combo was already 0", evs) + } +} + +func TestTenLinesRaiseTheLevelAndEmitLevelChanged(t *testing.T) { + g := New(1) + clearBoard(g) + g.Lines = 9 + fillRowExcept(&g.Board, Height-1, 0, KindJ) + g.Active = Piece{Kind: KindI, Rotation: 1, X: -2, Y: 0} + + evs := g.HardDrop() + + if g.Level != 2 { + t.Fatalf("Level = %d after 10 lines, want 2", g.Level) + } + found := false + for _, e := range evs { + if e.Kind == EventLevelChanged && e.Level == 2 { + found = true + } + } + if !found { + t.Fatalf("events = %v, want LevelChanged to 2", evs) + } +} + +func TestClearScoreUsesTheLevelBeforeTheLevelUp(t *testing.T) { + g := New(1) + clearBoard(g) + g.Lines = 9 + g.Level = 1 + g.Score = 0 + fillRowExcept(&g.Board, Height-1, 0, KindJ) + g.Active = Piece{Kind: KindI, Rotation: 1, X: -2, Y: 0} + dropped := Height - 1 - g.Active.Y // rows the piece will fall + + g.HardDrop() + + want := 100*1 + 2*dropped + if g.Score != want { + t.Errorf("Score = %d, want %d (single at level 1, not level 2)", g.Score, want) + } +} + +func TestHardDropScoresTwoPerCellAndLocksImmediately(t *testing.T) { + g := New(1) + clearBoard(g) + g.Active = Piece{Kind: KindO, X: 4, Y: 2} + g.Score = 0 + startY := g.Active.Y + + evs := g.HardDrop() + + var drop *Event + for i := range evs { + if evs[i].Kind == EventPieceHardDropped { + drop = &evs[i] + } + } + if drop == nil { + t.Fatalf("events = %v, want a PieceHardDropped", evs) + } + if drop.Distance != Height-2-startY { + t.Errorf("Distance = %d, want %d", drop.Distance, Height-2-startY) + } + if g.Score != 2*drop.Distance { + t.Errorf("Score = %d, want %d", g.Score, 2*drop.Distance) + } + if countEvents(evs, EventPieceLocked) != 1 { + t.Errorf("events = %v, want the piece to lock immediately", evs) + } + if !g.Board.Cells[Height-1][4].Filled { + t.Error("hard-dropped piece was not committed") + } +} + +func TestHardDropOnAGroundedPieceStillLocks(t *testing.T) { + g := New(1) + clearBoard(g) + g.Active = Piece{Kind: KindO, X: 4, Y: Height - 2} + g.Advance(10 * time.Millisecond) + evs := g.HardDrop() + if countEvents(evs, EventPieceLocked) != 1 { + t.Fatalf("events = %v, want one PieceLocked", evs) + } +} +``` + +Create `internal/game/hold_test.go`: + +```go +package game + +import "testing" + +func TestFirstHoldStoresTheActivePieceAndSpawnsTheNext(t *testing.T) { + g := New(1) + held := g.Active.Kind + wanted := g.Next[0] + + evs := g.UseHold() + + if g.Hold == nil || *g.Hold != held { + t.Fatalf("Hold = %v, want %s", g.Hold, held) + } + if g.Active.Kind != wanted { + t.Errorf("Active = %s, want the queued %s", g.Active.Kind, wanted) + } + if len(g.Next) != NextQueueLen { + t.Errorf("len(Next) = %d, want %d", len(g.Next), NextQueueLen) + } + if countEvents(evs, EventHoldUsed) != 1 { + t.Errorf("events = %v, want one HoldUsed", evs) + } + if g.CanHold { + t.Error("CanHold should be false after a hold") + } +} + +func TestSecondHoldSwapsAndReturnsToSpawnRotation(t *testing.T) { + g := New(1) + first := g.Active.Kind + g.UseHold() + second := g.Active.Kind + g.RotateCW() + g.MoveLeft() + g.CanHold = true // simulate the piece having locked and a new one spawning + + g.UseHold() + + if g.Active.Kind != first { + t.Errorf("Active = %s, want the previously held %s", g.Active.Kind, first) + } + if g.Hold == nil || *g.Hold != second { + t.Errorf("Hold = %v, want %s", g.Hold, second) + } + if g.Active.Rotation != 0 { + t.Errorf("Rotation = %d, want spawn rotation 0", g.Active.Rotation) + } + if g.Active.X != SpawnX || g.Active.Y != SpawnY { + t.Errorf("held piece spawned at (%d,%d), want (%d,%d)", g.Active.X, g.Active.Y, SpawnX, SpawnY) + } +} + +func TestSecondHoldBeforeLockingIsBlocked(t *testing.T) { + // Review Focus 4: hold pressed twice in a row. + g := New(1) + g.UseHold() + active, hold := g.Active, *g.Hold + + evs := g.UseHold() + + if len(evs) != 0 { + t.Errorf("blocked hold emitted %v, want no events", evs) + } + if g.Active != active || *g.Hold != hold { + t.Error("blocked hold changed the game state") + } +} + +func TestHoldIsRestoredAfterTheNextLock(t *testing.T) { + g := New(1) + g.UseHold() + if g.CanHold { + t.Fatal("test setup: CanHold should be false") + } + clearBoard(g) + g.HardDrop() + if !g.CanHold { + t.Error("CanHold should be restored once a piece locks") + } +} + +func TestHoldTimersResetForTheIncomingPiece(t *testing.T) { + g := New(1) + clearBoard(g) + g.Active = Piece{Kind: KindO, X: 4, Y: Height - 2} + g.Advance(300 * time.Millisecond) // accumulate lock time while grounded + g.CanHold = true + + g.UseHold() + + if g.LockAccumulator != 0 || g.LockResets != 0 || g.Grounded { + t.Errorf("stale timers after hold: lock=%v resets=%d grounded=%v", + g.LockAccumulator, g.LockResets, g.Grounded) + } +} +``` + +Add `import "time"` to `hold_test.go`. + +Create `internal/game/gameover_test.go`: + +```go +package game + +import ( + "testing" + "time" +) + +func TestBlockedSpawnEndsTheGameExactlyOnce(t *testing.T) { + // Review Focus 5: a lock that tops out. + g := New(1) + clearBoard(g) + for y := 0; y < Height; y++ { + fillRow(&g.Board, y, KindJ) + } + g.Board.Cells[Height-1][0] = Cell{} // one gap so the drop has somewhere to go + g.Active = Piece{Kind: KindO, X: -1, Y: 0} + + evs := g.HardDrop() + + if g.Phase != PhaseGameOver { + t.Fatalf("Phase = %v, want PhaseGameOver", g.Phase) + } + if n := countEvents(evs, EventGameOver); n != 1 { + t.Fatalf("GameOver events = %d, want exactly 1 (events: %v)", n, evs) + } + if evs2 := g.Advance(time.Second); len(evs2) != 0 { + t.Errorf("Advance after game over = %v, want no events", evs2) + } + if n := countEvents(g.Advance(time.Second), EventGameOver); n != 0 { + t.Error("GameOver emitted a second time") + } +} + +func TestEveryActionIsANoOpAfterGameOver(t *testing.T) { + // Review Focus 3: the player is mashing keys as they die. + g := New(1) + g.Phase = PhaseGameOver + before := *g + beforeBoard := g.Board + + actions := map[string]func() []Event{ + "MoveLeft": g.MoveLeft, + "MoveRight": g.MoveRight, + "SoftDrop": g.SoftDrop, + "RotateCW": g.RotateCW, + "RotateCCW": g.RotateCCW, + "HardDrop": g.HardDrop, + "UseHold": g.UseHold, + "Advance": func() []Event { return g.Advance(time.Second) }, + } + for name, fn := range actions { + if evs := fn(); len(evs) != 0 { + t.Errorf("%s after game over emitted %v, want no events", name, evs) + } + } + if g.Active != before.Active || g.Score != before.Score || g.Board != beforeBoard { + t.Error("an action mutated the game after game over") + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/game/ -run 'TestSingleClear|TestFirstHold|TestBlockedSpawn' -v` +Expected: FAIL — `g.HardDrop undefined`, `g.UseHold undefined`. + +- [ ] **Step 3: Extend lockActive and add HardDrop and UseHold** + +Replace the `lockActive` added in Task 8 with: + +```go +// lockActive commits the active piece, resolves clears and scoring, and spawns +// the next piece (§12). Scoring uses the level in force before this clear, so a +// clear that triggers a level-up is paid at the old rate. +func (g *Game) lockActive() []Event { + g.Board.Lock(g.Active) + evs := []Event{{Kind: EventPieceLocked, Piece: g.Active}} + + rows := g.Board.CompleteRows() + if len(rows) > 0 { + g.Board.ClearRows(rows) + g.Lines += len(rows) + g.Combo++ + g.Score += LineScore(len(rows), g.Level) + ComboBonus(g.Combo, g.Level) + evs = append(evs, Event{ + Kind: EventLinesCleared, + Rows: rows, + Combo: g.Combo, + Level: g.Level, + Score: g.Score, + }) + evs = append(evs, Event{Kind: EventComboChanged, Combo: g.Combo, Level: g.Level}) + if lv := LevelFor(g.Lines); lv != g.Level { + g.Level = lv + evs = append(evs, Event{Kind: EventLevelChanged, Level: lv}) + } + } else if g.Combo != 0 { + g.Combo = 0 + evs = append(evs, Event{Kind: EventComboChanged, Combo: 0, Level: g.Level}) + } + + return append(evs, g.spawnNext()...) +} + +// HardDrop slams the piece down, scores two points per cell, and locks +// immediately — the §12 lock delay is for gravity landings, and §18's impact +// fires on contact. +func (g *Game) HardDrop() []Event { + if g.Phase != PhasePlaying { + return nil + } + dist := 0 + for g.canMoveDown() { + g.Active.Y++ + dist++ + } + g.Score += 2 * dist + evs := []Event{{Kind: EventPieceHardDropped, Piece: g.Active, Distance: dist, Score: g.Score}} + return append(evs, g.lockActive()...) +} + +// UseHold swaps the active piece with the hold slot (§9). It works once per +// piece; the incoming piece always arrives in spawn rotation. +func (g *Game) UseHold() []Event { + if g.Phase != PhasePlaying || !g.CanHold { + return nil + } + outgoing := g.Active.Kind + evs := []Event{{Kind: EventHoldUsed, Piece: Piece{Kind: outgoing}}} + + if g.Hold == nil { + g.Hold = &outgoing + evs = append(evs, g.spawnNext()...) + } else { + incoming := *g.Hold + g.Hold = &outgoing + evs = append(evs, g.spawnPiece(incoming)...) + } + // spawnPiece and spawnNext re-enable holding for a fresh piece; a swap does not. + g.CanHold = false + return evs +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. If `TestSingleClearScoresAndReportsRows` fails on setup, fix the fixture (the second `g.Active` assignment is the live one — a vertical I at `X: 2` fills column 4) rather than the implementation. + +- [ ] **Step 5: Format, vet, commit** + +```bash +gofmt -l . && go vet ./... +git add internal/game/game.go internal/game/clear_test.go internal/game/hold_test.go internal/game/gameover_test.go +git commit -m "feat(game): clears, combo, level-up, hard drop, hold and game over" +``` + +--- + +### Task 10: Read-only snapshot for the renderer + +**Files:** +- Modify: `internal/game/game.go` (append `Snapshot`) +- Test: `internal/game/snapshot_test.go` + +**Interfaces:** +- Consumes: `Game`. +- Produces: `type Snapshot struct{ Board Board; Active Piece; GhostY int; HoldKind PieceKind; HasHold bool; Next []PieceKind; Score, Lines, Level, Combo int; Phase Phase; Seed int64 }`; `func (g *Game) Snapshot() Snapshot`. + +§37 requires that rendering not mutate game state. A value-copy snapshot makes that structural instead of a rule someone has to remember: `render` is handed a `Snapshot`, never a `*Game`. + +- [ ] **Step 1: Write the failing test** + +Create `internal/game/snapshot_test.go`: + +```go +package game + +import "testing" + +func TestSnapshotCopiesTheVisibleState(t *testing.T) { + g := New(99) + clearBoard(g) + g.Board.Cells[Height-1][3] = Cell{Filled: true, Kind: KindZ} + g.Score, g.Lines, g.Level, g.Combo = 1234, 42, 5, 3 + g.UseHold() + + s := g.Snapshot() + + if s.Score != 1234 || s.Lines != 42 || s.Level != 5 || s.Combo != 3 { + t.Errorf("stats not copied: %+v", s) + } + if s.Active != g.Active { + t.Errorf("Active = %+v, want %+v", s.Active, g.Active) + } + if s.GhostY != g.GhostY() { + t.Errorf("GhostY = %d, want %d", s.GhostY, g.GhostY()) + } + if !s.HasHold || s.HoldKind != *g.Hold { + t.Errorf("hold = (%v, %s), want (true, %s)", s.HasHold, s.HoldKind, *g.Hold) + } + if len(s.Next) != len(g.Next) { + t.Fatalf("len(Next) = %d, want %d", len(s.Next), len(g.Next)) + } + if s.Board.Cells[Height-1][3] != g.Board.Cells[Height-1][3] { + t.Error("board not copied") + } + if s.Seed != 99 { + t.Errorf("Seed = %d, want 99", s.Seed) + } +} + +func TestSnapshotWithNoHeldPiece(t *testing.T) { + s := New(1).Snapshot() + if s.HasHold { + t.Error("HasHold should be false on a fresh game") + } +} + +func TestMutatingASnapshotCannotTouchTheGame(t *testing.T) { + g := New(1) + clearBoard(g) + s := g.Snapshot() + + s.Board.Cells[0][0] = Cell{Filled: true, Kind: KindT} + s.Active.X = 99 + s.Score = 999999 + s.Next[0] = KindZ + + if g.Board.Cells[0][0].Filled { + t.Error("snapshot board aliases the game board") + } + if g.Active.X == 99 { + t.Error("snapshot Active aliases the game") + } + if g.Score == 999999 { + t.Error("snapshot Score aliases the game") + } + if g.Next[0] == KindZ && s.Next[0] == KindZ { + t.Error("snapshot Next slice aliases the game queue") + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/game/ -run TestSnapshot -v` +Expected: FAIL — `g.Snapshot undefined`. + +- [ ] **Step 3: Write the implementation** + +Append to `internal/game/game.go`: + +```go +// Snapshot is a value copy of everything the renderer needs (§37). Handing the +// renderer a Snapshot rather than a *Game means rendering cannot mutate game +// state even by accident. +type Snapshot struct { + Board Board + Active Piece + GhostY int + + HoldKind PieceKind + HasHold bool + Next []PieceKind + + Score int + Lines int + Level int + Combo int + + Phase Phase + Seed int64 +} + +// Snapshot captures the current state for rendering. +func (g *Game) Snapshot() Snapshot { + s := Snapshot{ + Board: g.Board, + Active: g.Active, + GhostY: g.GhostY(), + Next: append(make([]PieceKind, 0, len(g.Next)), g.Next...), + Score: g.Score, + Lines: g.Lines, + Level: g.Level, + Combo: g.Combo, + Phase: g.Phase, + Seed: g.Seed, + } + if g.Hold != nil { + s.HoldKind, s.HasHold = *g.Hold, true + } + return s +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/game/ -v` +Expected: PASS. + +- [ ] **Step 5: Format, vet, commit** + +```bash +gofmt -l . && go vet ./... +git add internal/game/game.go internal/game/snapshot_test.go +git commit -m "feat(game): value-copy snapshot for the renderer" +``` + +--- + +### Task 11: Determinism replay test + +**Files:** +- Test: `internal/game/determinism_test.go` + +**Interfaces:** +- Consumes: the whole engine. +- Produces: nothing exported. This task's deliverable is the proof that §35 holds. + +- [ ] **Step 1: Write the test** + +Create `internal/game/determinism_test.go`: + +```go +package game + +import ( + "fmt" + "math/rand/v2" + "strings" + "testing" + "time" +) + +// action is one entry in a canned input stream. +type action struct { + key string // "", "left", "right", "cw", "ccw", "soft", "hard", "hold" + dt time.Duration // elapsed time fed to Advance after the key +} + +// cannedStream builds a long, reproducible (input, dt) stream. Its own RNG is +// separate from any game so that generating the script never touches game state. +func cannedStream(n int) []action { + keys := []string{"", "left", "right", "cw", "ccw", "soft", "hard", "hold"} + r := rand.New(rand.NewPCG(0xC0FFEE, 0xBEEF)) + out := make([]action, 0, n) + for i := 0; i < n; i++ { + out = append(out, action{ + key: keys[r.IntN(len(keys))], + dt: time.Duration(r.IntN(90)+1) * time.Millisecond, + }) + } + return out +} + +// replay runs a stream against a fresh game and returns a fingerprint of the +// final state. +func replay(seed int64, stream []action) string { + g := New(seed) + events := 0 + for _, a := range stream { + switch a.key { + case "left": + events += len(g.MoveLeft()) + case "right": + events += len(g.MoveRight()) + case "cw": + events += len(g.RotateCW()) + case "ccw": + events += len(g.RotateCCW()) + case "soft": + events += len(g.SoftDrop()) + case "hard": + events += len(g.HardDrop()) + case "hold": + events += len(g.UseHold()) + } + events += len(g.Advance(a.dt)) + } + return fingerprint(g, events) +} + +func fingerprint(g *Game, events int) string { + var b strings.Builder + for y := 0; y < Height; y++ { + for x := 0; x < Width; x++ { + if c := g.Board.Cells[y][x]; c.Filled { + b.WriteString(c.Kind.String()) + } else { + b.WriteByte('.') + } + } + b.WriteByte('/') + } + fmt.Fprintf(&b, "score=%d lines=%d level=%d combo=%d phase=%v events=%d active=%+v next=%v", + g.Score, g.Lines, g.Level, g.Combo, g.Phase, events, g.Active, g.Next) + return b.String() +} + +func TestReplayOfACannedStreamIsReproducible(t *testing.T) { + stream := cannedStream(4000) + first := replay(8675309, stream) + for i := 0; i < 3; i++ { + if got := replay(8675309, stream); got != first { + t.Fatalf("replay %d diverged from the first run", i+1) + } + } +} + +func TestReplayReachesAMeaningfulState(t *testing.T) { + // A replay that ends on move one proves nothing. Assert the stream actually + // plays the game. + g := New(8675309) + for _, a := range cannedStream(4000) { + switch a.key { + case "hard": + g.HardDrop() + case "soft": + g.SoftDrop() + case "left": + g.MoveLeft() + case "right": + g.MoveRight() + case "cw": + g.RotateCW() + case "hold": + g.UseHold() + } + g.Advance(a.dt) + } + if g.Score == 0 { + t.Error("replay scored nothing; the canned stream is not exercising the game") + } +} + +func TestDifferentSeedsDivergeUnderTheSameStream(t *testing.T) { + stream := cannedStream(2000) + if replay(1, stream) == replay(2, stream) { + t.Error("two seeds produced identical final states; the seed is not reaching the bag") + } +} + +func TestSplittingDtDoesNotChangeGravityOutcomes(t *testing.T) { + // One 800ms step and eight 100ms steps must both drop the piece exactly once. + a, b := New(5), New(5) + clearBoard(a) + clearBoard(b) + a.Active = Piece{Kind: KindO, X: 4, Y: 2} + b.Active = Piece{Kind: KindO, X: 4, Y: 2} + + a.Advance(800 * time.Millisecond) + for i := 0; i < 8; i++ { + b.Advance(100 * time.Millisecond) + } + if a.Active.Y != b.Active.Y { + t.Fatalf("coarse dt landed at Y=%d, fine dt at Y=%d", a.Active.Y, b.Active.Y) + } +} +``` + +- [ ] **Step 2: Run the tests** + +Run: `go test ./internal/game/ -run 'TestReplay|TestDifferentSeeds|TestSplittingDt' -v` +Expected: PASS. If `TestReplayOfACannedStreamIsReproducible` fails, there is a real determinism bug — hunt for map iteration order, a second RNG, or a `time.Now()` call under `internal/game`. + +- [ ] **Step 3: Prove the engine never reads a clock** + +Run: + +```bash +grep -rn 'time\.Now\|time\.Since\|time\.Tick\|time\.After' internal/game/*.go | grep -v '_test.go' +``` + +Expected: no output. If anything matches, remove it — §49.2 is the whole basis of the replay test. + +- [ ] **Step 4: Run the full suite with the race detector** + +Run: `go test ./... -race -count=2` +Expected: PASS. `-count=2` catches state leaking through package-level variables. + +- [ ] **Step 5: Commit** + +```bash +gofmt -l . && go vet ./... +git add internal/game/determinism_test.go +git commit -m "test(game): seeded replay proves the engine is deterministic" +``` + +--- + +## Done when + +- `go test ./... -race` passes. +- `internal/game` contains no clock reads (Task 11 Step 3 greps clean). +- Every §40 engine test category has coverage: board (collision, bounds, row completion, removal, collapse), pieces (every rotation, wall kicks, failed rotation, spawn position), bag (all seven per bag, seeded reproducibility), hold (initial, swap, second blocked, restored after lock), drop (soft, hard, landing position, lock), score (line values, combo, drop scoring, level progression), game over (blocked spawn, state transition), determinism (canned replay). +- Plan 2 can start: it needs `game.New`, `game.Snapshot`, the action methods, `Advance`, and `game.Event`. diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/baseline-cosmic-2/2026-09-17-cosmic-tetris-2-playable-terminal.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/baseline-cosmic-2/2026-09-17-cosmic-tetris-2-playable-terminal.md new file mode 100644 index 000000000..29e74af1a --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/baseline-cosmic-2/2026-09-17-cosmic-tetris-2-playable-terminal.md @@ -0,0 +1,3666 @@ +# Cosmic Tetris — Plan 2: Playable Terminal Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Put a terminal on top of Plan 1's engine — Bubble Tea event loop, keyboard, board, HUD, next queue, hold, ghost, help, pause, restart, live resize, and the whole CLI flag surface — so that at the end of this plan Cosmic Tetris is already a genuinely good game with no effects at all (§42 Phase 2). + +**Architecture:** All drawing goes through one cell grid: `render.Canvas` holds a rune plus a comparable `Paint` per terminal cell, and emits Lip Gloss-styled runs on `String()`. Everything else (board, HUD, overlays) writes into that canvas, which is why compositing starfields and particles over the board in Plan 3 costs nothing new. `render.Layout` computes element positions from terminal size and owns the §49.3 drop order. The renderer is handed a `game.Snapshot` value, never a `*game.Game`, so §37's "rendering must not mutate game state" is structural. + +**Tech Stack:** Go 1.26, `charm.land/bubbletea/v2@v2.0.9`, `charm.land/lipgloss/v2@v2.0.6`, `charm.land/bubbles/v2@v2.2.1`, `github.com/charmbracelet/x/ansi` (already an indirect dependency; used by tests to strip ANSI), `github.com/charmbracelet/colorprofile` (already an indirect dependency; used for capability detection). + +**Spec:** `design.md` + +**Depends on:** Plan 1 (`plans/2026-09-17-cosmic-tetris-1-game-engine.md`) complete and merged. + +## Global Constraints + +- Language: Go. Module path `cosmic-tetris`. Go directive `go 1.26`. +- Repository layout is §33 of the spec, exactly. Deviations permitted across this plan set, and only these: `internal/game/events.go` (Plan 1), `internal/render/canvas.go` (this plan), `internal/render/overlays.go` (this plan). +- Bubble Tea is the application/event loop and must not be hidden behind a homegrown framework (§3). Use Lip Gloss for colour, borders, gradients, layout and text styling. Use Bubbles only for key bindings, help, and the boot spinner (§3). +- Imports are `charm.land/bubbletea/v2`, `charm.land/lipgloss/v2`, `charm.land/bubbles/v2` — pinned at v2.0.9 / v2.0.6 / v2.2.1. +- Bubble Tea v2 API facts that differ from v1 and will bite otherwise: `Model` is `Init() Cmd`, `Update(Msg) (Model, Cmd)`, `View() tea.View`. Alt screen is `v.AltScreen = true` on the returned `tea.View`, not a program option. Key presses arrive as `tea.KeyPressMsg`. `tea.Tick` fires once and must be re-issued. +- Board glyphs are two terminal columns per logical cell (§5). Board interior is therefore exactly 20 columns × 20 rows in every rendering mode. +- Glyphs are pinned by §49.4: ghost `░░` in full/reduced and `··` in ASCII; pieces are filled block glyphs (`██`, `[]` in ASCII) with a bright foreground — never a foreground+background pairing. The active piece renders one step brighter than locked cells. +- Minimum usable terminal is 40 columns × 24 rows; below that show the §31 too-small notice. Never crash from a resize (§31). +- Small-terminal drop order is §49.3: title border first, then mission control, then stat labels. NEXT never stacks above or below the board; at small sizes it sits beside the board and truncates to 3 upcoming pieces. +- The §4 wide-layout mockup is intent, not geometry (§49.7). The ANSI-stripped golden tests are the binding layout contract (§41). +- Rendering must not mutate game state (§37). `render` takes `game.Snapshot`; it must not import anything that lets it write to a `*game.Game`. +- Final CLI surface is exactly §49.5: bare, `--seed 1234`, `--ascii`, `--no-fx`, `--reduced-motion`, `--help`. Nothing else. +- Input must not wait for ticks (§36, §44). Key handling happens in `Update` on receipt, never deferred to the next frame. +- Every commit must leave `gofmt -l .` empty, `go vet ./...` clean, and `go test ./...` passing. + +## Review Focus + +1. **A terminal reporting 0×0 or 1×1**, which happens during startup and while a terminal is being dragged: must render the too-small notice, not index out of range. Pinned in Task 3 and Task 6. +2. **A very large terminal (300×100)**: the block stays centred, nothing overlaps, no panic, no quadratic blow-up. Pinned in Task 3 and Task 6. +3. **Board interior geometry across all three rendering modes**: exactly 20 columns and 20 rows, because a glyph that is not two columns wide silently desynchronises every x coordinate. Pinned in Task 4. +4. **HUD values wider than their field** (score past 8 digits, lines past 3, level past 2): the panel must not widen and shove the board sideways. Pinned in Task 5. +5. **A resize arriving while paused, in help, or after game over**: state survives, layout recomputes, nothing panics. Pinned in Task 10. + +--- + +### Task 1: The cell canvas + +**Files:** +- Create: `internal/render/canvas.go` +- Test: `internal/render/canvas_test.go` + +**Interfaces:** +- Consumes: nothing from Plan 1. +- Produces: `type RGB struct{ R, G, B uint8 }`; `func RGBFrom(c color.Color) RGB`; `type Paint struct{ FG RGB; Bold, Faint bool }`; `type Canvas struct{ ... }`; `func NewCanvas(w, h int) *Canvas`; `func (c *Canvas) Size() (w, h int)`; `func (c *Canvas) Resize(w, h int)`; `func (c *Canvas) Clear()`; `func (c *Canvas) Set(x, y int, r rune, p Paint)`; `func (c *Canvas) SetString(x, y int, s string, p Paint)`; `func (c *Canvas) String() string`. + +Out-of-range writes are silent no-ops. That single decision is what lets particles fly off the board and terminals shrink mid-frame without a panic. + +- [ ] **Step 1: Add the dependencies** + +```bash +cd "$(git rev-parse --show-toplevel)" +go get charm.land/bubbletea/v2@v2.0.9 charm.land/lipgloss/v2@v2.0.6 charm.land/bubbles/v2@v2.2.1 +``` + +- [ ] **Step 2: Write the failing test** + +Create `internal/render/canvas_test.go`: + +```go +package render + +import ( + "image/color" + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" +) + +var ( + cyan = Paint{FG: RGB{0x22, 0xEE, 0xFF}} + pink = Paint{FG: RGB{0xFF, 0x33, 0x99}, Bold: true} + plain = Paint{} +) + +func TestNewCanvasIsBlank(t *testing.T) { + c := NewCanvas(4, 2) + if got := ansi.Strip(c.String()); got != " \n " { + t.Fatalf("blank canvas = %q, want two rows of four spaces", got) + } +} + +func TestSetPlacesARune(t *testing.T) { + c := NewCanvas(4, 2) + c.Set(1, 0, 'X', cyan) + c.Set(3, 1, 'Y', plain) + if got, want := ansi.Strip(c.String()), " X \n Y"; got != want { + t.Fatalf("canvas = %q, want %q", got, want) + } +} + +func TestSetStringWritesLeftToRight(t *testing.T) { + c := NewCanvas(8, 1) + c.SetString(2, 0, "██", cyan) + if got, want := ansi.Strip(c.String()), " ██ "; got != want { + t.Fatalf("canvas = %q, want %q", got, want) + } +} + +func TestOutOfRangeWritesAreSilentNoOps(t *testing.T) { + c := NewCanvas(3, 2) + // If any of these panic or bleed, particles and resizes will crash the game. + c.Set(-1, 0, 'X', cyan) + c.Set(3, 0, 'X', cyan) + c.Set(0, -5, 'X', cyan) + c.Set(0, 2, 'X', cyan) + c.SetString(-4, 0, "hello", cyan) + c.SetString(2, 1, "overflowing", cyan) + c.SetString(0, 99, "nowhere", cyan) + + got := ansi.Strip(c.String()) + if got != " \n o" { + t.Fatalf("canvas = %q, want the clipped write only", got) + } +} + +func TestZeroSizedCanvasIsUsable(t *testing.T) { + // Terminals report 0x0 during startup and while being dragged. + c := NewCanvas(0, 0) + c.Set(0, 0, 'X', cyan) + c.SetString(0, 0, "nope", cyan) + if got := c.String(); got != "" { + t.Fatalf("zero canvas = %q, want empty", got) + } + c2 := NewCanvas(-5, -5) + if got := c2.String(); got != "" { + t.Fatalf("negative canvas = %q, want empty", got) + } +} + +func TestClearResetsEveryCell(t *testing.T) { + c := NewCanvas(3, 1) + c.SetString(0, 0, "abc", pink) + c.Clear() + if got := c.String(); got != " " { + t.Fatalf("cleared canvas = %q, want unstyled spaces", got) + } +} + +func TestResizeChangesGeometryAndClears(t *testing.T) { + c := NewCanvas(3, 1) + c.SetString(0, 0, "abc", pink) + c.Resize(5, 2) + if w, h := c.Size(); w != 5 || h != 2 { + t.Fatalf("Size = %dx%d, want 5x2", w, h) + } + if got := ansi.Strip(c.String()); got != " \n " { + t.Fatalf("resized canvas = %q, want blank 5x2", got) + } +} + +func TestOutputHasOneLinePerRow(t *testing.T) { + c := NewCanvas(6, 4) + if got := len(strings.Split(c.String(), "\n")); got != 4 { + t.Fatalf("got %d lines, want 4", got) + } +} + +func TestUnstyledCellsEmitNoEscapeCodes(t *testing.T) { + c := NewCanvas(4, 1) + c.SetString(0, 0, "abcd", plain) + if got := c.String(); got != "abcd" { + t.Fatalf("plain output = %q, want no escape codes", got) + } +} + +func TestStyledCellsEmitEscapeCodesAndStripBackToText(t *testing.T) { + c := NewCanvas(4, 1) + c.SetString(0, 0, "ab", cyan) + c.SetString(2, 0, "cd", pink) + out := c.String() + if !strings.Contains(out, "\x1b[") { + t.Fatalf("styled output has no escape codes: %q", out) + } + if got := ansi.Strip(out); got != "abcd" { + t.Fatalf("stripped output = %q, want %q", got, "abcd") + } +} + +func TestAdjacentCellsWithTheSamePaintBecomeOneRun(t *testing.T) { + c := NewCanvas(4, 1) + c.SetString(0, 0, "abcd", cyan) + // One run means exactly one style prefix and one reset. + if got := strings.Count(c.String(), "\x1b[0m"); got != 1 { + t.Fatalf("%d resets, want 1: runs are not being merged", got) + } +} + +func TestRGBFromConvertsStandardColors(t *testing.T) { + got := RGBFrom(color.RGBA{R: 0x12, G: 0x34, B: 0x56, A: 0xFF}) + if want := (RGB{0x12, 0x34, 0x56}); got != want { + t.Fatalf("RGBFrom = %v, want %v", got, want) + } +} +``` + +- [ ] **Step 3: Run the test to verify it fails** + +Run: `go test ./internal/render/ -run TestNewCanvas -v` +Expected: FAIL — `undefined: NewCanvas`. + +- [ ] **Step 4: Write the implementation** + +Create `internal/render/canvas.go`: + +```go +// Package render draws Cosmic Tetris. Everything is composited into a Canvas of +// terminal cells and emitted as Lip Gloss-styled runs, which is what lets the +// board, the HUD, the starfield and the particles share one coordinate space. +// +// Nothing here mutates game state: the renderer is handed a game.Snapshot value +// rather than a *game.Game (§37). +package render + +import ( + "image/color" + "strings" + + "charm.land/lipgloss/v2" +) + +// RGB is a truecolor value. Colours are resolved to RGB at the canvas boundary +// so that Paint stays comparable and runs of identical styling can be merged. +// Downsampling for 256-colour and 16-colour terminals is Bubble Tea's job. +type RGB struct{ R, G, B uint8 } + +// RGBFrom converts any color.Color to RGB. +func RGBFrom(c color.Color) RGB { + r, g, b, _ := c.RGBA() + return RGB{R: uint8(r >> 8), G: uint8(g >> 8), B: uint8(b >> 8)} +} + +func (c RGB) color() color.Color { return color.RGBA{R: c.R, G: c.G, B: c.B, A: 0xFF} } + +func (c RGB) isZero() bool { return c == RGB{} } + +// Paint is the styling of one cell. It is deliberately comparable. A zero FG +// means "no colour": pure black text is useless in a terminal, so the zero +// value doubles as unstyled. +type Paint struct { + FG RGB + Bold bool + Faint bool +} + +type cell struct { + Rune rune + Paint Paint +} + +// Canvas is a fixed grid of terminal cells. Writes outside the grid are silently +// dropped, which is what keeps stray particles and mid-frame resizes harmless. +type Canvas struct { + w, h int + cells []cell + styles map[Paint]lipgloss.Style + buf strings.Builder + run []rune +} + +// NewCanvas returns a blank canvas. Non-positive dimensions produce an empty +// canvas that accepts writes and renders as "". +func NewCanvas(w, h int) *Canvas { + c := &Canvas{styles: make(map[Paint]lipgloss.Style)} + c.Resize(w, h) + return c +} + +// Size returns the canvas dimensions. +func (c *Canvas) Size() (int, int) { return c.w, c.h } + +// Resize changes the grid and clears it. The backing slice is reused when it is +// already large enough (§38: reusable slices, no per-frame churn). +func (c *Canvas) Resize(w, h int) { + if w < 0 { + w = 0 + } + if h < 0 { + h = 0 + } + c.w, c.h = w, h + need := w * h + if cap(c.cells) < need { + c.cells = make([]cell, need) + } + c.cells = c.cells[:need] + c.Clear() +} + +// Clear resets every cell to an unstyled space. +func (c *Canvas) Clear() { + blank := cell{Rune: ' '} + for i := range c.cells { + c.cells[i] = blank + } +} + +// Set writes one rune. Out-of-range coordinates are ignored. +func (c *Canvas) Set(x, y int, r rune, p Paint) { + if x < 0 || y < 0 || x >= c.w || y >= c.h { + return + } + c.cells[y*c.w+x] = cell{Rune: r, Paint: p} +} + +// SetString writes s left to right from x, y, one rune per column, clipping at +// the edges. +func (c *Canvas) SetString(x, y int, s string, p Paint) { + if y < 0 || y >= c.h { + return + } + for _, r := range s { + if x >= c.w { + return + } + if x >= 0 { + c.cells[y*c.w+x] = cell{Rune: r, Paint: p} + } + x++ + } +} + +func (c *Canvas) styleFor(p Paint) lipgloss.Style { + if s, ok := c.styles[p]; ok { + return s + } + s := lipgloss.NewStyle() + if !p.FG.isZero() { + s = s.Foreground(p.FG.color()) + } + if p.Bold { + s = s.Bold(true) + } + if p.Faint { + s = s.Faint(true) + } + c.styles[p] = s + return s +} + +// String renders the canvas, merging horizontal runs of identical Paint into a +// single styled span. Unstyled runs are emitted as bare text, which keeps the +// golden tests readable. +func (c *Canvas) String() string { + c.buf.Reset() + for y := 0; y < c.h; y++ { + row := c.cells[y*c.w : (y+1)*c.w] + for i := 0; i < c.w; { + p := row[i].Paint + j := i + 1 + for j < c.w && row[j].Paint == p { + j++ + } + c.run = c.run[:0] + for k := i; k < j; k++ { + c.run = append(c.run, row[k].Rune) + } + text := string(c.run) + if p == (Paint{}) { + c.buf.WriteString(text) + } else { + c.buf.WriteString(c.styleFor(p).Render(text)) + } + i = j + } + if y < c.h-1 { + c.buf.WriteByte('\n') + } + } + return c.buf.String() +} +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS. + +- [ ] **Step 6: Format, vet, commit** + +```bash +gofmt -l . && go vet ./... +git add go.mod go.sum internal/render/canvas.go internal/render/canvas_test.go +git commit -m "feat(render): styled cell canvas with clipping and run merging" +``` + +--- + +### Task 2: Palette, rendering modes and glyph sets + +**Files:** +- Create: `internal/render/palette.go` +- Test: `internal/render/palette_test.go` + +**Interfaces:** +- Consumes: `RGB`, `Paint`, `game.PieceKind`. +- Produces: `type Mode uint8` with `ModeFull`, `ModeReduced`, `ModeASCII`; `func (m Mode) String() string`; `func ModeFor(profile colorprofile.Profile, forceASCII bool) Mode`; `type Glyphs struct{ Block, Ghost, Empty string; Stars [3][]rune; Debris []rune; Rings []rune; BorderH, BorderV, BorderTL, BorderTR, BorderBL, BorderBR string }`; `func GlyphsFor(m Mode) Glyphs`; `func PieceColor(k game.PieceKind) RGB`; `func LockedPaint(k game.PieceKind) Paint`; `func ActivePaint(k game.PieceKind) Paint`; `func GhostPaint() Paint`; `var BorderPalette [5]RGB`; `func Dim(c RGB, f float64) RGB`; `func Brighten(c RGB, f float64) RGB`. + +- [ ] **Step 1: Write the failing test** + +Create `internal/render/palette_test.go`: + +```go +package render + +import ( + "testing" + "unicode/utf8" + + "github.com/charmbracelet/colorprofile" + + "cosmic-tetris/internal/game" +) + +func TestModeForMapsColorProfiles(t *testing.T) { + cases := []struct { + profile colorprofile.Profile + force bool + want Mode + }{ + {colorprofile.TrueColor, false, ModeFull}, + {colorprofile.ANSI256, false, ModeReduced}, + {colorprofile.ANSI, false, ModeReduced}, + {colorprofile.ASCII, false, ModeASCII}, + {colorprofile.NoTTY, false, ModeASCII}, + {colorprofile.Unknown, false, ModeReduced}, + {colorprofile.TrueColor, true, ModeASCII}, // --ascii always wins + } + for _, c := range cases { + if got := ModeFor(c.profile, c.force); got != c.want { + t.Errorf("ModeFor(%v, %v) = %v, want %v", c.profile, c.force, got, c.want) + } + } +} + +func TestBlockAndGhostGlyphsAreTwoColumnsInEveryMode(t *testing.T) { + // One logical cell is 2 terminal columns (§5). Every glyph pair must be + // exactly two runes, each one column wide, or the board's x maths desyncs. + for _, m := range []Mode{ModeFull, ModeReduced, ModeASCII} { + g := GlyphsFor(m) + for name, s := range map[string]string{"Block": g.Block, "Ghost": g.Ghost, "Empty": g.Empty} { + if n := utf8.RuneCountInString(s); n != 2 { + t.Errorf("%v %s = %q is %d runes, want 2", m, name, s, n) + } + } + } +} + +func TestGlyphsMatchThePinnedChoices(t *testing.T) { + full := GlyphsFor(ModeFull) + if full.Block != "██" { + t.Errorf("full Block = %q, want %q", full.Block, "██") + } + if full.Ghost != "░░" { + t.Errorf("full Ghost = %q, want %q (§49.4)", full.Ghost, "░░") + } + if GlyphsFor(ModeReduced).Ghost != "░░" { + t.Error("reduced mode ghost must also be ░░ (§49.4)") + } + a := GlyphsFor(ModeASCII) + if a.Block != "[]" { + t.Errorf("ascii Block = %q, want %q", a.Block, "[]") + } + if a.Ghost != ".." { + t.Errorf("ascii Ghost = %q, want %q (§49.4)", a.Ghost, "..") + } +} + +func TestASCIIModeGlyphsAreAllASCII(t *testing.T) { + g := GlyphsFor(ModeASCII) + strs := []string{g.Block, g.Ghost, g.Empty, g.BorderH, g.BorderV, g.BorderTL, g.BorderTR, g.BorderBL, g.BorderBR} + for _, s := range strs { + for _, r := range s { + if r > 0x7F { + t.Errorf("ascii glyph %q contains non-ASCII rune %q", s, r) + } + } + } + for layer, runes := range g.Stars { + for _, r := range runes { + if r > 0x7F { + t.Errorf("ascii star layer %d contains non-ASCII rune %q", layer, r) + } + } + } + for _, r := range g.Debris { + if r > 0x7F { + t.Errorf("ascii debris contains non-ASCII rune %q", r) + } + } +} + +func TestStarfieldHasThreeNonEmptyLayers(t *testing.T) { + for _, m := range []Mode{ModeFull, ModeReduced, ModeASCII} { + g := GlyphsFor(m) + if len(g.Stars) != 3 { + t.Fatalf("%v: %d star layers, want 3 (§15)", m, len(g.Stars)) + } + for i, runes := range g.Stars { + if len(runes) == 0 { + t.Errorf("%v: star layer %d is empty", m, i) + } + } + } +} + +func TestEveryPieceHasADistinctColor(t *testing.T) { + seen := map[RGB]game.PieceKind{} + for k := game.PieceKind(0); k < game.KindCount; k++ { + c := PieceColor(k) + if c.isZero() { + t.Errorf("%s has no colour", k) + } + if prev, dup := seen[c]; dup { + t.Errorf("%s and %s share colour %v", k, prev, c) + } + seen[c] = k + } +} + +func TestActivePieceIsBrighterThanLockedCells(t *testing.T) { + // §49.4: the active piece renders one step brighter than locked cells. + for k := game.PieceKind(0); k < game.KindCount; k++ { + locked, active := LockedPaint(k), ActivePaint(k) + sum := func(c RGB) int { return int(c.R) + int(c.G) + int(c.B) } + if sum(active.FG) <= sum(locked.FG) { + t.Errorf("%s: active %v is not brighter than locked %v", k, active.FG, locked.FG) + } + } +} + +func TestPiecePaintUsesForegroundOnly(t *testing.T) { + // §49.4 pins filled glyphs with a bright foreground, not a fg+bg pairing. + // Paint has no background field at all, so this test guards the API shape. + p := ActivePaint(game.KindI) + if p.FG.isZero() { + t.Fatal("active paint has no foreground colour") + } +} + +func TestGhostIsDimAndNotBold(t *testing.T) { + g := GhostPaint() + if g.Bold { + t.Error("ghost should not be bold; it must never compete with locked blocks (§10)") + } + if !g.Faint { + t.Error("ghost should be faint") + } +} + +func TestBorderPaletteHasFiveStops(t *testing.T) { + for i, c := range BorderPalette { + if c.isZero() { + t.Errorf("border palette stop %d is unset", i) + } + } +} + +func TestDimAndBrightenStayInRangeAndMoveTheRightWay(t *testing.T) { + base := RGB{0x80, 0x80, 0x80} + if d := Dim(base, 0.5); d.R >= base.R { + t.Errorf("Dim = %v, want darker than %v", d, base) + } + if b := Brighten(base, 0.5); b.R <= base.R { + t.Errorf("Brighten = %v, want lighter than %v", b, base) + } + if got := Brighten(RGB{0xFF, 0xFF, 0xFF}, 5); got != (RGB{0xFF, 0xFF, 0xFF}) { + t.Errorf("Brighten overflowed to %v", got) + } + if got := Dim(RGB{0, 0, 0}, 5); got != (RGB{}) { + t.Errorf("Dim underflowed to %v", got) + } + if got := Dim(base, -1); got != base { + t.Errorf("Dim with a negative factor = %v, want %v unchanged", got, base) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/render/ -run TestModeFor -v` +Expected: FAIL — `undefined: ModeFor`. + +- [ ] **Step 3: Write the implementation** + +Create `internal/render/palette.go`: + +```go +package render + +import ( + "github.com/charmbracelet/colorprofile" + + "cosmic-tetris/internal/game" +) + +// Mode is how much the terminal can be trusted with (§32). +type Mode uint8 + +const ( + // ModeFull is Unicode plus truecolor: every effect, every gradient. + ModeFull Mode = iota + // ModeReduced is Unicode with a smaller colour space; Bubble Tea downsamples + // the truecolor we emit, so only the effect budget changes. + ModeReduced + // ModeASCII assumes nothing beyond ASCII and a handful of colours. + ModeASCII +) + +func (m Mode) String() string { + switch m { + case ModeFull: + return "full" + case ModeReduced: + return "reduced" + default: + return "ascii" + } +} + +// ModeFor picks a rendering mode from the detected colour profile. The --ascii +// flag always wins. +func ModeFor(profile colorprofile.Profile, forceASCII bool) Mode { + if forceASCII { + return ModeASCII + } + switch profile { + case colorprofile.TrueColor: + return ModeFull + case colorprofile.ASCII, colorprofile.NoTTY: + return ModeASCII + default: + // ANSI, ANSI256 and Unknown: assume Unicode works, be modest with colour. + return ModeReduced + } +} + +// Glyphs is the character vocabulary for a mode. Block, Ghost and Empty are all +// exactly two columns, because one logical cell is two terminal columns (§5). +type Glyphs struct { + Block string + Ghost string + Empty string + + // Stars holds the far, mid and near starfield glyph sets (§15). + Stars [3][]rune + // Debris are impact and line-collapse particles (§18, §19). + Debris []rune + // Rings are shockwave glyphs, smallest first (§24). + Rings []rune + + BorderH string + BorderV string + BorderTL string + BorderTR string + BorderBL string + BorderBR string +} + +var ( + unicodeGlyphs = Glyphs{ + Block: "██", + Ghost: "░░", + Empty: " ", + Stars: [3][]rune{ + {'.'}, + {'·', '˚'}, + {'✦', '✧'}, + }, + Debris: []rune{'·', '*', '✦', '+'}, + Rings: []rune{'·', '○', '◌', '◯'}, + BorderH: "═", + BorderV: "║", + BorderTL: "╔", + BorderTR: "╗", + BorderBL: "╚", + BorderBR: "╝", + } + + asciiGlyphs = Glyphs{ + Block: "[]", + Ghost: "..", + Empty: " ", + Stars: [3][]rune{ + {'.'}, + {',', '`'}, + {'*', '+'}, + }, + Debris: []rune{'.', '*', '+', 'o'}, + Rings: []rune{'.', 'o', 'O', '0'}, + BorderH: "-", + BorderV: "|", + BorderTL: "+", + BorderTR: "+", + BorderBL: "+", + BorderBR: "+", + } +) + +// GlyphsFor returns the glyph vocabulary for a mode. +func GlyphsFor(m Mode) Glyphs { + if m == ModeASCII { + return asciiGlyphs + } + return unicodeGlyphs +} + +// pieceColors is the neon space palette (§26). Coherent, not a rainbow toy. +var pieceColors = [game.KindCount]RGB{ + game.KindI: {0x2C, 0xE8, 0xF5}, // plasma cyan + game.KindJ: {0x3B, 0x6B, 0xF5}, // deep electric blue + game.KindL: {0xFF, 0x8C, 0x2B}, // solar orange + game.KindO: {0xFF, 0xD1, 0x4A}, // stellar gold + game.KindS: {0x4B, 0xE8, 0x7A}, // alien green + game.KindT: {0xA8, 0x5C, 0xFF}, // ultraviolet + game.KindZ: {0xFF, 0x3D, 0x7F}, // supernova pink +} + +// PieceColor is a piece family's base colour. +func PieceColor(k game.PieceKind) RGB { + if k >= game.KindCount { + return RGB{0x88, 0x88, 0x88} + } + return pieceColors[k] +} + +// LockedPaint styles a settled block: visually rich, but a step below the +// active piece (§26, §49.4). +func LockedPaint(k game.PieceKind) Paint { + return Paint{FG: Dim(PieceColor(k), 0.25)} +} + +// ActivePaint styles the falling piece: one step brighter, and bold (§49.4). +func ActivePaint(k game.PieceKind) Paint { + return Paint{FG: Brighten(PieceColor(k), 0.25), Bold: true} +} + +// GhostPaint styles the landing preview. Dim and never bold, so it can never +// compete with a locked block (§10). +func GhostPaint() Paint { + return Paint{FG: RGB{0x4A, 0x4F, 0x6B}, Faint: true} +} + +// BorderPalette is the board border's energy-state gradient (§25): +// deep violet, electric cyan, magenta, stellar blue, hot white. +var BorderPalette = [5]RGB{ + {0x5B, 0x22, 0xB8}, + {0x22, 0xE0, 0xF0}, + {0xE0, 0x2B, 0xC8}, + {0x3A, 0x6C, 0xF0}, + {0xF2, 0xF6, 0xFF}, +} + +// Dim darkens c by f (0..1). +func Dim(c RGB, f float64) RGB { + if f <= 0 { + return c + } + if f > 1 { + f = 1 + } + scale := func(v uint8) uint8 { return uint8(float64(v) * (1 - f)) } + return RGB{scale(c.R), scale(c.G), scale(c.B)} +} + +// Brighten lightens c toward white by f (0..1). +func Brighten(c RGB, f float64) RGB { + if f <= 0 { + return c + } + if f > 1 { + f = 1 + } + lift := func(v uint8) uint8 { return uint8(float64(v) + (255-float64(v))*f) } + return RGB{lift(c.R), lift(c.G), lift(c.B)} +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS. + +- [ ] **Step 5: Format, vet, commit** + +```bash +gofmt -l . && go vet ./... +git add internal/render/palette.go internal/render/palette_test.go +git commit -m "feat(render): neon palette, rendering modes and glyph vocabularies" +``` + +--- + +### Task 3: Responsive layout + +**Files:** +- Create: `internal/render/layout.go` +- Test: `internal/render/layout_test.go` + +**Interfaces:** +- Consumes: `game.Width`, `game.VisibleRows`. +- Produces: `const BoardInteriorW = 20`, `const BoardInteriorH = 20`, `const BoardOuterW = 22`, `const BoardOuterH = 22`, `const MinWidth = 40`, `const MinHeight = 24`; `type Layout struct{ W, H int; TooSmall bool; BoardX, BoardY int; LeftX, LeftW, RightX, RightW int; ShowTitle bool; TitleY int; ShowMission bool; MissionY int; ControlsY int; ShowHold bool; ShowStatLabels bool; NextCount int }`; `func Compute(w, h int) Layout`. + +Pinned geometry, derived from §49.3's budget: + +| rows | title | mission | controls | +|---|---|---|---| +| ≥25 | yes | yes | yes | +| 24 | no | yes | yes | +| <24 | too small | + +| cols | hold panel | stat labels | NEXT count | +|---|---|---|---| +| ≥52 | yes | yes | 5 | +| 46–51 | no | yes | 5 | +| 44–45 | no | no | 5 | +| 40–43 | no | no | 3 | +| <40 | too small | + +Left panel is 10 columns with labels, 8 without. Right panel is 10 columns. At exactly 40×24 the content is 8 + 22 + 10 = 40 columns and 22 + 1 + 1 = 24 rows: the minimum fits exactly, which is why those are the minimum. + +- [ ] **Step 1: Write the failing test** + +Create `internal/render/layout_test.go`: + +```go +package render + +import ( + "testing" + + "cosmic-tetris/internal/game" +) + +func TestBoardGeometryConstantsFollowFromTheBoard(t *testing.T) { + if BoardInteriorW != game.Width*2 { + t.Errorf("BoardInteriorW = %d, want %d (2 columns per cell)", BoardInteriorW, game.Width*2) + } + if BoardInteriorH != game.VisibleRows { + t.Errorf("BoardInteriorH = %d, want %d", BoardInteriorH, game.VisibleRows) + } + if BoardOuterW != BoardInteriorW+2 || BoardOuterH != BoardInteriorH+2 { + t.Errorf("outer = %dx%d, want interior plus a border ring", BoardOuterW, BoardOuterH) + } +} + +func TestBelowMinimumIsTooSmall(t *testing.T) { + for _, c := range []struct{ w, h int }{ + {0, 0}, {1, 1}, {39, 24}, {40, 23}, {34, 19}, {-5, -5}, + } { + if l := Compute(c.w, c.h); !l.TooSmall { + t.Errorf("Compute(%d,%d) is not TooSmall", c.w, c.h) + } + } +} + +func TestExactMinimumFits(t *testing.T) { + l := Compute(MinWidth, MinHeight) + if l.TooSmall { + t.Fatalf("Compute(%d,%d) should fit exactly", MinWidth, MinHeight) + } + if l.ShowTitle { + t.Error("title must be the first thing dropped (§49.3)") + } + if !l.ShowMission { + t.Error("mission control survives at the minimum") + } + if l.ShowHold { + t.Error("hold panel does not fit at 40 columns") + } + if l.ShowStatLabels { + t.Error("stat labels do not fit at 40 columns (§49.3: values stay, labels go)") + } + if l.NextCount != 3 { + t.Errorf("NextCount = %d, want 3 at the minimum (§49.3)", l.NextCount) + } + if l.BoardX < 0 || l.BoardX+BoardOuterW > MinWidth { + t.Errorf("board spans %d..%d, outside 0..%d", l.BoardX, l.BoardX+BoardOuterW, MinWidth) + } + if l.BoardY < 0 || l.BoardY+BoardOuterH > MinHeight { + t.Errorf("board spans rows %d..%d, outside 0..%d", l.BoardY, l.BoardY+BoardOuterH, MinHeight) + } +} + +func TestDropOrderIsTitleThenMission(t *testing.T) { + if l := Compute(80, 25); !l.ShowTitle || !l.ShowMission { + t.Errorf("at 25 rows: title=%v mission=%v, want both", l.ShowTitle, l.ShowMission) + } + if l := Compute(80, 24); l.ShowTitle || !l.ShowMission { + t.Errorf("at 24 rows: title=%v mission=%v, want title dropped only", l.ShowTitle, l.ShowMission) + } +} + +func TestWidthThresholds(t *testing.T) { + cases := []struct { + w int + hold, labels bool + next int + }{ + {40, false, false, 3}, + {43, false, false, 3}, + {44, false, false, 5}, + {46, false, true, 5}, + {52, true, true, 5}, + {100, true, true, 5}, + } + for _, c := range cases { + l := Compute(c.w, 30) + if l.ShowHold != c.hold || l.ShowStatLabels != c.labels || l.NextCount != c.next { + t.Errorf("Compute(%d,30) = hold:%v labels:%v next:%d, want hold:%v labels:%v next:%d", + c.w, l.ShowHold, l.ShowStatLabels, l.NextCount, c.hold, c.labels, c.next) + } + } +} + +func TestNextPanelIsAlwaysBesideTheBoard(t *testing.T) { + // §49.3: NEXT never stacks above or below the board. + for _, w := range []int{40, 44, 52, 80, 200} { + l := Compute(w, 30) + if l.RightX < l.BoardX+BoardOuterW { + t.Errorf("width %d: NEXT at x=%d overlaps the board ending at %d", w, l.RightX, l.BoardX+BoardOuterW) + } + if l.RightX+l.RightW > w { + t.Errorf("width %d: NEXT spans past the right edge", w) + } + } +} + +func TestPanelsNeverOverlapTheBoard(t *testing.T) { + for _, c := range []struct{ w, h int }{{40, 24}, {50, 30}, {80, 40}, {300, 100}} { + l := Compute(c.w, c.h) + if l.TooSmall { + t.Fatalf("Compute(%d,%d) unexpectedly too small", c.w, c.h) + } + if l.LeftX+l.LeftW > l.BoardX { + t.Errorf("%dx%d: left panel ends at %d, board starts at %d", c.w, c.h, l.LeftX+l.LeftW, l.BoardX) + } + if l.LeftX < 0 { + t.Errorf("%dx%d: left panel starts off-screen at %d", c.w, c.h, l.LeftX) + } + } +} + +func TestEverythingStaysOnScreenAtAnyReasonableSize(t *testing.T) { + // Review Focus 1 and 2: 0x0 through 300x100 must all produce sane layouts. + for w := 0; w <= 300; w += 7 { + for h := 0; h <= 100; h += 3 { + l := Compute(w, h) + if l.TooSmall { + continue + } + if l.BoardX < 0 || l.BoardX+BoardOuterW > w { + t.Fatalf("%dx%d: board x span %d..%d", w, h, l.BoardX, l.BoardX+BoardOuterW) + } + if l.BoardY < 0 || l.BoardY+BoardOuterH > h { + t.Fatalf("%dx%d: board y span %d..%d", w, h, l.BoardY, l.BoardY+BoardOuterH) + } + if l.ControlsY >= h { + t.Fatalf("%dx%d: controls row %d is off-screen", w, h, l.ControlsY) + } + if l.ShowMission && l.MissionY >= h { + t.Fatalf("%dx%d: mission row %d is off-screen", w, h, l.MissionY) + } + if l.ShowTitle && (l.TitleY < 0 || l.TitleY >= h) { + t.Fatalf("%dx%d: title row %d is off-screen", w, h, l.TitleY) + } + if l.ShowMission && l.MissionY < l.BoardY+BoardOuterH { + t.Fatalf("%dx%d: mission row %d overlaps the board", w, h, l.MissionY) + } + if l.ControlsY <= l.BoardY+BoardOuterH-1 { + t.Fatalf("%dx%d: controls row %d overlaps the board", w, h, l.ControlsY) + } + } + } +} + +func TestHugeTerminalCentresTheBlock(t *testing.T) { + l := Compute(300, 100) + if l.BoardX < 100 { + t.Errorf("board x = %d on a 300-column terminal, want it roughly centred", l.BoardX) + } + if l.BoardY < 30 { + t.Errorf("board y = %d on a 100-row terminal, want it roughly centred", l.BoardY) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/render/ -run TestBoardGeometry -v` +Expected: FAIL — `undefined: BoardInteriorW`. + +- [ ] **Step 3: Write the implementation** + +Create `internal/render/layout.go`: + +```go +package render + +import "cosmic-tetris/internal/game" + +// Board geometry in terminal cells. One logical cell is two columns (§5). +const ( + BoardInteriorW = game.Width * 2 + BoardInteriorH = game.VisibleRows + BoardOuterW = BoardInteriorW + 2 + BoardOuterH = BoardInteriorH + 2 +) + +// Minimum usable terminal (§31). At exactly this size the content is +// 8 + 22 + 10 = 40 columns and 22 + mission + controls = 24 rows, which is why +// these are the numbers. +const ( + MinWidth = 40 + MinHeight = 24 +) + +// Width thresholds for optional chrome. +const ( + holdWidth = 52 + statLabelsWidth = 46 + fullNextWidth = 44 +) + +// Panel widths. +const ( + leftWidthLabelled = 10 + leftWidthBare = 8 + rightWidth = 10 +) + +// Layout is where every element goes for one terminal size. All coordinates are +// absolute terminal cells; BoardX, BoardY is the board's top-left border cell. +type Layout struct { + W, H int + TooSmall bool + + BoardX, BoardY int + + LeftX, LeftW int + RightX, RightW int + + ShowTitle bool + TitleY int + + ShowMission bool + MissionY int + + ControlsY int + + ShowHold bool + ShowStatLabels bool + NextCount int +} + +// Compute lays out one frame. Chrome is dropped in the §49.3 order as space runs +// out; below the minimum the caller shows the too-small notice instead. +func Compute(w, h int) Layout { + l := Layout{W: w, H: h} + if w < MinWidth || h < MinHeight { + l.TooSmall = true + return l + } + + // Vertical budget: the board and the controls line are the last two things + // standing (§49.3). Title goes first, then mission control. + needed := BoardOuterH + 1 + l.ShowMission = h >= needed+1 + l.ShowTitle = h >= needed+2 + if l.ShowMission { + needed++ + } + if l.ShowTitle { + needed++ + } + top := (h - needed) / 2 + if top < 0 { + top = 0 + } + y := top + if l.ShowTitle { + l.TitleY = y + y++ + } + l.BoardY = y + y += BoardOuterH + if l.ShowMission { + l.MissionY = y + y++ + } + l.ControlsY = y + + // Horizontal budget. + l.ShowHold = w >= holdWidth + l.ShowStatLabels = w >= statLabelsWidth + l.NextCount = 3 + if w >= fullNextWidth { + l.NextCount = 5 + } + l.LeftW = leftWidthBare + if l.ShowStatLabels { + l.LeftW = leftWidthLabelled + } + l.RightW = rightWidth + + total := l.LeftW + BoardOuterW + l.RightW + left := (w - total) / 2 + if left < 0 { + left = 0 + } + l.LeftX = left + l.BoardX = left + l.LeftW + l.RightX = l.BoardX + BoardOuterW + return l +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS. + +- [ ] **Step 5: Format, vet, commit** + +```bash +gofmt -l . && go vet ./... +git add internal/render/layout.go internal/render/layout_test.go +git commit -m "feat(render): responsive layout with the pinned drop order" +``` + +--- + +### Task 4: Board rendering — border, locked stack, ghost, active piece + +**Files:** +- Create: `internal/render/board.go` +- Test: `internal/render/board_test.go` + +**Interfaces:** +- Consumes: `Canvas`, `Layout`, `Glyphs`, palette functions, `game.Snapshot`. +- Produces: `func DrawBorder(c *Canvas, l Layout, g Glyphs, colors [4]RGB)`; `func DrawStack(c *Canvas, l Layout, g Glyphs, s game.Snapshot)`; `func DrawGhost(c *Canvas, l Layout, g Glyphs, s game.Snapshot)`; `func DrawActive(c *Canvas, l Layout, g Glyphs, s game.Snapshot)`; `func boardCellXY(l Layout, x, y int) (int, int, bool)`. + +`boardCellXY` maps a logical board cell to its top-left terminal column and row inside the border, returning `false` for the hidden spawn rows. Every board drawing routine goes through it, so the hidden rows can never leak on screen. + +`colors` in `DrawBorder` is the four edge colours (top, right, bottom, left); Plan 3 animates them, and this plan passes four copies of one colour. + +- [ ] **Step 1: Write the failing test** + +Create `internal/render/board_test.go`: + +```go +package render + +import ( + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" + + "cosmic-tetris/internal/game" +) + +// fixtureSnapshot is a deterministic mid-game state used by board and frame tests. +func fixtureSnapshot() game.Snapshot { + var b game.Board + for x := 0; x < game.Width; x++ { + if x != 4 { + b.Cells[game.Height-1][x] = game.Cell{Filled: true, Kind: game.KindJ} + } + } + b.Cells[game.Height-2][0] = game.Cell{Filled: true, Kind: game.KindT} + b.Cells[game.Height-2][9] = game.Cell{Filled: true, Kind: game.KindS} + // A block in the hidden rows: it must never appear on screen. + b.Cells[1][0] = game.Cell{Filled: true, Kind: game.KindZ} + + return game.Snapshot{ + Board: b, + Active: game.Piece{Kind: game.KindT, Rotation: 0, X: 3, Y: 6}, + GhostY: game.Height - 4, + HoldKind: game.KindO, + HasHold: true, + Next: []game.PieceKind{game.KindI, game.KindS, game.KindZ, game.KindL, game.KindJ}, + Score: 129340, + Lines: 42, + Level: 7, + Combo: 2, + Phase: game.PhasePlaying, + Seed: 0x7F3A, + } +} + +func drawBoardOnly(t *testing.T, w, h int, m Mode) (*Canvas, Layout) { + t.Helper() + l := Compute(w, h) + if l.TooSmall { + t.Fatalf("Compute(%d,%d) is too small", w, h) + } + g := GlyphsFor(m) + c := NewCanvas(w, h) + white := RGB{0xFF, 0xFF, 0xFF} + DrawBorder(c, l, g, [4]RGB{white, white, white, white}) + s := fixtureSnapshot() + DrawStack(c, l, g, s) + DrawGhost(c, l, g, s) + DrawActive(c, l, g, s) + return c, l +} + +func lines(c *Canvas) []string { return strings.Split(ansi.Strip(c.String()), "\n") } + +func TestBoardInteriorIsTwentyByTwentyInEveryMode(t *testing.T) { + // Review Focus 3: a glyph that is not two columns desyncs every x coordinate. + for _, m := range []Mode{ModeFull, ModeReduced, ModeASCII} { + c, l := drawBoardOnly(t, 80, 30, m) + rows := lines(c) + g := GlyphsFor(m) + + top := rows[l.BoardY] + interior := top[l.BoardX+1 : l.BoardX+1+BoardInteriorW] + if want := strings.Repeat(g.BorderH, BoardInteriorW); interior != want { + t.Errorf("%v: top border interior = %q, want %q", m, interior, want) + } + for i := 0; i < BoardInteriorH; i++ { + row := rows[l.BoardY+1+i] + if got := string([]rune(row)[l.BoardX]); got != g.BorderV { + t.Errorf("%v: row %d left border = %q, want %q", m, i, got, g.BorderV) + } + if got := string([]rune(row)[l.BoardX+BoardOuterW-1]); got != g.BorderV { + t.Errorf("%v: row %d right border = %q, want %q", m, i, got, g.BorderV) + } + } + bottom := []rune(rows[l.BoardY+BoardOuterH-1]) + if got := string(bottom[l.BoardX]); got != g.BorderBL { + t.Errorf("%v: bottom-left corner = %q, want %q", m, got, g.BorderBL) + } + } +} + +func TestBorderCornersAreCorrect(t *testing.T) { + c, l := drawBoardOnly(t, 80, 30, ModeFull) + rows := lines(c) + g := GlyphsFor(ModeFull) + top := []rune(rows[l.BoardY]) + bot := []rune(rows[l.BoardY+BoardOuterH-1]) + checks := []struct { + name string + got string + want string + }{ + {"top-left", string(top[l.BoardX]), g.BorderTL}, + {"top-right", string(top[l.BoardX+BoardOuterW-1]), g.BorderTR}, + {"bottom-left", string(bot[l.BoardX]), g.BorderBL}, + {"bottom-right", string(bot[l.BoardX+BoardOuterW-1]), g.BorderBR}, + } + for _, c := range checks { + if c.got != c.want { + t.Errorf("%s = %q, want %q", c.name, c.got, c.want) + } + } +} + +func TestLockedCellsRenderAsBlockPairs(t *testing.T) { + c, l := drawBoardOnly(t, 80, 30, ModeFull) + rows := lines(c) + g := GlyphsFor(ModeFull) + // Bottom board row is visible row 19, one row above the bottom border. + bottom := []rune(rows[l.BoardY+BoardOuterH-2]) + // Column 0 is filled, column 4 is the gap. + if got := string(bottom[l.BoardX+1 : l.BoardX+3]); got != g.Block { + t.Errorf("filled cell = %q, want %q", got, g.Block) + } + if got := string(bottom[l.BoardX+1+8 : l.BoardX+1+10]); got != g.Empty { + t.Errorf("empty cell at column 4 = %q, want %q", got, g.Empty) + } +} + +func TestHiddenSpawnRowsAreNeverDrawn(t *testing.T) { + c, l := drawBoardOnly(t, 80, 30, ModeFull) + rows := lines(c) + g := GlyphsFor(ModeFull) + // The fixture puts a Z block at board row 1, which is hidden. + first := []rune(rows[l.BoardY+1]) + if got := string(first[l.BoardX+1 : l.BoardX+3]); got == g.Block { + t.Error("a hidden spawn row block leaked into the visible board") + } +} + +func TestBoardCellXYRejectsHiddenRows(t *testing.T) { + l := Compute(80, 30) + for y := 0; y < game.HiddenRows; y++ { + if _, _, ok := boardCellXY(l, 0, y); ok { + t.Errorf("board row %d should not be visible", y) + } + } + if _, _, ok := boardCellXY(l, 0, game.HiddenRows); !ok { + t.Errorf("board row %d should be the first visible row", game.HiddenRows) + } + x, y, ok := boardCellXY(l, 0, game.Height-1) + if !ok { + t.Fatal("bottom row should be visible") + } + if x != l.BoardX+1 || y != l.BoardY+BoardOuterH-2 { + t.Errorf("bottom-left cell at (%d,%d), want (%d,%d)", x, y, l.BoardX+1, l.BoardY+BoardOuterH-2) + } +} + +func TestActivePieceIsDrawnAtItsPosition(t *testing.T) { + c, l := drawBoardOnly(t, 80, 30, ModeFull) + rows := lines(c) + g := GlyphsFor(ModeFull) + s := fixtureSnapshot() + for _, cell := range s.Active.Cells() { + cx, cy, ok := boardCellXY(l, cell.X, cell.Y) + if !ok { + continue + } + got := string([]rune(rows[cy])[cx : cx+2]) + if got != g.Block { + t.Errorf("active cell %v renders as %q, want %q", cell, got, g.Block) + } + } +} + +func TestGhostIsDrawnBelowAndNeverOverLockedBlocks(t *testing.T) { + c, l := drawBoardOnly(t, 80, 30, ModeFull) + rows := lines(c) + g := GlyphsFor(ModeFull) + s := fixtureSnapshot() + + ghost := s.Active + ghost.Y = s.GhostY + found := 0 + for _, cell := range ghost.Cells() { + cx, cy, ok := boardCellXY(l, cell.X, cell.Y) + if !ok { + continue + } + if got := string([]rune(rows[cy])[cx : cx+2]); got == g.Ghost { + found++ + } + } + if found == 0 { + t.Fatalf("no ghost glyphs %q found on the board", g.Ghost) + } + + // Locked blocks keep their glyph: the ghost must never obscure them (§10). + bottom := []rune(rows[l.BoardY+BoardOuterH-1-1]) + if got := string(bottom[l.BoardX+1 : l.BoardX+3]); got != g.Block { + t.Errorf("locked cell overwritten by ghost: %q", got) + } +} + +func TestActivePieceIsDrawnOverTheGhost(t *testing.T) { + // §44: never obscure the active piece. + l := Compute(80, 30) + g := GlyphsFor(ModeFull) + c := NewCanvas(80, 30) + s := fixtureSnapshot() + s.GhostY = s.Active.Y // force a total overlap + DrawGhost(c, l, g, s) + DrawActive(c, l, g, s) + rows := lines(c) + cell := s.Active.Cells()[0] + cx, cy, _ := boardCellXY(l, cell.X, cell.Y) + if got := string([]rune(rows[cy])[cx : cx+2]); got != g.Block { + t.Errorf("overlapping cell = %q, want the active block %q", got, g.Block) + } +} + +func TestActivePieceAboveTheVisibleAreaDoesNotPanic(t *testing.T) { + l := Compute(80, 30) + g := GlyphsFor(ModeFull) + c := NewCanvas(80, 30) + s := fixtureSnapshot() + s.Active = game.Piece{Kind: game.KindI, Rotation: 1, X: 3, Y: -4} + s.GhostY = -4 + DrawStack(c, l, g, s) + DrawGhost(c, l, g, s) + DrawActive(c, l, g, s) + if len(lines(c)) != 30 { + t.Fatal("canvas geometry disturbed") + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/render/ -run TestBoardInterior -v` +Expected: FAIL — `undefined: DrawBorder`. + +- [ ] **Step 3: Write the implementation** + +Create `internal/render/board.go`: + +```go +package render + +import "cosmic-tetris/internal/game" + +// boardCellXY maps logical board cell x, y to the terminal column and row of its +// left-hand glyph column, inside the border. ok is false for the hidden spawn +// rows and for anything off the board, so hidden rows can never leak on screen. +func boardCellXY(l Layout, x, y int) (int, int, bool) { + if x < 0 || x >= game.Width || y < game.HiddenRows || y >= game.Height { + return 0, 0, false + } + return l.BoardX + 1 + x*2, l.BoardY + 1 + (y - game.HiddenRows), true +} + +// DrawBorder draws the board's frame (§25). colors are the top, right, bottom +// and left edge colours; Plan 3 animates them independently. +func DrawBorder(c *Canvas, l Layout, g Glyphs, colors [4]RGB) { + top, right, bottom, left := Paint{FG: colors[0]}, Paint{FG: colors[1]}, Paint{FG: colors[2]}, Paint{FG: colors[3]} + x0, y0 := l.BoardX, l.BoardY + x1, y1 := x0+BoardOuterW-1, y0+BoardOuterH-1 + + c.SetString(x0, y0, g.BorderTL, top) + c.SetString(x1, y0, g.BorderTR, top) + c.SetString(x0, y1, g.BorderBL, bottom) + c.SetString(x1, y1, g.BorderBR, bottom) + for x := x0 + 1; x < x1; x++ { + c.SetString(x, y0, g.BorderH, top) + c.SetString(x, y1, g.BorderH, bottom) + } + for y := y0 + 1; y < y1; y++ { + c.SetString(x0, y, g.BorderV, left) + c.SetString(x1, y, g.BorderV, right) + } +} + +// DrawStack draws the locked blocks and the empty interior. +func DrawStack(c *Canvas, l Layout, g Glyphs, s game.Snapshot) { + for y := game.HiddenRows; y < game.Height; y++ { + for x := 0; x < game.Width; x++ { + cx, cy, ok := boardCellXY(l, x, y) + if !ok { + continue + } + if cell := s.Board.Cells[y][x]; cell.Filled { + c.SetString(cx, cy, g.Block, LockedPaint(cell.Kind)) + } else { + c.SetString(cx, cy, g.Empty, Paint{}) + } + } + } +} + +// DrawGhost draws the landing preview underneath the active piece (§10). It only +// writes to cells that are currently empty, so it can never obscure a locked block. +func DrawGhost(c *Canvas, l Layout, g Glyphs, s game.Snapshot) { + ghost := s.Active + ghost.Y = s.GhostY + paint := GhostPaint() + for _, cell := range ghost.Cells() { + if cell.X < 0 || cell.X >= game.Width || cell.Y < 0 || cell.Y >= game.Height { + continue + } + if s.Board.Cells[cell.Y][cell.X].Filled { + continue + } + cx, cy, ok := boardCellXY(l, cell.X, cell.Y) + if !ok { + continue + } + c.SetString(cx, cy, g.Ghost, paint) + } +} + +// DrawActive draws the falling piece, one step brighter than the stack (§49.4). +// It is drawn last of the board layers, because nothing may obscure it (§44). +func DrawActive(c *Canvas, l Layout, g Glyphs, s game.Snapshot) { + paint := ActivePaint(s.Active.Kind) + for _, cell := range s.Active.Cells() { + cx, cy, ok := boardCellXY(l, cell.X, cell.Y) + if !ok { + continue + } + c.SetString(cx, cy, g.Block, paint) + } +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS. + +- [ ] **Step 5: Format, vet, commit** + +```bash +gofmt -l . && go vet ./... +git add internal/render/board.go internal/render/board_test.go +git commit -m "feat(render): board border, locked stack, ghost and active piece" +``` + +--- + +### Task 5: HUD — hold, next, stats, title, mission line, controls + +**Files:** +- Create: `internal/render/hud.go` +- Test: `internal/render/hud_test.go` + +**Interfaces:** +- Consumes: `Canvas`, `Layout`, `Glyphs`, palette, `game.Snapshot`. +- Produces: `func DrawTitle(c *Canvas, l Layout, seed int64)`; `func DrawLeftPanel(c *Canvas, l Layout, g Glyphs, s game.Snapshot)`; `func DrawNextPanel(c *Canvas, l Layout, g Glyphs, s game.Snapshot)`; `func DrawMission(c *Canvas, l Layout, msg string)`; `func DrawControls(c *Canvas, l Layout)`; `func FormatScore(n int) string`; `func FormatLines(n int) string`; `func FormatLevel(n int) string`; `func UniverseLabel(seed int64) string`. + +Field widths, and the rule that keeps Review Focus 4 honest: `FormatScore` renders `%08d`; anything that would exceed 10 characters is clamped to `9999999999`. Clamping is display-only — the engine keeps counting. `FormatLines` is `%03d` clamped to 5 characters, `FormatLevel` is `%02d` clamped to 3. No formatter may ever return a string longer than the panel is wide. + +- [ ] **Step 1: Write the failing test** + +Create `internal/render/hud_test.go`: + +```go +package render + +import ( + "strings" + "testing" + + "cosmic-tetris/internal/game" +) + +func TestFormatScorePadsToEightDigits(t *testing.T) { + cases := []struct{ in int; want string }{ + {0, "00000000"}, + {129340, "00129340"}, + {483200, "00483200"}, + {99999999, "99999999"}, + {100000000, "100000000"}, + {9999999999, "9999999999"}, + } + for _, c := range cases { + if got := FormatScore(c.in); got != c.want { + t.Errorf("FormatScore(%d) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestFormattersNeverExceedTheirField(t *testing.T) { + // Review Focus 4: an absurd score must not widen the panel and shove the board. + for _, n := range []int{0, 1, 999, 1 << 20, 1 << 40, 1 << 62} { + if got := FormatScore(n); len(got) > 10 { + t.Errorf("FormatScore(%d) = %q is %d chars, want at most 10", n, got, len(got)) + } + if got := FormatLines(n); len(got) > 5 { + t.Errorf("FormatLines(%d) = %q is %d chars, want at most 5", n, got, len(got)) + } + if got := FormatLevel(n); len(got) > 3 { + t.Errorf("FormatLevel(%d) = %q is %d chars, want at most 3", n, got, len(got)) + } + } + for _, n := range []int{-1, -99999} { + if got := FormatScore(n); len(got) > 10 { + t.Errorf("FormatScore(%d) = %q, want a bounded field", n, got) + } + } +} + +func TestFormatLinesAndLevel(t *testing.T) { + if got := FormatLines(42); got != "042" { + t.Errorf("FormatLines(42) = %q, want %q", got, "042") + } + if got := FormatLevel(7); got != "07" { + t.Errorf("FormatLevel(7) = %q, want %q", got, "07") + } + if got := FormatLevel(13); got != "13" { + t.Errorf("FormatLevel(13) = %q, want %q", got, "13") + } +} + +func TestUniverseLabelUsesTheSeed(t *testing.T) { + if got, want := UniverseLabel(0x7F3A), "LOCAL UNIVERSE 7F3A"; got != want { + t.Errorf("UniverseLabel = %q, want %q", got, want) + } + if got := UniverseLabel(8675309); len(got) != len("LOCAL UNIVERSE 7F3A") { + t.Errorf("UniverseLabel(%d) = %q, want a fixed width", 8675309, got) + } +} + +func drawHUD(t *testing.T, w, h int) (*Canvas, Layout) { + t.Helper() + l := Compute(w, h) + if l.TooSmall { + t.Fatalf("Compute(%d,%d) too small", w, h) + } + g := GlyphsFor(ModeFull) + c := NewCanvas(w, h) + s := fixtureSnapshot() + if l.ShowTitle { + DrawTitle(c, l, s.Seed) + } + DrawLeftPanel(c, l, g, s) + DrawNextPanel(c, l, g, s) + if l.ShowMission { + DrawMission(c, l, "GRAVITY TAX INCREASED") + } + DrawControls(c, l) + return c, l +} + +func TestWideHUDShowsEverything(t *testing.T) { + c, l := drawHUD(t, 80, 30) + out := strings.Join(lines(c), "\n") + for _, want := range []string{"COSMIC TETRIS", "LOCAL UNIVERSE 7F3A", "HOLD", "NEXT", "SCORE", "00129340", "LINES", "042", "LEVEL", "07", "GRAVITY TAX INCREASED"} { + if !strings.Contains(out, want) { + t.Errorf("wide HUD is missing %q", want) + } + } + if !l.ShowHold { + t.Error("wide layout should show HOLD") + } +} + +func TestNarrowHUDDropsLabelsButKeepsValues(t *testing.T) { + // §49.3: values stay, labels go — "042" not "LINES 042". + c, _ := drawHUD(t, 40, 24) + out := strings.Join(lines(c), "\n") + if strings.Contains(out, "LINES") || strings.Contains(out, "SCORE") || strings.Contains(out, "LEVEL") { + t.Errorf("narrow HUD still has stat labels:\n%s", out) + } + for _, want := range []string{"00129340", "042", "07"} { + if !strings.Contains(out, want) { + t.Errorf("narrow HUD is missing the value %q", want) + } + } + if strings.Contains(out, "HOLD") { + t.Error("narrow HUD should not show the HOLD panel") + } +} + +func TestNextPanelShowsTheRequestedCount(t *testing.T) { + g := GlyphsFor(ModeFull) + for _, c := range []struct{ w, want int }{{80, 5}, {40, 3}} { + l := Compute(c.w, 30) + cv := NewCanvas(c.w, 30) + DrawNextPanel(cv, l, g, fixtureSnapshot()) + rows := lines(cv) + blocks := 0 + for _, row := range rows { + r := []rune(row) + if len(r) < l.RightX+l.RightW { + continue + } + if strings.Contains(string(r[l.RightX:l.RightX+l.RightW]), g.Block) { + blocks++ + } + } + // Each queued piece occupies two rows of art. + if blocks < c.want*2-2 || blocks > c.want*2 { + t.Errorf("width %d: %d block rows in NEXT, want about %d for %d pieces", c.w, blocks, c.want*2, c.want) + } + } +} + +func TestNextPanelToleratesAShortQueue(t *testing.T) { + l := Compute(80, 30) + g := GlyphsFor(ModeFull) + c := NewCanvas(80, 30) + s := fixtureSnapshot() + s.Next = nil + DrawNextPanel(c, l, g, s) + s.Next = []game.PieceKind{game.KindI} + DrawNextPanel(c, l, g, s) + if len(lines(c)) != 30 { + t.Fatal("canvas geometry disturbed") + } +} + +func TestHoldPanelIsEmptyWhenNothingIsHeld(t *testing.T) { + l := Compute(80, 30) + g := GlyphsFor(ModeFull) + c := NewCanvas(80, 30) + s := fixtureSnapshot() + s.HasHold = false + DrawLeftPanel(c, l, g, s) + out := strings.Join(lines(c), "\n") + if !strings.Contains(out, "HOLD") { + t.Error("HOLD label should still be drawn with an empty slot") + } +} + +func TestHUDNeverWritesIntoTheBoardColumns(t *testing.T) { + for _, size := range []struct{ w, h int }{{40, 24}, {52, 26}, {80, 30}, {200, 60}} { + c, l := drawHUD(t, size.w, size.h) + for y, row := range lines(c) { + if y < l.BoardY || y >= l.BoardY+BoardOuterH { + continue + } + r := []rune(row) + if len(r) < l.BoardX+BoardOuterW { + continue + } + span := strings.TrimSpace(string(r[l.BoardX : l.BoardX+BoardOuterW])) + if span != "" { + t.Errorf("%dx%d row %d: HUD wrote %q into the board's columns", size.w, size.h, y, span) + } + } + } +} + +func TestControlsLineListsThePrimaryKeys(t *testing.T) { + c, l := drawHUD(t, 80, 30) + row := lines(c)[l.ControlsY] + for _, want := range []string{"move", "rotate", "YEET", "hold", "help"} { + if !strings.Contains(row, want) { + t.Errorf("controls line %q is missing %q", row, want) + } + } +} + +func TestControlsAndMissionLinesFitTheTerminal(t *testing.T) { + for _, size := range []struct{ w, h int }{{40, 24}, {46, 25}, {80, 30}} { + c, l := drawHUD(t, size.w, size.h) + rows := lines(c) + if got := len([]rune(rows[l.ControlsY])); got != size.w { + t.Errorf("%dx%d: controls row is %d columns, want %d", size.w, size.h, got, size.w) + } + if l.ShowMission { + if got := len([]rune(rows[l.MissionY])); got != size.w { + t.Errorf("%dx%d: mission row is %d columns, want %d", size.w, size.h, got, size.w) + } + } + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/render/ -run TestFormatScore -v` +Expected: FAIL — `undefined: FormatScore`. + +- [ ] **Step 3: Write the implementation** + +Create `internal/render/hud.go`: + +```go +package render + +import ( + "fmt" + "strings" + + "cosmic-tetris/internal/game" +) + +// Field widths. No formatter may return more than these, or the panel widens and +// shoves the board sideways. +const ( + scoreField = 10 + linesField = 5 + levelField = 3 +) + +var ( + labelPaint = Paint{FG: RGB{0x7A, 0x86, 0xB8}} + valuePaint = Paint{FG: RGB{0xE6, 0xEE, 0xFF}, Bold: true} + titlePaint = Paint{FG: RGB{0xF2, 0xF6, 0xFF}, Bold: true} + chromePaint = Paint{FG: RGB{0x5B, 0x64, 0x8C}} + missionPain = Paint{FG: RGB{0x9A, 0xE8, 0xF5}} +) + +// clampField truncates s so it never exceeds n characters. +func clampField(s string, n int) string { + if len(s) <= n { + return s + } + return strings.Repeat("9", n) +} + +// FormatScore renders the score zero-padded to eight digits, clamped to the +// field width. Clamping is display-only; the engine keeps counting (§45's +// "NUMBER BECAME BIGGER" is the joke that covers this). +func FormatScore(n int) string { + if n < 0 { + n = 0 + } + return clampField(fmt.Sprintf("%08d", n), scoreField) +} + +// FormatLines renders cleared lines, zero-padded to three digits. +func FormatLines(n int) string { + if n < 0 { + n = 0 + } + return clampField(fmt.Sprintf("%03d", n), linesField) +} + +// FormatLevel renders the level, zero-padded to two digits. +func FormatLevel(n int) string { + if n < 0 { + n = 0 + } + return clampField(fmt.Sprintf("%02d", n), levelField) +} + +// UniverseLabel is the seed dressed up as a location (§4). +func UniverseLabel(seed int64) string { + return fmt.Sprintf("LOCAL UNIVERSE %04X", uint16(seed)) +} + +// DrawTitle draws the top chrome line. +func DrawTitle(c *Canvas, l Layout, seed int64) { + left := "✦ COSMIC TETRIS" + right := UniverseLabel(seed) + c.SetString(l.LeftX, l.TitleY, left, titlePaint) + x := l.W - len(right) + if x < l.LeftX+len(left)+2 { + return // no room for the universe label; the title alone is enough + } + c.SetString(x, l.TitleY, right, chromePaint) +} + +// pieceArt returns the two rows of glyphs that preview a piece in a panel. +func pieceArt(g Glyphs, k game.PieceKind) [2]string { + var rows [2]string + p := game.Piece{Kind: k, Rotation: 0} + for row := 0; row < 2; row++ { + var b strings.Builder + for col := 0; col < 4; col++ { + filled := false + for _, cell := range p.Cells() { + if cell.X == col && cell.Y == row { + filled = true + } + } + if filled { + b.WriteString(g.Block) + } else { + b.WriteString(g.Empty) + } + } + rows[row] = b.String() + } + return rows +} + +// DrawLeftPanel draws HOLD (when it fits) and the score, lines and level values. +// Labels are dropped on narrow terminals; the values always stay (§49.3). +func DrawLeftPanel(c *Canvas, l Layout, g Glyphs, s game.Snapshot) { + y := l.BoardY + if l.ShowHold { + c.SetString(l.LeftX, y, "HOLD", labelPaint) + art := [2]string{strings.Repeat(g.Empty, 4), strings.Repeat(g.Empty, 4)} + if s.HasHold { + art = pieceArt(g, s.HoldKind) + } + paint := Paint{} + if s.HasHold { + paint = LockedPaint(s.HoldKind) + } + c.SetString(l.LeftX, y+1, art[0], paint) + c.SetString(l.LeftX, y+2, art[1], paint) + y += 5 + } else { + y += 1 + } + + stats := []struct { + label string + value string + }{ + {"SCORE", FormatScore(s.Score)}, + {"LINES", FormatLines(s.Lines)}, + {"LEVEL", FormatLevel(s.Level)}, + } + for _, st := range stats { + if l.ShowStatLabels { + c.SetString(l.LeftX, y, st.label, labelPaint) + y++ + } + c.SetString(l.LeftX, y, st.value, valuePaint) + y += 2 + } +} + +// DrawNextPanel draws the upcoming pieces beside the board. It never stacks +// above or below the board (§49.3). +func DrawNextPanel(c *Canvas, l Layout, g Glyphs, s game.Snapshot) { + x := l.RightX + 1 + c.SetString(x, l.BoardY, "NEXT", labelPaint) + y := l.BoardY + 1 + for i := 0; i < l.NextCount && i < len(s.Next); i++ { + k := s.Next[i] + art := pieceArt(g, k) + paint := LockedPaint(k) + if i == 0 { + paint = ActivePaint(k) + } + c.SetString(x, y, art[0], paint) + c.SetString(x, y+1, art[1], paint) + y += 3 + } +} + +// DrawMission draws the one-line status channel (§27). +func DrawMission(c *Canvas, l Layout, msg string) { + line := "☄ MISSION CONTROL: " + msg + if len(line) > l.W { + line = line[:l.W] + } + c.SetString(l.LeftX, l.MissionY, line, missionPain) +} + +// DrawControls draws the key hint line (§4). +func DrawControls(c *Canvas, l Layout) { + full := "←→ move ↑ rotate ↓ descend SPACE YEET C hold ? help" + short := "←→ ↑ ↓ SPACE YEET C hold ? help" + line := full + if len([]rune(line)) > l.W { + line = short + } + if len([]rune(line)) > l.W { + line = string([]rune(line)[:l.W]) + } + c.SetString(l.LeftX, l.ControlsY, line, chromePaint) +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/render/ -v` +Expected: PASS. If `TestHUDNeverWritesIntoTheBoardColumns` fails at 40 columns, the panel widths in `layout.go` and the strings here disagree — shorten the strings, do not widen the panels. + +- [ ] **Step 5: Format, vet, commit** + +```bash +gofmt -l . && go vet ./... +git add internal/render/hud.go internal/render/hud_test.go +git commit -m "feat(render): HUD panels, stats, mission line and controls" +``` + +--- + +### Task 6: The frame renderer and golden layout tests + +**Files:** +- Create: `internal/render/render.go` +- Test: `internal/render/render_test.go` +- Create: `internal/render/testdata/` (golden files, generated in Step 5) + +**Interfaces:** +- Consumes: everything in `render` so far. +- Produces: `type Screen uint8` with `ScreenBoot`, `ScreenPlaying`, `ScreenPaused`, `ScreenHelp`, `ScreenGameOver`; `type View struct{ Width, Height int; Screen Screen; Snapshot game.Snapshot; Mission string; Keys []KeyHint }`; `type KeyHint struct{ Keys, Desc string }`; `type Renderer struct{ ... }`; `func NewRenderer(m Mode) *Renderer`; `func (r *Renderer) SetMode(m Mode)`; `func (r *Renderer) Mode() Mode`; `func (r *Renderer) Render(v View) string`. + +Plan 3 adds an `FX *fx.World` field to `View` and an FX compositing step to `Render`. Nothing else about this file changes. + +- [ ] **Step 1: Write the failing test** + +Create `internal/render/render_test.go`: + +```go +package render + +import ( + "flag" + "os" + "path/filepath" + "strings" + "testing" + "unicode/utf8" + + "github.com/charmbracelet/x/ansi" +) + +var updateGolden = flag.Bool("update", false, "rewrite golden files") + +// assertGolden compares ANSI-stripped output against testdata/.golden. +func assertGolden(t *testing.T, name, got string) { + t.Helper() + path := filepath.Join("testdata", name+".golden") + if *updateGolden { + if err := os.MkdirAll("testdata", 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(got), 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(path) + if err != nil { + t.Fatalf("%v (run: go test ./internal/render/ -update)", err) + } + if got != string(want) { + t.Errorf("golden %s mismatch.\n--- got ---\n%s\n--- want ---\n%s", name, got, want) + } +} + +func testView(w, h int, screen Screen) View { + return View{ + Width: w, + Height: h, + Screen: screen, + Snapshot: fixtureSnapshot(), + Mission: "GRAVITY REMAINS MOSTLY LEGAL", + Keys: testKeyHints(), + } +} + +func testKeyHints() []KeyHint { + return []KeyHint{ + {"← → / h l", "move spacecraft"}, + {"↓ / j", "accelerate doom"}, + {"↑ / k / x", "rotate geometry"}, + {"z", "rotate other way"}, + {"SPACE", "YEET"}, + {"c", "quantum storage"}, + {"p", "suspend spacetime"}, + {"r", "reboot universe"}, + {"q", "abandon mission"}, + {"?", "close this nonsense"}, + } +} + +func render(t *testing.T, m Mode, v View) string { + t.Helper() + return ansi.Strip(NewRenderer(m).Render(v)) +} + +func TestFrameHasExactlyTerminalGeometry(t *testing.T) { + for _, size := range []struct{ w, h int }{{40, 24}, {46, 25}, {64, 28}, {80, 30}, {120, 40}, {300, 100}} { + out := render(t, ModeFull, testView(size.w, size.h, ScreenPlaying)) + rows := strings.Split(out, "\n") + if len(rows) != size.h { + t.Errorf("%dx%d: %d rows, want %d", size.w, size.h, len(rows), size.h) + } + for i, row := range rows { + if n := utf8.RuneCountInString(row); n != size.w { + t.Errorf("%dx%d: row %d is %d columns, want %d", size.w, size.h, i, n, size.w) + } + } + } +} + +func TestTooSmallTerminalShowsTheNotice(t *testing.T) { + // Review Focus 1: 0x0 and 1x1 happen for real during startup and dragging. + for _, size := range []struct{ w, h int }{{34, 19}, {39, 23}, {10, 5}, {1, 1}, {0, 0}} { + out := render(t, ModeFull, testView(size.w, size.h, ScreenPlaying)) + if size.w >= 20 && size.h >= 4 { + if !strings.Contains(out, "THIS UNIVERSE IS TOO SMALL") { + t.Errorf("%dx%d: notice missing from:\n%s", size.w, size.h, out) + } + if !strings.Contains(out, "resize terminal to continue") { + t.Errorf("%dx%d: resize hint missing", size.w, size.h) + } + if !strings.Contains(out, "current:") || !strings.Contains(out, "needed:") { + t.Errorf("%dx%d: current/needed lines missing from:\n%s", size.w, size.h, out) + } + } + rows := strings.Split(out, "\n") + if size.h > 0 && len(rows) != size.h { + t.Errorf("%dx%d: %d rows, want %d", size.w, size.h, len(rows), size.h) + } + } +} + +func TestTooSmallNoticeReportsTheActualSize(t *testing.T) { + out := render(t, ModeFull, testView(34, 19, ScreenPlaying)) + if !strings.Contains(out, "34") || !strings.Contains(out, "19") { + t.Errorf("notice does not report the current size:\n%s", out) + } + if !strings.Contains(out, "40") || !strings.Contains(out, "24") { + t.Errorf("notice does not report the needed size:\n%s", out) + } +} + +func TestZeroSizedRenderDoesNotPanic(t *testing.T) { + for _, size := range []struct{ w, h int }{{0, 0}, {0, 30}, {80, 0}, {-3, -3}} { + _ = NewRenderer(ModeFull).Render(testView(size.w, size.h, ScreenPlaying)) + } +} + +func TestRendererIsReusableAcrossSizes(t *testing.T) { + // The canvas is reused frame to frame; a shrink must not leave stale cells. + r := NewRenderer(ModeFull) + _ = r.Render(testView(120, 40, ScreenPlaying)) + out := ansi.Strip(r.Render(testView(40, 24, ScreenPlaying))) + rows := strings.Split(out, "\n") + if len(rows) != 24 { + t.Fatalf("%d rows after shrinking, want 24", len(rows)) + } + for i, row := range rows { + if n := utf8.RuneCountInString(row); n != 40 { + t.Fatalf("row %d is %d columns after shrinking, want 40", i, n) + } + } +} + +func TestRenderDoesNotMutateTheSnapshot(t *testing.T) { + v := testView(80, 30, ScreenPlaying) + before := v.Snapshot + _ = NewRenderer(ModeFull).Render(v) + if v.Snapshot.Score != before.Score || v.Snapshot.Active != before.Active || + v.Snapshot.Board != before.Board || v.Snapshot.Level != before.Level { + t.Error("Render mutated its input snapshot (§37)") + } +} + +func TestGoldenWideLayout(t *testing.T) { + assertGolden(t, "wide", render(t, ModeFull, testView(80, 30, ScreenPlaying))) +} + +func TestGoldenMediumLayout(t *testing.T) { + assertGolden(t, "medium", render(t, ModeFull, testView(50, 26, ScreenPlaying))) +} + +func TestGoldenSmallLayout(t *testing.T) { + assertGolden(t, "small", render(t, ModeFull, testView(40, 24, ScreenPlaying))) +} + +func TestGoldenTooSmall(t *testing.T) { + assertGolden(t, "too-small", render(t, ModeFull, testView(34, 19, ScreenPlaying))) +} + +func TestGoldenASCIIMode(t *testing.T) { + assertGolden(t, "ascii", render(t, ModeASCII, testView(80, 30, ScreenPlaying))) +} + +func TestASCIIGoldenIsPureASCII(t *testing.T) { + out := render(t, ModeASCII, testView(80, 30, ScreenPlaying)) + for i, r := range out { + if r > 0x7F { + t.Fatalf("byte %d of ASCII-mode output is non-ASCII: %q", i, r) + } + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/render/ -run TestFrameHasExactly -v` +Expected: FAIL — `undefined: NewRenderer`. + +- [ ] **Step 3: Write the implementation** + +Create `internal/render/render.go`: + +```go +package render + +import ( + "fmt" + + "cosmic-tetris/internal/game" +) + +// Screen is which of the game's faces is showing. It lives in render rather than +// app so that render does not have to import app. +type Screen uint8 + +const ( + ScreenBoot Screen = iota + ScreenPlaying + ScreenPaused + ScreenHelp + ScreenGameOver +) + +// KeyHint is one row of the flight manual (§39). +type KeyHint struct { + Keys string + Desc string +} + +// View is everything the renderer needs for one frame. It carries a +// game.Snapshot by value, so rendering cannot touch game state (§37). +type View struct { + Width, Height int + Screen Screen + Snapshot game.Snapshot + Mission string + Keys []KeyHint +} + +// Renderer draws frames. It reuses one canvas across frames (§38). +type Renderer struct { + mode Mode + glyphs Glyphs + canvas *Canvas +} + +// NewRenderer returns a renderer for a rendering mode. +func NewRenderer(m Mode) *Renderer { + return &Renderer{mode: m, glyphs: GlyphsFor(m), canvas: NewCanvas(0, 0)} +} + +// SetMode switches rendering mode, e.g. once the colour profile is known. +func (r *Renderer) SetMode(m Mode) { + r.mode = m + r.glyphs = GlyphsFor(m) +} + +// Mode reports the current rendering mode. +func (r *Renderer) Mode() Mode { return r.mode } + +// Render draws one frame and returns the styled string. The pipeline follows +// §37: layout, board, HUD, banners, mission control, controls. Plan 3 inserts +// the starfield and FX compositing steps into the same order. +func (r *Renderer) Render(v View) string { + if w, h := r.canvas.Size(); w != v.Width || h != v.Height { + r.canvas.Resize(v.Width, v.Height) + } else { + r.canvas.Clear() + } + + l := Compute(v.Width, v.Height) + if l.TooSmall { + drawTooSmall(r.canvas, v.Width, v.Height) + return r.canvas.String() + } + + border := BorderPalette[1] + DrawBorder(r.canvas, l, r.glyphs, [4]RGB{border, border, border, border}) + DrawStack(r.canvas, l, r.glyphs, v.Snapshot) + DrawGhost(r.canvas, l, r.glyphs, v.Snapshot) + DrawActive(r.canvas, l, r.glyphs, v.Snapshot) + + if l.ShowTitle { + DrawTitle(r.canvas, l, v.Snapshot.Seed) + } + DrawLeftPanel(r.canvas, l, r.glyphs, v.Snapshot) + DrawNextPanel(r.canvas, l, r.glyphs, v.Snapshot) + if l.ShowMission { + DrawMission(r.canvas, l, v.Mission) + } + DrawControls(r.canvas, l) + + drawOverlay(r.canvas, l, v) + + return r.canvas.String() +} + +// drawTooSmall renders the §31 notice, centred as well as the space allows. +func drawTooSmall(c *Canvas, w, h int) { + msg := []string{ + "THIS UNIVERSE IS TOO SMALL", + "", + "resize terminal to continue", + "", + fmt.Sprintf("current: %d × %d", w, h), + fmt.Sprintf("needed: approximately %d × %d", MinWidth, MinHeight), + } + paint := Paint{FG: RGB{0xF2, 0xF6, 0xFF}, Bold: true} + top := (h - len(msg)) / 2 + if top < 0 { + top = 0 + } + for i, line := range msg { + x := (w - len([]rune(line))) / 2 + if x < 0 { + x = 0 + } + c.SetString(x, top+i, line, paint) + } +} +``` + +`drawOverlay` is defined in Task 7. To keep this task's tests green now, add a temporary definition at the bottom of `render.go` and move it to `overlays.go` in Task 7: + +```go +// drawOverlay draws the pause, help and game-over panels. Task 7 fills this in. +func drawOverlay(c *Canvas, l Layout, v View) {} +``` + +Note: `drawTooSmall` uses `×` (U+00D7), matching §31's copy. That means the too-small notice is the one non-ASCII string in ASCII mode. Replace it with `x` when `MinWidth`-era ASCII purity matters — Task 6's `TestASCIIGoldenIsPureASCII` only renders a fitting terminal, and Plan 4 Task 4 extends the purity check to the notice. Use `fmt.Sprintf("current: %d x %d", w, h)` with a plain `x` now, so ASCII purity holds everywhere. + +- [ ] **Step 4: Run the geometry tests** + +Run: `go test ./internal/render/ -run 'TestFrame|TestTooSmall|TestZeroSized|TestRendererIsReusable|TestRenderDoesNot|TestASCIIGolden' -v` +Expected: PASS. + +- [ ] **Step 5: Generate and review the golden files** + +```bash +go test ./internal/render/ -update +``` + +Then read every generated file and check it by eye against §4's intent: + +```bash +cat internal/render/testdata/wide.golden +cat internal/render/testdata/medium.golden +cat internal/render/testdata/small.golden +cat internal/render/testdata/too-small.golden +cat internal/render/testdata/ascii.golden +``` + +Expected: the board is a clean 22×22 frame, panels sit beside it without touching it, no row is ragged, the ASCII golden has no box-drawing characters. If any of that is wrong, fix the code and regenerate — do not accept a bad golden. + +- [ ] **Step 6: Run the whole suite** + +Run: `go test ./... -v` +Expected: PASS. + +- [ ] **Step 7: Format, vet, commit** + +```bash +gofmt -l . && go vet ./... +git add internal/render/render.go internal/render/render_test.go internal/render/testdata +git commit -m "feat(render): frame renderer, too-small notice and golden layout tests" +``` + +--- + +### Task 7: Overlays — pause, help, game over + +**Files:** +- Create: `internal/render/overlays.go` +- Modify: `internal/render/render.go` (remove the temporary `drawOverlay` stub) +- Test: `internal/render/overlays_test.go` + +**Interfaces:** +- Consumes: `Canvas`, `Layout`, `View`, `KeyHint`. +- Produces: `func drawOverlay(c *Canvas, l Layout, v View)`; `func DrawPanel(c *Canvas, x, y int, lines []string, p Paint)`; `func PanelSize(lines []string) (w, h int)`. + +`DrawPanel` draws a rounded box around centred text. Every overlay is a `DrawPanel` call, so they cannot disagree about geometry. + +- [ ] **Step 1: Write the failing test** + +Create `internal/render/overlays_test.go`: + +```go +package render + +import ( + "strings" + "testing" + + "cosmic-tetris/internal/game" +) + +func TestPanelSizeCountsTheBorder(t *testing.T) { + w, h := PanelSize([]string{"abc", "de"}) + if w != 3+4 || h != 2+2 { + t.Fatalf("PanelSize = %dx%d, want %dx%d", w, h, 3+4, 2+2) + } +} + +func TestDrawPanelFramesItsText(t *testing.T) { + c := NewCanvas(20, 6) + DrawPanel(c, 1, 1, []string{"HELLO", "WORLD!"}, Paint{}) + rows := lines(c) + if !strings.Contains(rows[1], "╭") || !strings.Contains(rows[1], "╮") { + t.Errorf("top border missing: %q", rows[1]) + } + if !strings.Contains(rows[2], "HELLO") { + t.Errorf("first text row missing: %q", rows[2]) + } + if !strings.Contains(rows[4], "╰") || !strings.Contains(rows[4], "╯") { + t.Errorf("bottom border missing: %q", rows[4]) + } +} + +func TestDrawPanelClipsAtTheEdges(t *testing.T) { + c := NewCanvas(8, 3) + DrawPanel(c, 6, 2, []string{"far too wide for this canvas"}, Paint{}) + if len(lines(c)) != 3 { + t.Fatal("canvas geometry disturbed") + } +} + +func TestPauseOverlay(t *testing.T) { + out := render(t, ModeFull, testView(80, 30, ScreenPaused)) + for _, want := range []string{"TEMPORAL SUSPENSION", "SPACE IS PAUSED", "p resume"} { + if !strings.Contains(out, want) { + t.Errorf("pause overlay is missing %q:\n%s", want, out) + } + } +} + +func TestHelpOverlayListsEveryKeyHint(t *testing.T) { + v := testView(80, 30, ScreenHelp) + out := render(t, ModeFull, v) + if !strings.Contains(out, "FLIGHT MANUAL") { + t.Errorf("help overlay is missing its title:\n%s", out) + } + for _, k := range v.Keys { + if !strings.Contains(out, k.Desc) { + t.Errorf("help overlay is missing %q", k.Desc) + } + } +} + +func TestGameOverOverlayShowsTheFinalNumbers(t *testing.T) { + v := testView(80, 30, ScreenGameOver) + v.Snapshot.Score = 483200 + v.Snapshot.Lines = 127 + v.Snapshot.Level = 13 + v.Snapshot.Phase = game.PhaseGameOver + out := render(t, ModeFull, v) + for _, want := range []string{"UNIVERSE EXPIRED", "483200", "127", "13", "REBOOT UNIVERSE", "ACCEPT COSMIC DEATH"} { + if !strings.Contains(out, want) { + t.Errorf("game-over overlay is missing %q:\n%s", want, out) + } + } +} + +func TestPlayingScreenHasNoOverlay(t *testing.T) { + out := render(t, ModeFull, testView(80, 30, ScreenPlaying)) + for _, unwanted := range []string{"TEMPORAL SUSPENSION", "FLIGHT MANUAL", "UNIVERSE EXPIRED"} { + if strings.Contains(out, unwanted) { + t.Errorf("playing screen shows %q", unwanted) + } + } +} + +func TestOverlaysStillFitTheSmallestTerminal(t *testing.T) { + for _, screen := range []Screen{ScreenPaused, ScreenHelp, ScreenGameOver} { + out := render(t, ModeFull, testView(40, 24, screen)) + rows := strings.Split(out, "\n") + if len(rows) != 24 { + t.Fatalf("screen %v: %d rows, want 24", screen, len(rows)) + } + for i, row := range rows { + if n := len([]rune(row)); n != 40 { + t.Fatalf("screen %v row %d is %d columns, want 40", screen, i, n) + } + } + } +} + +func TestGoldenPause(t *testing.T) { + assertGolden(t, "pause", render(t, ModeFull, testView(80, 30, ScreenPaused))) +} + +func TestGoldenHelp(t *testing.T) { + assertGolden(t, "help", render(t, ModeFull, testView(80, 30, ScreenHelp))) +} + +func TestGoldenGameOver(t *testing.T) { + v := testView(80, 30, ScreenGameOver) + v.Snapshot.Score = 483200 + v.Snapshot.Lines = 127 + v.Snapshot.Level = 13 + v.Snapshot.Phase = game.PhaseGameOver + assertGolden(t, "gameover", render(t, ModeFull, v)) +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/render/ -run TestPanelSize -v` +Expected: FAIL — `undefined: PanelSize`. + +- [ ] **Step 3: Write the implementation** + +Create `internal/render/overlays.go`: + +```go +package render + +import ( + "fmt" + "strings" +) + +const panelPadX = 2 + +var ( + panelPaint = Paint{FG: RGB{0xC8, 0xD6, 0xFF}} + panelTitle = Paint{FG: RGB{0xF2, 0xF6, 0xFF}, Bold: true} +) + +// PanelSize is the outer size of a panel wrapping these text lines. +func PanelSize(lines []string) (int, int) { + inner := 0 + for _, s := range lines { + if n := len([]rune(s)); n > inner { + inner = n + } + } + return inner + 2*panelPadX, len(lines) + 2 +} + +// DrawPanel draws a rounded box at x, y around centred text lines. Writes +// outside the canvas are clipped by Canvas itself. +func DrawPanel(c *Canvas, x, y int, lines []string, p Paint) { + if p == (Paint{}) { + p = panelPaint + } + w, h := PanelSize(lines) + inner := w - 2 + c.SetString(x, y, "╭"+strings.Repeat("─", inner)+"╮", p) + for i, line := range lines { + row := y + 1 + i + c.SetString(x, row, "│", p) + c.SetString(x+w-1, row, "│", p) + pad := (inner - len([]rune(line))) / 2 + if pad < 0 { + pad = 0 + } + c.SetString(x+1+pad, row, line, panelTitle) + } + c.SetString(x, y+h-1, "╰"+strings.Repeat("─", inner)+"╯", p) +} + +// centrePanel draws a panel centred on the board. +func centrePanel(c *Canvas, l Layout, lines []string) { + w, h := PanelSize(lines) + x := l.BoardX + (BoardOuterW-w)/2 + y := l.BoardY + (BoardOuterH-h)/2 + if x < 0 { + x = 0 + } + if y < 0 { + y = 0 + } + DrawPanel(c, x, y, lines, panelPaint) +} + +// drawOverlay draws whichever modal panel the current screen calls for. +func drawOverlay(c *Canvas, l Layout, v View) { + switch v.Screen { + case ScreenPaused: + centrePanel(c, l, []string{ + "TEMPORAL SUSPENSION", + "", + "SPACE IS PAUSED", + "", + "p resume", + }) + case ScreenHelp: + lines := []string{"FLIGHT MANUAL", ""} + width := 0 + for _, k := range v.Keys { + if n := len([]rune(k.Keys)); n > width { + width = n + } + } + for _, k := range v.Keys { + lines = append(lines, fmt.Sprintf("%-*s %s", width, k.Keys, k.Desc)) + } + centrePanel(c, l, lines) + case ScreenGameOver: + s := v.Snapshot + centrePanel(c, l, []string{ + "UNIVERSE EXPIRED", + "", + "CAUSE: EXCESSIVE GEOMETRY", + "", + fmt.Sprintf("SCORE %d", s.Score), + fmt.Sprintf("LINES %d", s.Lines), + fmt.Sprintf("LEVEL %d", s.Level), + "", + "r REBOOT UNIVERSE", + "q ACCEPT COSMIC DEATH", + }) + } +} +``` + +Delete the temporary `drawOverlay` stub from `render.go`. + +- [ ] **Step 4: Run the tests, then regenerate goldens** + +Run: `go test ./internal/render/ -run 'TestPanel|TestDrawPanel|TestPause|TestHelp|TestGameOver|TestPlayingScreen|TestOverlaysStill' -v` +Expected: PASS. + +Then: `go test ./internal/render/ -update && go test ./internal/render/` +Expected: PASS. Read `testdata/pause.golden`, `help.golden` and `gameover.golden` and confirm each panel is centred on the board and the board is still visible around it. + +- [ ] **Step 5: Format, vet, commit** + +```bash +gofmt -l . && go vet ./... +git add internal/render/overlays.go internal/render/render.go internal/render/overlays_test.go internal/render/testdata +git commit -m "feat(render): pause, flight manual and game-over overlays" +``` + +--- + +### Task 8: Key bindings and the frame clock + +**Files:** +- Create: `internal/app/keys.go` +- Create: `internal/app/messages.go` +- Test: `internal/app/keys_test.go` + +**Interfaces:** +- Consumes: `render.KeyHint`. +- Produces: `type KeyMap struct{ Left, Right, SoftDrop, RotateCW, RotateCCW, HardDrop, Hold, Pause, Restart, Help, Quit key.Binding }`; `func DefaultKeyMap() KeyMap`; `func (k KeyMap) ShortHelp() []key.Binding`; `func (k KeyMap) FullHelp() [][]key.Binding`; `func (k KeyMap) Hints() []render.KeyHint`; `type FrameMsg struct{ Now time.Time }`; `const FrameInterval = 16 * time.Millisecond`; `const MaxFrameDelta = 100 * time.Millisecond`; `func FrameCmd() tea.Cmd`. + +Key repeat when the player holds left or right is the terminal's own auto-repeat: it delivers repeated `KeyPressMsg` values, and because `Update` acts on each immediately, movement repeats without any extra machinery (§8, §36). + +- [ ] **Step 1: Write the failing test** + +Create `internal/app/keys_test.go`: + +```go +package app + +import ( + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/bubbles/v2/key" +) + +// press builds the KeyPressMsg a terminal sends for a key name. +func press(name string) tea.KeyPressMsg { + switch name { + case "left": + return tea.KeyPressMsg{Code: tea.KeyLeft} + case "right": + return tea.KeyPressMsg{Code: tea.KeyRight} + case "up": + return tea.KeyPressMsg{Code: tea.KeyUp} + case "down": + return tea.KeyPressMsg{Code: tea.KeyDown} + case "space": + return tea.KeyPressMsg{Code: tea.KeySpace, Text: " "} + case "esc": + return tea.KeyPressMsg{Code: tea.KeyEscape} + default: + r := []rune(name)[0] + return tea.KeyPressMsg{Code: r, Text: string(r)} + } +} + +func TestEveryPrimaryKeyIsBound(t *testing.T) { + km := DefaultKeyMap() + cases := []struct { + keys []string + binding key.Binding + name string + }{ + {[]string{"left", "h", "a"}, km.Left, "Left"}, + {[]string{"right", "l", "d"}, km.Right, "Right"}, + {[]string{"down", "j", "s"}, km.SoftDrop, "SoftDrop"}, + {[]string{"up", "k", "x", "w"}, km.RotateCW, "RotateCW"}, + {[]string{"z"}, km.RotateCCW, "RotateCCW"}, + {[]string{"space"}, km.HardDrop, "HardDrop"}, + {[]string{"c"}, km.Hold, "Hold"}, + {[]string{"p"}, km.Pause, "Pause"}, + {[]string{"r"}, km.Restart, "Restart"}, + {[]string{"?"}, km.Help, "Help"}, + {[]string{"q", "esc"}, km.Quit, "Quit"}, + } + for _, c := range cases { + for _, k := range c.keys { + if !key.Matches(press(k), c.binding) { + t.Errorf("%s should match %q", c.name, k) + } + } + } +} + +func TestBindingsDoNotOverlap(t *testing.T) { + km := DefaultKeyMap() + all := map[string]string{} + named := map[string]key.Binding{ + "Left": km.Left, "Right": km.Right, "SoftDrop": km.SoftDrop, + "RotateCW": km.RotateCW, "RotateCCW": km.RotateCCW, "HardDrop": km.HardDrop, + "Hold": km.Hold, "Pause": km.Pause, "Restart": km.Restart, + "Help": km.Help, "Quit": km.Quit, + } + for name, b := range named { + for _, k := range b.Keys() { + if prev, dup := all[k]; dup { + t.Errorf("key %q is bound to both %s and %s", k, prev, name) + } + all[k] = name + } + } +} + +func TestEveryBindingHasHelpText(t *testing.T) { + km := DefaultKeyMap() + for _, b := range km.FullHelp()[0] { + h := b.Help() + if h.Key == "" || h.Desc == "" { + t.Errorf("binding %v has incomplete help: %+v", b.Keys(), h) + } + } +} + +func TestHintsCoverTheFlightManual(t *testing.T) { + hints := DefaultKeyMap().Hints() + if len(hints) < 10 { + t.Fatalf("%d hints, want at least the 10 rows of §39's flight manual", len(hints)) + } + joined := "" + for _, h := range hints { + joined += h.Keys + " " + h.Desc + "\n" + } + for _, want := range []string{"move spacecraft", "accelerate doom", "rotate geometry", "YEET", "quantum storage", "suspend spacetime", "reboot universe", "abandon mission"} { + if !contains(joined, want) { + t.Errorf("hints are missing %q (§39)", want) + } + } +} + +func contains(haystack, needle string) bool { + return len(haystack) >= len(needle) && (haystack == needle || indexOf(haystack, needle) >= 0) +} + +func indexOf(haystack, needle string) int { + for i := 0; i+len(needle) <= len(haystack); i++ { + if haystack[i:i+len(needle)] == needle { + return i + } + } + return -1 +} + +func TestFrameTimingConstants(t *testing.T) { + if FrameInterval > 17*time.Millisecond { + t.Errorf("FrameInterval = %v, want about 16ms for 60 Hz (§36)", FrameInterval) + } + if MaxFrameDelta < 50*time.Millisecond || MaxFrameDelta > 250*time.Millisecond { + t.Errorf("MaxFrameDelta = %v, want a sane catch-up clamp", MaxFrameDelta) + } +} + +func TestFrameCmdProducesAFrameMsg(t *testing.T) { + msg := FrameCmd()() + if _, ok := msg.(FrameMsg); !ok { + t.Fatalf("FrameCmd produced %T, want FrameMsg", msg) + } +} +``` + +Replace the hand-rolled `contains`/`indexOf` helpers with `strings.Contains` and an `import "strings"` — they are only written out here to make the intent unmistakable. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/app/ -run TestEveryPrimaryKey -v` +Expected: FAIL — `undefined: DefaultKeyMap`. + +- [ ] **Step 3: Write the implementation** + +Create `internal/app/keys.go`: + +```go +// Package app is the Bubble Tea layer: it owns the clock, the keyboard and the +// application state machine, and hands the renderer a read-only snapshot. +package app + +import ( + "charm.land/bubbles/v2/key" + + "cosmic-tetris/internal/render" +) + +// KeyMap is the whole control surface (§8). WASD aliases sit alongside the +// arrows and the vi keys. +type KeyMap struct { + Left key.Binding + Right key.Binding + SoftDrop key.Binding + RotateCW key.Binding + RotateCCW key.Binding + HardDrop key.Binding + Hold key.Binding + Pause key.Binding + Restart key.Binding + Help key.Binding + Quit key.Binding +} + +// DefaultKeyMap returns the shipped bindings. +func DefaultKeyMap() KeyMap { + return KeyMap{ + Left: key.NewBinding(key.WithKeys("left", "h", "a"), key.WithHelp("← → / h l", "move spacecraft")), + Right: key.NewBinding(key.WithKeys("right", "l", "d"), key.WithHelp("→ / l", "move right")), + SoftDrop: key.NewBinding(key.WithKeys("down", "j", "s"), key.WithHelp("↓ / j", "accelerate doom")), + RotateCW: key.NewBinding(key.WithKeys("up", "k", "x", "w"), key.WithHelp("↑ / k / x", "rotate geometry")), + RotateCCW: key.NewBinding(key.WithKeys("z"), key.WithHelp("z", "rotate other way")), + HardDrop: key.NewBinding(key.WithKeys(" ", "space"), key.WithHelp("SPACE", "YEET")), + Hold: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "quantum storage")), + Pause: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "suspend spacetime")), + Restart: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "reboot universe")), + Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "close this nonsense")), + Quit: key.NewBinding(key.WithKeys("q", "esc"), key.WithHelp("q", "abandon mission")), + } +} + +// ShortHelp satisfies the bubbles help.KeyMap interface. +func (k KeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Left, k.Right, k.RotateCW, k.SoftDrop, k.HardDrop, k.Hold, k.Help} +} + +// FullHelp satisfies the bubbles help.KeyMap interface. +func (k KeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{{ + k.Left, k.Right, k.SoftDrop, k.RotateCW, k.RotateCCW, + k.HardDrop, k.Hold, k.Pause, k.Restart, k.Quit, k.Help, + }} +} + +// Hints renders the bindings as the flight manual rows (§39). +func (k KeyMap) Hints() []render.KeyHint { + var out []render.KeyHint + for _, b := range k.FullHelp()[0] { + h := b.Help() + out = append(out, render.KeyHint{Keys: h.Key, Desc: h.Desc}) + } + return out +} +``` + +Create `internal/app/messages.go`: + +```go +package app + +import ( + "time" + + tea "charm.land/bubbletea/v2" +) + +// Frame timing (§36): one animation clock, elapsed-time gameplay. +const ( + // FrameInterval targets roughly 60 Hz of visual updates. + FrameInterval = 16 * time.Millisecond + // MaxFrameDelta caps the dt handed to the engine. Without it, a suspended + // terminal resumes by dumping minutes of gravity into one frame. + MaxFrameDelta = 100 * time.Millisecond +) + +// FrameMsg is the animation tick. It carries the time so that dt comes from the +// clock Bubble Tea already read, not a second reading of our own. +type FrameMsg struct { + Now time.Time +} + +// FrameCmd schedules the next frame. tea.Tick fires once, so every frame +// re-issues it. +func FrameCmd() tea.Cmd { + return tea.Tick(FrameInterval, func(t time.Time) tea.Msg { + return FrameMsg{Now: t} + }) +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/app/ -v` +Expected: PASS. If a `key.Matches` assertion fails for `space`, check whether the binding needs `" "` or `"space"` — keep both, as written. + +- [ ] **Step 5: Format, vet, commit** + +```bash +gofmt -l . && go vet ./... +git add internal/app/keys.go internal/app/messages.go internal/app/keys_test.go +git commit -m "feat(app): key bindings and the 60 Hz frame clock" +``` + +--- + +### Task 9: The Bubble Tea model and the CLI + +**Files:** +- Create: `internal/app/model.go` +- Create: `internal/app/update.go` +- Create: `cmd/cosmic-tetris/main.go` +- Test: `internal/app/update_test.go` + +**Interfaces:** +- Consumes: `game`, `render`, `KeyMap`, `FrameMsg`. +- Produces: `type State uint8` with `StateBoot`, `StatePlaying`, `StatePaused`, `StateHelp`, `StateGameOver`; `type Config struct{ Seed int64; SeedFixed, ASCII, NoFX, ReducedMotion bool }`; `type Model struct{ ... }`; `func New(cfg Config) Model`; `func (m Model) Init() tea.Cmd`; `func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd)`; `func (m Model) View() tea.View`; `func (m Model) Screen() render.Screen`; `func (m *Model) restart()`. + +`StateBoot` exists now and is skipped immediately; Plan 4 gives it the §29 sequence. + +- [ ] **Step 1: Write the failing test** + +Create `internal/app/update_test.go`: + +```go +package app + +import ( + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/colorprofile" + + "cosmic-tetris/internal/game" + "cosmic-tetris/internal/render" +) + +// newTestModel returns a sized, playing model with a fixed seed. +func newTestModel(t *testing.T) Model { + t.Helper() + m := New(Config{Seed: 8675309, SeedFixed: true}) + m = step(m, tea.WindowSizeMsg{Width: 80, Height: 30}) + m.State = StatePlaying + return m +} + +// step applies one message and returns the updated model. +func step(m Model, msg tea.Msg) Model { + next, _ := m.Update(msg) + return next.(Model) +} + +func TestWindowSizeIsRecorded(t *testing.T) { + m := step(New(Config{Seed: 1}), tea.WindowSizeMsg{Width: 100, Height: 40}) + if m.Width != 100 || m.Height != 40 { + t.Fatalf("size = %dx%d, want 100x40", m.Width, m.Height) + } +} + +func TestColorProfileSelectsTheRenderingMode(t *testing.T) { + m := newTestModel(t) + m = step(m, tea.ColorProfileMsg{Profile: colorprofile.TrueColor}) + if got := m.Renderer.Mode(); got != render.ModeFull { + t.Errorf("mode = %v, want full", got) + } + m = step(m, tea.ColorProfileMsg{Profile: colorprofile.ANSI256}) + if got := m.Renderer.Mode(); got != render.ModeReduced { + t.Errorf("mode = %v, want reduced", got) + } +} + +func TestASCIIFlagOverridesTheDetectedProfile(t *testing.T) { + m := New(Config{Seed: 1, ASCII: true}) + m = step(m, tea.ColorProfileMsg{Profile: colorprofile.TrueColor}) + if got := m.Renderer.Mode(); got != render.ModeASCII { + t.Errorf("mode = %v, want ascii: --ascii must win", got) + } +} + +func TestMovementKeysActImmediately(t *testing.T) { + // §36, §44: input must not wait for the next tick. + m := newTestModel(t) + x := m.Game.Active.X + m = step(m, press("left")) + if m.Game.Active.X != x-1 { + t.Fatalf("X = %d after left, want %d — input was deferred", m.Game.Active.X, x-1) + } + m = step(m, press("right")) + m = step(m, press("right")) + if m.Game.Active.X != x+1 { + t.Fatalf("X = %d after two rights, want %d", m.Game.Active.X, x+1) + } +} + +func TestRotationHoldAndHardDropKeysReachTheEngine(t *testing.T) { + m := newTestModel(t) + m = step(m, press("x")) + if m.Game.Active.Rotation == 0 && m.Game.Active.Kind != game.KindO { + t.Error("x did not rotate the piece") + } + + m = newTestModel(t) + m = step(m, press("c")) + if m.Game.Hold == nil { + t.Error("c did not hold the piece") + } + + m = newTestModel(t) + before := m.Game.Score + m = step(m, press("space")) + if m.Game.Score <= before { + t.Error("space did not hard drop") + } +} + +func TestSoftDropScoresAPoint(t *testing.T) { + m := newTestModel(t) + m = step(m, press("j")) + if m.Game.Score != 1 { + t.Errorf("Score = %d after soft drop, want 1", m.Game.Score) + } +} + +func TestPauseTogglesAndFreezesGravity(t *testing.T) { + m := newTestModel(t) + m = step(m, press("p")) + if m.State != StatePaused { + t.Fatalf("State = %v after p, want StatePaused", m.State) + } + + y := m.Game.Active.Y + m.LastFrame = time.Now().Add(-time.Second) + m = step(m, FrameMsg{Now: time.Now()}) + if m.Game.Active.Y != y { + t.Error("gravity ran while paused") + } + + m = step(m, press("p")) + if m.State != StatePlaying { + t.Fatalf("State = %v after second p, want StatePlaying", m.State) + } +} + +func TestGravityRunsOnFrameMessages(t *testing.T) { + m := newTestModel(t) + y := m.Game.Active.Y + now := time.Now() + m.LastFrame = now + // Ten 90ms frames is 900ms: more than one level-1 interval. + for i := 1; i <= 10; i++ { + m = step(m, FrameMsg{Now: now.Add(time.Duration(i) * 90 * time.Millisecond)}) + } + if m.Game.Active.Y <= y { + t.Fatalf("Y = %d after 900ms of frames, want more than %d", m.Game.Active.Y, y) + } +} + +func TestFrameDeltaIsClampedAfterALongStall(t *testing.T) { + // A suspended terminal must not dump minutes of gravity into one frame. + m := newTestModel(t) + y := m.Game.Active.Y + now := time.Now() + m.LastFrame = now + m = step(m, FrameMsg{Now: now.Add(10 * time.Minute)}) + if dropped := m.Game.Active.Y - y; dropped > 1 { + t.Fatalf("piece fell %d rows on one stalled frame, want at most 1", dropped) + } +} + +func TestFrameCommandKeepsTicking(t *testing.T) { + m := newTestModel(t) + _, cmd := m.Update(FrameMsg{Now: time.Now()}) + if cmd == nil { + t.Fatal("FrameMsg did not schedule the next frame; the clock would stop") + } +} + +func TestHelpTogglesAndReturnsToThePreviousScreen(t *testing.T) { + m := newTestModel(t) + m = step(m, press("?")) + if m.State != StateHelp { + t.Fatalf("State = %v after ?, want StateHelp", m.State) + } + m = step(m, press("?")) + if m.State != StatePlaying { + t.Fatalf("State = %v after second ?, want StatePlaying", m.State) + } + + m = step(m, press("p")) + m = step(m, press("?")) + m = step(m, press("?")) + if m.State != StatePaused { + t.Errorf("State = %v, want to return to StatePaused", m.State) + } +} + +func TestHelpFreezesGravity(t *testing.T) { + m := newTestModel(t) + m = step(m, press("?")) + y := m.Game.Active.Y + now := time.Now() + m.LastFrame = now + for i := 1; i <= 20; i++ { + m = step(m, FrameMsg{Now: now.Add(time.Duration(i) * 90 * time.Millisecond)}) + } + if m.Game.Active.Y != y { + t.Error("gravity ran while the flight manual was open") + } +} + +func TestGameOverStateIsEnteredFromTheEngineEvent(t *testing.T) { + m := newTestModel(t) + // Fill the board so the next spawn is blocked. + for y := 0; y < game.Height; y++ { + for x := 0; x < game.Width; x++ { + m.Game.Board.Cells[y][x] = game.Cell{Filled: true, Kind: game.KindJ} + } + } + m.Game.Board.Cells[game.Height-1][0] = game.Cell{} + m.Game.Active = game.Piece{Kind: game.KindO, X: -1, Y: 0} + m = step(m, press("space")) + if m.State != StateGameOver { + t.Fatalf("State = %v, want StateGameOver", m.State) + } +} + +func TestRestartRebuildsTheGame(t *testing.T) { + m := newTestModel(t) + m = step(m, press("space")) + m = step(m, press("space")) + if m.Game.Score == 0 { + t.Fatal("test setup: expected a non-zero score") + } + m = step(m, press("r")) + if m.Game.Score != 0 || m.Game.Lines != 0 || m.Game.Level != 1 { + t.Errorf("after restart: score=%d lines=%d level=%d, want a fresh game", + m.Game.Score, m.Game.Lines, m.Game.Level) + } + if m.State != StatePlaying { + t.Errorf("State = %v after restart, want StatePlaying", m.State) + } +} + +func TestRestartWithAFixedSeedReplaysTheSameGame(t *testing.T) { + m := newTestModel(t) + first := m.Game.Active.Kind + m = step(m, press("r")) + if m.Game.Active.Kind != first { + t.Errorf("first piece after restart = %s, want %s: --seed must be honoured", + m.Game.Active.Kind, first) + } + if m.Game.Seed != 8675309 { + t.Errorf("Seed = %d after restart, want 8675309", m.Game.Seed) + } +} + +func TestRestartWithoutAFixedSeedPicksANewUniverse(t *testing.T) { + m := New(Config{Seed: 8675309, SeedFixed: false}) + m = step(m, tea.WindowSizeMsg{Width: 80, Height: 30}) + m.State = StatePlaying + m = step(m, press("r")) + if m.Game.Seed == 8675309 { + t.Error("restart without --seed should choose a new seed") + } +} + +func TestRestartWorksFromGameOverAndPause(t *testing.T) { + for _, state := range []State{StateGameOver, StatePaused} { + m := newTestModel(t) + m.State = state + m = step(m, press("r")) + if m.State != StatePlaying { + t.Errorf("from %v, r left the model in %v, want StatePlaying", state, m.State) + } + } +} + +func TestQuitReturnsTheQuitCommand(t *testing.T) { + for _, k := range []string{"q", "esc"} { + m := newTestModel(t) + _, cmd := m.Update(press(k)) + if cmd == nil { + t.Fatalf("%q produced no command, want tea.Quit", k) + } + if _, ok := cmd().(tea.QuitMsg); !ok { + t.Errorf("%q did not produce a QuitMsg", k) + } + } +} + +func TestGameplayKeysAreIgnoredAfterGameOver(t *testing.T) { + m := newTestModel(t) + m.State = StateGameOver + before := m.Game.Active + for _, k := range []string{"left", "right", "j", "x", "z", "space", "c"} { + m = step(m, press(k)) + } + if m.Game.Active != before { + t.Error("a gameplay key changed the game after game over") + } +} + +func TestViewRendersTheCurrentScreenInTheAltScreen(t *testing.T) { + m := newTestModel(t) + v := m.View() + if !v.AltScreen { + t.Error("View should request the alt screen (full window mode)") + } + if !strings.Contains(v.Content, "\x1b[") && !strings.Contains(v.Content, "█") { + t.Errorf("View content looks empty: %q", v.Content[:min(80, len(v.Content))]) + } +} + +func TestScreenMapsStateToRenderScreen(t *testing.T) { + cases := map[State]render.Screen{ + StateBoot: render.ScreenBoot, + StatePlaying: render.ScreenPlaying, + StatePaused: render.ScreenPaused, + StateHelp: render.ScreenHelp, + StateGameOver: render.ScreenGameOver, + } + for state, want := range cases { + m := newTestModel(t) + m.State = state + if got := m.Screen(); got != want { + t.Errorf("State %v maps to %v, want %v", state, got, want) + } + } +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} +``` + +Drop the local `min` helper if the toolchain's builtin `min` is preferred; either is fine, just do not leave both. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/app/ -run TestWindowSize -v` +Expected: FAIL — `undefined: New`. + +- [ ] **Step 3: Write the model** + +Create `internal/app/model.go`: + +```go +package app + +import ( + "math/rand/v2" + "time" + + tea "charm.land/bubbletea/v2" + + "cosmic-tetris/internal/game" + "cosmic-tetris/internal/render" +) + +// State is which face of the application is showing. +type State uint8 + +const ( + StateBoot State = iota + StatePlaying + StatePaused + StateHelp + StateGameOver +) + +// Config is the parsed CLI surface (§49.5). +type Config struct { + Seed int64 + SeedFixed bool // true when --seed was given: restart replays the same universe + ASCII bool + NoFX bool + ReducedMotion bool +} + +// Model is the Bubble Tea model (§34). +type Model struct { + Cfg Config + Game *game.Game + + Width, Height int + State State + prevState State + + LastFrame time.Time + Keys KeyMap + Renderer *render.Renderer + + Mission string +} + +// New builds the initial model. +func New(cfg Config) Model { + mode := render.ModeReduced + if cfg.ASCII { + mode = render.ModeASCII + } + return Model{ + Cfg: cfg, + Game: game.New(cfg.Seed), + State: StatePlaying, + Keys: DefaultKeyMap(), + Renderer: render.NewRenderer(mode), + Mission: "NOMINALISH", + } +} + +// Init starts the frame clock and asks for the terminal size. +func (m Model) Init() tea.Cmd { + return tea.Batch(FrameCmd(), func() tea.Msg { return tea.RequestWindowSize() }) +} + +// Screen maps application state to the renderer's screen enum. +func (m Model) Screen() render.Screen { + switch m.State { + case StateBoot: + return render.ScreenBoot + case StatePaused: + return render.ScreenPaused + case StateHelp: + return render.ScreenHelp + case StateGameOver: + return render.ScreenGameOver + default: + return render.ScreenPlaying + } +} + +// restart rebuilds the game. With --seed the same universe replays; without it, +// a new one is drawn. This is the app's clock read, not the engine's (§49.2). +func (m *Model) restart() { + seed := m.Cfg.Seed + if !m.Cfg.SeedFixed { + seed = rand.Int64() + } + m.Game = game.New(seed) + m.State = StatePlaying + m.Mission = "UNIVERSE REBOOTED" + m.LastFrame = time.Time{} +} + +// View renders the current frame in the alternate screen buffer. +func (m Model) View() tea.View { + v := tea.NewView(m.Renderer.Render(render.View{ + Width: m.Width, + Height: m.Height, + Screen: m.Screen(), + Snapshot: m.Game.Snapshot(), + Mission: m.Mission, + Keys: m.Keys.Hints(), + })) + v.AltScreen = true + return v +} +``` + +- [ ] **Step 4: Write the update loop** + +Create `internal/app/update.go`: + +```go +package app + +import ( + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/bubbles/v2/key" + + "cosmic-tetris/internal/game" + "cosmic-tetris/internal/render" +) + +// Update handles one message. Key presses act immediately; they are never +// deferred to the next frame (§36, §44). +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.Width, m.Height = msg.Width, msg.Height + return m, nil + + case tea.ColorProfileMsg: + m.Renderer.SetMode(render.ModeFor(msg.Profile, m.Cfg.ASCII)) + return m, nil + + case FrameMsg: + return m.frame(msg.Now) + + case tea.KeyPressMsg: + return m.keyPress(msg) + } + return m, nil +} + +// frame advances one animation step. dt is clamped so that a suspended terminal +// resuming cannot dump minutes of gravity into a single frame. +func (m Model) frame(now time.Time) (tea.Model, tea.Cmd) { + dt := time.Duration(0) + if !m.LastFrame.IsZero() { + dt = now.Sub(m.LastFrame) + if dt > MaxFrameDelta { + dt = MaxFrameDelta + } + if dt < 0 { + dt = 0 + } + } + m.LastFrame = now + + if m.State == StatePlaying { + m.observe(m.Game.Advance(dt)) + } + return m, FrameCmd() +} + +// observe reacts to engine events. Plan 3 forwards them to the effects world; +// for now the only reaction the application needs is the game-over transition. +func (m *Model) observe(evs []game.Event) { + for _, e := range evs { + if e.Kind == game.EventGameOver { + m.State = StateGameOver + } + } +} + +func (m Model) keyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch { + case key.Matches(msg, m.Keys.Quit): + return m, tea.Quit + + case key.Matches(msg, m.Keys.Restart): + m.restart() + return m, nil + + case key.Matches(msg, m.Keys.Help): + if m.State == StateHelp { + m.State = m.prevState + } else { + m.prevState = m.State + m.State = StateHelp + } + return m, nil + + case key.Matches(msg, m.Keys.Pause): + switch m.State { + case StatePlaying: + m.State = StatePaused + case StatePaused: + m.State = StatePlaying + } + return m, nil + } + + if m.State != StatePlaying { + return m, nil + } + + var evs []game.Event + switch { + case key.Matches(msg, m.Keys.Left): + evs = m.Game.MoveLeft() + case key.Matches(msg, m.Keys.Right): + evs = m.Game.MoveRight() + case key.Matches(msg, m.Keys.SoftDrop): + evs = m.Game.SoftDrop() + case key.Matches(msg, m.Keys.RotateCW): + evs = m.Game.RotateCW() + case key.Matches(msg, m.Keys.RotateCCW): + evs = m.Game.RotateCCW() + case key.Matches(msg, m.Keys.HardDrop): + evs = m.Game.HardDrop() + case key.Matches(msg, m.Keys.Hold): + evs = m.Game.UseHold() + } + m.observe(evs) + return m, nil +} +``` + +Note: `StateBoot` is skipped for now — `New` starts in `StatePlaying`. Plan 4 Task 1 turns it on. + +- [ ] **Step 5: Write main.go** + +Create `cmd/cosmic-tetris/main.go`: + +```go +// Command cosmic-tetris is a falling-block puzzle game occurring during a +// completely unnecessary cosmological emergency. +package main + +import ( + "flag" + "fmt" + "math/rand/v2" + "os" + + tea "charm.land/bubbletea/v2" + + "cosmic-tetris/internal/app" +) + +func main() { + var ( + seed = flag.Int64("seed", 0, "start a specific universe (reproducible)") + ascii = flag.Bool("ascii", false, "ASCII glyphs only, for terminals that need it") + noFX = flag.Bool("no-fx", false, "disable cosmic effects; still a good game") + reducedMotion = flag.Bool("reduced-motion", false, "no screen shake, hyperdrive or shockwaves") + ) + flag.Parse() + + cfg := app.Config{ + Seed: *seed, + ASCII: *ascii, + NoFX: *noFX, + ReducedMotion: *reducedMotion, + } + flag.Visit(func(f *flag.Flag) { + if f.Name == "seed" { + cfg.SeedFixed = true + } + }) + if !cfg.SeedFixed { + cfg.Seed = rand.Int64() + } + + if _, err := tea.NewProgram(app.New(cfg), tea.WithFPS(60)).Run(); err != nil { + fmt.Fprintln(os.Stderr, "cosmic-tetris:", err) + os.Exit(1) + } +} +``` + +- [ ] **Step 6: Run the tests and build** + +Run: `go test ./... -v && go build ./...` +Expected: PASS and a clean build. + +- [ ] **Step 7: Check the CLI surface by hand** + +Run: `go run ./cmd/cosmic-tetris --help` +Expected: exactly `seed`, `ascii`, `no-fx`, `reduced-motion` — no other flags (§49.5). + +- [ ] **Step 8: Play it** + +Run: `go run ./cmd/cosmic-tetris --seed 8675309` +Expected: a playable game. Check by hand: arrows move, `x` rotates, space drops with a thud of a lock, `c` holds, `p` pauses, `?` opens the flight manual, `r` restarts, `q` quits, and resizing the window re-lays out without crashing. + +- [ ] **Step 9: Format, vet, commit** + +```bash +gofmt -l . && go vet ./... +git add internal/app/model.go internal/app/update.go internal/app/update_test.go cmd/cosmic-tetris/main.go +git commit -m "feat(app): Bubble Tea model, update loop and CLI" +``` + +--- + +### Task 10: Resize and state-transition hardening + +**Files:** +- Test: `internal/app/resize_test.go` +- Modify: `internal/app/update.go` only if a test exposes a real bug + +**Interfaces:** +- Consumes: the whole application. +- Produces: no new API. The deliverable is the guarantee that §31's "never crash from terminal resizing" holds. + +- [ ] **Step 1: Write the test** + +Create `internal/app/resize_test.go`: + +```go +package app + +import ( + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" +) + +// sizes covers plausible and implausible terminal geometries. +func sizes() []tea.WindowSizeMsg { + return []tea.WindowSizeMsg{ + {Width: 0, Height: 0}, + {Width: 1, Height: 1}, + {Width: 34, Height: 19}, + {Width: 40, Height: 24}, + {Width: 41, Height: 25}, + {Width: 52, Height: 26}, + {Width: 80, Height: 30}, + {Width: 300, Height: 100}, + {Width: 2, Height: 60}, + {Width: 200, Height: 3}, + } +} + +func TestEverySizeRendersWithoutPanicking(t *testing.T) { + m := New(Config{Seed: 1, SeedFixed: true}) + for _, s := range sizes() { + m = step(m, s) + out := m.View().Content + rows := strings.Split(ansi.Strip(out), "\n") + if s.Height > 0 && len(rows) != s.Height { + t.Errorf("%dx%d: %d rows, want %d", s.Width, s.Height, len(rows), s.Height) + } + } +} + +func TestResizingInEveryStatePreservesTheGame(t *testing.T) { + // Review Focus 5: resize while paused, in help, or after game over. + for _, state := range []State{StatePlaying, StatePaused, StateHelp, StateGameOver} { + m := newTestModel(t) + m.State = state + m = step(m, press("space")) // put something on the board when playing + score, lines, active := m.Game.Score, m.Game.Lines, m.Game.Active + + for _, s := range sizes() { + m = step(m, s) + _ = m.View() + } + + if m.State != state { + t.Errorf("state %v became %v after resizing", state, m.State) + } + if m.Game.Score != score || m.Game.Lines != lines || m.Game.Active != active { + t.Errorf("state %v: resizing changed the game", state) + } + } +} + +func TestShrinkingBelowMinimumAndBackRecovers(t *testing.T) { + m := newTestModel(t) + m = step(m, tea.WindowSizeMsg{Width: 20, Height: 10}) + if !strings.Contains(ansi.Strip(m.View().Content), "TOO SMALL") { + t.Fatal("shrinking below the minimum should show the notice") + } + m = step(m, tea.WindowSizeMsg{Width: 80, Height: 30}) + out := ansi.Strip(m.View().Content) + if strings.Contains(out, "TOO SMALL") { + t.Error("growing back did not restore the game view") + } + if !strings.Contains(out, "NEXT") { + t.Errorf("HUD missing after recovery:\n%s", out) + } +} + +func TestFramesKeepFlowingWhileTooSmall(t *testing.T) { + m := newTestModel(t) + m = step(m, tea.WindowSizeMsg{Width: 10, Height: 5}) + _, cmd := m.Update(FrameMsg{Now: time.Now()}) + if cmd == nil { + t.Fatal("the frame clock stopped while the terminal was too small") + } +} + +func TestALongSessionOfMixedMessagesStaysSane(t *testing.T) { + m := newTestModel(t) + now := time.Now() + keys := []string{"left", "right", "j", "x", "z", "space", "c", "p", "p", "?", "?"} + for i := 0; i < 600; i++ { + m = step(m, press(keys[i%len(keys)])) + now = now.Add(20 * time.Millisecond) + m = step(m, FrameMsg{Now: now}) + if i%50 == 0 { + m = step(m, sizes()[(i/50)%len(sizes())]) + } + _ = m.View() + } + if m.Game.Level < 1 { + t.Errorf("Level = %d after a long session, want at least 1", m.Game.Level) + } +} +``` + +- [ ] **Step 2: Run the tests** + +Run: `go test ./internal/app/ -run 'TestEverySize|TestResizingIn|TestShrinking|TestFramesKeep|TestALongSession' -v` +Expected: PASS. Any panic here is a real bug — fix `render` or `app`, not the test. + +- [ ] **Step 3: Run the whole suite with the race detector** + +Run: `go test ./... -race -count=2` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +gofmt -l . && go vet ./... +git add internal/app/resize_test.go +git commit -m "test(app): resize and state-transition hardening" +``` + +--- + +## Done when + +- `go test ./... -race` passes and `go build ./...` is clean. +- `go run ./cmd/cosmic-tetris` is a playable game: move, rotate, soft drop, hard drop, hold, ghost, next queue, score/lines/level, pause, help, restart, quit. +- Golden files exist for wide, medium, small, too-small, ASCII, pause, help and game over (§41). Nothing overlaps, board dimensions are right, resize does not panic, the HUD does not corrupt the board. +- `--seed`, `--ascii`, `--no-fx`, `--reduced-motion` and `--help` all parse; `--no-fx` and `--reduced-motion` are recorded in `Config` and consumed in Plan 3. +- Plan 3 can start: it needs `render.View` (to add an `FX` field), `render.Canvas`, `render.Layout`, the palette, and `game.Event`. diff --git a/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/baseline-cosmic-2/2026-09-17-cosmic-tetris-3-cosmic-effects.md b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/baseline-cosmic-2/2026-09-17-cosmic-tetris-3-cosmic-effects.md new file mode 100644 index 000000000..30a9630cd --- /dev/null +++ b/tests/inline-eval/results/2026-09-17-writing-plans-variants/plans/baseline-cosmic-2/2026-09-17-cosmic-tetris-3-cosmic-effects.md @@ -0,0 +1,3688 @@ +# Cosmic Tetris — Plan 3: Cosmic Effects Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the independent effects simulation — starfield, piece trails, hard-drop impact, line supernova, particles, shockwaves, screen shake, hyperdrive, animated border, banners and mission control — and composite it over the Plan 2 game, so that a four-line clear produces the reaction §43 demands (§42 Phases 3 and 4). + +**Architecture:** `internal/fx` is a separate simulation that observes `[]game.Event` and never receives a `*game.Game`, which is how §14's "may never modify GameState" becomes a property of the types rather than a promise. `fx` is deliberately colour-free and glyph-free: it produces positions, lifetimes, brightnesses, shape *indices* and tint *classes*, and `render` maps those onto its palette and the current mode's glyph vocabulary. Particles, trails, clears and shockwaves live in board-cell space so they follow the board wherever the layout puts it; only the starfield works in terminal-cell space, because it fills the whole screen. + +**Tech Stack:** Go 1.26, `charm.land/bubbletea/v2@v2.0.9`, `charm.land/lipgloss/v2@v2.0.6`, `charm.land/bubbles/v2@v2.2.1`, `math/rand/v2`. + +**Spec:** `design.md` + +**Depends on:** Plan 1 (`plans/2026-09-17-cosmic-tetris-1-game-engine.md`) and Plan 2 (`plans/2026-09-17-cosmic-tetris-2-playable-terminal.md`) complete and merged. + +## Global Constraints + +- Language: Go. Module path `cosmic-tetris`. Go directive `go 1.26`. +- Repository layout is §33 of the spec, exactly. Deviations permitted across this plan set, and only these: `internal/game/events.go` (Plan 1), `internal/render/canvas.go` (Plan 2), `internal/render/overlays.go` (Plan 2). This plan adds no new files outside §33's tree: `internal/fx/{world,particle,starfield,events}.go` and `internal/flavor/messages.go` are all in it. +- **FX may observe game events and may never modify game state (§14).** `internal/fx` must not import anything that gives it a `*game.Game`. It sees only `[]game.Event` values. +- FX randomness uses a different RNG from the game (§35, §49.6). `fx.World` and `flavor.Channel` each hold their own `*rand.Rand`; neither is the engine's. +- `internal/fx` never calls `time.Now()`. Effects advance by `Update(dt)`, like the engine. +- §44's restraint rules are hard requirements, not taste: never obscure the active piece; never make controls lag; never delay gameplay for animation; never require reading flavor text; no random effect may alter gameplay; screen shake never exceeds one cell; particles never permanently alter the rendered board. +- `--no-fx` produces a good game (§32): with effects off, the ANSI-stripped output must equal Plan 2's goldens exactly. +- `--reduced-motion` suppresses screen shake, hyperdrive acceleration and shockwaves, and leaves colour, trails and particles alone (§49.5). +- ASCII mode substitutes glyphs without `fx` knowing: `fx` emits shape indices, `render` indexes the mode's glyph sets. +- Board readability is sacred (§15, §21). Inside the board interior only far-layer stars are drawn. +- No goroutine per particle or per frame; no filesystem work during gameplay; reusable slices (§38). A few hundred particles is the working budget. +- Every commit must leave `gofmt -l .` empty, `go vet ./...` clean, and `go test ./...` passing. + +## Review Focus + +1. **A huge `dt` reaching `World.Update`** — a suspended and resumed terminal, or a debugger pause: particles must not teleport off into NaN or skip their whole lifetime in a way that leaves permanent artefacts. Pinned in Task 5. +2. **A burst of events far exceeding the particle budget** — a long stall that lands twenty events on one frame, or combo 15: the particle count must stay capped and memory must stay flat. Pinned in Task 1 and Task 5. +3. **Enabling effects must not change the game** — piece order, score and timing must be bit-identical with FX on, off, and in reduced motion. Pinned in Task 10. +4. **The active piece staying visible during the heaviest frame** — a four-line clear at combo 7 with shake, banner and 500 particles (§44). Pinned in Task 10. +5. **A resize mid-effect** — shrinking to 40×24 while particles are in flight at board coordinates that no longer map on screen, and while the starfield is sized for the old terminal. Pinned in Task 6 and Task 9. + +--- + +### Task 1: Particles + +**Files:** +- Create: `internal/fx/particle.go` +- Test: `internal/fx/particle_test.go` + +**Interfaces:** +- Consumes: `game.PieceKind`. +- Produces: `type Tint uint8` with `TintDebris`, `TintStar`, `TintPiece`, `TintFlash`; `type Particle struct{ X, Y, VX, VY, Life, MaxLife, Brightness float64; Shape uint8; Tint Tint; Kind game.PieceKind }`; `func (p Particle) Fade() float64`; `const MaxParticles = 512`; `type Field struct{ ... }`; `func (f *Field) Len() int`; `func (f *Field) All() []Particle`; `func (f *Field) Add(p Particle) bool`; `func (f *Field) Reset()`; `func (f *Field) Update(dt, gravity, drag float64, b Bounds)`; `type Bounds struct{ MinX, MinY, MaxX, MaxY float64 }`; `func (f *Field) EmitBurst(rng *rand.Rand, x, y float64, n int, speed float64, t Tint, k game.PieceKind, shapes uint8)`; `func (f *Field) EmitSpray(rng *rand.Rand, x, y, vx, vy, spread float64, n int, t Tint, k game.PieceKind, shapes uint8)`. + +Deviation from §23's struct, and why: the spec's `Particle` holds a `Glyph rune`. Ours holds `Shape uint8`, an index into the current mode's glyph set, so ASCII mode substitutes glyphs at render time and `fx` never needs to know whether the terminal has Unicode. `Brightness` and a `Tint` class replace a colour for the same reason. + +- [ ] **Step 1: Write the failing test** + +Create `internal/fx/particle_test.go`: + +```go +package fx + +import ( + "math" + "math/rand/v2" + "testing" + + "cosmic-tetris/internal/game" +) + +func testRNG() *rand.Rand { return rand.New(rand.NewPCG(0xFACE, 0xB00C)) } + +var wideBounds = Bounds{MinX: -8, MinY: -8, MaxX: 32, MaxY: 40} + +func TestFadeIsLifeOverMaxLife(t *testing.T) { + p := Particle{Life: 0.25, MaxLife: 0.5} + if got := p.Fade(); math.Abs(got-0.5) > 1e-9 { + t.Errorf("Fade = %v, want 0.5", got) + } + if got := (Particle{Life: 1, MaxLife: 0}).Fade(); got != 0 { + t.Errorf("Fade with no MaxLife = %v, want 0 (no divide by zero)", got) + } +} + +func TestAddAndAllTrackParticles(t *testing.T) { + var f Field + if f.Len() != 0 { + t.Fatalf("fresh field has %d particles", f.Len()) + } + if !f.Add(Particle{X: 1, Y: 2, Life: 1, MaxLife: 1}) { + t.Fatal("Add returned false on an empty field") + } + if f.Len() != 1 || f.All()[0].X != 1 { + t.Fatalf("field = %+v", f.All()) + } +} + +func TestFieldIsCappedAndStaysFlat(t *testing.T) { + // Review Focus 2: a burst far past the budget must not grow without bound. + var f Field + for i := 0; i < MaxParticles*4; i++ { + f.Add(Particle{Life: 1, MaxLife: 1}) + } + if f.Len() != MaxParticles { + t.Fatalf("Len = %d, want the cap %d", f.Len(), MaxParticles) + } + if got := cap(f.All()); got > MaxParticles { + t.Errorf("backing array grew to %d, want at most %d", got, MaxParticles) + } + if f.Add(Particle{Life: 1, MaxLife: 1}) { + t.Error("Add on a full field should report false") + } +} + +func TestUpdateIntegratesPositionAndVelocity(t *testing.T) { + var f Field + f.Add(Particle{X: 0, Y: 0, VX: 2, VY: -4, Life: 1, MaxLife: 1}) + f.Update(0.5, 10, 1, wideBounds) + p := f.All()[0] + if math.Abs(p.X-1) > 1e-9 { + t.Errorf("X = %v, want 1", p.X) + } + if math.Abs(p.Y-(-2)) > 1e-9 { + t.Errorf("Y = %v, want -2", p.Y) + } + if math.Abs(p.VY-1) > 1e-9 { + t.Errorf("VY = %v, want 1 after 0.5s of gravity 10", p.VY) + } + if math.Abs(p.Life-0.5) > 1e-9 { + t.Errorf("Life = %v, want 0.5", p.Life) + } +} + +func TestDragSlowsParticles(t *testing.T) { + var f Field + f.Add(Particle{VX: 10, Life: 1, MaxLife: 1}) + f.Update(0.1, 0, 0.5, wideBounds) + if got := f.All()[0].VX; got >= 10 { + t.Errorf("VX = %v, want less than 10 under drag", got) + } +} + +func TestDeadParticlesAreRemoved(t *testing.T) { + var f Field + f.Add(Particle{Life: 0.05, MaxLife: 1}) + f.Add(Particle{Life: 5, MaxLife: 5}) + f.Update(0.1, 0, 1, wideBounds) + if f.Len() != 1 { + t.Fatalf("Len = %d, want 1 after one particle expired", f.Len()) + } + if f.All()[0].Life < 1 { + t.Error("the wrong particle survived") + } +} + +func TestParticlesOutsideBoundsAreCulled(t *testing.T) { + var f Field + f.Add(Particle{X: 100, Y: 0, Life: 5, MaxLife: 5}) + f.Add(Particle{X: 0, Y: -100, Life: 5, MaxLife: 5}) + f.Add(Particle{X: 5, Y: 5, Life: 5, MaxLife: 5}) + f.Update(0.016, 0, 1, wideBounds) + if f.Len() != 1 { + t.Fatalf("Len = %d, want 1: out-of-viewport particles must be culled", f.Len()) + } +} + +func TestUpdateSurvivesAbsurdAndDegenerateDeltas(t *testing.T) { + // Review Focus 1: a resumed terminal hands us a very large dt. + for _, dt := range []float64{0, -1, 0.001, 600, math.MaxFloat64} { + var f Field + f.Add(Particle{X: 5, Y: 5, VX: 3, VY: -3, Life: 1, MaxLife: 1}) + f.Update(dt, 20, 0.9, wideBounds) + for _, p := range f.All() { + if math.IsNaN(p.X) || math.IsNaN(p.Y) || math.IsInf(p.X, 0) || math.IsInf(p.Y, 0) { + t.Fatalf("dt=%v produced a non-finite particle %+v", dt, p) + } + } + } +} + +func TestEmitBurstSpreadsParticlesRadially(t *testing.T) { + var f Field + f.EmitBurst(testRNG(), 5, 10, 24, 6, TintDebris, game.KindT, 4) + if f.Len() != 24 { + t.Fatalf("Len = %d, want 24", f.Len()) + } + var up, down, left, right int + for _, p := range f.All() { + if p.MaxLife <= 0 || p.Life <= 0 { + t.Fatalf("particle has no life: %+v", p) + } + if p.Tint != TintDebris || p.Kind != game.KindT { + t.Errorf("particle carries %v/%v, want TintDebris/KindT", p.Tint, p.Kind) + } + if p.Shape >= 4 { + t.Errorf("Shape = %d, want an index below 4", p.Shape) + } + switch { + case p.VY < -0.5: + up++ + case p.VY > 0.5: + down++ + } + switch { + case p.VX < -0.5: + left++ + case p.VX > 0.5: + right++ + } + } + if up == 0 || down == 0 || left == 0 || right == 0 { + t.Errorf("burst is not radial: up=%d down=%d left=%d right=%d", up, down, left, right) + } +} + +func TestEmitBurstRespectsTheCap(t *testing.T) { + var f Field + f.EmitBurst(testRNG(), 5, 5, MaxParticles*3, 6, TintDebris, game.KindI, 4) + if f.Len() != MaxParticles { + t.Fatalf("Len = %d, want the cap %d", f.Len(), MaxParticles) + } +} + +func TestEmitSprayIsDirectional(t *testing.T) { + var f Field + f.EmitSpray(testRNG(), 4, 4, 0, 12, 0.3, 20, TintFlash, game.KindI, 4) + for _, p := range f.All() { + if p.VY <= 0 { + t.Errorf("spray particle moves against its direction: VY = %v", p.VY) + } + } +} + +func TestEmittersAreDeterministicForAGivenSeed(t *testing.T) { + var a, b Field + a.EmitBurst(testRNG(), 5, 10, 32, 6, TintDebris, game.KindZ, 4) + b.EmitBurst(testRNG(), 5, 10, 32, 6, TintDebris, game.KindZ, 4) + pa, pb := a.All(), b.All() + for i := range pa { + if pa[i] != pb[i] { + t.Fatalf("particle %d differs: %+v vs %+v", i, pa[i], pb[i]) + } + } +} + +func TestResetEmptiesWithoutReallocating(t *testing.T) { + var f Field + f.EmitBurst(testRNG(), 5, 5, 64, 6, TintDebris, game.KindO, 4) + before := cap(f.All()) + f.Reset() + if f.Len() != 0 { + t.Fatalf("Len = %d after Reset", f.Len()) + } + f.Add(Particle{Life: 1, MaxLife: 1}) + if got := cap(f.All()); got != before { + t.Errorf("cap = %d after Reset, want the slice reused at %d", got, before) + } +} + +func TestZeroFieldIsUsable(t *testing.T) { + var f Field + f.Update(0.016, 10, 0.9, wideBounds) + if f.Len() != 0 || f.All() == nil && f.Len() != 0 { + t.Fatal("the zero Field should be usable without initialisation") + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/fx/ -run TestFadeIsLife -v` +Expected: FAIL — `undefined: Particle`. + +- [ ] **Step 3: Write the implementation** + +Create `internal/fx/particle.go`: + +```go +// Package fx is the cosmic effects simulation. It observes game events and never +// modifies game state (§14): it is handed []game.Event values and has no way to +// reach a *game.Game. +// +// fx is colour-free and glyph-free on purpose. It produces positions, lifetimes, +// brightnesses, shape indices and tint classes; render maps those onto its +// palette and the current mode's glyph vocabulary, which is how ASCII mode +// substitutes glyphs without fx knowing anything about Unicode. +// +// Particles, trails, clears and shockwaves live in board-cell space, so they +// follow the board wherever the layout puts it. Only the starfield works in +// terminal-cell space, because it fills the screen. +package fx + +import ( + "math" + "math/rand/v2" + + "cosmic-tetris/internal/game" +) + +// Tint is a particle's colour class. render resolves it against its palette. +type Tint uint8 + +const ( + // TintDebris is cool grey-white rubble. + TintDebris Tint = iota + // TintStar is starfield colouring. + TintStar + // TintPiece takes the colour of Particle.Kind. + TintPiece + // TintFlash is hot white: impacts, supernovae, shockwave fronts. + TintFlash +) + +// MaxParticles caps the simulation. §38 calls a few hundred trivial; this is the +// ceiling that keeps a pathological event burst from growing memory. +const MaxParticles = 512 + +// Particle is one speck of terminal-space physics (§23). +type Particle struct { + X, Y float64 + VX, VY float64 + + Life float64 + MaxLife float64 + + Brightness float64 + Shape uint8 + Tint Tint + Kind game.PieceKind +} + +// Fade is remaining life as a fraction, 0 when spent. +func (p Particle) Fade() float64 { + if p.MaxLife <= 0 { + return 0 + } + f := p.Life / p.MaxLife + if f < 0 { + return 0 + } + if f > 1 { + return 1 + } + return f +} + +// Bounds is the cull region in the particles' own coordinate space. +type Bounds struct { + MinX, MinY, MaxX, MaxY float64 +} + +func (b Bounds) contains(x, y float64) bool { + return x >= b.MinX && x <= b.MaxX && y >= b.MinY && y <= b.MaxY +} + +// Field is a capped particle pool. The zero value is ready to use and the +// backing slice is reused across bursts (§38). +type Field struct { + ps []Particle +} + +// Len is the live particle count. +func (f *Field) Len() int { return len(f.ps) } + +// All returns the live particles. The slice is owned by the Field; treat it as +// read-only. +func (f *Field) All() []Particle { return f.ps } + +// Add appends a particle, reporting false when the field is full. +func (f *Field) Add(p Particle) bool { + if len(f.ps) >= MaxParticles { + return false + } + if f.ps == nil { + f.ps = make([]Particle, 0, MaxParticles) + } + f.ps = append(f.ps, p) + return true +} + +// Reset drops every particle and keeps the backing array. +func (f *Field) Reset() { f.ps = f.ps[:0] } + +// Update integrates one step and compacts out particles that died or left the +// viewport. dt is clamped, because a resumed terminal can hand us minutes. +func (f *Field) Update(dt, gravity, drag float64, b Bounds) { + if !(dt > 0) { // also catches NaN + return + } + if dt > maxStepSeconds { + dt = maxStepSeconds + } + if drag <= 0 || drag > 1 { + drag = 1 + } + damp := math.Pow(drag, dt*60) + + live := f.ps[:0] + for _, p := range f.ps { + p.X += p.VX * dt + p.Y += p.VY * dt + p.VY += gravity * dt + p.VX *= damp + p.VY *= damp + p.Life -= dt + if p.Life <= 0 || !b.contains(p.X, p.Y) { + continue + } + live = append(live, p) + } + f.ps = live +} + +// EmitBurst throws n particles radially from x, y (§23's radial explosion force). +// shapes is the size of the glyph set render will index with Shape. +func (f *Field) EmitBurst(rng *rand.Rand, x, y float64, n int, speed float64, t Tint, k game.PieceKind, shapes uint8) { + if shapes == 0 { + shapes = 1 + } + for i := 0; i < n; i++ { + angle := rng.Float64() * 2 * math.Pi + mag := speed * (0.35 + 0.65*rng.Float64()) + life := 0.28 + 0.5*rng.Float64() + if !f.Add(Particle{ + X: x, Y: y, + VX: math.Cos(angle) * mag, + VY: math.Sin(angle) * mag * 0.6, // terminal cells are tall + Life: life, + MaxLife: life, + Brightness: 0.6 + 0.4*rng.Float64(), + Shape: uint8(rng.IntN(int(shapes))), + Tint: t, + Kind: k, + }) { + return + } + } +} + +// EmitSpray throws n particles along vx, vy with an angular spread in radians. +func (f *Field) EmitSpray(rng *rand.Rand, x, y, vx, vy, spread float64, n int, t Tint, k game.PieceKind, shapes uint8) { + if shapes == 0 { + shapes = 1 + } + base := math.Atan2(vy, vx) + mag := math.Hypot(vx, vy) + for i := 0; i < n; i++ { + angle := base + (rng.Float64()-0.5)*2*spread + m := mag * (0.6 + 0.6*rng.Float64()) + life := 0.2 + 0.35*rng.Float64() + if !f.Add(Particle{ + X: x, Y: y, + VX: math.Cos(angle) * m, + VY: math.Sin(angle) * m, + Life: life, + MaxLife: life, + Brightness: 0.7 + 0.3*rng.Float64(), + Shape: uint8(rng.IntN(int(shapes))), + Tint: t, + Kind: k, + }) { + return + } + } +} +``` + +`maxStepSeconds` is declared in Task 3's `events.go` as `const maxStepSeconds = 0.1`. To keep this task compiling on its own, declare it here now and move it to `events.go` in Task 3: + +```go +// maxStepSeconds clamps one simulation step. A suspended terminal resuming must +// not fast-forward the whole spectacle in a single frame. +const maxStepSeconds = 0.1 +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/fx/ -v` +Expected: PASS. + +- [ ] **Step 5: Format, vet, commit** + +```bash +gofmt -l . && go vet ./... +git add internal/fx/particle.go internal/fx/particle_test.go +git commit -m "feat(fx): capped particle field with radial and directional emitters" +``` + +--- + +### Task 2: Starfield + +**Files:** +- Create: `internal/fx/starfield.go` +- Test: `internal/fx/starfield_test.go` + +**Interfaces:** +- Consumes: `math/rand/v2`. +- Produces: `type Star struct{ X, Y float64; Layer uint8; Shape uint8; Bright float64 }`; `const StarLayers = 3`; `type Starfield struct{ ... }`; `func (s *Starfield) Resize(rng *rand.Rand, w, h int)`; `func (s *Starfield) Size() (int, int)`; `func (s *Starfield) All() []Star`; `func (s *Starfield) Update(rng *rand.Rand, dt, speedMul float64)`; `func (s *Starfield) Density() float64`; `func (s *Starfield) SetDensity(d float64)`. + +Layer speeds are 0.55, 1.6 and 3.4 cells per second (§15's slow/medium/fast). Stars drift downward and wrap to the top. `speedMul` carries both the level ramp and hyperdrive; the caller computes it. + +- [ ] **Step 1: Write the failing test** + +Create `internal/fx/starfield_test.go`: + +```go +package fx + +import ( + "math" + "testing" +) + +func TestResizeFillsTheFieldWithThreeLayers(t *testing.T) { + var s Starfield + s.Resize(testRNG(), 80, 30) + if w, h := s.Size(); w != 80 || h != 30 { + t.Fatalf("Size = %dx%d, want 80x30", w, h) + } + if s.All() == nil || len(s.All()) == 0 { + t.Fatal("no stars after Resize") + } + seen := map[uint8]int{} + for _, st := range s.All() { + if st.Layer >= StarLayers { + t.Fatalf("star in layer %d, want below %d", st.Layer, StarLayers) + } + if st.X < 0 || st.X > 80 || st.Y < 0 || st.Y > 30 { + t.Errorf("star outside the field: %+v", st) + } + if st.Bright <= 0 || st.Bright > 1 { + t.Errorf("star brightness %v out of range", st.Bright) + } + seen[st.Layer]++ + } + for l := uint8(0); l < StarLayers; l++ { + if seen[l] == 0 { + t.Errorf("layer %d is empty (§15 wants three depth layers)", l) + } + } +} + +func TestFarLayerIsTheMostPopulousAndDimmest(t *testing.T) { + var s Starfield + s.Resize(testRNG(), 80, 30) + count := map[uint8]int{} + bright := map[uint8]float64{} + for _, st := range s.All() { + count[st.Layer]++ + bright[st.Layer] += st.Bright + } + if count[0] <= count[2] { + t.Errorf("far layer has %d stars, near has %d: want more far stars", count[0], count[2]) + } + avg := func(l uint8) float64 { return bright[l] / float64(count[l]) } + if avg(0) >= avg(2) { + t.Errorf("far avg brightness %v is not dimmer than near %v", avg(0), avg(2)) + } +} + +func TestStarsDriftDownwardAtLayerSpeeds(t *testing.T) { + var s Starfield + s.Resize(testRNG(), 80, 30) + before := make([]Star, len(s.All())) + copy(before, s.All()) + s.Update(testRNG(), 0.5, 1) + + var moved [StarLayers]float64 + for i, st := range s.All() { + if st.Layer != before[i].Layer { + t.Fatal("Update reordered the stars; the test needs stable indices") + } + d := st.Y - before[i].Y + if d < 0 { + d += float64(30) // wrapped + } + moved[st.Layer] += d + } + if !(moved[0] < moved[1] && moved[1] < moved[2]) { + t.Errorf("layer drift %v is not increasing with depth", moved) + } +} + +func TestStarsWrapAtTheBottom(t *testing.T) { + var s Starfield + s.Resize(testRNG(), 40, 24) + s.Update(testRNG(), 5, 4) + for _, st := range s.All() { + if st.Y < 0 || st.Y > 24 { + t.Fatalf("star escaped the field after a long step: %+v", st) + } + } +} + +func TestSpeedMultiplierScalesDrift(t *testing.T) { + measure := func(mul float64) float64 { + var s Starfield + s.Resize(testRNG(), 80, 30) + y0 := s.All()[0].Y + s.Update(testRNG(), 0.1, mul) + return s.All()[0].Y - y0 + } + slow, fast := measure(1), measure(4) + if fast <= slow { + t.Errorf("drift with mul 4 (%v) is not greater than with mul 1 (%v)", fast, slow) + } +} + +func TestUpdateToleratesAbsurdDeltas(t *testing.T) { + for _, dt := range []float64{0, -3, 600, math.MaxFloat64} { + var s Starfield + s.Resize(testRNG(), 80, 30) + s.Update(testRNG(), dt, 8) + for _, st := range s.All() { + if math.IsNaN(st.Y) || math.IsInf(st.Y, 0) || st.Y < 0 || st.Y > 30 { + t.Fatalf("dt=%v produced %+v", dt, st) + } + } + } +} + +func TestResizeToNothingIsSafe(t *testing.T) { + // Review Focus 5: a terminal can report 0x0 mid-drag. + var s Starfield + s.Resize(testRNG(), 80, 30) + s.Resize(testRNG(), 0, 0) + s.Update(testRNG(), 0.016, 1) + if len(s.All()) != 0 { + t.Errorf("%d stars in a 0x0 field", len(s.All())) + } + s.Resize(testRNG(), -5, -5) + s.Update(testRNG(), 0.016, 1) + s.Resize(testRNG(), 80, 30) + if len(s.All()) == 0 { + t.Error("field did not repopulate after growing back") + } +} + +func TestResizePreservesRoughDensity(t *testing.T) { + var small, large Starfield + small.Resize(testRNG(), 40, 24) + large.Resize(testRNG(), 160, 48) + if len(large.All()) <= len(small.All()) { + t.Errorf("large field has %d stars, small has %d: density should scale with area", + len(large.All()), len(small.All())) + } + if len(large.All()) > 2000 { + t.Errorf("%d stars on a 160x48 terminal is too many", len(large.All())) + } +} + +func TestDensityBoostAddsStars(t *testing.T) { + // §20: a four-line clear temporarily increases star density. + var s Starfield + s.Resize(testRNG(), 80, 30) + base := len(s.All()) + s.SetDensity(2) + s.Update(testRNG(), 0.016, 1) + if len(s.All()) <= base { + t.Errorf("%d stars at density 2, want more than %d", len(s.All()), base) + } + s.SetDensity(1) + for i := 0; i < 200; i++ { + s.Update(testRNG(), 0.016, 1) + } + if len(s.All()) > base+base/4 { + t.Errorf("%d stars after the boost decayed, want about %d", len(s.All()), base) + } +} + +func TestStarfieldIsDeterministic(t *testing.T) { + run := func() []Star { + var s Starfield + rng := testRNG() + s.Resize(rng, 80, 30) + for i := 0; i < 60; i++ { + s.Update(rng, 0.016, 1.5) + } + out := make([]Star, len(s.All())) + copy(out, s.All()) + return out + } + a, b := run(), run() + if len(a) != len(b) { + t.Fatalf("lengths %d vs %d", len(a), len(b)) + } + for i := range a { + if a[i] != b[i] { + t.Fatalf("star %d differs: %+v vs %+v", i, a[i], b[i]) + } + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/fx/ -run TestResizeFills -v` +Expected: FAIL — `undefined: Starfield`. + +- [ ] **Step 3: Write the implementation** + +Create `internal/fx/starfield.go`: + +```go +package fx + +import "math/rand/v2" + +// StarLayers is the number of depth layers (§15). +const StarLayers = 3 + +// Star is one background speck in terminal-cell space. +type Star struct { + X, Y float64 + Layer uint8 + Shape uint8 + Bright float64 +} + +// layerSpeed is downward drift in cells per second: slow, medium, fast (§15). +var layerSpeed = [StarLayers]float64{0.55, 1.6, 3.4} + +// layerShare is each layer's fraction of the population. Far stars dominate so +// the sky reads as depth rather than confetti. +var layerShare = [StarLayers]float64{0.6, 0.28, 0.12} + +// layerBright is each layer's base brightness. +var layerBright = [StarLayers]float64{0.35, 0.6, 1.0} + +// cellsPerStar sets the baseline density: one star per this many terminal cells. +const cellsPerStar = 26 + +// Starfield is the drifting background. The zero value is an empty field. +type Starfield struct { + stars []Star + w, h int + density float64 +} + +// Size is the field's terminal dimensions. +func (s *Starfield) Size() (int, int) { return s.w, s.h } + +// All returns the live stars. Owned by the Starfield; treat as read-only. +func (s *Starfield) All() []Star { return s.stars } + +// Density is the current multiplier on the baseline population. +func (s *Starfield) Density() float64 { + if s.density <= 0 { + return 1 + } + return s.density +} + +// SetDensity boosts the population, e.g. during a four-line clear (§20). It +// decays back toward 1 in Update. +func (s *Starfield) SetDensity(d float64) { + if d < 1 { + d = 1 + } + if d > 4 { + d = 4 + } + s.density = d +} + +// target is the star count the current size and density call for. +func (s *Starfield) target() int { + if s.w <= 0 || s.h <= 0 { + return 0 + } + return int(float64(s.w*s.h) / cellsPerStar * s.Density()) +} + +// Resize repopulates the field for a new terminal size. +func (s *Starfield) Resize(rng *rand.Rand, w, h int) { + if w < 0 { + w = 0 + } + if h < 0 { + h = 0 + } + s.w, s.h = w, h + s.stars = s.stars[:0] + for len(s.stars) < s.target() { + s.stars = append(s.stars, s.spawn(rng, true)) + } +} + +// spawn makes one star. anywhere places it at a random height, as opposed to +// entering from the top. +func (s *Starfield) spawn(rng *rand.Rand, anywhere bool) Star { + layer := uint8(0) + r := rng.Float64() + switch { + case r > layerShare[0]+layerShare[1]: + layer = 2 + case r > layerShare[0]: + layer = 1 + } + y := 0.0 + if anywhere { + y = rng.Float64() * float64(s.h) + } + return Star{ + X: rng.Float64() * float64(s.w), + Y: y, + Layer: layer, + Shape: uint8(rng.IntN(4)), + Bright: layerBright[layer] * (0.7 + 0.3*rng.Float64()), + } +} + +// Update drifts the stars downward. speedMul carries the level ramp and any +// hyperdrive boost; the caller computes it. +func (s *Starfield) Update(rng *rand.Rand, dt, speedMul float64) { + if s.w <= 0 || s.h <= 0 { + s.stars = s.stars[:0] + return + } + if !(dt > 0) { + return + } + if dt > maxStepSeconds { + dt = maxStepSeconds + } + if speedMul < 0 { + speedMul = 0 + } + + h := float64(s.h) + for i := range s.stars { + st := &s.stars[i] + st.Y += layerSpeed[st.Layer] * speedMul * dt + for st.Y > h { + st.Y -= h + st.X = rng.Float64() * float64(s.w) + } + } + + // Density relaxes back to the baseline, adding or dropping a few stars per + // frame rather than repopulating in one jump. + if s.density > 1 { + s.density -= dt + if s.density < 1 { + s.density = 1 + } + } + target := s.target() + for i := 0; i < 4 && len(s.stars) < target; i++ { + s.stars = append(s.stars, s.spawn(rng, false)) + } + for i := 0; i < 4 && len(s.stars) > target; i++ { + s.stars = s.stars[:len(s.stars)-1] + } +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/fx/ -v` +Expected: PASS. `TestStarsDriftDownwardAtLayerSpeeds` relies on `Update` keeping star indices stable — it must not reorder. + +- [ ] **Step 5: Format, vet, commit** + +```bash +gofmt -l . && go vet ./... +git add internal/fx/starfield.go internal/fx/starfield_test.go +git commit -m "feat(fx): three-layer drifting starfield with density boosts" +``` + +--- +### Task 3: Event-driven effect state machines + +**Files:** +- Create: `internal/fx/events.go` +- Modify: `internal/fx/particle.go` (move `maxStepSeconds` here) +- Test: `internal/fx/events_test.go` + +**Interfaces:** +- Consumes: `game.PieceKind`. +- Produces: `const maxStepSeconds = 0.1`; `type Shake struct{ ... }` with `Trigger(strength float64)`, `Update(dt float64)`, `Offset() (int, int)`, `Active() bool`; `type Shockwave struct{ X, Y, Age, Life float64 }` with `Radius() float64` and `Fade() float64`; `type Hyperdrive struct{ ... }` with `Trigger()`, `Update(dt float64)`, `Multiplier() float64`, `Active() bool`; `type Banner struct{ Text, Sub string; Age, Life float64 }` with `Fade() float64`; `type Border struct{ ... }` with `Update(dt float64)`, `Pulse(amount float64)`, `Phase() float64`, `Energy() float64`; `type LineClear struct{ Rows []int; Age, Life float64 }` with `Phase() ClearPhase`; `type ClearPhase uint8` with `ClearCriticalMass`, `ClearSupernova`, `ClearCollapse`; `type TrailCell struct{ X, Y int; Fade float64; Kind game.PieceKind }`; `const ShakeDuration`, `ShockwaveLife`, `HyperdriveLife`, `BannerLife`, `ClearLife`, `TrailLife`. + +The §18 shake pattern is walked, not randomised, so shake is reproducible and can never exceed one cell (§44). + +- [ ] **Step 1: Write the failing test** + +Create `internal/fx/events_test.go`: + +```go +package fx + +import ( + "math" + "testing" + "time" + + "cosmic-tetris/internal/game" +) + +func TestDurationConstantsMatchTheSpec(t *testing.T) { + cases := []struct { + name string + got time.Duration + want time.Duration + }{ + {"ShakeDuration", ShakeDuration, 80 * time.Millisecond}, // §18 + {"ShockwaveLife", ShockwaveLife, 300 * time.Millisecond}, // §24 + {"HyperdriveLife", HyperdriveLife, 1100 * time.Millisecond}, // §16 + {"BannerLife", BannerLife, 700 * time.Millisecond}, // §20 + {"ClearLife", ClearLife, 220 * time.Millisecond}, // §19 + } + for _, c := range cases { + if c.got != c.want { + t.Errorf("%s = %v, want %v", c.name, c.got, c.want) + } + } + if TrailLife < 100*time.Millisecond || TrailLife > 160*time.Millisecond { + t.Errorf("TrailLife = %v, want 100-160ms (§17)", TrailLife) + } +} + +func TestShakeNeverExceedsOneCell(t *testing.T) { + // §44: never make screen shake exceed roughly one cell. + var s Shake + for _, strength := range []float64{0.1, 1, 4, 100} { + s.Trigger(strength) + for i := 0; i < 40; i++ { + dx, dy := s.Offset() + if dx < -1 || dx > 1 || dy < -1 || dy > 1 { + t.Fatalf("strength %v produced offset (%d,%d)", strength, dx, dy) + } + s.Update(0.008) + } + } +} + +func TestShakeSettlesToZero(t *testing.T) { + var s Shake + s.Trigger(1) + if !s.Active() { + t.Fatal("shake is not active after Trigger") + } + s.Update(ShakeDuration.Seconds() * 2) + if s.Active() { + t.Error("shake still active after twice its duration") + } + if dx, dy := s.Offset(); dx != 0 || dy != 0 { + t.Errorf("settled offset = (%d,%d), want (0,0)", dx, dy) + } +} + +func TestShakeWalksThePinnedPattern(t *testing.T) { + // §18's pattern is deterministic, so two runs must agree exactly. + run := func() [][2]int { + var s Shake + s.Trigger(1) + var out [][2]int + for i := 0; i < 12; i++ { + dx, dy := s.Offset() + out = append(out, [2]int{dx, dy}) + s.Update(0.008) + } + return out + } + a, b := run(), run() + for i := range a { + if a[i] != b[i] { + t.Fatalf("step %d differs: %v vs %v — shake must be deterministic", i, a[i], b[i]) + } + } + moved := false + for _, o := range a { + if o != [2]int{0, 0} { + moved = true + } + } + if !moved { + t.Error("shake never moved anything") + } +} + +func TestShockwaveExpandsAndFades(t *testing.T) { + w := Shockwave{X: 5, Y: 10, Life: ShockwaveLife.Seconds()} + r0 := w.Radius() + w.Age += 0.1 + if w.Radius() <= r0 { + t.Errorf("radius %v did not grow from %v", w.Radius(), r0) + } + if w.Fade() >= 1 { + t.Errorf("Fade = %v after ageing, want below 1", w.Fade()) + } + w.Age = w.Life + if got := w.Fade(); got != 0 { + t.Errorf("Fade at end of life = %v, want 0", got) + } +} + +func TestHyperdriveFollowsTheSpecTimeline(t *testing.T) { + // §16: pause, stretch, violent acceleration, peak at 500ms, decay, normal. + var h Hyperdrive + if got := h.Multiplier(); got != 1 { + t.Fatalf("idle multiplier = %v, want 1", got) + } + h.Trigger() + at := func(seconds float64) float64 { + var hh Hyperdrive + hh.Trigger() + for t := 0.0; t < seconds; t += 0.004 { + hh.Update(0.004) + } + return hh.Multiplier() + } + pause, mid, peak, decay := at(0.001), at(0.1), at(0.5), at(0.9) + if pause > 0.3 { + t.Errorf("multiplier at 0ms = %v, want a near-pause", pause) + } + if !(mid > pause) { + t.Errorf("no acceleration by 100ms: %v after %v", mid, pause) + } + if !(peak > mid && peak > 4) { + t.Errorf("peak at 500ms = %v, want a violent peak above 4", peak) + } + if !(decay < peak) { + t.Errorf("no decay by 900ms: %v vs peak %v", decay, peak) + } + if got := at(1.2); math.Abs(got-1) > 0.01 { + t.Errorf("multiplier after 1.2s = %v, want back to 1", got) + } +} + +func TestHyperdriveRetriggerRestarts(t *testing.T) { + var h Hyperdrive + h.Trigger() + h.Update(0.9) + h.Trigger() + if !h.Active() { + t.Fatal("retrigger did not reactivate hyperdrive") + } + h.Update(0.5) + if h.Multiplier() < 4 { + t.Errorf("multiplier %v after retrigger, want the peak again", h.Multiplier()) + } +} + +func TestBorderPhaseDriftsSlowlyAndPulsesDecay(t *testing.T) { + var b Border + p0 := b.Phase() + b.Update(1) + if b.Phase() == p0 { + t.Error("border phase does not drift (§25: colour shifts over time)") + } + if d := b.Phase() - p0; d > 0.2 { + t.Errorf("phase moved %v in one second, want a subtle shift", d) + } + if got := b.Energy(); got != 0 { + t.Errorf("resting energy = %v, want 0", got) + } + b.Pulse(1) + if b.Energy() <= 0 { + t.Fatal("Pulse did not raise energy") + } + b.Update(2) + if got := b.Energy(); got != 0 { + t.Errorf("energy = %v two seconds after a pulse, want 0", got) + } +} + +func TestBorderPhaseStaysInUnitRange(t *testing.T) { + var b Border + for i := 0; i < 5000; i++ { + b.Update(0.05) + if p := b.Phase(); p < 0 || p >= 1 { + t.Fatalf("phase = %v, want [0,1)", p) + } + } +} + +func TestBorderEnergyIsClamped(t *testing.T) { + var b Border + b.Pulse(100) + if got := b.Energy(); got > 1 { + t.Errorf("Energy = %v, want at most 1", got) + } +} + +func TestClearPhasesRunAThroughC(t *testing.T) { + c := LineClear{Rows: []int{20, 21}, Life: ClearLife.Seconds()} + if c.Phase() != ClearCriticalMass { + t.Errorf("phase at age 0 = %v, want ClearCriticalMass", c.Phase()) + } + c.Age = c.Life * 0.5 + if c.Phase() != ClearSupernova { + t.Errorf("phase at half life = %v, want ClearSupernova", c.Phase()) + } + c.Age = c.Life * 0.9 + if c.Phase() != ClearCollapse { + t.Errorf("phase at 90%% = %v, want ClearCollapse", c.Phase()) + } +} + +func TestBannerFade(t *testing.T) { + b := Banner{Text: "✦ EVENT HORIZON ✦", Life: BannerLife.Seconds()} + if got := b.Fade(); got != 1 { + t.Errorf("fresh banner Fade = %v, want 1", got) + } + b.Age = b.Life + if got := b.Fade(); got != 0 { + t.Errorf("expired banner Fade = %v, want 0", got) + } +} + +func TestTrailCellCarriesItsPieceKind(t *testing.T) { + // render needs the kind to colour the trail; fx stays colour-free. + c := TrailCell{X: 4, Y: 9, Fade: 0.5, Kind: game.KindS} + if c.Kind != game.KindS { + t.Fatalf("Kind = %v", c.Kind) + } +} + +func TestMaxStepSecondsClampsOneFrame(t *testing.T) { + if maxStepSeconds <= 0 || maxStepSeconds > 0.2 { + t.Fatalf("maxStepSeconds = %v, want a small positive clamp", maxStepSeconds) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/fx/ -run TestDurationConstants -v` +Expected: FAIL — `undefined: ShakeDuration`. + +- [ ] **Step 3: Write the implementation** + +Create `internal/fx/events.go`: + +```go +package fx + +import ( + "time" + + "cosmic-tetris/internal/game" +) + +// Effect durations, all pinned by the spec. +const ( + // ShakeDuration is §18's 80ms impact shake. + ShakeDuration = 80 * time.Millisecond + // ShockwaveLife is §24's ~300ms ring. + ShockwaveLife = 300 * time.Millisecond + // HyperdriveLife is §16's full 1100ms sequence. + HyperdriveLife = 1100 * time.Millisecond + // BannerLife is §20's ~700ms banner. + BannerLife = 700 * time.Millisecond + // ClearLife is §19's ~220ms line-clear animation. + ClearLife = 220 * time.Millisecond + // TrailLife is §17's short-lived ion trail. + TrailLife = 140 * time.Millisecond +) + +// maxStepSeconds clamps one simulation step. A suspended terminal resuming must +// not fast-forward the whole spectacle in a single frame. +const maxStepSeconds = 0.1 + +// shakePattern is §18's deterministic shake walk. Every entry is within one +// cell, which is how §44's limit is guaranteed rather than hoped for. +var shakePattern = [5][2]int{{0, 1}, {-1, 0}, {1, 0}, {0, -1}, {0, 0}} + +// shakeStep is how long each pattern entry holds. +const shakeStep = ShakeDuration / 5 + +// Shake is the impact screen shake. The zero value is at rest. +type Shake struct { + remaining float64 + elapsed float64 + strength float64 +} + +// Trigger starts (or restarts) a shake. strength scales duration, never +// amplitude: amplitude is always at most one cell. +func (s *Shake) Trigger(strength float64) { + if strength <= 0 { + return + } + if strength > 3 { + strength = 3 + } + s.strength = strength + s.remaining = ShakeDuration.Seconds() * strength + s.elapsed = 0 +} + +// Update ages the shake. +func (s *Shake) Update(dt float64) { + if !(dt > 0) || s.remaining <= 0 { + return + } + if dt > maxStepSeconds { + dt = maxStepSeconds + } + s.elapsed += dt + s.remaining -= dt + if s.remaining <= 0 { + s.remaining = 0 + s.elapsed = 0 + } +} + +// Active reports whether a shake is in progress. +func (s *Shake) Active() bool { return s.remaining > 0 } + +// Offset is the current board displacement in whole terminal cells. +func (s *Shake) Offset() (int, int) { + if s.remaining <= 0 { + return 0, 0 + } + i := int(s.elapsed/shakeStep.Seconds()) % len(shakePattern) + return shakePattern[i][0], shakePattern[i][1] +} + +// Shockwave is an expanding ring in board-cell space (§24). +type Shockwave struct { + X, Y float64 + Age float64 + Life float64 +} + +// maxShockwaveRadius keeps a ring inside a sane region of the board. +const maxShockwaveRadius = 14 + +// Radius is the ring's current radius in board cells. +func (w Shockwave) Radius() float64 { + if w.Life <= 0 { + return 0 + } + return maxShockwaveRadius * (w.Age / w.Life) +} + +// Fade is remaining life as a fraction. +func (w Shockwave) Fade() float64 { + if w.Life <= 0 { + return 0 + } + f := 1 - w.Age/w.Life + if f < 0 { + return 0 + } + if f > 1 { + return 1 + } + return f +} + +// hyperKeys is §16's timeline as piecewise-linear keyframes of +// (seconds, star speed multiplier). +var hyperKeys = [...]struct{ t, mul float64 }{ + {0.00, 0.0}, // stars pause + {0.05, 0.4}, // stretch + {0.10, 3.0}, // violent acceleration + {0.50, 8.0}, // peak + {0.80, 3.5}, // decay + {1.10, 1.0}, // normal +} + +// Hyperdrive accelerates the starfield for absolutely no reason (§16). +type Hyperdrive struct { + age float64 + active bool +} + +// Trigger starts the sequence from the beginning. +func (h *Hyperdrive) Trigger() { + h.age = 0 + h.active = true +} + +// Update ages the sequence. +func (h *Hyperdrive) Update(dt float64) { + if !h.active || !(dt > 0) { + return + } + if dt > maxStepSeconds { + dt = maxStepSeconds + } + h.age += dt + if h.age >= HyperdriveLife.Seconds() { + h.active = false + h.age = 0 + } +} + +// Active reports whether hyperdrive is engaged. +func (h *Hyperdrive) Active() bool { return h.active } + +// Multiplier is the current starfield speed multiplier, 1 at rest. +func (h *Hyperdrive) Multiplier() float64 { + if !h.active { + return 1 + } + for i := 1; i < len(hyperKeys); i++ { + if h.age <= hyperKeys[i].t { + prev, next := hyperKeys[i-1], hyperKeys[i] + span := next.t - prev.t + if span <= 0 { + return next.mul + } + f := (h.age - prev.t) / span + return prev.mul + (next.mul-prev.mul)*f + } + } + return 1 +} + +// Banner is a large transient headline (§20, §22). +type Banner struct { + Text string + Sub string + Age float64 + Life float64 +} + +// Fade is remaining life as a fraction. +func (b Banner) Fade() float64 { + if b.Life <= 0 { + return 0 + } + f := 1 - b.Age/b.Life + if f < 0 { + return 0 + } + if f > 1 { + return 1 + } + return f +} + +// Border is the board frame's energy state (§25): a slowly drifting colour phase +// plus a transient energy level that major events pulse. +type Border struct { + phase float64 + energy float64 +} + +// borderDrift is how fast the resting colour phase moves, in cycles per second. +// §25 asks for subtle: one full cycle every ~50 seconds. +const borderDrift = 0.02 + +// Update drifts the phase and decays the energy. +func (b *Border) Update(dt float64) { + if !(dt > 0) { + return + } + if dt > maxStepSeconds { + dt = maxStepSeconds + } + // Energy makes the gradient move rapidly around the border during events. + b.phase += borderDrift * dt * (1 + 12*b.energy) + for b.phase >= 1 { + b.phase -= 1 + } + for b.phase < 0 { + b.phase += 1 + } + b.energy -= dt + if b.energy < 0 { + b.energy = 0 + } +} + +// Pulse raises the border's energy, brightening it and speeding the gradient. +func (b *Border) Pulse(amount float64) { + if amount <= 0 { + return + } + b.energy += amount + if b.energy > 1 { + b.energy = 1 + } +} + +// Phase is the resting colour position, in [0,1). +func (b *Border) Phase() float64 { return b.phase } + +// Energy is the excitement level, in [0,1]. +func (b *Border) Energy() float64 { return b.energy } + +// ClearPhase is which stage of §19's animation a clear is in. +type ClearPhase uint8 + +const ( + // ClearCriticalMass is phase A: the row destabilises. + ClearCriticalMass ClearPhase = iota + // ClearSupernova is phase B: the explosion travels outward from centre. + ClearSupernova + // ClearCollapse is phase C: the row fragments into debris. + ClearCollapse +) + +// LineClear is one in-flight clear animation. The engine has already removed the +// rows; this is rendering catching up (§19). The animation uses a hot-white +// energy ramp rather than reproducing the pre-clear colours, which is why +// game.Event does not need to carry the cleared cells. +type LineClear struct { + Rows []int + Age float64 + Life float64 +} + +// Phase is the current stage. +func (c LineClear) Phase() ClearPhase { + if c.Life <= 0 { + return ClearCollapse + } + switch f := c.Age / c.Life; { + case f < 0.3: + return ClearCriticalMass + case f < 0.75: + return ClearSupernova + default: + return ClearCollapse + } +} + +// TrailCell is one fading afterimage of a piece cell, in board-cell space (§17). +// It carries its piece kind so render can colour it; fx stays colour-free. +type TrailCell struct { + X, Y int + Fade float64 + Kind game.PieceKind +} +``` + +Move `maxStepSeconds` out of `particle.go` — it lives here now. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/fx/ -v` +Expected: PASS. + +- [ ] **Step 5: Format, vet, commit** + +```bash +gofmt -l . && go vet ./... +git add internal/fx/events.go internal/fx/particle.go internal/fx/events_test.go +git commit -m "feat(fx): shake, shockwave, hyperdrive, border energy and clear phases" +``` + +--- +### Task 4: Mission Control and the flavour voice + +**Files:** +- Create: `internal/flavor/messages.go` +- Test: `internal/flavor/messages_test.go` + +**Interfaces:** +- Consumes: `game.Event`, `game.PieceKind`, `math/rand/v2`. +- Produces: `func Pick(rng *rand.Rand, from []string) string`; `var Idle, Lock, Clear, Tetris, LevelUp, Combo, Rare []string`; `func TetrisBanner(rng *rand.Rand) string`; `func LevelSubtitle(rng *rand.Rand) string`; `func ComboLine(rng *rand.Rand, combo int) string`; `type Channel struct{ ... }`; `func NewChannel(seed int64) *Channel`; `func (c *Channel) Observe(evs []game.Event)`; `func (c *Channel) Update(dt time.Duration)`; `func (c *Channel) Line() string`; `func (c *Channel) Pulse() float64`. + +`Channel` is the one-line status channel from §27 with the two rules that section actually imposes: messages are event-triggered, and they are given time to breathe. A message holds for `MessageHold` and a lower-priority event cannot interrupt a higher-priority one that is still fresh. `Channel` lives in `flavor`, not `fx`, so `--no-fx` still has commentary (§32: the boring mode is still a good game). + +- [ ] **Step 1: Write the failing test** + +Create `internal/flavor/messages_test.go`: + +```go +package flavor + +import ( + "math/rand/v2" + "strings" + "testing" + "time" + + "cosmic-tetris/internal/game" +) + +func rng() *rand.Rand { return rand.New(rand.NewPCG(0x5EED, 0x1234)) } + +func TestPickReturnsAMemberAndHandlesEmpty(t *testing.T) { + list := []string{"A", "B", "C"} + for i := 0; i < 50; i++ { + got := Pick(rng(), list) + if got != "A" && got != "B" && got != "C" { + t.Fatalf("Pick returned %q", got) + } + } + if got := Pick(rng(), nil); got != "" { + t.Errorf("Pick(nil) = %q, want the empty string", got) + } +} + +func TestMessageTablesAreNonEmptyAndUppercase(t *testing.T) { + tables := map[string][]string{ + "Idle": Idle, "Lock": Lock, "Clear": Clear, "Tetris": Tetris, + "LevelUp": LevelUp, "Combo": Combo, "Rare": Rare, + } + for name, table := range tables { + if len(table) < 3 { + t.Errorf("%s has %d messages, want at least 3", name, len(table)) + } + for _, m := range table { + if m == "" { + t.Errorf("%s contains an empty message", name) + } + if strings.ToUpper(m) != m { + t.Errorf("%s message %q is not upper case; the status channel shouts", name, m) + } + if len(m) > 52 { + t.Errorf("%s message %q is %d chars; it will not fit the mission line", name, m, len(m)) + } + } + } +} + +func TestSpecMessagesArePresent(t *testing.T) { + all := strings.Join(append(append(append([]string{}, Idle...), Lock...), append(Clear...)...), "|") + all += "|" + strings.Join(append(append([]string{}, Tetris...), Rare...), "|") + all += "|" + strings.Join(append(append([]string{}, LevelUp...), Combo...), "|") + for _, want := range []string{ + "NOMINALISH", // §27 + "GRAVITY REMAINS MOSTLY LEGAL", // §27 + "TETROMINO INJECTION SUCCESSFUL", // §27 + "MOON NOTIFIED", // §27 + "GRAVITY TAX INCREASED", // §22 + "NUMBER BECAME BIGGER", // §45 + "KINETIC ROD DEPLOYED", // §45 + "CUBE ADJACENT OBJECT SECURED", // §45 + } { + if !strings.Contains(all, want) { + t.Errorf("the message tables are missing %q", want) + } + } +} + +func TestTetrisBannerAndLevelSubtitle(t *testing.T) { + if got := TetrisBanner(rng()); got == "" { + t.Error("TetrisBanner returned nothing") + } + if got := LevelSubtitle(rng()); got == "" { + t.Error("LevelSubtitle returned nothing") + } +} + +func TestComboLineNamesTheCombo(t *testing.T) { + got := ComboLine(rng(), 5) + if !strings.Contains(got, "COMBO 5") { + t.Errorf("ComboLine(5) = %q, want it to mention COMBO 5 (§21)", got) + } + if !strings.Contains(got, "//") { + t.Errorf("ComboLine(5) = %q, want §21's COMBO n // REASON shape", got) + } +} + +func TestChannelStartsNominalish(t *testing.T) { + c := NewChannel(1) + if got := c.Line(); got != "NOMINALISH" { + t.Errorf("opening line = %q, want NOMINALISH (§27)", got) + } +} + +func TestChannelReactsToEvents(t *testing.T) { + c := NewChannel(1) + before := c.Line() + c.Observe([]game.Event{{Kind: game.EventLinesCleared, Rows: []int{21}, Level: 1}}) + if c.Line() == before { + t.Error("a line clear did not change the mission line") + } +} + +func TestHigherPriorityEventsWinAndLowerOnesDoNotInterrupt(t *testing.T) { + c := NewChannel(1) + c.Observe([]game.Event{{Kind: game.EventLinesCleared, Rows: []int{18, 19, 20, 21}, Level: 3}}) + tetrisLine := c.Line() + c.Observe([]game.Event{{Kind: game.EventPieceLocked}}) + if c.Line() != tetrisLine { + t.Errorf("a lock overwrote a fresh four-line message: %q", c.Line()) + } + c.Update(MessageHold + time.Second) + c.Observe([]game.Event{{Kind: game.EventPieceLocked}}) + if c.Line() == tetrisLine { + t.Error("after the hold expired a lock should be able to speak") + } +} + +func TestMessagesAreGivenTimeToBreathe(t *testing.T) { + // §27: do not rotate messages constantly. + c := NewChannel(1) + c.Observe([]game.Event{{Kind: game.EventPieceLocked}}) + first := c.Line() + for i := 0; i < 20; i++ { + c.Update(20 * time.Millisecond) + c.Observe([]game.Event{{Kind: game.EventPieceLocked}}) + if c.Line() != first { + t.Fatalf("line changed to %q after %dms; messages must hold for %v", + c.Line(), (i+1)*20, MessageHold) + } + } +} + +func TestKineticRodAndCubeEasterEggs(t *testing.T) { + // §45: hard-dropping a vertical I, and holding an O. + c := NewChannel(1) + c.Observe([]game.Event{{ + Kind: game.EventPieceHardDropped, + Piece: game.Piece{Kind: game.KindI, Rotation: 1}, + }}) + if c.Line() != "KINETIC ROD DEPLOYED" { + t.Errorf("line = %q, want KINETIC ROD DEPLOYED", c.Line()) + } + + c2 := NewChannel(1) + c2.Observe([]game.Event{{Kind: game.EventHoldUsed, Piece: game.Piece{Kind: game.KindO}}}) + if c2.Line() != "CUBE ADJACENT OBJECT SECURED" { + t.Errorf("line = %q, want CUBE ADJACENT OBJECT SECURED", c2.Line()) + } +} + +func TestComboFourAndUpPulsesTheHUD(t *testing.T) { + // §21: at combo 4 the HUD begins pulsing. + c := NewChannel(1) + if got := c.Pulse(); got != 0 { + t.Fatalf("resting Pulse = %v, want 0", got) + } + c.Observe([]game.Event{{Kind: game.EventComboChanged, Combo: 2}}) + if got := c.Pulse(); got != 0 { + t.Errorf("Pulse at combo 2 = %v, want 0", got) + } + c.Observe([]game.Event{{Kind: game.EventComboChanged, Combo: 4}}) + if got := c.Pulse(); got <= 0 { + t.Errorf("Pulse at combo 4 = %v, want a positive pulse", got) + } + c.Observe([]game.Event{{Kind: game.EventComboChanged, Combo: 0}}) + if got := c.Pulse(); got != 0 { + t.Errorf("Pulse after the combo broke = %v, want 0", got) + } +} + +func TestIdleChatterEventuallySpeaksAndThenStops(t *testing.T) { + // §45's long-idle line, and §27's "do not rotate constantly". + c := NewChannel(1) + changes := 0 + last := c.Line() + for i := 0; i < 60*60; i++ { // one simulated minute at 60 Hz + c.Update(16 * time.Millisecond) + if c.Line() != last { + changes++ + last = c.Line() + } + } + if changes == 0 { + t.Error("the channel never said anything in a minute of idling") + } + if changes > 12 { + t.Errorf("%d idle messages in one minute; that is constant rotation", changes) + } +} + +func TestChannelIsDeterministicForASeed(t *testing.T) { + run := func() []string { + c := NewChannel(0xC0FFEE) + var out []string + for i := 0; i < 300; i++ { + c.Update(16 * time.Millisecond) + if i%37 == 0 { + c.Observe([]game.Event{{Kind: game.EventPieceLocked}}) + } + out = append(out, c.Line()) + } + return out + } + a, b := run(), run() + for i := range a { + if a[i] != b[i] { + t.Fatalf("step %d differs: %q vs %q", i, a[i], b[i]) + } + } +} + +func TestLineIsNeverEmpty(t *testing.T) { + c := NewChannel(7) + for i := 0; i < 500; i++ { + c.Update(33 * time.Millisecond) + c.Observe([]game.Event{{Kind: game.EventKind(i % 9)}}) + if c.Line() == "" { + t.Fatal("the mission line went empty") + } + } +} +``` + +Note on `TestSpecMessagesArePresent`: the `append(Clear...)` expression as written will not compile. Write the concatenation plainly instead: + +```go + var all []string + for _, table := range [][]string{Idle, Lock, Clear, Tetris, LevelUp, Combo, Rare} { + all = append(all, table...) + } + joined := strings.Join(all, "|") +``` + +and check `strings.Contains(joined, want)`. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/flavor/ -run TestPickReturns -v` +Expected: FAIL — `undefined: Pick`. + +- [ ] **Step 3: Write the implementation** + +Create `internal/flavor/messages.go`: + +```go +// Package flavor is Mission Control's voice: the one-line status channel from +// §27, the banners, and the small easter eggs from §45. +// +// It lives outside fx so that --no-fx still has commentary. Its randomness comes +// from its own generator, never the game's (§35). +package flavor + +import ( + "fmt" + "math/rand/v2" + "time" + + "cosmic-tetris/internal/game" +) + +// MessageHold is how long a message stays before anything of equal or lower +// priority may replace it. §27: give them time to breathe. +const MessageHold = 2200 * time.Millisecond + +// IdleGap is how long the channel waits before filling silence with chatter. +const IdleGap = 7 * time.Second + +// Message tables. All upper case: the status channel shouts. +var ( + // Idle is filler for quiet stretches. + Idle = []string{ + "NOMINALISH", + "GRAVITY REMAINS MOSTLY LEGAL", + "STRUCTURAL VIBES: QUESTIONABLE", + "LOCAL UNIVERSE STABLE*", + "* DEFINITION OF STABLE UNDER REVIEW", + "PHYSICS TEAM SAYS KEEP GOING", + "CAPTAIN?", + } + + // Lock follows an ordinary placement. + Lock = []string{ + "TETROMINO INJECTION SUCCESSFUL", + "GEOMETRY ACCEPTED", + "BLOCK FILED UNDER MISCELLANEOUS", + "ORBITAL OSHA HAS ENTERED THE CHAT", + } + + // Clear follows a one-, two- or three-row clear. + Clear = []string{ + "ROW DECOMMISSIONED", + "MATTER SUCCESSFULLY DELETED", + "MOON NOTIFIED", + "WE HAVE EXCEEDED THE RECOMMENDED NUMBER OF BLOCKS", + } + + // Tetris follows a four-row clear and doubles as banner copy (§20). + Tetris = []string{ + "✦ EVENT HORIZON ✦", + "QUADRUPLE COSMIC INCIDENT", + "FOUR ROWS HAVE LEFT THE CHAT", + "SPACE-TIME HAS FILED A COMPLAINT", + } + + // LevelUp are §22's gravity-anomaly subtitles. + LevelUp = []string{ + "GRAVITY TAX INCREASED", + "LOCAL PHYSICS UPDATED WITHOUT CONSENT", + "PLEASE SECURE ALL LOOSE TETROMINOES", + } + + // Combo are §21's escalating combo reasons. + Combo = []string{ + "UNAUTHORIZED ORBITAL MANEUVER", + "STRUCTURAL REALITY FAILURE", + "NASA DENIES EVERYTHING", + "MISSION CONTROL HAS LOST CONTROL OF THE MISSION", + } + + // Rare are §45's occasional oddities. + Rare = []string{ + "DID YOU KNOW YOU'RE IN A TERMINAL?", + "NUMBER BECAME BIGGER", + "THE MOON HAS STOPPED RETURNING CALLS", + } +) + +// Pick chooses one message. An empty table yields "". +func Pick(rng *rand.Rand, from []string) string { + if len(from) == 0 { + return "" + } + return from[rng.IntN(len(from))] +} + +// TetrisBanner is the headline for a four-line clear (§20). +func TetrisBanner(rng *rand.Rand) string { return Pick(rng, Tetris) } + +// LevelSubtitle is the subtitle for a gravity anomaly (§22). +func LevelSubtitle(rng *rand.Rand) string { return Pick(rng, LevelUp) } + +// ComboLine renders §21's "COMBO n // REASON". +func ComboLine(rng *rand.Rand, combo int) string { + return fmt.Sprintf("COMBO %d // %s", combo, Pick(rng, Combo)) +} + +// priority orders who gets to speak. A fresh message cannot be replaced by +// something of equal or lower priority until MessageHold has passed. +type priority uint8 + +const ( + prioIdle priority = iota + prioLock + prioClear + prioEvent + prioMajor +) + +// Channel is the mission-control status line (§27). +type Channel struct { + rng *rand.Rand + line string + prio priority + age time.Duration + quiet time.Duration + pulse float64 + combo int +} + +// NewChannel returns a channel with its own generator. The seed must not be the +// game's generator (§35); the caller derives it from the run's seed. +func NewChannel(seed int64) *Channel { + return &Channel{ + rng: rand.New(rand.NewPCG(uint64(seed), 0x243F6A8885A308D3)), + line: "NOMINALISH", + prio: prioIdle, + } +} + +// Line is the current message. Never empty. +func (c *Channel) Line() string { + if c.line == "" { + return "NOMINALISH" + } + return c.line +} + +// Pulse is the HUD pulse intensity, non-zero from combo 4 up (§21). +func (c *Channel) Pulse() float64 { return c.pulse } + +// say replaces the line if the new message outranks what is on screen or the +// current one has had its time. +func (c *Channel) say(p priority, msg string) { + if msg == "" { + return + } + if p <= c.prio && c.age < MessageHold { + return + } + c.line, c.prio, c.age, c.quiet = msg, p, 0, 0 +} + +// Observe reacts to engine events. +func (c *Channel) Observe(evs []game.Event) { + for _, e := range evs { + switch e.Kind { + case game.EventPieceHardDropped: + if e.Piece.Kind == game.KindI && e.Piece.Rotation%2 == 1 { + c.say(prioEvent, "KINETIC ROD DEPLOYED") // §45 + } + case game.EventHoldUsed: + if e.Piece.Kind == game.KindO { + c.say(prioEvent, "CUBE ADJACENT OBJECT SECURED") // §45 + } + case game.EventPieceLocked: + c.say(prioLock, Pick(c.rng, Lock)) + case game.EventLinesCleared: + if len(e.Rows) >= 4 { + c.say(prioMajor, Pick(c.rng, Tetris)) + } else { + c.say(prioClear, Pick(c.rng, Clear)) + } + case game.EventComboChanged: + c.combo = e.Combo + if e.Combo >= 4 { + c.pulse = 1 + } else { + c.pulse = 0 + } + if e.Combo >= 5 { + c.say(prioMajor, ComboLine(c.rng, e.Combo)) + } else if e.Combo >= 2 { + c.say(prioEvent, ComboLine(c.rng, e.Combo)) + } + case game.EventLevelChanged: + c.say(prioMajor, Pick(c.rng, LevelUp)) + case game.EventGameOver: + c.say(prioMajor, "SIGNAL LOST") + } + } +} + +// Update ages the current message and fills long silences with chatter. +func (c *Channel) Update(dt time.Duration) { + if dt <= 0 { + return + } + c.age += dt + c.quiet += dt + if c.pulse > 0 && c.combo >= 4 { + // The pulse itself is a steady oscillation; render turns it into + // brightness. Keep it at full while the combo stands. + c.pulse = 1 + } + if c.quiet >= IdleGap { + table := Idle + if c.rng.IntN(12) == 0 { + table = Rare // §45: keep the oddities occasional + } + c.line, c.prio, c.age, c.quiet = Pick(c.rng, table), prioIdle, 0, 0 + } +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/flavor/ -v` +Expected: PASS. If `TestIdleChatterEventuallySpeaksAndThenStops` reports too many changes, raise `IdleGap`; do not weaken the test — §27 is explicit that messages must not rotate constantly. + +- [ ] **Step 5: Format, vet, commit** + +```bash +gofmt -l . && go vet ./... +git add internal/flavor/messages.go internal/flavor/messages_test.go +git commit -m "feat(flavor): mission control status channel and banner copy" +``` + +--- +### Task 5: The FX world + +**Files:** +- Create: `internal/fx/world.go` +- Test: `internal/fx/world_test.go` + +**Interfaces:** +- Consumes: everything in `fx` so far, `flavor`, `game.Event`. +- Produces: `type Options struct{ Seed int64; Enabled, ReducedMotion bool }`; `type World struct{ ... }`; `func NewWorld(opts Options) *World`; `func (w *World) Enabled() bool`; `func (w *World) ReducedMotion() bool`; `func (w *World) Resize(width, height int)`; `func (w *World) Observe(evs []game.Event)`; `func (w *World) Update(dt time.Duration)`; `func (w *World) Stars() []Star`; `func (w *World) Particles() []Particle`; `func (w *World) Trails() []TrailCell`; `func (w *World) Clears() []LineClear`; `func (w *World) Shockwaves() []Shockwave`; `func (w *World) ShakeOffset() (int, int)`; `func (w *World) BorderPhase() float64`; `func (w *World) BorderEnergy() float64`; `func (w *World) Banner() (Banner, bool)`; `func (w *World) StarSpeed() float64`; `func (w *World) Level() int`. + +`Observe` takes `[]game.Event` and nothing else. That signature is §14's guarantee: there is no `*game.Game` in scope, so no amount of carelessness inside `fx` can write to game state. + +A disabled world (`--no-fx`) accepts every call and produces nothing: no stars, no particles, zero shake, zero border energy, no banner. That is what makes Task 10's "no-fx output equals the Plan 2 goldens" test possible. + +- [ ] **Step 1: Write the failing test** + +Create `internal/fx/world_test.go`: + +```go +package fx + +import ( + "os" + "strings" + "testing" + "time" + + "cosmic-tetris/internal/game" +) + +func newTestWorld(t *testing.T) *World { + t.Helper() + w := NewWorld(Options{Seed: 0xBEEF, Enabled: true}) + w.Resize(80, 30) + return w +} + +func hardDrop(distance int) game.Event { + return game.Event{ + Kind: game.EventPieceHardDropped, + Piece: game.Piece{Kind: game.KindT, X: 3, Y: 18}, + Distance: distance, + } +} + +func clearOf(n int) game.Event { + rows := make([]int, n) + for i := range rows { + rows[i] = game.Height - 1 - i + } + return game.Event{Kind: game.EventLinesCleared, Rows: rows, Level: 3, Combo: 1} +} + +func TestFXNeverReferencesTheGameStruct(t *testing.T) { + // §14: the FX system may never modify GameState. It observes []game.Event + // values, so a *game.Game must never appear in this package. + entries, err := os.ReadDir(".") + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + if !strings.HasSuffix(e.Name(), ".go") || strings.HasSuffix(e.Name(), "_test.go") { + continue + } + src, err := os.ReadFile(e.Name()) + if err != nil { + t.Fatal(err) + } + for _, forbidden := range []string{"*game.Game", "game.Game{", "game.New(", "game.Snapshot"} { + if strings.Contains(string(src), forbidden) { + t.Errorf("%s mentions %q; fx must only see game events", e.Name(), forbidden) + } + } + } +} + +func TestDisabledWorldProducesNothing(t *testing.T) { + w := NewWorld(Options{Seed: 1, Enabled: false}) + w.Resize(80, 30) + w.Observe([]game.Event{hardDrop(12), clearOf(4), {Kind: game.EventLevelChanged, Level: 5}}) + w.Update(500 * time.Millisecond) + + if w.Enabled() { + t.Error("Enabled reports true for a disabled world") + } + if n := len(w.Stars()); n != 0 { + t.Errorf("%d stars in a disabled world", n) + } + if n := len(w.Particles()); n != 0 { + t.Errorf("%d particles in a disabled world", n) + } + if n := len(w.Trails()); n != 0 { + t.Errorf("%d trails in a disabled world", n) + } + if n := len(w.Clears()); n != 0 { + t.Errorf("%d clears in a disabled world", n) + } + if n := len(w.Shockwaves()); n != 0 { + t.Errorf("%d shockwaves in a disabled world", n) + } + if dx, dy := w.ShakeOffset(); dx != 0 || dy != 0 { + t.Errorf("shake offset (%d,%d) in a disabled world", dx, dy) + } + if got := w.BorderEnergy(); got != 0 { + t.Errorf("border energy %v in a disabled world", got) + } + if _, ok := w.Banner(); ok { + t.Error("a disabled world raised a banner") + } +} + +func TestEnabledWorldHasAStarfieldAfterResize(t *testing.T) { + w := newTestWorld(t) + if len(w.Stars()) == 0 { + t.Fatal("no starfield") + } +} + +func TestMovementLeavesATrailThatExpires(t *testing.T) { + w := newTestWorld(t) + w.Observe([]game.Event{{ + Kind: game.EventPieceMoved, + Piece: game.Piece{Kind: game.KindS, X: 4, Y: 8}, + }}) + if len(w.Trails()) == 0 { + t.Fatal("a move left no trail (§17)") + } + for _, c := range w.Trails() { + if c.Kind != game.KindS { + t.Errorf("trail cell carries kind %v, want KindS", c.Kind) + } + if c.Fade <= 0 || c.Fade > 1 { + t.Errorf("trail fade %v out of range", c.Fade) + } + } + w.Update(TrailLife * 2) + if n := len(w.Trails()); n != 0 { + t.Errorf("%d trails after twice their lifetime", n) + } +} + +func TestRotationAlsoLeavesATrail(t *testing.T) { + w := newTestWorld(t) + w.Observe([]game.Event{{ + Kind: game.EventPieceRotated, + Piece: game.Piece{Kind: game.KindJ, X: 4, Y: 8}, + }}) + if len(w.Trails()) == 0 { + t.Error("a rotation left no trail") + } +} + +func TestHardDropShakesFlashesAndEmitsDebris(t *testing.T) { + w := newTestWorld(t) + w.Observe([]game.Event{hardDrop(14)}) + if dx, dy := w.ShakeOffset(); dx == 0 && dy == 0 { + t.Error("hard drop did not shake the board (§18)") + } + if w.BorderEnergy() <= 0 { + t.Error("hard drop did not flash the border (§18)") + } + if len(w.Particles()) == 0 { + t.Error("hard drop emitted no impact particles (§18)") + } + if len(w.Trails()) == 0 { + t.Error("hard drop left no vertical ion trail (§18)") + } +} + +func TestHardDropTrailSpansTheCellsCrossed(t *testing.T) { + w := newTestWorld(t) + w.Observe([]game.Event{hardDrop(10)}) + minY, maxY := 999, -999 + for _, c := range w.Trails() { + if c.Y < minY { + minY = c.Y + } + if c.Y > maxY { + maxY = c.Y + } + } + if maxY-minY < 8 { + t.Errorf("ion trail spans %d rows, want about the 10 crossed", maxY-minY) + } +} + +func TestSingleClearAnimatesAndPulsesTheBorder(t *testing.T) { + w := newTestWorld(t) + w.Observe([]game.Event{clearOf(1)}) + clears := w.Clears() + if len(clears) != 1 { + t.Fatalf("%d clear animations, want 1", len(clears)) + } + if len(clears[0].Rows) != 1 { + t.Errorf("clear covers %d rows, want 1", len(clears[0].Rows)) + } + if w.BorderEnergy() <= 0 { + t.Error("a clear did not react on the border (§43)") + } + w.Update(ClearLife * 2) + if n := len(w.Clears()); n != 0 { + t.Errorf("%d clears after twice their lifetime", n) + } +} + +func TestFourLineClearTriggersEverything(t *testing.T) { + // §20: hyperdrive, larger shake, border pulse, particle eruption, + // star density increase, giant banner — simultaneously. + w := newTestWorld(t) + stars := len(w.Stars()) + w.Observe([]game.Event{clearOf(4)}) + + if w.StarSpeed() <= 1 { + t.Errorf("StarSpeed = %v, want hyperdrive engaged", w.StarSpeed()) + } + if dx, dy := w.ShakeOffset(); dx == 0 && dy == 0 { + t.Error("no shake on a four-line clear") + } + if w.BorderEnergy() < 0.9 { + t.Errorf("border energy %v, want a near-full pulse", w.BorderEnergy()) + } + if len(w.Particles()) < 40 { + t.Errorf("%d particles, want an eruption", len(w.Particles())) + } + b, ok := w.Banner() + if !ok || b.Text == "" { + t.Error("no banner on a four-line clear") + } + w.Update(16 * time.Millisecond) + if len(w.Stars()) <= stars { + t.Errorf("%d stars, want the temporary density increase above %d", len(w.Stars()), stars) + } + w.Update(BannerLife * 2) + if _, ok := w.Banner(); ok { + t.Error("banner outlived its duration") + } +} + +func TestFourLineClearShakesHarderThanAHardDrop(t *testing.T) { + dur := func(e game.Event) int { + w := newTestWorld(t) + w.Observe([]game.Event{e}) + n := 0 + for i := 0; i < 200; i++ { + dx, dy := w.ShakeOffset() + if dx != 0 || dy != 0 { + n++ + } + w.Update(8 * time.Millisecond) + } + return n + } + if drop, tetris := dur(hardDrop(12)), dur(clearOf(4)); tetris <= drop { + t.Errorf("four-line shake lasted %d frames, hard drop %d: §20 wants larger", tetris, drop) + } +} + +func TestCombosEscalate(t *testing.T) { + // §21: sparks at 2, meteors at 3, chaos at 5+. + count := func(combo int) int { + w := newTestWorld(t) + w.Observe([]game.Event{{Kind: game.EventComboChanged, Combo: combo, Level: 2}}) + return len(w.Particles()) + } + c2, c3, c6 := count(2), count(3), count(6) + if c2 == 0 { + t.Error("combo 2 produced no sparks") + } + if c3 <= c2 { + t.Errorf("combo 3 (%d particles) does not escalate past combo 2 (%d)", c3, c2) + } + if c6 <= c3 { + t.Errorf("combo 6 (%d particles) does not escalate past combo 3 (%d)", c6, c3) + } + w := newTestWorld(t) + w.Observe([]game.Event{{Kind: game.EventComboChanged, Combo: 6, Level: 2}}) + if len(w.Shockwaves()) == 0 { + t.Error("a large combo produced no shockwave (§24)") + } + if w.StarSpeed() <= 1 { + t.Error("a large combo did not trigger hyperdrive (§16)") + } +} + +func TestComboOneOrZeroIsCalm(t *testing.T) { + w := newTestWorld(t) + w.Observe([]game.Event{{Kind: game.EventComboChanged, Combo: 1}}) + if len(w.Particles()) != 0 || len(w.Shockwaves()) != 0 { + t.Error("combo 1 should not escalate anything") + } +} + +func TestLevelChangeRaisesTheBannerAndStarSpeed(t *testing.T) { + w := newTestWorld(t) + base := w.StarSpeed() + w.Observe([]game.Event{{Kind: game.EventLevelChanged, Level: 9}}) + if w.Level() != 9 { + t.Errorf("Level = %d, want 9", w.Level()) + } + if w.StarSpeed() <= base { + t.Errorf("StarSpeed = %v at level 9, want faster than %v at level 1 (§15)", w.StarSpeed(), base) + } + b, ok := w.Banner() + if !ok { + t.Fatal("no level-up banner (§22)") + } + if !strings.Contains(b.Text, "GRAVITY ANOMALY") { + t.Errorf("banner text = %q, want §22's GRAVITY ANOMALY headline", b.Text) + } + if !strings.Contains(b.Sub, "LEVEL") { + t.Errorf("banner sub = %q, want it to name the level", b.Sub) + } +} + +func TestStarSpeedIsCapped(t *testing.T) { + w := newTestWorld(t) + w.Observe([]game.Event{{Kind: game.EventLevelChanged, Level: 99}}) + w.Update(2 * time.Second) // let hyperdrive finish + if got := w.StarSpeed(); got > 3 { + t.Errorf("StarSpeed = %v at level 99, want a capped subtle increase (§15)", got) + } +} + +func TestLockEmitsASmallPuff(t *testing.T) { + w := newTestWorld(t) + w.Observe([]game.Event{{ + Kind: game.EventPieceLocked, + Piece: game.Piece{Kind: game.KindL, X: 3, Y: 19}, + }}) + if len(w.Particles()) == 0 { + t.Error("a lock emitted nothing at all") + } +} + +func TestReducedMotionSuppressesShakeHyperdriveAndShockwaves(t *testing.T) { + // §49.5: suppress shake, hyperdrive acceleration and shockwaves; leave + // colour, trails and particles alone. + w := NewWorld(Options{Seed: 3, Enabled: true, ReducedMotion: true}) + w.Resize(80, 30) + w.Observe([]game.Event{hardDrop(14), clearOf(4), {Kind: game.EventComboChanged, Combo: 7}}) + + if !w.ReducedMotion() { + t.Error("ReducedMotion reports false") + } + if dx, dy := w.ShakeOffset(); dx != 0 || dy != 0 { + t.Errorf("shake offset (%d,%d) under reduced motion", dx, dy) + } + if got := w.StarSpeed(); got > 2 { + t.Errorf("StarSpeed = %v under reduced motion, want no hyperdrive", got) + } + if n := len(w.Shockwaves()); n != 0 { + t.Errorf("%d shockwaves under reduced motion", n) + } + if len(w.Particles()) == 0 { + t.Error("reduced motion should keep particles") + } + if len(w.Trails()) == 0 { + t.Error("reduced motion should keep trails") + } + if w.BorderEnergy() <= 0 { + t.Error("reduced motion should keep the border reacting") + } + if _, ok := w.Banner(); !ok { + t.Error("reduced motion should keep banners") + } +} + +func TestUpdateSurvivesAHugeDelta(t *testing.T) { + // Review Focus 1: a suspended terminal resumes with minutes of elapsed time. + w := newTestWorld(t) + w.Observe([]game.Event{hardDrop(12), clearOf(4)}) + w.Update(10 * time.Minute) + for _, p := range w.Particles() { + if p.X != p.X || p.Y != p.Y { // NaN check + t.Fatalf("non-finite particle %+v", p) + } + } + if dx, dy := w.ShakeOffset(); dx < -1 || dx > 1 || dy < -1 || dy > 1 { + t.Errorf("shake offset (%d,%d) after a huge delta", dx, dy) + } + for _, s := range w.Stars() { + if s.Y < 0 || s.Y > 30 { + t.Fatalf("star escaped after a huge delta: %+v", s) + } + } + w.Update(-5 * time.Second) + w.Update(0) +} + +func TestEventFloodStaysWithinBudget(t *testing.T) { + // Review Focus 2: twenty events on one frame, repeatedly. + w := newTestWorld(t) + flood := []game.Event{} + for i := 0; i < 20; i++ { + flood = append(flood, hardDrop(12), clearOf(4), game.Event{Kind: game.EventComboChanged, Combo: 15}) + } + for i := 0; i < 100; i++ { + w.Observe(flood) + w.Update(16 * time.Millisecond) + if n := len(w.Particles()); n > MaxParticles { + t.Fatalf("%d particles, over the %d cap", n, MaxParticles) + } + if n := len(w.Clears()); n > MaxClears { + t.Fatalf("%d clear animations, over the %d cap", n, MaxClears) + } + if n := len(w.Shockwaves()); n > MaxShockwaves { + t.Fatalf("%d shockwaves, over the %d cap", n, MaxShockwaves) + } + if n := len(w.Trails()); n > MaxTrails { + t.Fatalf("%d trail cells, over the %d cap", n, MaxTrails) + } + } +} + +func TestWorldIsDeterministicForASeed(t *testing.T) { + run := func() []Particle { + w := NewWorld(Options{Seed: 0xD15EA5E, Enabled: true}) + w.Resize(80, 30) + for i := 0; i < 120; i++ { + if i%20 == 0 { + w.Observe([]game.Event{hardDrop(9), clearOf(2)}) + } + w.Update(16 * time.Millisecond) + } + out := make([]Particle, len(w.Particles())) + copy(out, w.Particles()) + return out + } + a, b := run(), run() + if len(a) != len(b) { + t.Fatalf("particle counts %d vs %d", len(a), len(b)) + } + for i := range a { + if a[i] != b[i] { + t.Fatalf("particle %d differs: %+v vs %+v", i, a[i], b[i]) + } + } +} + +func TestDifferentSeedsDiverge(t *testing.T) { + mk := func(seed int64) *World { + w := NewWorld(Options{Seed: seed, Enabled: true}) + w.Resize(80, 30) + w.Observe([]game.Event{hardDrop(12)}) + return w + } + a, b := mk(1), mk(2) + same := true + for i := range a.Particles() { + if i < len(b.Particles()) && a.Particles()[i] != b.Particles()[i] { + same = false + } + } + if same { + t.Error("two seeds produced identical effects; the FX RNG is not seeded") + } +} + +func TestResizeMidEffectKeepsEverythingValid(t *testing.T) { + // Review Focus 5: shrink while particles are in flight. + w := newTestWorld(t) + w.Observe([]game.Event{hardDrop(14), clearOf(4)}) + for _, size := range [][2]int{{40, 24}, {0, 0}, {300, 100}, {1, 1}, {80, 30}} { + w.Resize(size[0], size[1]) + w.Update(16 * time.Millisecond) + width, height := size[0], size[1] + for _, s := range w.Stars() { + if s.X < 0 || s.X > float64(width) || s.Y < 0 || s.Y > float64(height) { + t.Fatalf("star %+v outside %dx%d", s, width, height) + } + } + } +} + +func TestObserveOnNilAndEmptyEventsIsSafe(t *testing.T) { + w := newTestWorld(t) + w.Observe(nil) + w.Observe([]game.Event{}) + w.Observe([]game.Event{{}}) + w.Update(16 * time.Millisecond) +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/fx/ -run TestDisabledWorld -v` +Expected: FAIL — `undefined: NewWorld`. + +- [ ] **Step 3: Write the implementation** + +Create `internal/fx/world.go`: + +```go +package fx + +import ( + "fmt" + "math/rand/v2" + "time" + + "cosmic-tetris/internal/flavor" + "cosmic-tetris/internal/game" +) + +// Budgets. Every list is capped so an event flood cannot grow memory (§38). +const ( + MaxTrails = 256 + MaxClears = 8 + MaxShockwaves = 6 +) + +// Options configures the effects simulation. +type Options struct { + // Seed seeds the FX generator. It must be derived from, but is never, the + // game's generator (§35, §49.6). + Seed int64 + // Enabled is false under --no-fx: the world then produces nothing at all. + Enabled bool + // ReducedMotion suppresses shake, hyperdrive and shockwaves (§49.5). + ReducedMotion bool +} + +// World is the effects simulation. It observes game events and cannot reach game +// state: Observe takes []game.Event values and nothing else (§14). +type World struct { + opts Options + rng *rand.Rand + + stars Starfield + particles Field + trails []TrailCell + clears []LineClear + shocks []Shockwave + + shake Shake + hyper Hyperdrive + border Border + banner Banner + + level int +} + +// NewWorld builds an effects world. Its generator is independent of the game's. +func NewWorld(opts Options) *World { + return &World{ + opts: opts, + rng: rand.New(rand.NewPCG(uint64(opts.Seed), 0xB5026F5AA96619E9)), + level: 1, + } +} + +// Enabled reports whether effects are on. +func (w *World) Enabled() bool { return w.opts.Enabled } + +// ReducedMotion reports whether motion effects are suppressed. +func (w *World) ReducedMotion() bool { return w.opts.ReducedMotion } + +// Level is the last level the world was told about; it drives star speed. +func (w *World) Level() int { return w.level } + +// Resize tells the starfield the terminal size. Only the starfield works in +// terminal coordinates; everything else lives in board-cell space. +func (w *World) Resize(width, height int) { + if !w.opts.Enabled { + return + } + w.stars.Resize(w.rng, width, height) +} + +// particleBounds is the cull region in board-cell space: generous enough for +// debris to arc outside the board before dying. +var particleBounds = Bounds{ + MinX: -6, + MinY: -6, + MaxX: game.Width + 6, + MaxY: game.Height + 8, +} + +// Update advances every effect by dt. dt is clamped, so a terminal that was +// suspended for ten minutes resumes with one ordinary frame. +func (w *World) Update(dt time.Duration) { + if !w.opts.Enabled || dt <= 0 { + return + } + secs := dt.Seconds() + if secs > maxStepSeconds { + secs = maxStepSeconds + } + + w.stars.Update(w.rng, secs, w.StarSpeed()) + w.particles.Update(secs, 9, 0.94, particleBounds) + w.shake.Update(secs) + w.hyper.Update(secs) + w.border.Update(secs) + + // Trails fade. + live := w.trails[:0] + step := secs / TrailLife.Seconds() + for _, c := range w.trails { + c.Fade -= step + if c.Fade > 0 { + live = append(live, c) + } + } + w.trails = live + + // Clears age out. + liveClears := w.clears[:0] + for _, c := range w.clears { + c.Age += secs + if c.Age < c.Life { + // Phase C sheds debris as the row fragments (§19). + if c.Phase() == ClearCollapse { + w.emitClearDebris(c) + } + liveClears = append(liveClears, c) + } + } + w.clears = liveClears + + // Shockwaves expand and die. + liveShocks := w.shocks[:0] + for _, s := range w.shocks { + s.Age += secs + if s.Age < s.Life { + liveShocks = append(liveShocks, s) + } + } + w.shocks = liveShocks + + if w.banner.Life > 0 { + w.banner.Age += secs + if w.banner.Age >= w.banner.Life { + w.banner = Banner{} + } + } + + // A tiny shooting star, occasionally (§45). + if w.rng.Float64() < secs*0.12 { + w.particles.EmitSpray(w.rng, w.rng.Float64()*game.Width, -2, 6, 9, 0.08, 3, TintStar, 0, 4) + } +} + +// Observe reacts to engine events (§14). It reads values only. +func (w *World) Observe(evs []game.Event) { + if !w.opts.Enabled { + return + } + for _, e := range evs { + switch e.Kind { + case game.EventPieceMoved, game.EventPieceRotated: + w.pushTrail(e.Piece, 1) + + case game.EventPieceHardDropped: + w.hardDrop(e) + + case game.EventPieceLocked: + w.lock(e.Piece) + + case game.EventLinesCleared: + w.linesCleared(e) + + case game.EventComboChanged: + w.comboChanged(e) + + case game.EventLevelChanged: + w.levelChanged(e) + } + } +} + +// pushTrail records the piece's cells as fading afterimages (§17). +func (w *World) pushTrail(p game.Piece, fade float64) { + for _, c := range p.Cells() { + if len(w.trails) >= MaxTrails { + return + } + w.trails = append(w.trails, TrailCell{X: c.X, Y: c.Y, Fade: fade, Kind: p.Kind}) + } +} + +// hardDrop is §18: ion trail, impact particles, screen shake, border flash. +func (w *World) hardDrop(e game.Event) { + // 1. Vertical ion trail through the cells crossed. + for d := 0; d <= e.Distance; d++ { + ghost := e.Piece + ghost.Y = e.Piece.Y - d + w.pushTrail(ghost, 1-float64(d)/float64(e.Distance+1)) + } + + // 2. Impact particles from the contact area. + strength := 0.4 + float64(e.Distance)/float64(game.VisibleRows) + for _, c := range e.Piece.Cells() { + w.particles.EmitBurst(w.rng, float64(c.X)+0.5, float64(c.Y)+0.5, + int(6*strength)+3, 7*strength, TintDebris, e.Piece.Kind, 4) + } + + // 3. Screen shake, and 4. border flash. + w.triggerShake(strength) + w.border.Pulse(0.5 * strength) +} + +// lock is a small dust puff so an ordinary placement still feels physical. +func (w *World) lock(p game.Piece) { + for _, c := range p.Cells() { + w.particles.EmitBurst(w.rng, float64(c.X)+0.5, float64(c.Y)+0.5, 2, 2.5, TintDebris, p.Kind, 4) + } + w.border.Pulse(0.12) +} + +// linesCleared starts §19's animation, and for four rows the §20 spectacle. +func (w *World) linesCleared(e game.Event) { + if len(w.clears) < MaxClears { + rows := make([]int, len(e.Rows)) + copy(rows, e.Rows) + w.clears = append(w.clears, LineClear{Rows: rows, Life: ClearLife.Seconds()}) + } + w.border.Pulse(0.35 + 0.15*float64(len(e.Rows))) + + if len(e.Rows) < 4 { + return + } + // §20: a four-line clear is a major astronomical event. + w.triggerHyperdrive() + w.triggerShake(2.5) + w.border.Pulse(1) + w.stars.SetDensity(2.2) + w.banner = Banner{ + Text: flavor.TetrisBanner(w.rng), + Life: BannerLife.Seconds(), + } + centre := float64(game.Width) / 2 + for _, row := range e.Rows { + y := float64(row) + 0.5 + for x := 0; x < game.Width; x++ { + // Particles inherit horizontal velocity from their offset from the + // centre of the row (§19). + vx := (float64(x) + 0.5 - centre) * 2.2 + w.particles.EmitSpray(w.rng, float64(x)+0.5, y, vx, -4, 0.5, 2, TintFlash, 0, 4) + } + } + w.addShockwave(centre, float64(e.Rows[len(e.Rows)/2])) +} + +// comboChanged escalates with the combo (§21). Board readability stays sacred: +// the escalation is in particle count and border energy, never in anything that +// covers the board wholesale. +func (w *World) comboChanged(e game.Event) { + switch { + case e.Combo >= 5: + w.triggerHyperdrive() + w.addShockwave(float64(game.Width)/2, float64(game.Height)-6) + w.border.Pulse(0.9) + w.particles.EmitBurst(w.rng, float64(game.Width)/2, float64(game.Height)-8, 60, 9, TintFlash, 0, 4) + case e.Combo >= 3: + w.border.Pulse(0.5) + w.particles.EmitBurst(w.rng, float64(game.Width)/2, float64(game.Height)-8, 24, 6, TintDebris, 0, 4) + case e.Combo >= 2: + w.border.Pulse(0.3) + w.particles.EmitBurst(w.rng, float64(game.Width)/2, float64(game.Height)-8, 10, 4, TintFlash, 0, 4) + } +} + +// levelChanged raises §22's notification. It never pauses the game. +func (w *World) levelChanged(e game.Event) { + if e.Level > 0 { + w.level = e.Level + } + w.border.Pulse(0.7) + w.banner = Banner{ + Text: "GRAVITY ANOMALY DETECTED", + Sub: fmt.Sprintf("LEVEL %02d · %s", w.level, flavor.LevelSubtitle(w.rng)), + Life: BannerLife.Seconds(), + } +} + +// emitClearDebris sheds a few particles per frame during §19's collapse phase. +func (w *World) emitClearDebris(c LineClear) { + centre := float64(game.Width) / 2 + for _, row := range c.Rows { + x := w.rng.Float64() * game.Width + vx := (x - centre) * 1.5 + w.particles.EmitSpray(w.rng, x, float64(row)+0.5, vx, 2, 0.6, 1, TintDebris, 0, 4) + } +} + +// triggerShake respects §49.5's reduced motion. +func (w *World) triggerShake(strength float64) { + if w.opts.ReducedMotion { + return + } + w.shake.Trigger(strength) +} + +// triggerHyperdrive respects §49.5's reduced motion. +func (w *World) triggerHyperdrive() { + if w.opts.ReducedMotion { + return + } + w.hyper.Trigger() +} + +// addShockwave respects §49.5's reduced motion and §24's "use sparingly". +func (w *World) addShockwave(x, y float64) { + if w.opts.ReducedMotion || len(w.shocks) >= MaxShockwaves { + return + } + w.shocks = append(w.shocks, Shockwave{X: x, Y: y, Life: ShockwaveLife.Seconds()}) +} + +// StarSpeed is the starfield multiplier: a subtle level ramp (§15) times any +// hyperdrive boost (§16). +func (w *World) StarSpeed() float64 { + level := 1 + 0.06*float64(w.level-1) + if level > 2 { + level = 2 + } + return level * w.hyper.Multiplier() +} + +// Stars are the background specks, in terminal-cell space. +func (w *World) Stars() []Star { return w.stars.All() } + +// Particles are the live particles, in board-cell space. +func (w *World) Particles() []Particle { return w.particles.All() } + +// Trails are the fading piece afterimages, in board cells. +func (w *World) Trails() []TrailCell { return w.trails } + +// Clears are the in-flight line-clear animations. +func (w *World) Clears() []LineClear { return w.clears } + +// Shockwaves are the expanding rings, in board-cell space. +func (w *World) Shockwaves() []Shockwave { return w.shocks } + +// ShakeOffset is the board displacement in whole cells, never more than one. +func (w *World) ShakeOffset() (int, int) { return w.shake.Offset() } + +// BorderPhase is the border's resting colour position, in [0,1). +func (w *World) BorderPhase() float64 { return w.border.Phase() } + +// BorderEnergy is the border's excitement level, in [0,1]. +func (w *World) BorderEnergy() float64 { return w.border.Energy() } + +// Banner returns the current headline, if any. +func (w *World) Banner() (Banner, bool) { + if w.banner.Life <= 0 || w.banner.Text == "" { + return Banner{}, false + } + return w.banner, true +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/fx/ -v` +Expected: PASS. + +Two failures to expect and fix properly rather than by loosening the test: +- `TestFourLineClearShakesHarderThanAHardDrop` requires the four-line strength (2.5) to exceed the hard-drop strength for a full-height drop; check `Shake.Trigger`'s clamp is above 2.5. +- `TestEventFloodStaysWithinBudget` fails if `pushTrail` is allowed past `MaxTrails` in the middle of a piece; it returns early per cell, which is correct. + +- [ ] **Step 5: Prove the game and FX generators are separate** + +Run: + +```bash +grep -rn 'rand.New' internal/fx/*.go internal/game/*.go internal/flavor/*.go | grep -v '_test.go' +``` + +Expected: exactly one `rand.New` in `internal/game/game.go`, one in `internal/fx/world.go`, one in `internal/flavor/messages.go`, with three different second PCG words. No file passes a generator between packages. + +- [ ] **Step 6: Format, vet, commit** + +```bash +gofmt -l . && go vet ./... +git add internal/fx/world.go internal/fx/world_test.go +git commit -m "feat(fx): effects world observing game events" +``` + +--- +### Task 6: Compositing FX into the frame + +**Files:** +- Modify: `internal/render/palette.go` (tint resolution) +- Modify: `internal/render/board.go` (board-space FX layers, `DrawStack` no longer erases the background) +- Modify: `internal/render/render.go` (`View.FX`, starfield, §37 pipeline order) +- Test: `internal/render/fx_test.go` + +**Interfaces:** +- Consumes: `fx.World` and its accessors, `Glyphs`, `Layout`, `Canvas`. +- Produces: `func TintPaint(t fx.Tint, k game.PieceKind, brightness float64) Paint`; `func StarPaint(layer uint8, bright float64) Paint`; `func TrailPaint(k game.PieceKind, fade float64) Paint`; `func boardPointXY(l Layout, x, y float64) (int, int, bool)`; `func DrawStars(c *Canvas, l Layout, g Glyphs, stars []fx.Star)`; `func DrawTrails(c *Canvas, l Layout, g Glyphs, cells []fx.TrailCell)`; `func DrawClears(c *Canvas, l Layout, g Glyphs, clears []fx.LineClear)`; `func DrawShockwaves(c *Canvas, l Layout, g Glyphs, waves []fx.Shockwave)`; `func DrawParticles(c *Canvas, l Layout, g Glyphs, ps []fx.Particle)`; `View.FX *fx.World` (nil means no effects). + +Two rules make this readable rather than a mess: +- Inside the board interior only far-layer stars are drawn (§15: never make the background so busy that the board is harder to read). +- Board-local FX is composited *before* the active piece, so nothing can obscure it (§37 step 6 followed by §44's first rule; the active piece is redrawn last). + +- [ ] **Step 1: Write the failing test** + +Create `internal/render/fx_test.go`: + +```go +package render + +import ( + "strings" + "testing" + "time" + + "github.com/charmbracelet/x/ansi" + + "cosmic-tetris/internal/fx" + "cosmic-tetris/internal/game" +) + +// fxWorld returns a world with a deterministic seed, wound forward a little. +func fxWorld(t *testing.T, w, h int, evs ...game.Event) *fx.World { + t.Helper() + world := fx.NewWorld(fx.Options{Seed: 0x1234, Enabled: true}) + world.Resize(w, h) + world.Observe(evs) + world.Update(16 * time.Millisecond) + return world +} + +func fxView(w, h int, world *fx.World) View { + v := testView(w, h, ScreenPlaying) + v.FX = world + return v +} + +func TestBoardPointMapsFractionalCoordinates(t *testing.T) { + l := Compute(80, 30) + x, y, ok := boardPointXY(l, 0.4, float64(game.HiddenRows)+0.2) + if !ok { + t.Fatal("a point inside the board reported out of range") + } + if x != l.BoardX+1 || y != l.BoardY+1 { + t.Errorf("point maps to (%d,%d), want (%d,%d)", x, y, l.BoardX+1, l.BoardY+1) + } + if _, _, ok := boardPointXY(l, -3, 10); ok { + t.Error("a point left of the board should be out of range") + } + if _, _, ok := boardPointXY(l, 5, 0.5); ok { + t.Error("a point in the hidden rows should be out of range") + } + if _, _, ok := boardPointXY(l, 99, 99); ok { + t.Error("a point past the board should be out of range") + } +} + +func TestNilFXRendersTheSameAsPlan2(t *testing.T) { + plain := render(t, ModeFull, testView(80, 30, ScreenPlaying)) + assertGolden(t, "wide", plain) +} + +func TestDisabledFXRendersTheSameAsNilFX(t *testing.T) { + // --no-fx must be byte-identical to no effects at all. + off := fx.NewWorld(fx.Options{Seed: 9, Enabled: false}) + off.Resize(80, 30) + off.Observe([]game.Event{{Kind: game.EventPieceHardDropped, Distance: 12}}) + off.Update(100 * time.Millisecond) + + want := render(t, ModeFull, testView(80, 30, ScreenPlaying)) + got := render(t, ModeFull, fxView(80, 30, off)) + if got != want { + t.Errorf("--no-fx output differs from no FX at all:\n--- got ---\n%s\n--- want ---\n%s", got, want) + } +} + +func TestStarsAppearAroundTheBoard(t *testing.T) { + world := fxWorld(t, 80, 30) + out := render(t, ModeFull, fxView(80, 30, world)) + l := Compute(80, 30) + rows := strings.Split(out, "\n") + + outside := 0 + for y, row := range rows { + r := []rune(row) + for x, ch := range r { + if ch == ' ' { + continue + } + inBoard := x >= l.BoardX && x < l.BoardX+BoardOuterW && y >= l.BoardY && y < l.BoardY+BoardOuterH + if !inBoard && (ch == '.' || ch == '·' || ch == '˚' || ch == '✦' || ch == '✧') { + outside++ + } + } + } + if outside == 0 { + t.Errorf("no stars drawn outside the board:\n%s", out) + } +} + +func TestOnlyFarStarsAppearInsideTheBoard(t *testing.T) { + // §15: never make the background so busy that the board is harder to read. + world := fxWorld(t, 80, 30) + l := Compute(80, 30) + g := GlyphsFor(ModeFull) + c := NewCanvas(80, 30) + DrawStars(c, l, g, world.Stars()) + rows := lines(c) + + near := string(g.Stars[2]) + for y := l.BoardY + 1; y < l.BoardY+BoardOuterH-1; y++ { + r := []rune(rows[y]) + for x := l.BoardX + 1; x < l.BoardX+BoardOuterW-1; x++ { + if strings.ContainsRune(near, r[x]) { + t.Fatalf("a near-layer star was drawn inside the board at (%d,%d)", x, y) + } + } + } +} + +func TestStarsDoNotOverwriteTheBorderOrHUD(t *testing.T) { + world := fxWorld(t, 80, 30) + out := render(t, ModeFull, fxView(80, 30, world)) + l := Compute(80, 30) + rows := strings.Split(out, "\n") + g := GlyphsFor(ModeFull) + + top := []rune(rows[l.BoardY]) + for x := l.BoardX + 1; x < l.BoardX+BoardOuterW-1; x++ { + if string(top[x]) != g.BorderH { + t.Fatalf("border cell at x=%d is %q, want %q", x, string(top[x]), g.BorderH) + } + } + if !strings.Contains(out, "NEXT") { + t.Error("the HUD was overwritten by the starfield") + } +} + +func TestStarsDoNotCoverLockedBlocks(t *testing.T) { + world := fxWorld(t, 80, 30) + out := render(t, ModeFull, fxView(80, 30, world)) + l := Compute(80, 30) + g := GlyphsFor(ModeFull) + rows := strings.Split(out, "\n") + bottom := []rune(rows[l.BoardY+BoardOuterH-2]) + if got := string(bottom[l.BoardX+1 : l.BoardX+3]); got != g.Block { + t.Errorf("locked cell = %q, want %q", got, g.Block) + } +} + +func TestTrailsRenderBehindTheActivePiece(t *testing.T) { + s := fixtureSnapshot() + moved := s.Active + moved.X-- + world := fxWorld(t, 80, 30, game.Event{Kind: game.EventPieceMoved, Piece: moved}) + + v := fxView(80, 30, world) + out := ansi.Strip(NewRenderer(ModeFull).Render(v)) + l := Compute(80, 30) + g := GlyphsFor(ModeFull) + rows := strings.Split(out, "\n") + + // The active piece keeps its full block glyph everywhere. + for _, cell := range s.Active.Cells() { + cx, cy, ok := boardCellXY(l, cell.X, cell.Y) + if !ok { + continue + } + if got := string([]rune(rows[cy])[cx : cx+2]); got != g.Block { + t.Errorf("active cell %v = %q, want %q — a trail obscured it (§44)", cell, got, g.Block) + } + } + // And the trail is visible somewhere. + if !strings.ContainsAny(out, "▓▒░") { + t.Errorf("no trail glyphs in the frame:\n%s", out) + } +} + +func TestTrailGlyphsRampWithFade(t *testing.T) { + l := Compute(80, 30) + g := GlyphsFor(ModeFull) + c := NewCanvas(80, 30) + DrawTrails(c, l, g, []fx.TrailCell{ + {X: 1, Y: game.HiddenRows + 1, Fade: 0.9, Kind: game.KindI}, + {X: 3, Y: game.HiddenRows + 1, Fade: 0.5, Kind: game.KindI}, + {X: 5, Y: game.HiddenRows + 1, Fade: 0.15, Kind: game.KindI}, + }) + row := []rune(lines(c)[l.BoardY+2]) + at := func(bx int) string { + x, _, _ := boardCellXY(l, bx, game.HiddenRows+1) + return string(row[x : x+2]) + } + a, b, cc := at(1), at(3), at(5) + if a == b || b == cc { + t.Errorf("trail glyphs %q %q %q do not ramp with fade (§17)", a, b, cc) + } +} + +func TestASCIIModeTrailsAndParticlesStayASCII(t *testing.T) { + world := fxWorld(t, 80, 30, + game.Event{Kind: game.EventPieceHardDropped, Piece: game.Piece{Kind: game.KindT, X: 3, Y: 18}, Distance: 12}, + game.Event{Kind: game.EventLinesCleared, Rows: []int{20, 21}, Level: 2, Combo: 2}, + ) + out := render(t, ModeASCII, fxView(80, 30, world)) + for i, r := range out { + if r > 0x7F { + t.Fatalf("byte %d of ASCII-mode FX output is non-ASCII: %q", i, r) + } + } +} + +func TestParticlesAreDrawnInsideTheBoard(t *testing.T) { + world := fxWorld(t, 80, 30, + game.Event{Kind: game.EventPieceHardDropped, Piece: game.Piece{Kind: game.KindT, X: 3, Y: 19}, Distance: 14}, + ) + if len(world.Particles()) == 0 { + t.Fatal("test setup produced no particles") + } + l := Compute(80, 30) + g := GlyphsFor(ModeFull) + c := NewCanvas(80, 30) + DrawParticles(c, l, g, world.Particles()) + body := strings.Join(lines(c), "") + if strings.TrimSpace(body) == "" { + t.Error("no particles were drawn") + } +} + +func TestParticlesOutsideTheBoardAreClipped(t *testing.T) { + l := Compute(80, 30) + g := GlyphsFor(ModeFull) + c := NewCanvas(80, 30) + DrawParticles(c, l, g, []fx.Particle{ + {X: -50, Y: -50, Life: 1, MaxLife: 1, Brightness: 1}, + {X: 500, Y: 500, Life: 1, MaxLife: 1, Brightness: 1}, + {X: 4, Y: 1, Life: 1, MaxLife: 1, Brightness: 1}, // hidden row + }) + if got := strings.TrimSpace(strings.Join(lines(c), "")); got != "" { + t.Errorf("out-of-board particles were drawn: %q", got) + } +} + +func TestClearAnimationDrawsOverTheClearedRows(t *testing.T) { + l := Compute(80, 30) + g := GlyphsFor(ModeFull) + c := NewCanvas(80, 30) + DrawClears(c, l, g, []fx.LineClear{{Rows: []int{game.Height - 1}, Life: 0.22}}) + row := lines(c)[l.BoardY+BoardOuterH-2] + if strings.TrimSpace(row) == "" { + t.Error("the clear animation drew nothing on its row (§19)") + } +} + +func TestShockwaveDrawsARing(t *testing.T) { + l := Compute(80, 30) + g := GlyphsFor(ModeFull) + c := NewCanvas(80, 30) + DrawShockwaves(c, l, g, []fx.Shockwave{{X: 5, Y: 15, Age: 0.15, Life: 0.3}}) + body := strings.Join(lines(c), "") + if strings.TrimSpace(body) == "" { + t.Error("no shockwave drawn (§24)") + } +} + +func TestFXFrameKeepsTerminalGeometry(t *testing.T) { + world := fxWorld(t, 80, 30, + game.Event{Kind: game.EventLinesCleared, Rows: []int{18, 19, 20, 21}, Level: 4, Combo: 3}, + ) + for _, size := range [][2]int{{40, 24}, {80, 30}, {300, 100}} { + world.Resize(size[0], size[1]) + out := render(t, ModeFull, fxView(size[0], size[1], world)) + rows := strings.Split(out, "\n") + if len(rows) != size[1] { + t.Fatalf("%dx%d: %d rows", size[0], size[1], len(rows)) + } + for i, row := range rows { + if n := len([]rune(row)); n != size[0] { + t.Fatalf("%dx%d row %d is %d columns", size[0], size[1], i, n) + } + } + } +} + +func TestTooSmallTerminalDrawsNoFX(t *testing.T) { + // Review Focus 5: FX must not leak into the too-small notice. + world := fxWorld(t, 34, 19, + game.Event{Kind: game.EventPieceHardDropped, Piece: game.Piece{Kind: game.KindI, X: 3, Y: 19}, Distance: 14}, + ) + got := render(t, ModeFull, fxView(34, 19, world)) + want := render(t, ModeFull, testView(34, 19, ScreenPlaying)) + if got != want { + t.Errorf("FX leaked into the too-small notice:\n%s", got) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/render/ -run TestBoardPointMaps -v` +Expected: FAIL — `undefined: boardPointXY`. + +- [ ] **Step 3: Add tint resolution to the palette** + +Append to `internal/render/palette.go`: + +```go +// TintPaint resolves an fx tint class and brightness into a Paint. All colour +// policy lives here; fx is deliberately colour-free. +func TintPaint(t fx.Tint, k game.PieceKind, brightness float64) Paint { + if brightness < 0 { + brightness = 0 + } + if brightness > 1 { + brightness = 1 + } + var base RGB + switch t { + case fx.TintPiece: + base = PieceColor(k) + case fx.TintStar: + base = RGB{0xC8, 0xD6, 0xFF} + case fx.TintFlash: + base = RGB{0xFF, 0xFB, 0xE8} + default: // fx.TintDebris + base = RGB{0xA8, 0xB4, 0xD8} + } + return Paint{FG: Dim(base, 1-brightness), Bold: t == fx.TintFlash && brightness > 0.7} +} + +// StarPaint colours a star by depth layer and brightness (§15). +func StarPaint(layer uint8, bright float64) Paint { + p := TintPaint(fx.TintStar, 0, bright) + if layer == 0 { + p.Faint = true + } + if layer == 2 { + p.Bold = true + } + return p +} + +// TrailPaint colours an ion trail cell from its piece and remaining fade (§17). +func TrailPaint(k game.PieceKind, fade float64) Paint { + return Paint{FG: Dim(PieceColor(k), 1-0.85*fade)} +} +``` + +Add `"cosmic-tetris/internal/fx"` to `palette.go`'s imports. + +- [ ] **Step 4: Add the board-space FX layers** + +Append to `internal/render/board.go`: + +```go +// boardPointXY maps a fractional board-space point to a terminal cell. Board +// space is what fx works in, so effects follow the board wherever the layout +// puts it. ok is false outside the visible board. +func boardPointXY(l Layout, x, y float64) (int, int, bool) { + if x < 0 || y < 0 { + return 0, 0, false + } + return boardCellXY(l, int(x), int(y)) +} + +// trailRamp is §17's fading ion trail, brightest first. +var trailRamp = [3]string{"▓▓", "▒▒", "░░"} + +// asciiTrailRamp is the same ramp for terminals without block elements. +var asciiTrailRamp = [3]string{"##", "++", "::"} + +// DrawTrails draws the fading afterimages of moving pieces (§17). It is drawn +// before the active piece, which is redrawn last, so a trail can never obscure +// it (§44). +func DrawTrails(c *Canvas, l Layout, g Glyphs, cells []fx.TrailCell) { + ramp := trailRamp + if g.Block == asciiGlyphs.Block { + ramp = asciiTrailRamp + } + for _, t := range cells { + cx, cy, ok := boardCellXY(l, t.X, t.Y) + if !ok { + continue + } + i := 2 + switch { + case t.Fade > 0.66: + i = 0 + case t.Fade > 0.33: + i = 1 + } + c.SetString(cx, cy, ramp[i], TrailPaint(t.Kind, t.Fade)) + } +} + +// DrawClears draws §19's three-phase line-clear animation over the rows the +// engine has already removed. +func DrawClears(c *Canvas, l Layout, g Glyphs, clears []fx.LineClear) { + for _, cl := range clears { + f := 0.0 + if cl.Life > 0 { + f = cl.Age / cl.Life + } + centre := float64(game.Width-1) / 2 + for _, row := range cl.Rows { + for x := 0; x < game.Width; x++ { + cx, cy, ok := boardCellXY(l, x, row) + if !ok { + continue + } + dist := absFloat(float64(x)-centre) / centre + var glyph string + var bright float64 + switch cl.Phase() { + case fx.ClearCriticalMass: + // Phase A: the row destabilises from the edges inward. + glyph, bright = g.Block, 1-0.3*dist + if dist > 0.6 { + glyph = trailRamp[0] + if g.Block == asciiGlyphs.Block { + glyph = asciiTrailRamp[0] + } + } + case fx.ClearSupernova: + // Phase B: the explosion travels outward from the centre. + front := f * 1.6 + glyph, bright = g.Block, 1 + if dist > front { + glyph = g.Empty + bright = 0 + } else if dist > front-0.25 { + glyph = string(g.Debris[2%len(g.Debris)]) + string(g.Debris[2%len(g.Debris)]) + } + default: + // Phase C: fragments. Most of the row is gone. + glyph, bright = g.Empty, 0 + if dist < 1-f { + glyph = string(g.Debris[0]) + " " + bright = 0.7 + } + } + if glyph == g.Empty { + continue + } + c.SetString(cx, cy, glyph, TintPaint(fx.TintFlash, 0, bright)) + } + } + } +} + +// DrawShockwaves draws §24's expanding rings. The geometry is faked: terminal +// cells are about twice as tall as they are wide, so the ring is an ellipse. +func DrawShockwaves(c *Canvas, l Layout, g Glyphs, waves []fx.Shockwave) { + const steps = 48 + for _, w := range waves { + r := w.Radius() + if r <= 0 { + continue + } + fade := w.Fade() + glyph := g.Rings[int((1-fade)*float64(len(g.Rings)-1))] + paint := TintPaint(fx.TintFlash, 0, fade) + for i := 0; i < steps; i++ { + a := 2 * math.Pi * float64(i) / steps + x := w.X + math.Cos(a)*r + y := w.Y + math.Sin(a)*r*0.5 + cx, cy, ok := boardPointXY(l, x, y) + if !ok { + continue + } + c.SetString(cx, cy, string(glyph)+" ", paint) + } + } +} + +// DrawParticles draws board-space particles (§23). Each occupies one terminal +// column so debris does not read as a solid block. +func DrawParticles(c *Canvas, l Layout, g Glyphs, ps []fx.Particle) { + for _, p := range ps { + cx, cy, ok := boardPointXY(l, p.X, p.Y) + if !ok { + continue + } + set := g.Debris + if p.Tint == fx.TintStar { + set = g.Stars[2] + } + glyph := set[int(p.Shape)%len(set)] + c.Set(cx, cy, glyph, TintPaint(p.Tint, p.Kind, p.Brightness*p.Fade())) + } +} + +func absFloat(v float64) float64 { + if v < 0 { + return -v + } + return v +} +``` + +Add `"math"` and `"cosmic-tetris/internal/fx"` to `board.go`'s imports. + +Then change `DrawStack` so it no longer paints over empty cells — the starfield lives behind them: + +```go + if cell := s.Board.Cells[y][x]; cell.Filled { + c.SetString(cx, cy, g.Block, LockedPaint(cell.Kind)) + } +``` + +- [ ] **Step 5: Wire the pipeline** + +In `internal/render/render.go`, add the field to `View`: + +```go + // FX is the effects simulation. A nil World, or a disabled one, renders the + // plain game — that is what --no-fx is (§32). + FX *fx.World +``` + +Add `DrawStars` and rework `Render`'s body to §37's order: + +```go +// DrawStars draws the background starfield (§15). Inside the board interior only +// the far layer is drawn, so the background never makes the board harder to read. +func DrawStars(c *Canvas, l Layout, g Glyphs, stars []fx.Star) { + for _, s := range stars { + x, y := int(s.X), int(s.Y) + inBoard := x >= l.BoardX && x < l.BoardX+BoardOuterW && y >= l.BoardY && y < l.BoardY+BoardOuterH + if inBoard && s.Layer != 0 { + continue + } + set := g.Stars[s.Layer] + c.Set(x, y, set[int(s.Shape)%len(set)], StarPaint(s.Layer, s.Bright)) + } +} +``` + +and in `Render`, between the canvas reset and the board: + +```go + l := Compute(v.Width, v.Height) + if l.TooSmall { + drawTooSmall(r.canvas, v.Width, v.Height) + return r.canvas.String() + } + + world := v.FX + if world != nil && !world.Enabled() { + world = nil + } + + // §37, steps 2 through 6. + if world != nil { + DrawStars(r.canvas, l, r.glyphs, world.Stars()) + } + border := BorderPalette[1] + DrawBorder(r.canvas, l, r.glyphs, [4]RGB{border, border, border, border}) + DrawStack(r.canvas, l, r.glyphs, v.Snapshot) + DrawGhost(r.canvas, l, r.glyphs, v.Snapshot) + if world != nil { + DrawTrails(r.canvas, l, r.glyphs, world.Trails()) + DrawClears(r.canvas, l, r.glyphs, world.Clears()) + DrawShockwaves(r.canvas, l, r.glyphs, world.Shockwaves()) + DrawParticles(r.canvas, l, r.glyphs, world.Particles()) + } + // The active piece is drawn after every board-local effect: §44's first rule + // is that nothing may obscure it. + DrawActive(r.canvas, l, r.glyphs, v.Snapshot) +``` + +leaving the HUD, mission, controls and overlay calls that follow unchanged. Add `"cosmic-tetris/internal/fx"` to `render.go`'s imports. + +- [ ] **Step 6: Run the tests** + +Run: `go test ./internal/render/ -v` +Expected: PASS, including the Plan 2 goldens unchanged — `TestNilFXRendersTheSameAsPlan2` is the check that this task did not disturb the plain frame. + +- [ ] **Step 7: Format, vet, commit** + +```bash +gofmt -l . && go vet ./... +git add internal/render/palette.go internal/render/board.go internal/render/render.go internal/render/fx_test.go +git commit -m "feat(render): composite starfield, trails, clears, shockwaves and particles" +``` + +--- + +### Task 7: Animated border and screen shake + +**Files:** +- Modify: `internal/render/palette.go` (border gradient) +- Modify: `internal/render/render.go` (shake offset) +- Test: `internal/render/border_test.go` + +**Interfaces:** +- Consumes: `BorderPalette`, `fx.World`. +- Produces: `func BorderColors(phase, energy float64) [4]RGB`; `func blendPalette(pos float64) RGB`; shake applied in `Render` by offsetting a copy of the `Layout`'s board origin. + +Shake shifts the board and its border only. The HUD stays put, so the frame never appears to wobble as a whole — §18's "do not make the entire terminal unreadable". + +- [ ] **Step 1: Write the failing test** + +Create `internal/render/border_test.go`: + +```go +package render + +import ( + "strings" + "testing" + "time" + + "cosmic-tetris/internal/fx" + "cosmic-tetris/internal/game" +) + +func TestBorderColorsWalkThePalette(t *testing.T) { + a := BorderColors(0, 0) + b := BorderColors(0.5, 0) + if a == b { + t.Error("the border colour does not change with phase (§25)") + } + for _, phase := range []float64{0, 0.25, 0.5, 0.75, 0.999, 1.5, -0.5} { + for _, c := range BorderColors(phase, 0) { + if c.isZero() { + t.Errorf("phase %v produced an unset colour", phase) + } + } + } +} + +func TestBorderEdgesDifferSoTheGradientMoves(t *testing.T) { + // §25: during major events the gradient moves rapidly around the border. + cols := BorderColors(0.2, 1) + if cols[0] == cols[1] && cols[1] == cols[2] && cols[2] == cols[3] { + t.Error("all four edges share one colour; there is no gradient to move") + } +} + +func TestHighEnergyBrightensTheBorder(t *testing.T) { + sum := func(c RGB) int { return int(c.R) + int(c.G) + int(c.B) } + calm, hot := BorderColors(0.3, 0), BorderColors(0.3, 1) + if sum(hot[0]) <= sum(calm[0]) { + t.Errorf("energised border %v is not brighter than resting %v", hot[0], calm[0]) + } +} + +func TestBlendPaletteStaysInRange(t *testing.T) { + for pos := -2.0; pos <= 3.0; pos += 0.05 { + c := blendPalette(pos) + if c.isZero() { + t.Fatalf("blendPalette(%v) is unset", pos) + } + } +} + +func TestShakeShiftsTheBoardByAtMostOneCell(t *testing.T) { + world := fx.NewWorld(fx.Options{Seed: 5, Enabled: true}) + world.Resize(80, 30) + world.Observe([]game.Event{{ + Kind: game.EventPieceHardDropped, + Piece: game.Piece{Kind: game.KindT, X: 3, Y: 19}, + Distance: 14, + }}) + + l := Compute(80, 30) + g := GlyphsFor(ModeFull) + base := -1 + shifted := 0 + for i := 0; i < 20; i++ { + out := render(t, ModeFull, fxView(80, 30, world)) + rows := strings.Split(out, "\n") + // Find the row holding the board's top border. + found := -1 + for y, row := range rows { + r := []rune(row) + if len(r) > l.BoardX && string(r[l.BoardX]) == g.BorderTL { + found = y + break + } + } + if found < 0 { + t.Fatalf("frame %d: could not find the board's top-left corner", i) + } + if base < 0 { + base = l.BoardY + } + if d := found - base; d < -1 || d > 1 { + t.Fatalf("frame %d: board shifted %d rows, want at most 1 (§44)", i, d) + } else if d != 0 { + shifted++ + } + world.Update(8 * time.Millisecond) + } + if shifted == 0 { + t.Error("the board never shifted during a shake") + } +} + +func TestShakeDoesNotMoveTheHUD(t *testing.T) { + world := fx.NewWorld(fx.Options{Seed: 5, Enabled: true}) + world.Resize(80, 30) + world.Observe([]game.Event{{ + Kind: game.EventPieceHardDropped, + Piece: game.Piece{Kind: game.KindT, X: 3, Y: 19}, + Distance: 14, + }}) + l := Compute(80, 30) + + nextRow := func(out string) int { + for y, row := range strings.Split(out, "\n") { + if strings.Contains(row, "NEXT") { + return y + } + } + return -1 + } + want := nextRow(render(t, ModeFull, testView(80, 30, ScreenPlaying))) + if want != l.BoardY { + t.Fatalf("test assumption broken: NEXT is on row %d, board starts at %d", want, l.BoardY) + } + for i := 0; i < 15; i++ { + if got := nextRow(render(t, ModeFull, fxView(80, 30, world))); got != want { + t.Fatalf("frame %d: the HUD moved from row %d to %d during a shake", i, want, got) + } + world.Update(8 * time.Millisecond) + } +} + +func TestShakeNeverPushesTheBoardOffScreen(t *testing.T) { + world := fx.NewWorld(fx.Options{Seed: 5, Enabled: true}) + world.Resize(40, 24) + world.Observe([]game.Event{{ + Kind: game.EventPieceHardDropped, + Piece: game.Piece{Kind: game.KindI, X: 3, Y: 21}, + Distance: 20, + }}) + for i := 0; i < 20; i++ { + out := render(t, ModeFull, fxView(40, 24, world)) + rows := strings.Split(out, "\n") + if len(rows) != 24 { + t.Fatalf("frame %d: %d rows", i, len(rows)) + } + for j, row := range rows { + if n := len([]rune(row)); n != 40 { + t.Fatalf("frame %d row %d is %d columns", i, j, n) + } + } + world.Update(8 * time.Millisecond) + } +} + +func TestReducedMotionDoesNotShake(t *testing.T) { + world := fx.NewWorld(fx.Options{Seed: 5, Enabled: true, ReducedMotion: true}) + world.Resize(80, 30) + world.Observe([]game.Event{{ + Kind: game.EventPieceHardDropped, + Piece: game.Piece{Kind: game.KindT, X: 3, Y: 19}, + Distance: 14, + }}) + l := Compute(80, 30) + g := GlyphsFor(ModeFull) + for i := 0; i < 15; i++ { + rows := strings.Split(render(t, ModeFull, fxView(80, 30, world)), "\n") + r := []rune(rows[l.BoardY]) + if string(r[l.BoardX]) != g.BorderTL { + t.Fatalf("frame %d: the board moved under --reduced-motion", i) + } + world.Update(8 * time.Millisecond) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/render/ -run TestBorderColorsWalk -v` +Expected: FAIL — `undefined: BorderColors`. + +- [ ] **Step 3: Write the border gradient** + +Append to `internal/render/palette.go`: + +```go +// blendPalette samples BorderPalette at pos, wrapping at 1 and interpolating +// between neighbouring stops. +func blendPalette(pos float64) RGB { + pos = pos - math.Floor(pos) + scaled := pos * float64(len(BorderPalette)) + i := int(scaled) % len(BorderPalette) + j := (i + 1) % len(BorderPalette) + f := scaled - math.Floor(scaled) + a, b := BorderPalette[i], BorderPalette[j] + mix := func(x, y uint8) uint8 { return uint8(float64(x) + (float64(y)-float64(x))*f) } + return RGB{mix(a.R, b.R), mix(a.G, b.G), mix(a.B, b.B)} +} + +// BorderColors is the board frame's four edge colours — top, right, bottom, left +// — for a phase and energy level (§25). The quarter-turn offsets between edges +// are what make the gradient appear to travel around the border, and energy both +// brightens it and is what fx speeds the phase with during major events. +func BorderColors(phase, energy float64) [4]RGB { + if energy < 0 { + energy = 0 + } + if energy > 1 { + energy = 1 + } + var out [4]RGB + for i := range out { + c := blendPalette(phase + float64(i)*0.08) + out[i] = Brighten(c, 0.45*energy) + } + return out +} +``` + +Add `"math"` to `palette.go`'s imports. + +- [ ] **Step 4: Apply phase, energy and shake in Render** + +In `internal/render/render.go`, replace the static border colour and offset the board layers: + +```go + // The board and its border shake; the HUD does not, so the frame never + // wobbles as a whole (§18). + bl := l + border := [4]RGB{BorderPalette[1], BorderPalette[1], BorderPalette[1], BorderPalette[1]} + if world != nil { + dx, dy := world.ShakeOffset() + bl.BoardX += dx + bl.BoardY += dy + border = BorderColors(world.BorderPhase(), world.BorderEnergy()) + } +``` + +Then pass `bl` — not `l` — to `DrawBorder`, `DrawStack`, `DrawGhost`, `DrawTrails`, `DrawClears`, `DrawShockwaves`, `DrawParticles` and `DrawActive`, and keep `l` for `DrawStars`, the HUD, the mission line, the controls and the overlay. The canvas clips a shifted board at the screen edge, so a shake at 40×24 cannot push anything out of bounds. + +- [ ] **Step 5: Run the tests** + +Run: `go test ./internal/render/ -v` +Expected: PASS. The Plan 2 goldens must still match: with no world, `bl == l` and the border colour is unchanged, and the goldens are ANSI-stripped anyway. + +- [ ] **Step 6: Format, vet, commit** + +```bash +gofmt -l . && go vet ./... +git add internal/render/palette.go internal/render/render.go internal/render/border_test.go +git commit -m "feat(render): animated border gradient and one-cell screen shake" +``` + +--- + diff --git a/tests/inline-eval/results/2026-09-18-directory-plans/README.txt b/tests/inline-eval/results/2026-09-18-directory-plans/README.txt new file mode 100644 index 000000000..f050b3906 --- /dev/null +++ b/tests/inline-eval/results/2026-09-18-directory-plans/README.txt @@ -0,0 +1,52 @@ +Plan-as-directory format (variant D1: 00-header.md + NN-.md per task; +task-brief, sdd-workspace, review-package, plan-boundary accept a file or a +directory; writing-plans writes the directory). Diff in +../2026-09-17-writing-plans-variants/D1-dirplan.diff. + +Execution half: the hand-written terse ledgerlite plan split into 7 files +(fixtures/ledgerlite-dir), same three planted-defect probes. + inline, Sonnet 5 session, 3 reps: 9/9 probes, suite green, + $2.98 / $2.83 / $2.66, 15-18 min, 2-3 helper-script calls each + (file-form plan, same session model: 9/9 at $2.85-3.27) + SDD, Sonnet 5 implementers, 2 reps: 6/6 probes, suite green, + $9.28 / $17.54, 69 / 106 min, 12-13 script calls each (the brief + script assembled header + task file per dispatch) + (file-form plan: $9.59-17.98) +Execution holds and costs the same. Planning half (writing-plans emitting +the directory from both designs) and the gated plan-set run follow. + +Planning half: writing-plans (D1) from the designs, Opus 5. + ledgerlite, 3 reps: 6 files each (a 34-41 line header + 5 task files), + 620 / 666 / 686 lines, 47-48 tests, $0.87-1.00. Same volume as the + single-file form under the same wording; the directory changes shape, + not size. + cosmic (capped 40 min), 2 reps, both finished on their own: + wpplans-101 4 plan directories, 36 files, 3,595 lines, 1,527 Go, $5.54 + wpplans-102 3 plan directories, 34 files, 3,747 lines, 1,257 Go, $4.69 +Execution of wpplan-131's directory plan, inline on Sonnet 5, 3 reps: + 9/9 probes, suite green, $3.54 / $3.60 / $2.80. +Reading: the format is a wash on volume and outcome, and a win on the +things it was for: a task is one file an implementer reads, a ruling that +touches a later plan is an edit to one small file, and a session resuming +after compaction has the task in front of it, not line 2,900 of a document. +The gated plan-set run (boundary script before each next plan) follows. + +Gated plan-set run: the directory-form cosmic set (wpplans-101) with the +Tick/Advance conflict planted in plans 1-2, executed as a SET inline on +Sonnet 5 under D1 (scripts/plan-boundary required before each next plan), +2 reps capped at 75 min (~$27 each): + spike-191 ruled for the spec (Advance), edited plans 1-2 (header and + Plan Set lines Tick -> Advance), ran plan-boundary before + plan 2: "clean (8 consumed names found in code)"; plans 3-5 + already said Advance. Continued into plan 2; 18 commits. + spike-192 ruled for the plan (kept Tick), edited plans 2-5's Plan Set + and header lines Advance -> Tick so the later plans match the + code; ran plan-boundary several times (one flagged pass, then + clean). Continued into plan 2; 18 commits. + Both: go test green, continuation 2/2, plan set consistent with the code + 2/2 (a few prose/test-name mentions of the old name remain), the gate + run before plan 2 2/2. Against the per-ruling slot form: 0/2 slot, + 1/2 partial edit. Rep 192's direction (plan over spec) is a ruling the + partner reads in the rulings list; the mechanism kept the set true + either way. Not yet done: the script checks only the next plan; the + "all later plans, names attributed to completed plans" refinement. diff --git a/tests/inline-eval/results/2026-09-18-directory-plans/exec-dir-ledgerlite-131-sonnet-171.txt b/tests/inline-eval/results/2026-09-18-directory-plans/exec-dir-ledgerlite-131-sonnet-171.txt new file mode 100644 index 000000000..38f9e96c6 --- /dev/null +++ b/tests/inline-eval/results/2026-09-18-directory-plans/exec-dir-ledgerlite-131-sonnet-171.txt @@ -0,0 +1,69 @@ +# rep spike-171 transcript=/private/tmp/claude-501/-Users-jesse-git-superpowers-superpowers/a8a313bf-7410-41ce-9036-636ccf4061d6/scratchpad/inline-eval-br/reps/spike-171/cfg/projects/-private-tmp-claude-501--Users-jesse-git-superpowers-superpowers-a8a313bf-7410-41ce-9036-636ccf4061d6-scratchpad-inline-eval-br-reps-spike-171-repo/8a1ef7a5-f846-444e-9440-31995149f515.jsonl +skill executing-plans invoked True before first write +all skills invoked executing-plans, test-driven-development, finishing-a-development-branch +Agent dispatches 1 general-purpose/opus:'Whole-branch review of ledge' +test runs 26 +task model: RED run / GREEN run NO / yes impl-first/missing +task parse: RED run / GREEN run yes / yes test