diff --git a/CHANGELOG-annotate-pretext-reflow.md b/CHANGELOG-annotate-pretext-reflow.md deleted file mode 100644 index ff2c33c..0000000 --- a/CHANGELOG-annotate-pretext-reflow.md +++ /dev/null @@ -1,90 +0,0 @@ -# Annotate DocGen โ€” Pretext Scanline Reflow Engine - -- New `{{Annotate:}}` DocGen tag with canvas-based freehand annotation overlay -- New `@text:` source mode renders text on-canvas with Pretext-style scanline reflow -- Scanline engine (`reflowText`, `getFreeIntervals`) samples 1px pixel rows from an offscreen mask canvas to compute per-line free intervals -- Real-time text reflow: as user draws, each stroke updates the mask and text re-routes live around strokes -- `layoutNextLine()` pattern used per-row: per-line width narrows when a stroke occupies that row's x-range -- Offscreen mask canvas approach: strokes drawn at full resolution, scanline reads `getImageData(0, y, width, 1)` โ€” O(width) per row, ~0.5โ€“1.5ms total -- Added `โ†ฉ Undo` button with stroke history stack (Ctrl+Z equivalent) -- Added `๐Ÿ—‘ Clear` button (no confirm dialog) with undo stack support -- Added `๐Ÿ“ฅ PNG` button โ€” exports composite canvas (strokes + text) as PNG download -- Added `๐Ÿ“– Present` button โ€” calls `M.setViewMode('preview')` to hide editor and enter live reading/drawing mode -- `Present` auto-scrolls annotation card into view after mode switch -- Removed blocking `confirm()` dialog from Clear; instant clear with undo restore -- Canvas auto-resizes to match `ann-source-text-reflow` element height or defaults to 320px -- `redrawAll` handles dual-layer rendering: text layer first, strokes on top -- Fixed: `data-text` attribute stripped by DOMPurify โ€” added `data-text`, `data-reflow` to `ADD_ATTR` whitelist in `renderer.js` -- Fixed: Text now stored as hidden `` textContent inside the reflow div โ€” textContent always survives DOMPurify regardless of attribute whitelist -- Fixed: `initCanvas` reads from `textSpan.textContent` first (bulletproof), falls back to `dataset.text` -- Fixed: `M.insertAtCursor` replaced with `M.setViewMode('preview')` for the insert action โ€” annotation stays live and drawable instead of becoming a static image -- Fixed: CORS/SecurityError on external images handled with try/catch and fallback to annotation-only export -- Fixed: Image insertion uses `gen-img:ID` registry pattern (same as `draw-docgen.js`) to bypass DOMPurify's stripping of `data:` URLs -- Added `.ann-present-btn` CSS with green gradient, `font-weight: 600`, and hover transform -- Added `.ann-reflow-text` CSS: visually hidden span (position absolute, 1ร—1px clip) so stored text is invisible but accessible to canvas reader -- New `public/pretext-reflow-demo.html` โ€” 4-tab interactive demo: Float Image, Draw Exclusion, Both Together, How It Works -- Float Image tab: image float left/right with width %, top offset sliders, image picker (picsum.photos), and own-image upload -- Draw Exclusion tab: side-by-side DOM (text buried) vs Pretext (scanline reflow) comparison -- Both Together tab: image float + freehand strokes combined, both exclude text simultaneously -- How It Works tab: annotated `layoutNextLine()` code explanation with image and scanline approach -- New `css/annotate-docgen.css` with full card UI, toolbar, color swatches, size slider, dark mode -- New `js/annotate-docgen.js` (~710 lines): parser, canvas init, scanline engine, stroke management, export, present - ---- - -## Summary - -Integrates a real-time, pixel-precise Pretext-style text reflow engine into the `{{Annotate:}}` DocGen tag. Text flows live around freehand annotations using a scanline mask canvas algorithm (O(width) per row, ~0.5โ€“1.5ms/frame). The "Present" button transitions TextAgent to preview-only mode, keeping the annotation card fully drawable while reading. A companion interactive demo (`pretext-reflow-demo.html`) illustrates all three reflow scenarios: image float, freehand, and both combined. - ---- - -## 1. Annotate DocGen Tag โ€” New `{{Annotate:}}` tag - -**Files:** `js/annotate-docgen.js`, `css/annotate-docgen.css` -**What:** Full DocGen card with toolbar (pen/highlighter/eraser/line/arrow/rect/circle), color swatches, size slider, canvas overlay over any `@text:`, `@img:`, or `@url:` source. Undo/Clear/PNG/Present buttons in header. -**Impact:** Authors can write `{{Annotate: Title @text: body text}}` in markdown and get a live, interactive annotation canvas with real-time Pretext text reflow. - ---- - -## 2. Scanline Reflow Engine - -**Files:** `js/annotate-docgen.js` (`reflowText`, `getFreeIntervals`, `buildMask`) -**What:** Offscreen mask canvas accumulates stroke paths. Per text row, `getImageData(0, y, width, 1)` scans a single pixel row for occupied x-ranges. `getFreeIntervals` returns free segments. `reflowText` packs words into free intervals using `canvas.measureText()` for width arithmetic โ€” exactly the Pretext `layoutNextLine()` pattern. -**Impact:** Text wraps with pixel precision around any freehand shape drawn by the user. Performance: ~0.5โ€“1.5ms per full reflow at typical canvas sizes. - ---- - -## 3. DOMPurify Fix โ€” `data-text` Whitelist + Hidden Span - -**Files:** `js/renderer.js`, `js/annotate-docgen.js`, `css/annotate-docgen.css` -**What:** `data-text` and `data-reflow` added to `ADD_ATTR` in `renderer.js`. Additionally, text now stored inside a hidden `` (position absolute, 1ร—1px clip) as textContent โ€” survives DOMPurify with zero whitelisting needed. `initCanvas` reads textContent first, `dataset.text` as fallback. -**Impact:** Fixed core bug where `@text:` content was silently dropped by DOMPurify, leaving a blank canvas with no text rendered. - ---- - -## 4. Present Mode โ€” Live Reading + Drawing - -**Files:** `js/annotate-docgen.js`, `css/annotate-docgen.css` -**What:** "๐Ÿ“– Present" button replaces "Insert". Calls `M.setViewMode('preview')` to hide the editor and expand preview to full width. Annotation card remains a live canvas โ€” user reads and draws simultaneously. Falls back to manual CSS hide of `.editor-pane` if `M.setViewMode` is unavailable. -**Impact:** Completes the author workflow: write โ†’ annotate โ†’ present. The annotation is never "frozen" as a static image โ€” it stays interactive. - ---- - -## 5. Pretext Reflow Demo - -**Files:** `public/pretext-reflow-demo.html` -**What:** Self-contained 4-tab demo showing: (1) float image with `layoutNextLine()` per-line width narrowing, (2) freehand scanline reflow vs DOM comparison, (3) image + strokes combined, (4) API explainer. Image picker uses picsum.photos; own-image upload supported. Performance metrics shown live. -**Impact:** Demonstrates all three `layoutNextLine()` reflow patterns in an interactive standalone page. Accessible at `/pretext-reflow-demo.html`. - ---- - -## Files Changed (6 total) - -| File | Lines Changed | Type | -|------|:---:|------| -| `js/annotate-docgen.js` | +710 | New module โ€” full Annotate DocGen | -| `css/annotate-docgen.css` | +340 | New stylesheet โ€” card, toolbar, reflow badge | -| `js/renderer.js` | +2 | DOMPurify ADD_ATTR: added `data-text`, `data-reflow` | -| `src/main.js` | +5 | Phase lazy-load registration for annotate-docgen | -| `public/pretext-reflow-demo.html` | +720 | New interactive Pretext reflow demo | -| `public/annotate-example.md` | +12 | Example markdown using `{{Annotate:}}` tag | diff --git a/CHANGELOG-chart-bugfixes.md b/CHANGELOG-chart-bugfixes.md deleted file mode 100644 index 78e0cdf..0000000 --- a/CHANGELOG-chart-bugfixes.md +++ /dev/null @@ -1,54 +0,0 @@ -# Chart Bug Fixes โ€” 6 Bugs Resolved - -- Fixed: ECharts memory leak โ€” old chart instances never disposed on re-render, causing growing memory usage -- Fixed: `@code` brace-depth tracker desynced on braces inside string literals and comments -- Fixed: `stripTypeScript` regex mangled valid JS (e.g. `{ type: "string" }` had `: "string"` removed) -- Fixed: "Add Series" insert position used fragile `- 2` offset instead of scanning for `}}` -- Fixed: Area-style applied unintentionally to stacked line charts via confusing boolean logic -- Fixed: Block index desync risk when `transformChartMarkdown` re-parsed `fullMatch` substrings - ---- - -## Summary -Fixed 6 bugs in the ECharts `{{Chart:}}` DocGen tag system: a memory leak from undisposed chart instances, brace-depth tracking that ignored strings/comments, TypeScript stripping that could corrupt valid JS object values, a fragile insert offset in the Add Series feature, confusing area-style default logic, and a re-parsing pattern that risked block index desync. - ---- - -## 1. ECharts Memory Leak Fix -**Files:** `js/renderer.js` -**What:** Added `M._activeCharts` array to track all `ec.init()` instances. At the start of each render cycle, old instances are `dispose()`d before creating new ones. All 3 `ec.init()` call sites (async code mode, sync code mode, declarative JSON mode) now push to this array. -**Impact:** Prevents growing memory usage and stale ResizeObserver callbacks during long editing sessions. - -## 2. String/Comment-Aware Brace Tracking -**Files:** `js/chart-docgen.js` -**What:** Replaced naive character-by-character `{`/`}` counting with a scanner that skips braces inside `"`, `'`, `` ` `` string literals, `//` line comments, and `/* */` block comments. Applied in both `parseChartBlocks` (existing) and the new `parseConfigFromBody` helper. -**Impact:** Prevents parsing failures when ECharts code contains unbalanced braces in strings like `formatter: "{value}%"`. - -## 3. TypeScript Stripping Regex Fix -**Files:** `js/chart-docgen.js` -**What:** Anchored the type annotation removal regex to `let/const/var` declarations with a `(?=\s*=)` lookahead, preventing it from matching `: "string"` in object values like `{ type: "string" }`. -**Impact:** Prevents silent code corruption when ECharts templates contain common JS patterns. - -## 4. Robust "Add Series" Insert Position -**Files:** `js/chart-docgen.js` -**What:** Replaced `blocks[idx].end - 2` with `text.lastIndexOf('}}', blocks[idx].end - 1)` plus a safety check ensuring the found position is within the block. -**Impact:** Correctly inserts new series content before `}}` regardless of whitespace or newlines. - -## 5. Simplified Area-Style Logic -**Files:** `js/chart-docgen.js` -**What:** Changed `if (cfg.area || (type === 'line' && cfg.area !== false && cfg.stack))` to `if (cfg.area === true)`. -**Impact:** Area fill now only applied when explicitly set via `@area: true`, removing confusing implicit behavior. - -## 6. Block Index Desync Prevention -**Files:** `js/chart-docgen.js` -**What:** Extracted `parseConfigFromBody(body)` as a shared helper function. `transformChartMarkdown` now calls this directly on `rb.body` instead of re-parsing `rb.fullMatch` via `parseChartBlocks()`, which could return multiple blocks if `fullMatch` contained nested `{{Chart:}}` references. -**Impact:** Eliminates risk of wrong chart being deleted/modified when documents contain chart documentation. - ---- - -## Files Changed (2 total) - -| File | Lines Changed | Type | -|------|:---:|------| -| `js/chart-docgen.js` | +99 โˆ’6 | Bug fixes 1-4, 5-6 | -| `js/renderer.js` | +10 โˆ’0 | Memory leak fix (Bug 5) | diff --git a/CHANGELOG-podcast-system.md b/CHANGELOG-podcast-system.md deleted file mode 100644 index d0b19cc..0000000 --- a/CHANGELOG-podcast-system.md +++ /dev/null @@ -1,82 +0,0 @@ -# Podcast Generation System & TTS Worker Fixes - -- Added `{{@Podcast:}}` tag for AI-powered podcast generation from any document content -- Built multi-speaker script generation with configurable styles (debate, interview, chat, lecture, storytelling) -- Integrated Kokoro TTS multi-speaker synthesis with voice pre-fetching and per-chunk progress -- Created podcast marketplace UI with pre-built podcast templates and search/filter -- Added podcast template system with 15+ curated templates across tech, science, business categories -- Built WAV audio creation from Float32Array TTS output with download support -- Added real-time podcast generation progress UI with phase indicators (research โ†’ script โ†’ audio โ†’ done) -- Extracted `processMultiSegments()` as standalone async function in TTS worker for reliable synthesis -- Fixed: TTS worker message delivery bug โ€” bundled segments with `init` message to process in same handler execution -- Fixed: Service worker cache-first strategy serving stale `tts-worker.js` โ€” added cache-busting `?v=` param -- Fixed: Worker files now excluded from service worker `shouldCacheResponse()` caching -- Bumped service worker cache version from `v2` โ†’ `v3` to force cache invalidation -- Added `worker.onerror` handler on main thread to catch worker-level errors -- Added TTS worker version identifier (`TTS_WORKER_VERSION`) with startup logging -- Added `_pendingMultiSegments` backup mechanism in `status: ready` handler -- Added per-chunk 90s timeout via `Promise.race` to prevent infinite synthesis hangs -- Added event loop yields (`setTimeout(0)`) between WASM calls so `postMessage` flushes -- Added voice pre-fetch phase before synthesis to separate network vs WASM issues -- Added heartbeat logger (10s interval) during multi-speaker synthesis -- Added detailed timestamped logging across `textToSpeech.js`, `tts-worker.js`, `podcast-docgen.js` -- Added help mode entries for podcast generation feature -- Added podcast renderer integration in `renderer.js` for `{{@Podcast:}}` tag processing - ---- - -## Summary -Complete podcast generation system: users write `{{@Podcast: topic}}` in any document and get an AI-generated multi-speaker podcast with web research, script writing, and Kokoro TTS audio synthesis. Also fixed a critical TTS worker bug where the service worker's cache-first strategy served stale worker code, and the Web Worker silently dropped `speak-multi` messages sent after the async `init` handler completed. - ---- - -## 1. Podcast Document Generator (`{{@Podcast:}}` Tag) -**Files:** `js/podcast-docgen.js`, `css/podcast-docgen.css` -**What:** New IIFE component that intercepts `{{@Podcast: topic}}` tags in rendered markdown. Performs 3-phase generation: (1) web search research via Jina API, (2) AI script generation with `[Speaker]` markers, (3) Kokoro TTS multi-speaker audio synthesis. Includes `parseScript()` for speaker segmentation, `createWavBlob()` for audio encoding, and real-time progress UI with phase indicators. -**Impact:** Users can generate full podcast episodes from any topic directly in their documents โ€” no external tools needed. - -## 2. Podcast Marketplace -**Files:** `js/podcast-marketplace.js`, `css/podcast-marketplace.css`, `js/templates/podcasts.js` -**What:** Built a browsable marketplace UI with 15+ curated podcast templates across categories (Tech, Science, Business, Creative, Education). Includes search/filter, category tabs, template cards with metadata (duration, speakers, style), and one-click generation. Templates define speaker count, style, custom prompts, and voice assignments. -**Impact:** Users can browse and generate podcasts from pre-built templates without writing prompts. - -## 3. TTS Worker Multi-Speaker Fix (Critical Bug) -**Files:** `js/tts-worker.js`, `js/textToSpeech.js` -**What:** The Web Worker silently dropped `speak-multi` messages sent after the async `init` handler completed. Root cause: service worker served cached `tts-worker.js` via cache-first strategy, AND the worker couldn't reliably process a second `postMessage` after `init`. Fix: (1) extracted `processMultiSegments()` as standalone function, (2) bundled segments with `init` message via `pendingSegments` field, (3) worker processes segments inline at end of init handler, (4) added cache-busting `?v=` param to worker URL, (5) added `_pendingMultiSegments` backup dispatch from `status: ready` handler. -**Impact:** Podcast TTS synthesis now works reliably โ€” previously it hung forever after model loaded. - -## 4. Service Worker Cache Fix -**Files:** `sw.js` -**What:** Bumped `CACHE_NAME` from `textagent-v2` to `textagent-v3` to invalidate stale caches. Added exclusion for `*worker*` files in `shouldCacheResponse()` so worker JS is always fetched fresh. This prevents the cache-first strategy from serving outdated worker code. -**Impact:** Future worker code changes take effect immediately without manual cache clearing. - -## 5. TTS Synthesis Robustness -**Files:** `js/tts-worker.js`, `js/textToSpeech.js` -**What:** Added per-chunk 90s timeout (`Promise.race`), event loop yields between WASM calls (`await setTimeout(0)`), voice pre-fetch phase, heartbeat logger, `worker.onerror` handler, and version stamping. Failed chunks are skipped gracefully instead of aborting the entire podcast. -**Impact:** Audio synthesis is more resilient โ€” provides real-time progress, detects hangs, and degrades gracefully on failures. - -## 6. Integration & UI Updates -**Files:** `index.html`, `js/renderer.js`, `js/templates.js`, `js/modal-templates.js`, `js/help-mode.js`, `src/main.js` -**What:** Added podcast module imports in `main.js`, podcast tag processing in `renderer.js`, marketplace modal in `modal-templates.js`, help entries in `help-mode.js`, and toolbar button in `templates.js`. Updated `index.html` with podcast CSS imports. -**Impact:** Podcast features are fully integrated into the TextAgent UI with discoverable entry points. - ---- - -## Files Changed (14 total) - -| File | Lines Changed | Type | -|------|:---:|------| -| `js/podcast-docgen.js` | +1046 | New: podcast generation engine | -| `js/podcast-marketplace.js` | +923 | New: marketplace UI | -| `css/podcast-marketplace.css` | +730 | New: marketplace styles | -| `css/podcast-docgen.css` | +406 | New: podcast player styles | -| `js/templates/podcasts.js` | +279 | New: podcast templates | -| `js/textToSpeech.js` | +205 โˆ’30 | Multi-speaker fix, worker caching, error handling | -| `js/tts-worker.js` | +203 โˆ’0 | processMultiSegments, bundled init, version stamp | -| `index.html` | +30 โˆ’23 | CSS imports, podcast integration | -| `js/renderer.js` | +12 โˆ’1 | Podcast tag processing | -| `js/help-mode.js` | +11 | Podcast help entries | -| `src/main.js` | +9 | Module imports | -| `js/templates.js` | +4 โˆ’1 | Toolbar button | -| `sw.js` | +3 โˆ’1 | Cache version bump, worker exclusion | -| `js/modal-templates.js` | +1 | Marketplace modal | diff --git a/CHANGELOG-share-link-loader.md b/CHANGELOG-share-link-loader.md deleted file mode 100644 index 2418c23..0000000 --- a/CHANGELOG-share-link-loader.md +++ /dev/null @@ -1,38 +0,0 @@ -# Share Link Loading Overlay โ€” Eliminate Flash of Bare UI - -- Added full-screen loading overlay (`#share-loading-overlay`) that shows instantly when a share URL is detected -- Overlay activates before any JS or CSS loads via inline ` + + +\`\`\` +` + } +]; diff --git a/src/main.js b/src/main.js index df21e9c..cf1c63f 100644 --- a/src/main.js +++ b/src/main.js @@ -190,6 +190,7 @@ async function loadModules() { import('../js/templates/charts-parallel-gallery.js'), import('../js/templates/charts-graph-gallery.js'), import('../js/templates/podcasts.js'), + import('../js/templates/tools.js'), ]); await import('../js/templates.js'); diff --git a/tests/feature/calculator-tool.spec.js b/tests/feature/calculator-tool.spec.js new file mode 100644 index 0000000..1f79ecd --- /dev/null +++ b/tests/feature/calculator-tool.spec.js @@ -0,0 +1,274 @@ +// @ts-check +import { test, expect } from '@playwright/test'; + +/** + * Tools โ†’ Calculator template โ€” end-to-end behavioural tests. + * + * The calculator is shipped as a single `html-autorun` block inside the + * Tools template. We load the template content into the editor, wait for + * the renderer to mount it as a sandboxed iframe, then drive the buttons + * from inside that iframe and assert on the two-line display + history + * side panel. + */ + +const EDITOR_SELECTOR = '#markdown-editor'; + +test.describe('Tools โ€” Calculator template', () => { + test.setTimeout(60_000); + + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.waitForSelector(EDITOR_SELECTOR, { state: 'visible' }); + await page.waitForFunction(() => { + const w = /** @type {any} */ (window); + return w.MDView + && typeof w.MDView.openTemplateModal === 'function' + && Array.isArray(w.__MDV_TEMPLATES_TOOLS) + && w.__MDV_TEMPLATES_TOOLS.length > 0; + }, null, { timeout: 20_000 }); + + // Inject the Calculator template directly into the editor โ€” sidesteps + // the "discard current content?" confirm modal that the UI flow uses. + await page.evaluate(() => { + const w = /** @type {any} */ (window); + const tpl = w.__MDV_TEMPLATES_TOOLS[0]; + const editor = /** @type {HTMLTextAreaElement} */ ( + document.getElementById('markdown-editor') + ); + editor.value = tpl.content; + editor.dispatchEvent(new Event('input', { bubbles: true })); + if (w.MDView && typeof w.MDView.renderMarkdown === 'function') { + w.MDView.renderMarkdown(); + } + }); + + // Wait for the html-autorun iframe to mount and its script to wire up. + await page.waitForFunction(() => { + const iframe = /** @type {HTMLIFrameElement|null} */ ( + document.querySelector('.executable-html-container[data-autorun] iframe') + ); + if (!iframe || !iframe.contentDocument) return false; + return !!iframe.contentDocument.getElementById('result-line') + && !!iframe.contentDocument.querySelector('button[data-num="7"]'); + }, null, { timeout: 15_000 }); + }); + + /** Helper: run a sequence of button selectors inside the calculator iframe + * and return the {top, bot} display state after the last click. + */ + async function press(page, ...selectors) { + return await page.evaluate((sels) => { + const iframe = /** @type {HTMLIFrameElement} */ ( + document.querySelector('.executable-html-container[data-autorun] iframe') + ); + const idoc = /** @type {Document} */ (iframe.contentDocument); + for (const s of sels) { + const btn = /** @type {HTMLButtonElement} */ (idoc.querySelector(s)); + btn.click(); + } + return { + top: /** @type {HTMLElement} */ (idoc.getElementById('expr-line')).textContent, + bot: /** @type {HTMLElement} */ (idoc.getElementById('result-line')).textContent, + }; + }, selectors); + } + + async function clearAll(page) { + await page.evaluate(() => { + const iframe = /** @type {HTMLIFrameElement} */ ( + document.querySelector('.executable-html-container[data-autorun] iframe') + ); + const idoc = /** @type {Document} */ (iframe.contentDocument); + /** @type {HTMLButtonElement} */ (idoc.querySelector('button[data-act="clear"]')).click(); + const clr = idoc.getElementById('clear-h'); + if (clr) /** @type {HTMLButtonElement} */ (clr).click(); + }); + } + + test('chains operands but only collapses to result on =', async ({ page }) => { + await clearAll(page); + // 33 + 43 + 56 + 10 = + const out = await press(page, + 'button[data-num="3"]', 'button[data-num="3"]', + 'button[data-op="+"]', + 'button[data-num="4"]', 'button[data-num="3"]', + 'button[data-op="+"]', + 'button[data-num="5"]', 'button[data-num="6"]', + 'button[data-op="+"]', + 'button[data-num="1"]', 'button[data-num="0"]', + 'button[data-act="eq"]', + ); + expect(out.bot).toBe('142'); + expect(out.top).toBe('33+43+56+10 ='); + }); + + test('BODMAS: multiplication binds tighter than addition', async ({ page }) => { + await clearAll(page); + // 2 + 3 * 4 = 14 (not 20) + const out = await press(page, + 'button[data-num="2"]', 'button[data-op="+"]', + 'button[data-num="3"]', 'button[data-op="*"]', + 'button[data-num="4"]', 'button[data-act="eq"]', + ); + expect(out.bot).toBe('14'); + }); + + test('BODMAS: brackets override precedence', async ({ page }) => { + await clearAll(page); + // (2 + 3) * 4 = 20 + const out = await press(page, + 'button[data-bracket="("]', + 'button[data-num="2"]', 'button[data-op="+"]', 'button[data-num="3"]', + 'button[data-bracket=")"]', + 'button[data-op="*"]', 'button[data-num="4"]', + 'button[data-act="eq"]', + ); + expect(out.bot).toBe('20'); + }); + + test('BODMAS: power outranks multiplication', async ({ page }) => { + await clearAll(page); + // 3 * 2 ^ 3 = 24, not (3*2)^3 = 216 + const out = await press(page, + 'button[data-num="3"]', 'button[data-op="*"]', + 'button[data-num="2"]', 'button[data-op="^"]', + 'button[data-num="3"]', 'button[data-act="eq"]', + ); + expect(out.bot).toBe('24'); + }); + + test('paste evaluates a full BODMAS expression', async ({ page }) => { + await clearAll(page); + const out = await page.evaluate(() => { + const iframe = /** @type {HTMLIFrameElement} */ ( + document.querySelector('.executable-html-container[data-autorun] iframe') + ); + const idoc = /** @type {Document} */ (iframe.contentDocument); + const win = /** @type {Window} */ (iframe.contentWindow); + const resultLine = /** @type {HTMLElement} */ (idoc.getElementById('result-line')); + const ev = new win.Event('paste', { bubbles: true, cancelable: true }); + Object.defineProperty(ev, 'clipboardData', { + value: { getData: () => '(2+3)^2*4' }, + }); + resultLine.dispatchEvent(ev); + return { + top: /** @type {HTMLElement} */ (idoc.getElementById('expr-line')).textContent, + bot: resultLine.textContent, + }; + }); + expect(out.bot).toBe('100'); + }); + + test('paste rejects unsafe input (no eval injection)', async ({ page }) => { + await clearAll(page); + const bot = await page.evaluate(() => { + const iframe = /** @type {HTMLIFrameElement} */ ( + document.querySelector('.executable-html-container[data-autorun] iframe') + ); + const idoc = /** @type {Document} */ (iframe.contentDocument); + const win = /** @type {Window} */ (iframe.contentWindow); + const resultLine = /** @type {HTMLElement} */ (idoc.getElementById('result-line')); + const ev = new win.Event('paste', { bubbles: true, cancelable: true }); + Object.defineProperty(ev, 'clipboardData', { + value: { getData: () => 'alert(1)' }, + }); + resultLine.dispatchEvent(ev); + return resultLine.textContent; + }); + expect(bot).toBe('Error'); + }); + + test('editing the expression line re-evaluates and updates the result', async ({ page }) => { + await clearAll(page); + // First build (2+3)^2*4 = 100 + await press(page, + 'button[data-bracket="("]', + 'button[data-num="2"]', 'button[data-op="+"]', 'button[data-num="3"]', + 'button[data-bracket=")"]', + 'button[data-op="^"]', 'button[data-num="2"]', + 'button[data-op="*"]', 'button[data-num="4"]', + 'button[data-act="eq"]', + ); + + // Edit the small expression line to 100/(4+1) and blur โ€” bottom should become 20 + const after = await page.evaluate(() => { + const iframe = /** @type {HTMLIFrameElement} */ ( + document.querySelector('.executable-html-container[data-autorun] iframe') + ); + const idoc = /** @type {Document} */ (iframe.contentDocument); + const win = /** @type {Window} */ (iframe.contentWindow); + const expr = /** @type {HTMLElement} */ (idoc.getElementById('expr-line')); + expr.focus(); + expr.textContent = '100/(4+1)'; + expr.dispatchEvent(new win.FocusEvent('blur', { bubbles: true })); + return { + top: expr.textContent, + bot: /** @type {HTMLElement} */ (idoc.getElementById('result-line')).textContent, + }; + }); + expect(after.top).toBe('100/(4+1) ='); + expect(after.bot).toBe('20'); + }); + + test('editing a history row re-evaluates and pushes result back to display', async ({ page }) => { + await clearAll(page); + // Build 7 * 8 = 56 so we have one history row + await press(page, + 'button[data-num="7"]', 'button[data-op="*"]', 'button[data-num="8"]', + 'button[data-act="eq"]', + ); + + // Edit the row's expression to "9*9" and confirm the display becomes 81 + const after = await page.evaluate(() => { + const iframe = /** @type {HTMLIFrameElement} */ ( + document.querySelector('.executable-html-container[data-autorun] iframe') + ); + const idoc = /** @type {Document} */ (iframe.contentDocument); + const win = /** @type {Window} */ (iframe.contentWindow); + const row = /** @type {HTMLElement} */ (idoc.querySelector('#hist-list .row')); + const exprCell = /** @type {HTMLElement} */ (row.querySelector('.expr')); + exprCell.focus(); + exprCell.textContent = '9*9'; + exprCell.dispatchEvent(new win.FocusEvent('blur', { bubbles: true })); + return /** @type {HTMLElement} */ (idoc.getElementById('result-line')).textContent; + }); + expect(after).toBe('81'); + }); + + test('backspace cancels a pending operator', async ({ page }) => { + await clearAll(page); + // Press 5, then + โ€” pending operator, display shows 5 + await press(page, 'button[data-num="5"]', 'button[data-op="+"]'); + // Press โŒซ โ€” operator should be cancelled, display back to 5 with no chain + const out = await press(page, 'button[data-act="back"]'); + expect(out.bot).toBe('5'); + expect(out.top).toBe(''); + }); + + test('AC clears state and history (button-driven Clear)', async ({ page }) => { + await clearAll(page); + await press(page, + 'button[data-num="2"]', 'button[data-op="+"]', 'button[data-num="3"]', + 'button[data-act="eq"]', + ); + await page.evaluate(() => { + const iframe = /** @type {HTMLIFrameElement} */ ( + document.querySelector('.executable-html-container[data-autorun] iframe') + ); + const idoc = /** @type {Document} */ (iframe.contentDocument); + /** @type {HTMLButtonElement} */ (idoc.querySelector('button[data-act="clear"]')).click(); + }); + const state = await page.evaluate(() => { + const iframe = /** @type {HTMLIFrameElement} */ ( + document.querySelector('.executable-html-container[data-autorun] iframe') + ); + const idoc = /** @type {Document} */ (iframe.contentDocument); + return { + top: /** @type {HTMLElement} */ (idoc.getElementById('expr-line')).textContent, + bot: /** @type {HTMLElement} */ (idoc.getElementById('result-line')).textContent, + }; + }); + expect(state.bot).toBe('0'); + expect(state.top).toBe(''); + }); +}); diff --git a/tests/feature/template-loading.spec.js b/tests/feature/template-loading.spec.js index 6c9a689..bc42a71 100644 --- a/tests/feature/template-loading.spec.js +++ b/tests/feature/template-loading.spec.js @@ -101,6 +101,32 @@ test.describe('Template System', () => { expect(filteredCount).toBeGreaterThanOrEqual(1); }); + test('tools category pill exists and Calculator template loads', async ({ page }) => { + await page.evaluate(() => { + /** @type {any} */ (window).MDView.openTemplateModal(); + }); + await page.waitForTimeout(500); + + const toolsPillExists = await page.evaluate(() => { + return !!document.querySelector('#template-categories .template-cat-btn[data-category="tools"]'); + }); + expect(toolsPillExists).toBe(true); + + await page.evaluate(() => { + const btn = /** @type {HTMLButtonElement} */ ( + document.querySelector('#template-categories .template-cat-btn[data-category="tools"]') + ); + btn.click(); + }); + await page.waitForTimeout(400); + + const calcCount = await page.evaluate(() => { + const cards = document.querySelectorAll('#template-modal .template-card'); + return Array.from(cards).filter((c) => /Calculator/i.test(c.textContent || '')).length; + }); + expect(calcCount).toBeGreaterThanOrEqual(1); + }); + test('closeTemplateModal closes the modal', async ({ page }) => { await page.evaluate(() => { /** @type {any} */ (window).MDView.openTemplateModal();