import assert from 'node:assert/strict'; import { createServer } from 'node:http'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { spawn } from 'node:child_process'; import { delay, evaluate, launchChromium } from './raw-cdp-browser.mjs'; const host = '127.0.0.1'; const appRoot = resolve(import.meta.dirname, '..'); const viteEntrypoint = resolve(appRoot, 'node_modules/vite/bin/vite.js'); const basePath = process.env.CARTESIUM_BASE_PATH ?? ''; const calculator2dPath = `${basePath}/calc/2d`; const calculator3dPath = `${basePath}/calc/3d`; const browserProfile = await mkdtemp(join(tmpdir(), 'cartesium-visual-editor-')); let vite; let chromium; let browser; try { const port = await findFreePort(); const origin = `http://${host}:${port}`; vite = spawn( process.execPath, [viteEntrypoint, 'dev', '--host', host, '--port', String(port), '--strictPort'], { cwd: appRoot, stdio: ['ignore', 'pipe', 'pipe'] } ); const viteOutput = []; collectOutput(vite, viteOutput); await waitForServer(`${origin}${calculator2dPath}`, vite, viteOutput); mark('Vite ready'); const launched = await launchChromium(browserProfile); mark('Chromium ready'); chromium = launched.process; browser = launched.browser; const { targetId } = await browser.command('Target.createTarget', { url: 'about:blank' }); const { sessionId } = await browser.command('Target.attachToTarget', { targetId, flatten: true }); const command = (method, params = {}) => browser.command(method, params, sessionId); const browserErrors = []; const browserDialogs = []; browser.on('Runtime.exceptionThrown', (params, sourceSessionId) => { if (sourceSessionId === sessionId) browserErrors.push(params.exceptionDetails?.text ?? 'Browser exception'); }); browser.on('Runtime.consoleAPICalled', (params, sourceSessionId) => { if (sourceSessionId === sessionId && params.type === 'error') { browserErrors.push(params.args?.map(argument => argument.value ?? argument.description).join(' ') ?? 'Console error'); } }); browser.on('Page.javascriptDialogOpening', (params, sourceSessionId) => { if (sourceSessionId !== sessionId) return; browserDialogs.push(params.type); void command('Page.handleJavaScriptDialog', { accept: true }); }); await command('Page.enable'); await command('Runtime.enable'); await command('Emulation.setDeviceMetricsOverride', { width: 1320, height: 900, deviceScaleFactor: 1, mobile: false }); await command('Page.navigate', { url: `${origin}${calculator2dPath}` }); await waitForEvaluation(command, `!!document.querySelector('button[aria-label="Expression UI view"]')`, 'calculator'); await evaluate(command, `window.confirm = () => true`); mark('calculator loaded'); await evaluate(command, `(() => { const button = [...document.querySelectorAll('.document-actions button')] .find(candidate => candidate.textContent.trim() === 'New'); button?.click(); })()`); mark('new graph selected'); const blankState = await waitForEvaluation(command, `(() => { const row = document.querySelector('[data-expression-id]'); const editor = row?.querySelector('[aria-label="Expression 1, visual math"]'); return { visual: Boolean(editor?.matches('.mq-editable-field')), rows: [...document.querySelectorAll('[data-expression-id]')].map(item => ({ id: item.getAttribute('data-expression-id'), text: item.innerText.slice(0, 500), labels: [...item.querySelectorAll('[aria-label]')].map(element => element.getAttribute('aria-label')), fallbacks: [...item.querySelectorAll('.math-fallback')].map(element => ({ value: element.value, label: element.getAttribute('aria-label') })) })), assets: performance.getEntriesByType('resource').filter(item => item.name.includes('mathquill')).map(item => item.name), url: location.href }; })()`, 'blank visual expression editor', 30_000, state => state.visual); const expectedMathQuillPrefix = `${origin}${basePath}/mathquill/`; assert(blankState.assets.some(url => url.startsWith(expectedMathQuillPrefix)), `MathQuill assets must load below the configured base path (${expectedMathQuillPrefix})`); mark('blank MathQuill ready'); await clickSelector(command, '[aria-label="Expression 1, visual math"].mq-editable-field'); await typeText(command, 'y=x^2'); const initialLatex = await inspectLatex(command, 1); assert.equal(compactLatex(initialLatex), 'y=x^2', 'real MathQuill typing must create the intended graph expression'); mark('MathQuill typed expression'); await clickViewButton(command, 'Visual'); await clickSelector(command, '[aria-label="Expression 1, visual math"].mq-editable-field'); await pressKey(command, 'End'); await typeText(command, '+1'); const afterTyping = await inspectLatex(command, 1); assert.equal(compactLatex(afterTyping), 'y=x^2+1', 'typing after a row update must retain the expression'); mark('MathQuill update persisted through Svelte'); await pressChord(command, ['Control', 'z']); assert.equal(compactLatex(await inspectLatex(command, 1)), 'y=x^2+', 'Ctrl-Z must undo the last visual edit'); await pressChord(command, ['Control', 'z']); assert.equal(compactLatex(await inspectLatex(command, 1)), 'y=x^2', 'a second Ctrl-Z must undo the previous visual edit'); await pressChord(command, ['Control', 'Shift', 'z']); assert.equal(compactLatex(await inspectLatex(command, 1)), 'y=x^2+', 'Ctrl-Shift-Z must redo visual typing'); await pressChord(command, ['Control', 'Shift', 'z']); assert.equal(compactLatex(await inspectLatex(command, 1)), 'y=x^2+1', 'a second Ctrl-Shift-Z must restore visual typing'); mark('visual undo and redo'); await clickViewButton(command, 'Visual'); await clickSelector(command, '[aria-label="Expression 1, visual math"].mq-editable-field'); await pressKey(command, 'End'); await pressKey(command, 'Backspace'); await waitForEvaluation(command, `!!document.querySelector('[data-expression-id] .draft-message')`, 'incomplete-expression draft diagnostic'); await typeText(command, '1/2'); await pressKey(command, 'Tab'); const fractionLatex = await inspectLatex(command, 1); assert(/\\frac\{1\}\{2\}/.test(fractionLatex), 'typing slash and Tab must create a fraction in MathQuill'); mark('fraction typing and Tab navigation'); const completedState = await waitForEvaluation(command, `({ diagnostic: document.querySelector('[data-expression-id="expr-1"] .diagnostic')?.textContent ?? '', draft: Boolean(document.querySelector('[data-expression-id="expr-1"] .draft-message')), source: window.MathQuill?.getInterface(3)(document.querySelector('[aria-label="Expression 1, visual math"].mq-editable-field'))?.latex() ?? '' })`, 'completed visual input', 15_000, state => !state.diagnostic && !state.draft); assert.equal(compactLatex(completedState.source), compactLatex(fractionLatex), 'completed visual input must clear its engine diagnostic without changing the source'); await clickViewButton(command, 'Text'); const sourceViewValue = await evaluate(command, `document.querySelector('textarea[aria-label="All graph expressions"]')?.value`); assert.match(sourceViewValue, /^y=.*x.*\^.*2.*\+.*1.*\/.*2/, 'TXT must expose the canonical ASCII expression'); await clickViewButton(command, 'Visual'); await waitForEvaluation(command, `!!document.querySelector('[aria-label="Expression 1, visual math"].mq-editable-field')`, 'visual editor after global TXT view'); assert.equal(compactLatex(await inspectLatex(command, 1)), compactLatex(fractionLatex), 'switching global views must retain the same visual presentation'); await evaluate(command, `document.querySelector('button.add-expression')?.click()`); await waitForEvaluation(command, `!!document.querySelector('[aria-label="Expression 2, visual math"].mq-editable-field')`, 'second visual expression editor'); await clickSelector(command, '[aria-label="Expression 2, visual math"].mq-editable-field'); await typeText(command, '#22'); await typeText(command, '1'); await pressKey(command, 'Tab'); await typeText(command, '2'); await pressKey(command, 'Tab'); await typeText(command, '3'); await pressKey(command, 'Tab'); await typeText(command, '4'); await waitForEvaluation(command, `!!document.querySelector('.mq-matrix-resize-handle')`, 'matrix resize handle'); await evaluate(command, `document.querySelector('.mq-matrix-resize-handle').focus()`); await pressKey(command, 'ArrowDown'); await pressKey(command, 'ArrowRight'); await pressKey(command, 'Escape'); const emptyMatrixCellPoints = await evaluate(command, `(() => [...document.querySelectorAll( '[aria-label="Expression 2, visual math"].mq-editable-field .mq-matrix td' )].filter(cell => !cell.textContent.trim()).map(cell => { const rect = cell.getBoundingClientRect(); return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }; }))()`); assert.equal(emptyMatrixCellPoints.length, 5, 'resizing the matrix must create five editable empty cells'); for (const [index, point] of emptyMatrixCellPoints.entries()) { await clickPoint(command, point); await typeText(command, String(index + 5)); } const matrixLatex = await inspectLatex(command, 2); const matrixState = await waitForEvaluation(command, `(() => { const row = document.querySelectorAll('[data-expression-id]')[1]; return { draft: row.querySelector('.draft-message')?.textContent ?? '', cells: [...row.querySelectorAll('.mq-matrix td')].map(cell => cell.textContent), latex: window.MathQuill.getInterface(3)(row.querySelector('.mq-editable-field')).latex() }; })()`, 'settled matrix input', 15_000, state => !state.draft.includes('Checking')); assert.equal(matrixState.draft, '', `matrix cells must all be complete: ${JSON.stringify(matrixState)}`); assert(matrixLatex.includes('\\begin{bmatrix}'), '#22 must create a MathQuill matrix'); assert((matrixLatex.match(/\\\\/g) ?? []).length >= 2, 'resize handle must add a matrix row'); assert((matrixLatex.match(/&/g) ?? []).length >= 6, 'resize handle must expand matrix columns'); mark('matrix insertion and resizing'); await evaluate(command, `document.querySelector('button.add-expression')?.click()`); await waitForEvaluation(command, `!!document.querySelector('[aria-label="Expression 3, visual math"].mq-editable-field')`, 'third visual expression editor'); await clickViewButton(command, 'Text'); await waitForEvaluation(command, `!!document.querySelector('textarea[aria-label="All graph expressions"]')`, 'all-expression batch editor'); await evaluate(command, `(() => { const editor = document.querySelector('textarea[aria-label="All graph expressions"]'); editor.value = editor.value ? editor.value + ';\\n\\na := 2' : 'a := 2'; editor.dispatchEvent(new Event('input', { bubbles: true })); })()`); const fullBatch = await evaluate(command, `document.querySelector('textarea[aria-label="All graph expressions"]').value`); assert.equal(fullBatch.split(';\n\n').length, 3, 'batch text view must include every expression row'); assert.match(fullBatch, /y\s*=\s*x\^\(?2\)?\+.*1.*\/.*2/, 'batch text view must include the first expression in ASCII'); assert.match(fullBatch, /\[\s*1\s+2\s+5;\s*3\s+4\s+6;\s*7\s+8\s+9\s*\]/, 'batch text view must include the visual matrix as ASCII'); assert.match(fullBatch, /a\s*:?=\s*2/, 'batch text view must include the text-edited expression'); mark('all-expression batch view'); await setTextareaValue(command, 'textarea[aria-label="All graph expressions"]', fullBatch.replace(/a\s*:?=\s*2/, 'a := 3')); await evaluate(command, `document.querySelector('button[aria-label="Expression UI view"]')?.click()`); await waitForEvaluation(command, `!!document.querySelector('[aria-label="Expression 1, visual math"].mq-editable-field')`, 'restored visual rows after batch editing'); assert.equal(await evaluate(command, `document.querySelector('[aria-label="Expression 1, visual math"]') !== null && document.querySelector('[aria-label="Expression 2, visual math"]') !== null`), true, 'batch editing must preserve neighboring visual rows'); mark('all-expression batch edit preserved visual rows'); const drawing = await waitForEvaluation(command, `(() => { const canvas = document.querySelector('.plot-canvas'); if (!canvas || canvas.classList.contains('initially-hidden')) return null; const pixels = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data; let curvePixels = 0; for (let index = 0; index < pixels.length; index += 4) { if (pixels[index + 3] && pixels[index + 2] > 120 && pixels[index + 2] > pixels[index + 1] * 1.15 && pixels[index + 1] > pixels[index] * 1.25) curvePixels += 1; } return { curvePixels, canvasClass: canvas.className, diagnostic: document.querySelector('[data-expression-id="expr-1"] .diagnostic')?.textContent ?? '' }; })()`, 'WASM-rendered visual graph', 30_000, state => Boolean(state && state.curvePixels > 10 && !state.diagnostic)); assert(drawing.curvePixels > 10, 'the visual expression must reach the WASM scene and draw a curve'); mark('WASM graph rendered'); await evaluate(command, `window.__visualEditorPromptCalls = 0; window.prompt = () => { window.__visualEditorPromptCalls += 1; return 'Visual editor integration test'; }`); await evaluate(command, `(() => { const button = [...document.querySelectorAll('.document-actions button')] .find(candidate => candidate.textContent.trim() === 'Save as'); button?.click(); })()`); const saveState = await waitForEvaluation(command, `(() => { const store = JSON.parse(localStorage.getItem('cartesium.documents') || '{}'); return { status: document.querySelector('.share-status')?.textContent?.trim() ?? '', promptCalls: window.__visualEditorPromptCalls, documents: store.documents?.map(item => ({ id: item.id, name: item.document?.snapshot?.name })) ?? [], buttons: [...document.querySelectorAll('.document-actions button')].map(button => button.textContent.trim()) }; })()`, 'Save As result', 20_000, state => Boolean(state.status)); assert.equal(saveState.status, 'Saved a new copy in this browser', `Save As must complete successfully: ${JSON.stringify(saveState)}`); assert.equal(saveState.promptCalls, 1, 'Save As must ask for a title before persisting the graph'); const savedDocumentId = saveState.documents.find(item => item.name === 'Visual editor integration test')?.id; assert(savedDocumentId, 'Save As must create a durable document id'); const savedModes = await evaluate(command, `(() => { const store = JSON.parse(localStorage.getItem('cartesium.documents') || '{}'); const saved = store.documents?.find(item => item.document?.snapshot?.name === 'Visual editor integration test'); return saved?.document?.snapshot?.expressions?.map(({ input_mode, source, editor_mode, visual_source, visual_source_for }) => ({ input_mode, source, editor_mode, visual_source, visual_source_for })); })()`); assert.deepEqual(savedModes?.map(({ input_mode }) => input_mode ?? 'text'), ['text', 'text', 'text'], 'new documents must persist in the canonical ASCII language'); assert.match(savedModes[0].source, /^y=.*x.*\^.*2.*\+.*1.*\/.*2/, 'save must retain evaluated ASCII source'); assert(savedModes[0].visual_source?.includes('\\frac{1}{2}'), 'save must retain visual presentation separately'); assert.equal(savedModes[0].visual_source_for, savedModes[0].source, 'visual presentation must be associated with the exact canonical source'); assert.match(savedModes[1].source, /\[\s*1\s+2\s+5\s*;/, 'save must retain the matrix in ASCII source'); assert(savedModes[1].visual_source?.includes('\\begin{bmatrix}'), 'save must retain matrix LaTeX presentation'); assert.equal(savedModes[2].source, 'a := 3', 'batch editing must save text edits'); mark('saved canonical document with visual presentation'); await evaluate(command, `(() => { const store = JSON.parse(localStorage.getItem('cartesium.documents') || '{}'); const saved = store.documents?.find(item => item.document?.snapshot?.name === 'Visual editor integration test'); for (const expression of saved?.document?.snapshot?.expressions ?? []) expression.editor_mode = 'text'; localStorage.setItem('cartesium.documents', JSON.stringify(store)); })()`); await command('Page.navigate', { url: `${origin}${calculator2dPath}?openDocument=${encodeURIComponent(savedDocumentId)}` }); await waitForEvaluation(command, `!!document.querySelector('button[aria-label="Expression UI view"]')`, 'saved document reload'); const reloadedEditorState = await waitForEvaluation(command, `(() => { const row = document.querySelector('[data-expression-id="expr-1"]'); const matrixRow = document.querySelector('[data-expression-id="expr-2"]'); const host = row?.querySelector('[aria-label="Expression 1, visual math"]'); const matrixHost = matrixRow?.querySelector('[aria-label="Expression 2, visual math"]'); return { url: location.href, editable: Boolean(host?.matches('.mq-editable-field')), matrixEditable: Boolean(matrixHost?.matches('.mq-editable-field')), status: row?.querySelector('.editor-status')?.textContent?.trim() ?? '', matrixStatus: matrixRow?.querySelector('.editor-status')?.textContent?.trim() ?? '', fallback: row?.querySelector('.math-fallback')?.value ?? null, assets: performance.getEntriesByType('resource').filter(item => item.name.includes('mathquill')).map(item => item.name), rows: [...document.querySelectorAll('[data-expression-id]')].map(item => ({ id: item.getAttribute('data-expression-id'), labels: [...item.querySelectorAll('[aria-label]')].map(element => element.getAttribute('aria-label')), text: item.innerText.slice(0, 300) })) }; })()`, 'saved document MathQuill reload', 30_000, state => state.editable && state.matrixEditable && !state.matrixStatus); const reloaded = await evaluate(command, `({ rows: [...document.querySelectorAll('[data-expression-id]')].map(row => ({ id: row.getAttribute('data-expression-id'), visual: Boolean(row.querySelector('[aria-label$="visual math"]')?.matches('.mq-editable-field')), visualLatex: window.MathQuill?.getInterface(3)(row.querySelector('[aria-label$="visual math"]'))?.latex() ?? null, })), batchView: document.querySelector('textarea[aria-label="All graph expressions"]')?.value ?? null })`); assert.equal(reloaded.rows.length, 3, 'reload must restore all saved expression rows'); assert.equal(reloaded.rows.every(row => row.visual), true, 'saved editor_mode text preferences must still open every row visually'); assert.match(reloaded.rows[0].visualLatex, /\\frac\{1\}\{2\}/, 'reload must restore the saved visual presentation for the first row'); mark('saved canonical document reloaded'); // A real MathQuill bmatrix inequality must lower to canonical matrix ASCII, // render through the browser Wasm scene builder, and survive save/reopen. await evaluate(command, `([...document.querySelectorAll('.document-actions button')] .find(button => button.textContent.trim() === 'New'))?.click()`); await waitForEvaluation(command, `!!document.querySelector('[aria-label="Expression 1, visual math"].mq-editable-field')`, 'new bmatrix inequality graph'); const bmatrixLatex = String.raw`\begin{bmatrix}1&2\\3&4\end{bmatrix}\cdot\begin{bmatrix}x\\y\end{bmatrix}\leq\begin{bmatrix}5\\4\end{bmatrix}`; const authoredBmatrixLatex = await evaluate(command, `(() => { const host = document.querySelector('[aria-label="Expression 1, visual math"].mq-editable-field'); const field = window.MathQuill.getInterface(3)(host); field.latex(${JSON.stringify(bmatrixLatex)}); field.typedText('+0'); field.keystroke('Backspace'); field.keystroke('Backspace'); return field.latex(); })()`); assert(authoredBmatrixLatex.includes('\\begin{bmatrix}'), 'the actual MathQuill field must retain the authored bmatrix inequality'); await clickViewButton(command, 'Text'); const bmatrixSource = await waitForEvaluation(command, `document.querySelector('textarea[aria-label="All graph expressions"]')?.value ?? null`, 'bmatrix inequality canonical ASCII', 30_000, Boolean); assert((bmatrixSource.match(/;/g) ?? []).length >= 3, `the visual writer must preserve all matrix row boundaries: ${bmatrixSource}`); assert(!bmatrixSource.includes(','), `the visual writer must not coerce the column matrix to a vector: ${bmatrixSource}`); await clickViewButton(command, 'Visual'); const bmatrixEditor = await waitForEvaluation(command, `(() => { const row = document.querySelector('[data-expression-id="expr-1"]'); return { latex: window.MathQuill.getInterface(3)(row.querySelector('.mq-editable-field'))?.latex() ?? '', diagnostic: row.querySelector('.diagnostic')?.textContent ?? '', draft: row.querySelector('.draft-message')?.textContent ?? '', status: row.querySelector('.editor-status')?.textContent ?? '' }; })()`, 'valid bmatrix visual editor', 30_000, state => state.latex.includes('\\begin{bmatrix}') && !state.diagnostic && !state.draft && !state.status); assert.match(bmatrixEditor.latex, /\\le(?:q)?/); const regionProbe = await evaluate(command, `import(${JSON.stringify(`${basePath}/src/lib/engine/cartesium.js`)}).then(async ({ buildScene }) => { const viewport = { x_min: -10, x_max: 10, y_min: -10, y_max: 10, width: 240, height: 240 }; const build = (source, input_mode = 'text') => buildScene({ viewport, expressions: [{ id: 'expr-1', input_mode, source, enabled: true, color: null }], variable_overrides: {} }); const matrixScene = await build(${JSON.stringify(bmatrixSource)}); const latexScene = await build(${JSON.stringify(bmatrixLatex)}, 'latex'); const vectorScene = await build('[1 2; 3 4] * [x, y] <= [5, 4]'); const matrixFill = matrixScene.layers.find(layer => layer.type === 'fill'); const latexFill = latexScene.layers.find(layer => layer.type === 'fill'); const vectorFill = vectorScene.layers.find(layer => layer.type === 'fill'); const polygonsFor = (scene, fill) => { const packed = scene.packed_geometry?.polygons?.find(item => item.geometryId === fill?.geometry_id); if (!packed) return fill?.polygons ?? []; let start = 0; return Array.from(packed.breaks, end => { const polygon = []; for (let index = start; index < end; index += 1) { polygon.push([packed.coordinates[index * 2], packed.coordinates[index * 2 + 1]]); } start = end; return polygon; }); }; const matrixPolygons = polygonsFor(matrixScene, matrixFill); const latexPolygons = polygonsFor(latexScene, latexFill); const vectorPolygons = polygonsFor(vectorScene, vectorFill); const contains = (polygon, point) => { let inside = false; for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { const [xi, yi] = polygon[i], [xj, yj] = polygon[j]; if ((yi > point[1]) !== (yj > point[1]) && point[0] < (xj - xi) * (point[1] - yi) / (yj - yi) + xi) inside = !inside; } return inside; }; return { matrixDiagnostics: matrixScene.diagnostics, latexDiagnostics: latexScene.diagnostics, vectorDiagnostics: vectorScene.diagnostics, matrixFillColor: matrixFill?.color ?? null, matrixPolygons, matrixRectangles: matrixFill?.rectangles ?? [], latexPolygons, latexRectangles: latexFill?.rectangles ?? [], vectorPolygons, vectorRectangles: vectorFill?.rectangles ?? [], insideOrigin: Boolean(matrixPolygons.some(polygon => contains(polygon, [0, 0])) || matrixFill?.rectangles?.some(([xMin, xMax, yMin, yMax]) => xMin <= 0 && 0 <= xMax && yMin <= 0 && 0 <= yMax)), outsidePoint: Boolean(matrixPolygons.some(polygon => contains(polygon, [4, 2])) || matrixFill?.rectangles?.some(([xMin, xMax, yMin, yMax]) => xMin <= 4 && 4 <= xMax && yMin <= 2 && 2 <= yMax)) }; })`); assert.deepEqual(regionProbe.matrixDiagnostics, [], 'the canonical bmatrix expression must reach Wasm without diagnostics'); assert.deepEqual(regionProbe.latexDiagnostics, [], 'the authored MathQuill LaTeX expression must reach Wasm without diagnostics'); assert.deepEqual(regionProbe.vectorDiagnostics, [], 'the equivalent vector expression must reach Wasm without diagnostics'); assert.deepEqual(regionProbe.matrixPolygons, regionProbe.vectorPolygons, 'bmatrix and vector spellings must produce the same feasible filled region'); assert.deepEqual(regionProbe.matrixPolygons, regionProbe.latexPolygons, 'canonical ASCII and authored LaTeX must produce the same feasible filled region'); assert.deepEqual(regionProbe.matrixRectangles, regionProbe.vectorRectangles, 'bmatrix and vector spellings must produce the same sampled feasible fill'); assert.deepEqual(regionProbe.matrixRectangles, regionProbe.latexRectangles, 'canonical ASCII and authored LaTeX must produce the same sampled feasible fill'); assert(regionProbe.matrixPolygons.length > 0 || regionProbe.matrixRectangles.length > 0, `the Wasm scene must contain a feasible-region fill: ${JSON.stringify({ bmatrixSource, regionProbe })}`); assert.equal(regionProbe.insideOrigin, true, 'the Wasm fill must contain the feasible origin'); assert.equal(regionProbe.outsidePoint, false, 'the Wasm fill must exclude an infeasible sample'); const paintedBmatrix = await waitForEvaluation(command, `(() => { const canvas = document.querySelector('.plot-canvas'); if (!canvas || canvas.classList.contains('initially-hidden')) return null; const pixels = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data; const hex = ${JSON.stringify(regionProbe.matrixFillColor)}?.match(/^#([0-9a-f]{6})$/i)?.[1]; if (!hex) return { fillPixels: 0, fillColor: ${JSON.stringify(regionProbe.matrixFillColor)} }; const expected = [0, 2, 4].map(index => Number.parseInt(hex.slice(index, index + 2), 16)); let fillPixels = 0; for (let index = 0; index < pixels.length; index += 4) { if (pixels[index + 3] > 20 && Math.abs(pixels[index] - expected[0]) < 8 && Math.abs(pixels[index + 1] - expected[1]) < 8 && Math.abs(pixels[index + 2] - expected[2]) < 8) fillPixels += 1; } return { fillPixels, fillColor: ${JSON.stringify(regionProbe.matrixFillColor)}, width: canvas.width, height: canvas.height }; })()`, 'bmatrix inequality visible in graph canvas', 30_000, state => Boolean(state && state.fillPixels > 1000)); assert(paintedBmatrix.fillPixels > 1000, `the actual graph canvas must paint the bmatrix fill color over an area: ${JSON.stringify(paintedBmatrix)}`); mark('visual bmatrix inequality serialized, rendered, and matched the vector feasible region'); await evaluate(command, `window.__bmatrixSavePromptCalls = 0; window.prompt = () => { window.__bmatrixSavePromptCalls += 1; return 'Bmatrix inequality integration test'; }; [...document.querySelectorAll('.document-actions button')] .find(button => button.textContent.trim() === 'Save as')?.click()`); const savedBmatrix = await waitForEvaluation(command, `(() => { const store = JSON.parse(localStorage.getItem('cartesium.documents') || '{}'); const saved = store.documents?.find(item => item.document?.snapshot?.name === 'Bmatrix inequality integration test'); const expression = saved?.document?.snapshot?.expressions?.[0]; return { id: saved?.id ?? null, status: document.querySelector('.share-status')?.textContent?.trim() ?? '', promptCalls: window.__bmatrixSavePromptCalls, expression: expression ? { input_mode: expression.input_mode, source: expression.source, visual_source: expression.visual_source, visual_source_for: expression.visual_source_for } : null }; })()`, 'saved bmatrix inequality', 20_000, state => Boolean(state.id && state.status)); assert.equal(savedBmatrix.status, 'Saved a new copy in this browser'); assert.equal(savedBmatrix.promptCalls, 1); assert.equal(savedBmatrix.expression.input_mode ?? 'text', 'text', 'visual entry must persist as canonical ASCII input'); assert.equal(savedBmatrix.expression.source, bmatrixSource, 'save must retain the canonical matrix source exactly'); assert(savedBmatrix.expression.visual_source.includes('\\begin{bmatrix}'), 'save must retain the bmatrix visual presentation'); assert.equal(savedBmatrix.expression.visual_source_for, bmatrixSource, 'the visual presentation must remain tied to its canonical ASCII source'); await command('Page.navigate', { url: `${origin}${calculator2dPath}?openDocument=${encodeURIComponent(savedBmatrix.id)}` }); await waitForEvaluation(command, `!!document.querySelector('button[aria-label="Expression UI view"]')`, 'reopened bmatrix inequality'); const reopenedBmatrix = await waitForEvaluation(command, `(() => { const row = document.querySelector('[data-expression-id="expr-1"]'); if (!row || !window.MathQuill) return {latex:'',diagnostic:'',canvas:false}; return { latex: window.MathQuill.getInterface(3)(row.querySelector('.mq-editable-field'))?.latex() ?? '', diagnostic: row.querySelector('.diagnostic')?.textContent ?? '', canvas: Boolean(document.querySelector('.plot-canvas:not(.initially-hidden)')) }; })()`, 'reopened bmatrix editor and graph', 30_000, state => state.latex.includes('\\begin{bmatrix}') && !state.diagnostic && state.canvas); assert.match(reopenedBmatrix.latex, /\\le(?:q)?/); await clickViewButton(command, 'Text'); assert.equal(await evaluate(command, `document.querySelector('textarea[aria-label="All graph expressions"]')?.value`), bmatrixSource, 'reopened bmatrix expression must keep the same canonical ASCII source'); mark('saved bmatrix inequality reopened with its visual and canonical source intact'); // Invalid ASCII remains visible verbatim in UI and can be repaired in the // single global TXT editor without losing the user's source. await waitForEvaluation(command, `!!document.querySelector('textarea[aria-label="All graph expressions"]')`, 'global TXT editor for invalid source'); await setTextareaValue(command, 'textarea[aria-label="All graph expressions"]', 'y = @'); await clickViewButton(command, 'Visual'); const invalidPreview = await waitForEvaluation(command, `(() => { const preview = document.querySelector('[data-expression-id="expr-1"] .source-preview'); return { source: preview?.value ?? '', readOnly: preview?.getAttribute('aria-readonly') ?? '', label: preview?.getAttribute('aria-label') ?? '', diagnostic: document.querySelector('[data-expression-id="expr-1"] .status')?.textContent ?? '' }; })()`, 'invalid ASCII read-only preview', 15_000, state => state.source === 'y = @' && state.readOnly === 'true' && state.diagnostic); assert.match(invalidPreview.label, /original Cartesium source/); await clickViewButton(command, 'Text'); assert.equal(await evaluate(command, `document.querySelector('textarea[aria-label="All graph expressions"]')?.value`), 'y = @', 'TXT must retain invalid ASCII verbatim for recovery'); await setTextareaValue(command, 'textarea[aria-label="All graph expressions"]', 'y = x + 1'); await clickViewButton(command, 'Visual'); await waitForEvaluation(command, `!!document.querySelector('[aria-label="Expression 1, visual math"].mq-editable-field')`, 'repaired ASCII expression in visual editor'); mark('invalid ASCII preview and global TXT recovery'); // Supported ASCII entered in the batch editor must be immediately usable in // the visual editor, including graph forms that previously had no serializer. await evaluate(command, `(() => { [...document.querySelectorAll('.document-actions button')] .find(button => button.textContent.trim() === 'New')?.click(); })()`); await waitForEvaluation(command, `!!document.querySelector('[aria-label="Expression 1, visual math"].mq-editable-field')`, 'new ASCII-to-visual compatibility graph'); await evaluate(command, `document.querySelector('button[aria-label="Expression text view"]')?.click()`); await waitForEvaluation(command, `!!document.querySelector('textarea[aria-label="All graph expressions"]')`, 'ASCII batch view for compatibility expressions'); const compatibilityBatch = [ 'a := 0', 'xs := [1, 2, 3]', 'ys := [3, 5, 7]', '(2*cos(t), 4*sin(t)) {0 + a <= t <= pi + a}', 'ys ~ m*xs + b', 'angle := atan2(1, 2)', 'rounded := floor(1.7)', 'cost := 4', 'sinrate2 := 3', 'thing2 := sinrate2', 'fancy2(x) := x + 1', 'result := fancy2(2)', 'squares := [u^2 for u in 1:5]' ].join(';\n\n'); await setTextareaValue(command, 'textarea[aria-label="All graph expressions"]', compatibilityBatch); await evaluate(command, `document.querySelector('button[aria-label="Expression UI view"]')?.click()`); const compatibleRows = await waitForEvaluation(command, `(() => { const rows = [...document.querySelectorAll('[data-expression-id]')]; return { count: rows.length, visualCount: rows.filter(row => row.querySelector('[aria-label$="visual math"].mq-editable-field')).length, latex: rows.map(row => window.MathQuill?.getInterface(3)(row.querySelector('[aria-label$="visual math"]'))?.latex() ?? ''), rows: rows.map(row => ({ id: row.getAttribute('data-expression-id'), text: row.innerText.slice(0, 600), status: row.querySelector('.editor-status')?.textContent ?? '', diagnostic: row.querySelector('.diagnostic')?.textContent ?? '', fallback: row.querySelector('.math-fallback')?.value ?? null, rawEditor: Boolean(row.querySelector('.source-preview')), viewButtons: row.querySelectorAll('.expression-editor .heading button').length })) }; })()`, 'all supported ASCII rows visually editable', 30_000, state => state.count === 13 && state.visualCount === 13); assert.equal(compatibleRows.rows.some(row => row.rawEditor), false, 'raw source editing must remain available only in global TXT'); assert.equal(compatibleRows.rows.some(row => row.viewButtons > 0), false, 'expression rows must not expose individual UI/TXT switches'); const converterProbe = await evaluate(command, `import(${JSON.stringify(`${basePath}/src/lib/engine/cartesium.js`)}).then(async ({ convertExpressionSource }) => ({ atan2: await convertExpressionSource('angle := atan2(1, 2)', 'text', 'latex'), floor: await convertExpressionSource('rounded := floor(1.7)', 'text', 'latex') }))`); const mathQuillProbe = await evaluate(command, `(() => { const host = document.createElement('div'); document.body.append(host); const field = window.MathQuill.getInterface(3).MathField(host, { autoOperatorNames: 'sin cos tan asin acos atan sec csc cot sinh cosh tanh exp ln log sqrt abs floor ceil round sign real imag conj arg min max mod length sum product mean dot norm transpose' }); field.latex(${JSON.stringify('\\text{angle}:=\\text{atan2}\\left(1,2\\right)')}); const result = field.latex(); field.revert(); host.remove(); return result; })()`); assert.match(converterProbe.atan2, /\\text\{atan2\}/, 'the source converter must emit a stable text wrapper for builtin names containing digits'); assert.match(mathQuillProbe, /\\text\{atan2\}/, 'MathQuill must preserve the converter’s named-function wrapper on export'); assert.match(compatibleRows.latex[3], /\\cos.*t.*\\sin/, 'the requested parametric curve must display visually'); assert.match(compatibleRows.latex[3], /\\le.*t.*\\le/, 'parametric restrictions must display visually'); assert.match(compatibleRows.latex[4], /\\sim/, 'regression using ~ must display as a visual relation'); assert.match(compatibleRows.latex[5], /\\text\{atan2\}/, 'actual MathQuill editing must preserve builtin function names that contain digits'); assert.match(compatibleRows.latex[6], /\\lfloor.*\\rfloor/, 'actual MathQuill editing must display the floor operator with its native notation'); assert.match(compatibleRows.latex[7], /\\text\{cost\}/, 'actual MathQuill editing must preserve names containing known operator substrings'); assert.match(compatibleRows.latex[8], /\\text\{sinrate2\}/, 'actual MathQuill editing must preserve identifiers containing known operator names and digits'); assert.match(compatibleRows.latex[9], /\\text\{thing2\}/, 'actual MathQuill editing must preserve named identifiers containing digits'); assert.match(compatibleRows.latex[10], /\\text\{fancy2\}/, 'actual MathQuill editing must preserve custom function names containing digits'); assert.match(compatibleRows.latex[11], /\\text\{fancy2\}/, 'actual MathQuill editing must preserve custom function calls containing digits'); assert(compatibleRows.latex[12].includes('\\text{ for }'), 'actual MathQuill editing must preserve sequence comprehension keywords without turning spacing into commas'); mark('batch ASCII auto-opened parametric, regression, named-function and comprehension rows visually'); const resolvedFit = await waitForEvaluation(command, `document.querySelector('[aria-label="Resolved regression"]')?.textContent ?? ''`, 'batch regression fit result', 30_000, text => /m\s*=\s*2/.test(text) && /b\s*=\s*1/.test(text) && /RMSE\s*=\s*0(?:\D|$)/.test(text)); assert.match(resolvedFit, /m\s*=\s*2/, 'the regression row must resolve the slope from the named sequence data'); assert.match(resolvedFit, /b\s*=\s*1/, 'the regression row must resolve the intercept from the named sequence data'); assert.match(resolvedFit, /RMSE\s*=\s*0/, 'the regression row must fit the named sequence data exactly'); mark('batch regression resolved m=2, b=1, RMSE=0'); // Save an incomplete MathQuill draft, then reopen it and complete the same // draft. Its evaluated ASCII source must remain blank throughout recovery. await evaluate(command, `(() => { [...document.querySelectorAll('.document-actions button')] .find(button => button.textContent.trim() === 'New')?.click(); })()`); await waitForEvaluation(command, `!!document.querySelector('[aria-label="Expression 1, visual math"].mq-editable-field')`, 'new graph for persisted visual draft'); await clickSelector(command, '[aria-label="Expression 1, visual math"].mq-editable-field'); await typeText(command, 'y=x^2'); await clickViewButton(command, 'Text'); const lastGoodDraftSource = await waitForEvaluation(command, `document.querySelector('textarea[aria-label="All graph expressions"]')?.value ?? ''`, 'last good visual draft source', 15_000, Boolean); assert.match(lastGoodDraftSource, /^y=.*x.*\^.*2/, 'the initial valid expression must have a canonical ASCII source before making a draft'); await clickViewButton(command, 'Visual'); await clickSelector(command, '[aria-label="Expression 1, visual math"].mq-editable-field'); await pressKey(command, 'End'); await typeText(command, '+'); const incompleteDraft = await waitForEvaluation(command, `(() => ({ message: document.querySelector('[data-expression-id="expr-1"] .draft-message')?.textContent ?? '', latex: window.MathQuill?.getInterface(3)(document.querySelector('[aria-label="Expression 1, visual math"].mq-editable-field'))?.latex() ?? '' }))()`, 'incomplete visual draft state', 15_000, state => Boolean(state.message && state.latex.endsWith('+'))); assert.match(incompleteDraft.message, /no active ASCII expression/i, 'incomplete visual drafts must explain that no evaluated ASCII source exists'); await clickViewButton(command, 'Text'); assert.equal(await evaluate(command, `document.querySelector('textarea[aria-label="All graph expressions"]')`), null, 'an unfinished draft must keep the global TXT editor closed rather than show stale evaluated ASCII'); assert.match(await evaluate(command, `document.querySelector('[data-expression-id="expr-1"] .draft-message')?.textContent ?? ''`), /Finish or clear this draft/, 'the visual row must explain how to unblock TXT'); await evaluate(command, `document.querySelector('button[aria-label="Expression text view"]')?.click()`); const blockedBatchState = await waitForEvaluation(command, `({ status: document.querySelector('.batch-mode-status')?.textContent ?? '', batch: Boolean(document.querySelector('textarea[aria-label="All graph expressions"]')) })`, 'batch view blocks an unfinished draft', 15_000, state => Boolean(state.status)); assert.equal(blockedBatchState.batch, false, 'batch editing must not silently omit an unfinished draft row'); assert.match(blockedBatchState.status, /unfinished visual draft/, 'batch view must explain how to recover the unfinished row'); await clickViewButton(command, 'Visual'); await waitForEvaluation(command, `!!document.querySelector('[aria-label="Expression 1, visual math"].mq-editable-field')`, 'visual draft row after batch recovery'); await evaluate(command, `window.__visualDraftPromptCalls = 0; window.prompt = () => { window.__visualDraftPromptCalls += 1; return 'Recoverable draft integration test'; }`); await evaluate(command, `([...document.querySelectorAll('.document-actions button')] .find(button => button.textContent.trim() === 'Save as'))?.click()`); const draftSave = await waitForEvaluation(command, `(() => { const store = JSON.parse(localStorage.getItem('cartesium.documents') || '{}'); const saved = store.documents?.find(item => item.document?.snapshot?.name === 'Recoverable draft integration test'); const expression = saved?.document?.snapshot?.expressions?.[0]; return { id: saved?.id ?? null, status: document.querySelector('.share-status')?.textContent?.trim() ?? '', promptCalls: window.__visualDraftPromptCalls, source: expression?.source ?? null, draft: expression?.visual_draft ?? null }; })()`, 'incomplete visual draft save', 20_000, state => Boolean(state.id && state.status)); assert.equal(draftSave.status, 'Saved a new copy in this browser'); assert.equal(draftSave.promptCalls, 1); assert.equal(draftSave.source, '', 'saved incomplete draft must have no evaluated source'); assert.equal(draftSave.draft.latex, incompleteDraft.latex, 'save must preserve the exact unfinished LaTeX draft'); assert.match(draftSave.draft.diagnostic, /incomplete|expression|unexpected end of input/i, 'save must preserve an actionable diagnostic with the draft'); await evaluate(command, `document.querySelector('button[aria-label="Clear unfinished visual draft for Expression 1"]')?.click()`); await waitForEvaluation(command, `!document.querySelector('[data-expression-id="expr-1"] .draft-message') && document.querySelector('[aria-label="Expression 1, visual math"].mq-editable-field')`, 'cleared visual draft'); await clickViewButton(command, 'Text'); assert.equal(await evaluate(command, `document.querySelector('textarea[aria-label="All graph expressions"]')?.value`), '', 'clearing a draft must allow TXT to open without preserving stale evaluated source'); await command('Page.navigate', { url: `${origin}${calculator2dPath}?openDocument=${encodeURIComponent(draftSave.id)}` }); await waitForEvaluation(command, `!!document.querySelector('button[aria-label="Expression UI view"]')`, 'reopened incomplete draft'); const restoredDraft = await waitForEvaluation(command, `(() => ({ visual: Boolean(document.querySelector('[aria-label="Expression 1, visual math"].mq-editable-field')), message: document.querySelector('[data-expression-id="expr-1"] .draft-message')?.textContent ?? '', latex: window.MathQuill?.getInterface(3)(document.querySelector('[aria-label="Expression 1, visual math"].mq-editable-field'))?.latex() ?? '' }))()`, 'recoverable draft after reload', 30_000, state => state.visual && state.message && state.latex.endsWith('+')); assert.equal(restoredDraft.latex, incompleteDraft.latex, 'reopening must restore the same unfinished visual expression'); await clickSelector(command, '[aria-label="Expression 1, visual math"].mq-editable-field'); await pressKey(command, 'End'); await typeText(command, '1'); const recoveredDraft = await waitForEvaluation(command, `(() => ({ message: document.querySelector('[data-expression-id="expr-1"] .draft-message')?.textContent ?? '', latex: window.MathQuill?.getInterface(3)(document.querySelector('[aria-label="Expression 1, visual math"].mq-editable-field'))?.latex() ?? '' }))()`, 'completed previously incomplete draft', 15_000, state => !state.message && state.latex.endsWith('+1')); assert.equal(compactLatex(recoveredDraft.latex), 'y=x^2+1', 'editing a restored draft must update the same source rather than revert to stale content'); await clickViewButton(command, 'Text'); assert.match(await evaluate(command, `document.querySelector('textarea[aria-label="All graph expressions"]')?.value ?? ''`), /^y=.*x.*\^.*2.*\+.*1$/, 'completing the saved draft must create the canonical ASCII source'); mark('unfinished visual draft blocked stale evaluation, survived save/reopen, and recovered'); // External slider and animation values become the visual editor's new undo // baseline. Real range and keyboard input exercise the same path used by // pointer drags and animation ticks. await evaluate(command, `window.confirm = () => true`); await evaluate(command, `(() => { [...document.querySelectorAll('.document-actions button')] .find(button => button.textContent.trim() === 'New')?.click(); })()`); // New preserves the global UI/TXT choice; this flow last used TXT while // recovering a saved draft, so return to the visual panel explicitly. await clickViewButton(command, 'UI'); const sliderGraph = await waitForEvaluation(command, `(() => ({ visual: Boolean(document.querySelector('[aria-label="Expression 1, visual math"].mq-editable-field')), rows: [...document.querySelectorAll('[data-expression-id]')].map(row => ({ id: row.getAttribute('data-expression-id'), text: row.innerText.slice(0, 240), fields: [...row.querySelectorAll('[aria-label]')].map(element => element.getAttribute('aria-label')) })), viewButtons: [...document.querySelectorAll('.expression-panel button[aria-label]')] .map(button => button.getAttribute('aria-label')), status: document.querySelector('.share-status')?.textContent ?? '' }))()`, 'visual slider history graph', 30_000, state => state.visual); assert.equal(sliderGraph.rows.length, 1, 'new graph must start with one expression'); await clickSelector(command, '[aria-label="Expression 1, visual math"].mq-editable-field'); await typeText(command, 'a:=2'); await waitForEvaluation(command, `Boolean(document.querySelector('[data-expression-id="expr-1"] input[type="range"][aria-label="a value"]'))`, 'scalar variable slider'); const slider = '[data-expression-id="expr-1"] input[type="range"][aria-label="a value"]'; await evaluate(command, `document.querySelector(${JSON.stringify(slider)}).focus()`); await pressKey(command, 'ArrowRight'); const sliderSource = await waitForEvaluation(command, `(() => ({ latex: window.MathQuill?.getInterface(3)(document.querySelector('[aria-label="Expression 1, visual math"].mq-editable-field'))?.latex() ?? '', diagnostic: document.querySelector('[data-expression-id="expr-1"] .diagnostic')?.textContent ?? '' }))()`, 'visual source after slider update', 15_000, state => compactForBrowser(state.latex) !== 'a:=2' && !state.diagnostic); const sliderBaseline = sliderSource.latex; await clickSelector(command, '[aria-label="Expression 1, visual math"].mq-editable-field'); await pressKey(command, 'End'); await typeText(command, '+'); await pressChord(command, ['Control', 'z']); assert.equal(compactLatex(await inspectLatex(command, 1)), compactLatex(sliderBaseline), 'undo after a slider update must return to the accepted slider value'); await typeText(command, '3'); const sliderTyping = await waitForEvaluation(command, `(() => ({ latex: window.MathQuill?.getInterface(3)(document.querySelector('[aria-label="Expression 1, visual math"].mq-editable-field'))?.latex() ?? '', diagnostic: document.querySelector('[data-expression-id="expr-1"] .diagnostic')?.textContent ?? '', fallback: Boolean(document.querySelector('[data-expression-id="expr-1"] .math-fallback')) }))()`, 'typing after slider undo', 15_000, state => state.latex !== sliderBaseline && !state.diagnostic && !state.fallback); assert(sliderTyping.latex.startsWith(sliderBaseline), 'visual typing must remain available after slider undo'); await clickSelector(command, '[aria-label="Animate a"]'); await waitForEvaluation(command, `(() => ({ latex: window.MathQuill?.getInterface(3)(document.querySelector('[aria-label="Expression 1, visual math"].mq-editable-field'))?.latex() ?? '', diagnostic: document.querySelector('[data-expression-id="expr-1"] .diagnostic')?.textContent ?? '' }))()`, 'animated visual source', 15_000, state => state.latex !== sliderTyping.latex && !state.diagnostic); await clickSelector(command, '[aria-label="Pause a"]'); await delay(150); const animationBaseline = await inspectLatex(command, 1); await clickSelector(command, '[aria-label="Expression 1, visual math"].mq-editable-field'); await pressKey(command, 'End'); await typeText(command, '+0'); await waitForEvaluation(command, `(() => ({ latex: window.MathQuill?.getInterface(3)(document.querySelector('[aria-label="Expression 1, visual math"].mq-editable-field'))?.latex() ?? '', diagnostic: document.querySelector('[data-expression-id="expr-1"] .diagnostic')?.textContent ?? '', fallback: Boolean(document.querySelector('[data-expression-id="expr-1"] .math-fallback')) }))()`, 'valid edit after animation settles', 15_000, state => compactLatex(state.latex) === `${compactLatex(animationBaseline)}+0` && !state.diagnostic && !state.fallback); await pressChord(command, ['Control', 'z']); await pressChord(command, ['Control', 'z']); const animationTyping = await waitForEvaluation(command, `(() => ({ latex: window.MathQuill?.getInterface(3)(document.querySelector('[aria-label="Expression 1, visual math"].mq-editable-field'))?.latex() ?? '', diagnostic: document.querySelector('[data-expression-id="expr-1"] .diagnostic')?.textContent ?? '', fallback: Boolean(document.querySelector('[data-expression-id="expr-1"] .math-fallback')) }))()`, 'undo after animation edit', 15_000, state => compactLatex(state.latex) === compactLatex(animationBaseline) && !state.diagnostic && !state.fallback); assert.equal(compactLatex(animationTyping.latex), compactLatex(animationBaseline), 'undo after animation must return to its settled value, not an earlier frame'); const preConversionLatex = animationTyping.latex; await clickViewButton(command, 'Text'); const textScalar = await waitForEvaluation(command, `(() => ({ source: document.querySelector('textarea[aria-label="All graph expressions"]')?.value ?? null, visualSelected: document.querySelector('button[aria-label="Expression UI view"][aria-pressed="true"]')?.textContent.trim() ?? '', diagnostic: document.querySelector('[data-expression-id="expr-1"] .diagnostic')?.textContent ?? '' }))()`, 'canonical scalar in Text view', 15_000, state => Boolean(state.source && !state.diagnostic)); const textScalarValue = scalarAssignmentValue(textScalar.source); assert(Number.isFinite(textScalarValue), `shared scalar source must retain a numeric assignment: ${textScalar.source}`); assert.equal(textScalarValue, scalarAssignmentValue(preConversionLatex), 'switching a nonempty visual scalar to Text must preserve its value'); await clickViewButton(command, 'Visual'); const roundTrippedScalar = await waitForEvaluation(command, `(() => ({ latex: window.MathQuill?.getInterface(3)(document.querySelector('[aria-label="Expression 1, visual math"].mq-editable-field'))?.latex() ?? '', visualSelected: document.querySelector('button[aria-label="Expression UI view"][aria-pressed="true"]')?.textContent.trim() ?? '', diagnostic: document.querySelector('[data-expression-id="expr-1"] .diagnostic')?.textContent ?? '', fallback: Boolean(document.querySelector('[data-expression-id="expr-1"] .math-fallback')) }))()`, 'nonempty scalar converted back to visual', 15_000, state => Boolean(state.latex && state.visualSelected === 'UI' && !state.diagnostic && !state.fallback)); assert.equal(compactLatex(roundTrippedScalar.latex), compactLatex(preConversionLatex), 'switching a settled visual scalar to Text and back must preserve its source semantics'); mark('slider and animation updates reset visual undo baseline and editor views preserve semantics'); // Conventional notation is automatic for ASCII imports, and source-style // functions typed in MathQuill are polished on blur with a separate undo step. await evaluate(command, `([...document.querySelectorAll('.document-actions button')] .find(button => button.textContent.trim() === 'New')?.click())`); await clickViewButton(command, 'UI'); await waitForEvaluation(command, `!!document.querySelector('[aria-label="Expression 1, visual math"].mq-editable-field')`, 'blank notation editor'); await clickViewButton(command, 'TXT'); const notationBatch = "A:=[1 2;3 4];\na:=integral(t^2,t,0,3);\nlo:=floor(-1.2);\nhi:=ceil(-1.2);\nn:=norm([3,4]);\nB:=transpose(A);\nq:=conj(2);\nl:=log(8,2);\ns:=sum([k^2 for k in 1:3]);\np:=product([k for k in 1:4]);\nc:=pi+e+tau;\nu:=sum([1,2,3]);\nv:=product([k for k in 1:2:5])"; await setTextareaValue(command, 'textarea[aria-label="All graph expressions"]', notationBatch); await clickViewButton(command, 'UI'); const notationRows = await waitForEvaluation(command, `(() => { const rows = [...document.querySelectorAll('[data-expression-id]')]; return { latex: rows.map(row => window.MathQuill.getInterface(3)(row.querySelector('.mq-editable-field'))?.latex() ?? ''), diagnostics: [...document.querySelectorAll('.diagnostic,.draft-message,.math-fallback')].map(node => node.textContent) }; })()`, 'conventional imported notation', 30_000, state => state.latex.length === 13 && state.latex.every(Boolean) && !state.diagnostics.length); for (const [position, marker] of [[1, "\\int"], [2, "\\lfloor"], [3, "\\lceil"], [4, "\\lVert"], [5, "\\top"], [6, "\\overline"], [7, "\\log_{2}"], [8, "\\sum_{k=1}^{3}"], [9, "\\prod_{k=1}^{4}"], [10, "\\pi+e+\\tau"], [11, "\\sum"], [12, "\\prod"]]) { assert(notationRows.latex[position].includes(marker), `automatic notation ${marker}: ${notationRows.latex[position]}`); } await clickViewButton(command, 'TXT'); assert.deepEqual((await evaluate(command, `document.querySelector('textarea[aria-label="All graph expressions"]').value`)).split(/;\s*/).map(source => source.trim()), notationBatch.split(/;\s*/).map(source => source.trim()), 'rendering notation must preserve authored ASCII'); await clickViewButton(command, 'UI'); await waitForEvaluation(command, `!!document.querySelector('[aria-label="Expression 9, visual math"] .mq-to')`, 'editable summation bounds'); await evaluate(command, `document.querySelector('[aria-label="Expression 9, visual math"]').scrollIntoView({block:'center'})`); await delay(100); await clickSelector(command, '[aria-label="Expression 9, visual math"] .mq-to'); await pressKey(command, 'End'); await pressKey(command, 'Backspace'); await typeText(command, '4'); await waitForEvaluation(command, `window.MathQuill.getInterface(3)(document.querySelector('[aria-label="Expression 9, visual math"]')).latex().includes('^{4}') && !document.querySelector('.draft-message,.diagnostic')`, 'edited summation upper bound'); await evaluate(command, `window.MathQuill.getInterface(3)(document.querySelector('[aria-label="Expression 11, visual math"]')).focus().moveToRightEnd().typedText('+1');true`); await waitForEvaluation(command, `window.MathQuill.getInterface(3)(document.querySelector('[aria-label="Expression 11, visual math"]')).latex().endsWith('+1') && !document.querySelector('.draft-message,.diagnostic')`, 'edited symbolic constants'); await clickViewButton(command, 'TXT'); const editedNotation = await evaluate(command, `document.querySelector('textarea[aria-label="All graph expressions"]').value`); assert(editedNotation.includes('for k in 1:4'), 'editing a visual upper bound must update the canonical comprehension'); assert(editedNotation.includes('pi+e+tau+1'), 'editing symbolic constants must retain their ASCII names'); await evaluate(command, `([...document.querySelectorAll('.document-actions button')] .find(button => button.textContent.trim() === 'New')?.click())`); await clickViewButton(command, 'UI'); await waitForEvaluation(command, `!!document.querySelector('[aria-label="Expression 1, visual math"].mq-editable-field')`, 'live shorthand editor'); const shorthandArguments = ['1.2','1.2','[3,4]','A+B','z','x,2','x,x,0,1','[1,2,3]','[1,2,3]', String.raw`\left[k^{2}\text{ for }k\text{ in }1:3\right]`, String.raw`\left[k\text{ for }k\text{ in }1:4\right]`]; const aliasStyles = await evaluate(command, `Promise.all([ import(${JSON.stringify(`${basePath}/src/lib/components/polish-math-notation.js`)}), import(${JSON.stringify(`${basePath}/src/lib/engine/cartesium.js`)}) ]).then(async ([{polishMathNotation},{areExpressionSourcesEquivalent}]) => { const commands = ['floor','ceil','norm','transpose','conj','log','integral','sum','product','sum','product']; const argumentsList = ${JSON.stringify(shorthandArguments)}; const results = []; for (let index=0;index=9,bounds:exported.includes('_{k=1}'),equivalent:await areExpressionSourcesEquivalent(original,exported,'latex')}); field.revert(); host.remove(); } return results; })`); for (const result of aliasStyles) assert(result.changed && result.equivalent && (!result.bounded || result.bounds), JSON.stringify(result)); await evaluate(command, `window.MathQuill.getInterface(3)(document.querySelector('[aria-label="Expression 1, visual math"].mq-editable-field')).focus().typedText('floor(1.2)');true`); await waitForEvaluation(command, `!document.querySelector('.draft-message') && !document.querySelector('.diagnostic')`, 'completed live floor shorthand'); const floorCommand = String.fromCharCode(92) + 'lfloor'; const functionalFloor = await inspectLatex(command, 1); assert(functionalFloor.includes('floor') && !functionalFloor.includes(floorCommand), 'keep the input steady while typing'); await evaluate(command, `document.querySelector('button[aria-label="Expression UI view"]').focus()`); await waitForEvaluation(command, `window.MathQuill.getInterface(3)(document.querySelector('[aria-label="Expression 1, visual math"].mq-editable-field')).latex().includes(${JSON.stringify(floorCommand)}) && !document.querySelector('.draft-message')`, 'automatic floor notation on blur'); await clickSelector(command, '[aria-label="Expression 1, visual math"].mq-editable-field'); await pressChord(command, ['Control', 'z']); assert.equal(await inspectLatex(command, 1), functionalFloor, 'automatic formatting must be independently undoable'); await pressChord(command, ['Control', 'Shift', 'z']); assert((await inspectLatex(command, 1)).includes(floorCommand), 'automatic formatting redo must restore the glyph'); await evaluate(command, `window.MathQuill.getInterface(3)(document.querySelector('[aria-label="Expression 1, visual math"].mq-editable-field')).select().typedText('integral(x^2').keystroke('Right').typedText(',x,0,1)');true`); await waitForEvaluation(command, `({latex:window.MathQuill.getInterface(3)(document.querySelector('[aria-label="Expression 1, visual math"].mq-editable-field')).latex(),draft:document.querySelector('.draft-message')?.textContent,diagnostic:document.querySelector('.diagnostic')?.textContent})`, 'completed integral shorthand', 15_000, state => !state.draft && !state.diagnostic); assert((await inspectLatex(command, 1)).includes('integral'), 'integral must be recognized as a whole function name'); await evaluate(command, `document.querySelector('button[aria-label="Expression UI view"]').focus()`); const integralCommand = String.fromCharCode(92) + 'int'; await waitForEvaluation(command, `window.MathQuill.getInterface(3)(document.querySelector('[aria-label="Expression 1, visual math"].mq-editable-field')).latex().includes(${JSON.stringify(integralCommand)}) && !document.querySelector('.draft-message')`, 'automatic integral notation on blur'); mark('automatic conventional notation, stable input, and formatting undo/redo'); mark('opening 3D calculator'); await command('Page.navigate', { url: `${origin}${calculator3dPath}` }); await waitForEvaluation(command, `!!document.querySelector('button[aria-label="Expression UI view"]')`, '3D calculator'); await evaluate(command, `window.confirm = () => true`); await evaluate(command, `(() => { [...document.querySelectorAll('.document-actions button')] .find(button => button.textContent.trim() === 'New')?.click(); })()`); await waitForEvaluation(command, `!!document.querySelector('[aria-label="Expression 1, visual math"].mq-editable-field')`, 'blank 3D visual expression editor'); await clickSelector(command, '[aria-label="Expression 1, visual math"].mq-editable-field'); await typeText(command, 'z=x^2'); await pressKey(command, 'ArrowRight'); await typeText(command, '+y^2'); const threeDimensional = await waitForEvaluation(command, `(() => ({ latex: window.MathQuill?.getInterface(3)(document.querySelector('[aria-label="Expression 1, visual math"].mq-editable-field'))?.latex() ?? '', diagnostic: document.querySelector('[data-expression-id="expr-1"] .diagnostic')?.textContent ?? '', engineError: document.querySelector('.render-status .error')?.textContent ?? '', engineBusy: Boolean(document.querySelector('.render-status .progress')), assets: performance.getEntriesByType('resource').filter(item => item.name.includes('mathquill')).map(item => item.name) }))()`, '3D visual engine preview', 30_000, state => state.latex.replace(/\s/g, '') === 'z=x^{2}+y^{2}' && !state.diagnostic && !state.engineError && !state.engineBusy); assert(threeDimensional.assets.some(url => url.startsWith(expectedMathQuillPrefix)), '3D MathQuill assets must load below the configured base path'); mark('3D visual expression reached the engine without diagnostics'); await evaluate(command, `document.querySelector('button[aria-label="Expression text view"]')?.click()`); await waitForEvaluation(command, `!!document.querySelector('textarea[aria-label="All graph expressions"]')`, '3D all-expression batch view'); const threeDimensionalBatch = 'z=x^2+y^2;\n\n(1,2,3);\na:=sum([k for k in 1:3]);\nb:=product([k for k in 1:3]);\nc:=pi+e+tau'; await setTextareaValue(command, 'textarea[aria-label="All graph expressions"]', threeDimensionalBatch); await evaluate(command, `document.querySelector('button[aria-label="Expression UI view"]')?.click()`); const threeDimensionalRows = await waitForEvaluation(command, `(() => { const rows = [...document.querySelectorAll('[data-expression-id]')]; return { count: rows.length, visualCount: rows.filter(row => row.querySelector('[aria-label$="visual math"].mq-editable-field')).length, latex: rows.map(row => window.MathQuill?.getInterface(3)(row.querySelector('[aria-label$="visual math"]'))?.latex() ?? '') }; })()`, '3D ASCII rows available in the visual editor', 30_000, state => state.count === 5 && state.visualCount === 5 && state.latex.every(Boolean)); assert.match(threeDimensionalRows.latex[0], /z.*x.*y/, '3D explicit surface entered as ASCII must stay visually editable'); assert.match(threeDimensionalRows.latex[1], /1.*2.*3/, '3D point entered as ASCII must stay visually editable'); assert(threeDimensionalRows.latex[2].includes('\\sum_{k=1}^{3}'), '3D must display bounded sums'); assert(threeDimensionalRows.latex[3].includes('\\prod_{k=1}^{3}'), '3D must display bounded products'); assert(threeDimensionalRows.latex[4].includes('\\pi+e+\\tau'), '3D must preserve symbolic constants'); mark('3D batch edits every expression and supports visual reopening'); assert.deepEqual(browserErrors, [], `browser runtime and console errors must remain empty: ${JSON.stringify(browserErrors)}`); console.log(JSON.stringify({ initialLatex, afterTyping, fractionLatex, matrixLatex, drawing, savedModes, reloaded, reloadedEditorState, compatibleRows, draftSave, restoredDraft, recoveredDraft, sliderSource, sliderTyping, animationBaseline, animationTyping, textScalar, roundTrippedScalar, browserErrors, browserDialogs, threeDimensional, threeDimensionalRows })); } finally { browser?.socket.close(); chromium?.kill(); vite?.kill(); await rm(browserProfile, { recursive: true, force: true }); } async function inspectLatex(command, position) { const source = await evaluate(command, `(() => { const host = document.querySelector('[aria-label="Expression ${position}, visual math"].mq-editable-field'); return window.MathQuill?.getInterface(3)(host)?.latex(); })()`); assert.equal(typeof source, 'string', `expression ${position} LaTeX source must be available`); return source; } function compactLatex(source = '') { return source.replace(/[{}\\\s]/g, ''); } function compactForBrowser(source = '') { return source.replace(/[{}\\\s]/g, ''); } function scalarAssignmentValue(source = '') { const value = source.match(/a\s*:?=\s*(-?\d+(?:\.\d+)?)/)?.[1]; return value === undefined ? Number.NaN : Number(value); } function mark(message) { console.log(`[visual-editor] ${message}`); } async function clickViewButton(command, view) { const ariaLabel = view === 'Visual' || view === 'UI' ? 'Expression UI view' : 'Expression text view'; await evaluate(command, `(() => { document.querySelector(${JSON.stringify(`button[aria-label="${ariaLabel}"]`)})?.click(); })()`); await delay(60); } async function clickSelector(command, selector) { const point = await evaluate(command, `(() => { const element = document.querySelector(${JSON.stringify(selector)}); if (!element) return null; const rect = element.getBoundingClientRect(); return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }; })()`); assert(point, `Browser target ${selector} must be present`); await clickPoint(command, point); } async function clickPoint(command, point) { await command('Input.dispatchMouseEvent', { type: 'mouseMoved', ...point }); await command('Input.dispatchMouseEvent', { type: 'mousePressed', button: 'left', buttons: 1, clickCount: 1, ...point }); await command('Input.dispatchMouseEvent', { type: 'mouseReleased', button: 'left', buttons: 0, clickCount: 1, ...point }); } async function typeText(command, source) { for (const character of source) await pressCharacter(command, character); } async function pressCharacter(command, character) { const description = characterInfo(character); const common = { key: description.key, code: description.code, windowsVirtualKeyCode: description.virtualKeyCode, nativeVirtualKeyCode: description.virtualKeyCode, modifiers: description.modifiers }; await command('Input.dispatchKeyEvent', { type: 'keyDown', ...common }); await command('Input.dispatchKeyEvent', { type: 'char', ...common, text: character, unmodifiedText: description.unmodifiedText ?? character }); await command('Input.dispatchKeyEvent', { type: 'keyUp', ...common }); } function characterInfo(character) { if (/^[a-z]$/i.test(character)) { const upper = character.toUpperCase(); return { key: character, code: `Key${upper}`, virtualKeyCode: upper.charCodeAt(0), modifiers: 0 }; } if (/^[0-9]$/.test(character)) { return { key: character, code: `Digit${character}`, virtualKeyCode: character.charCodeAt(0), modifiers: 0 }; } const punctuation = { '#': { key: '#', code: 'Digit3', virtualKeyCode: 51, modifiers: 8, unmodifiedText: '3' }, '=': { key: '=', code: 'Equal', virtualKeyCode: 187, modifiers: 0 }, '+': { key: '+', code: 'Equal', virtualKeyCode: 187, modifiers: 8, unmodifiedText: '=' }, '-': { key: '-', code: 'Minus', virtualKeyCode: 189, modifiers: 0 }, '/': { key: '/', code: 'Slash', virtualKeyCode: 191, modifiers: 0 }, '^': { key: '^', code: 'Digit6', virtualKeyCode: 54, modifiers: 8, unmodifiedText: '6' }, ':': { key: ':', code: 'Semicolon', virtualKeyCode: 186, modifiers: 8, unmodifiedText: ';' }, ' ': { key: ' ', code: 'Space', virtualKeyCode: 32, modifiers: 0 } }; const result = punctuation[character]; if (!result) throw new Error(`No keyboard mapping for ${JSON.stringify(character)}.`); return result; } async function pressKey(command, key) { const common = { key, code: ({ Backspace: 'Backspace', Tab: 'Tab', End: 'End', ArrowRight: 'ArrowRight' })[key], windowsVirtualKeyCode: ({ Backspace: 8, Tab: 9, End: 35, ArrowRight: 39 })[key], nativeVirtualKeyCode: ({ Backspace: 8, Tab: 9, End: 35, ArrowRight: 39 })[key] }; await command('Input.dispatchKeyEvent', { type: 'keyDown', ...common }); await command('Input.dispatchKeyEvent', { type: 'keyUp', ...common }); } async function pressChord(command, keys) { const modifiers = (keys.includes('Control') ? 2 : 0) | (keys.includes('Shift') ? 8 : 0) | (keys.includes('Alt') ? 1 : 0); const key = keys.at(-1); if (key === 'Control' || key === 'Shift' || key === 'Alt') throw new Error('Chord needs a final action key.'); const modifierKey = keys[0]; const modifierCode = ({ Control: 'ControlLeft', Shift: 'ShiftLeft', Alt: 'AltLeft' })[modifierKey]; const modifierVirtualCode = ({ Control: 17, Shift: 16, Alt: 18 })[modifierKey]; await command('Input.dispatchKeyEvent', { type: 'keyDown', key: modifierKey, code: modifierCode, windowsVirtualKeyCode: modifierVirtualCode, nativeVirtualKeyCode: modifierVirtualCode, modifiers: modifierVirtualCode === 16 ? 8 : modifierVirtualCode === 17 ? 2 : 1 }); if (keys.includes('Shift') && modifierKey !== 'Shift') { await command('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Shift', code: 'ShiftLeft', windowsVirtualKeyCode: 16, nativeVirtualKeyCode: 16, modifiers }); } const vk = key.toUpperCase().charCodeAt(0); await command('Input.dispatchKeyEvent', { type: 'keyDown', key, code: `Key${key.toUpperCase()}`, windowsVirtualKeyCode: vk, nativeVirtualKeyCode: vk, modifiers }); await command('Input.dispatchKeyEvent', { type: 'keyUp', key, code: `Key${key.toUpperCase()}`, windowsVirtualKeyCode: vk, nativeVirtualKeyCode: vk, modifiers }); if (keys.includes('Shift') && modifierKey !== 'Shift') { await command('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Shift', code: 'ShiftLeft', windowsVirtualKeyCode: 16, nativeVirtualKeyCode: 16, modifiers }); } await command('Input.dispatchKeyEvent', { type: 'keyUp', key: modifierKey, code: modifierCode, windowsVirtualKeyCode: modifierVirtualCode, nativeVirtualKeyCode: modifierVirtualCode, modifiers: 0 }); } async function setTextareaValue(command, selector, value) { await evaluate(command, `(() => { const textarea = document.querySelector(${JSON.stringify(selector)}); textarea.value = ${JSON.stringify(value)}; textarea.dispatchEvent(new Event('input', { bubbles: true })); })()`); await delay(80); } async function waitForEvaluation(command, expression, label, timeoutMs = 30_000, ready = Boolean) { const deadline = Date.now() + timeoutMs; let last; while (Date.now() < deadline) { last = await evaluate(command, expression); if (ready(last)) return last; await delay(100); } throw new Error(`Timed out waiting for ${label}; last browser value: ${JSON.stringify(last)}`); } async function findFreePort() { const server = createServer(); await new Promise((resolveListen, reject) => { server.once('error', reject); server.listen(0, host, resolveListen); }); const address = server.address(); if (!address || typeof address === 'string') throw new Error('Could not find an available port.'); const { port } = address; await new Promise((resolveClose, reject) => server.close((error) => (error ? reject(error) : resolveClose())) ); return port; } async function waitForServer(url, child, output) { const deadline = Date.now() + 30_000; while (Date.now() < deadline) { if (child.exitCode !== null) throw new Error(`Vite exited: ${output.slice(-10).join('')}`); try { if ((await fetch(url)).ok) return; } catch { // Vite is still starting. } await delay(100); } throw new Error(`Timed out waiting for ${url}: ${output.slice(-10).join('')}`); } function collectOutput(child, output) { for (const stream of [child.stdout, child.stderr]) { stream?.on('data', (chunk) => output.push(chunk.toString())); } }