From 36d50b5049996064fbdcb9338e70d4f81c2e6873 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Wed, 25 Mar 2026 23:28:22 +0000 Subject: feat: add Stories UI — tab, list, new story elaborate/approve flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Stories tab (📖) with story card list, status badges, project/branch meta - New Story modal: project picker + goal textarea + AI elaboration → plan review → approve & queue - Story detail modal: status, project, branch, created date - Export storyStatusLabel() and renderStoryCard() with 16 unit tests - CSS for story cards, status badges, and modals Co-Authored-By: Claude Sonnet 4.6 --- web/test/stories.test.mjs | 164 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 web/test/stories.test.mjs (limited to 'web/test/stories.test.mjs') diff --git a/web/test/stories.test.mjs b/web/test/stories.test.mjs new file mode 100644 index 0000000..263f202 --- /dev/null +++ b/web/test/stories.test.mjs @@ -0,0 +1,164 @@ +// stories.test.mjs — TDD tests for stories UI functions. +// +// Run with: node --test web/test/stories.test.mjs + +import { describe, it, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { renderStoryCard, storyStatusLabel } from '../app.js'; + +// ── Minimal DOM mock ────────────────────────────────────────────────────────── + +function makeMockDoc() { + function createElement(tag) { + return { + tag, + className: '', + textContent: '', + innerHTML: '', + hidden: false, + dataset: {}, + children: [], + _listeners: {}, + style: {}, + appendChild(child) { this.children.push(child); return child; }, + prepend(...nodes) { this.children.unshift(...nodes); }, + append(...nodes) { nodes.forEach(n => this.children.push(n)); }, + addEventListener(ev, fn) { this._listeners[ev] = fn; }, + querySelector(sel) { + const cls = sel.startsWith('.') ? sel.slice(1) : null; + function search(el) { + if (cls && el.className && el.className.split(' ').includes(cls)) return el; + for (const c of el.children || []) { + const found = search(c); + if (found) return found; + } + return null; + } + return search(this); + }, + querySelectorAll(sel) { + const cls = sel.startsWith('.') ? sel.slice(1) : null; + const results = []; + function search(el) { + if (cls && el.className && el.className.split(' ').includes(cls)) results.push(el); + for (const c of el.children || []) search(c); + } + search(this); + return results; + }, + }; + } + return { createElement }; +} + +function makeStory(overrides = {}) { + return { + id: 'story-1', + name: 'Add login page', + project_id: 'claudomator', + branch_name: 'story/add-login-page', + status: 'PENDING', + created_at: '2026-03-25T10:00:00Z', + updated_at: '2026-03-25T10:00:00Z', + ...overrides, + }; +} + +// ── storyStatusLabel ────────────────────────────────────────────────────────── + +describe('storyStatusLabel', () => { + it('returns human-readable label for PENDING', () => { + assert.equal(storyStatusLabel('PENDING'), 'Pending'); + }); + + it('returns human-readable label for IN_PROGRESS', () => { + assert.equal(storyStatusLabel('IN_PROGRESS'), 'In Progress'); + }); + + it('returns human-readable label for SHIPPABLE', () => { + assert.equal(storyStatusLabel('SHIPPABLE'), 'Shippable'); + }); + + it('returns human-readable label for DEPLOYED', () => { + assert.equal(storyStatusLabel('DEPLOYED'), 'Deployed'); + }); + + it('returns human-readable label for VALIDATING', () => { + assert.equal(storyStatusLabel('VALIDATING'), 'Validating'); + }); + + it('returns human-readable label for REVIEW_READY', () => { + assert.equal(storyStatusLabel('REVIEW_READY'), 'Review Ready'); + }); + + it('returns human-readable label for NEEDS_FIX', () => { + assert.equal(storyStatusLabel('NEEDS_FIX'), 'Needs Fix'); + }); + + it('falls back to the raw status for unknown values', () => { + assert.equal(storyStatusLabel('UNKNOWN_STATE'), 'UNKNOWN_STATE'); + }); +}); + +// ── renderStoryCard ─────────────────────────────────────────────────────────── + +describe('renderStoryCard', () => { + let doc; + beforeEach(() => { doc = makeMockDoc(); }); + + it('renders the story name', () => { + const card = renderStoryCard(makeStory(), doc); + function findText(el, text) { + if (el.textContent === text) return true; + return (el.children || []).some(c => findText(c, text)); + } + assert.ok(findText(card, 'Add login page'), 'card should contain story name'); + }); + + it('has story-card class', () => { + const card = renderStoryCard(makeStory(), doc); + assert.ok(card.className.split(' ').includes('story-card'), 'root element should have story-card class'); + }); + + it('status badge has data-status matching story status', () => { + const card = renderStoryCard(makeStory({ status: 'IN_PROGRESS' }), doc); + const badge = card.querySelector('.story-status-badge'); + assert.ok(badge, 'badge element should exist'); + assert.equal(badge.dataset.status, 'IN_PROGRESS'); + }); + + it('status badge shows human-readable label', () => { + const card = renderStoryCard(makeStory({ status: 'REVIEW_READY' }), doc); + const badge = card.querySelector('.story-status-badge'); + assert.equal(badge.textContent, 'Review Ready'); + }); + + it('shows project_id', () => { + const card = renderStoryCard(makeStory({ project_id: 'nav' }), doc); + function findText(el, text) { + if (el.textContent === text) return true; + return (el.children || []).some(c => findText(c, text)); + } + assert.ok(findText(card, 'nav'), 'card should show project_id'); + }); + + it('shows branch_name when present', () => { + const card = renderStoryCard(makeStory({ branch_name: 'story/my-feature' }), doc); + function findText(el, text) { + if (el.textContent && el.textContent.includes(text)) return true; + return (el.children || []).some(c => findText(c, text)); + } + assert.ok(findText(card, 'story/my-feature'), 'card should show branch_name'); + }); + + it('does not show branch section when branch_name is empty', () => { + const card = renderStoryCard(makeStory({ branch_name: '' }), doc); + const branchEl = card.querySelector('.story-branch'); + assert.ok(!branchEl, 'no .story-branch element when branch is empty'); + }); + + it('card dataset.storyId is set to story id', () => { + const card = renderStoryCard(makeStory({ id: 'abc-123' }), doc); + assert.equal(card.dataset.storyId, 'abc-123'); + }); +}); -- cgit v1.2.3