diff --git a/package.json b/package.json index 8430e14..a1292bd 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,8 @@ "build": "vite build", "preview": "vite preview", "test": "node --test --import tsx 'scripts/*.test.ts'", - "test:frontmatter": "node --test --import tsx scripts/frontMatter.test.ts scripts/frontMatterDisclosure.test.ts", - "test:workflows": "node --test --import tsx scripts/exportHtml.test.ts scripts/exportOpenPrompt.test.ts scripts/reloadOpenToolbar.test.ts scripts/editorToolbar.test.ts scripts/titlebarToolbar.test.ts scripts/toolbarCustomizationWiring.test.ts", - "test:settings-scroll": "node --test --import tsx scripts/previewScrollSync.test.ts scripts/toolbarCustomizationWiring.test.ts", + "test:frontmatter": "node --test --import tsx scripts/frontMatter.test.ts scripts/frontMatterProseBlock.test.ts", + "test:workflows": "node --test --import tsx scripts/exportHtml.test.ts scripts/exportOpenPrompt.test.ts scripts/editorToolbar.test.ts scripts/titlebarToolbar.test.ts", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "build:test-bundle": "node scripts/build-test-bundle.mjs", diff --git a/scripts/batchCloseConfirmation.test.ts b/scripts/batchCloseConfirmation.test.ts deleted file mode 100644 index e2e93fa..0000000 --- a/scripts/batchCloseConfirmation.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import test from 'node:test'; - -const viewer = readFileSync(new URL('../src/lib/MarkdownViewer.svelte', import.meta.url), 'utf8'); - -test('batch tab-close commands use the existing dirty-tab confirmation flow', () => { - assert.match(viewer, /async function closeTabsWithConfirmation\(tabIds: string\[\]\)/); - assert.match( - viewer, - /for \(const tabId of tabIds\) \{\s*if \(!\(await canCloseTab\(tabId\)\)\) return;\s*tabManager\.closeTab\(tabId\);\s*\}/, - ); - assert.match(viewer, /menu-tab-close-others[\s\S]*await closeTabsWithConfirmation\(tabsToClose\)/); - assert.match(viewer, /menu-tab-close-right[\s\S]*await closeTabsWithConfirmation\(tabsToClose\)/); - assert.match(viewer, /appWindow\.listen\('menu-tab-close-others', async \(event\)/); - assert.match(viewer, /appWindow\.listen\('menu-tab-close-right', async \(event\)/); -}); diff --git a/scripts/findCollapsedMatches.test.ts b/scripts/findCollapsedMatches.test.ts deleted file mode 100644 index a6b2ce4..0000000 --- a/scripts/findCollapsedMatches.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import test from 'node:test'; - -import { sliceBetween } from './sourceTree.js'; - -const findBar = readFileSync('src/lib/components/FindBar.svelte', 'utf8'); - -test('find keeps counting matches inside collapsed folds', () => { - // Reference systems all keep hidden-but-present text findable and reveal - // it on a hit: VS Code unfolds the region it jumps into, and Chrome makes - // `hidden=until-found` / closed `
` content matchable precisely - // because it can reveal it. Dropping the matches from the count would be - // the `display: none` rule, which is not what a collapsed fold is. - const acceptNode = sliceBetween(findBar, 'function isHostElement(', 'function isInsideHost('); - - assert.doesNotMatch(acceptNode, /is-collapsed/); - assert.doesNotMatch(acceptNode, /foldable-content-wrapper/); - assert.doesNotMatch(acceptNode, /markdown-alert-content/); -}); - -test('find knows both kinds of collapsed fold container', () => { - assert.match(findBar, /\.foldable-content-wrapper\.is-collapsed/); - assert.match(findBar, /\.markdown-alert-content\.is-collapsed/); -}); - -test('activating a match reveals the folds hiding it before scrolling to it', () => { - const setActive = sliceBetween(findBar, 'function setActive(', 'export function next()'); - - const reveal = setActive.indexOf('revealFoldsAround('); - const scroll = setActive.indexOf('scrollIntoView('); - - assert.ok(reveal !== -1, 'setActive must reveal the collapsed folds around the match'); - assert.ok(scroll !== -1, 'setActive must still scroll the match into view'); - assert.ok(reveal < scroll, 'the fold has to open before the scroll target is measured'); -}); - -test('revealing a fold goes through the viewer toggle instead of stripping the class', () => { - // `collapsedHeaders` in MarkdownViewer.svelte is the source of truth for - // fold state: it re-applies `is-collapsed` on every re-render and feeds - // the ToC. Clearing the class here would create a second source of truth - // that the next render silently reverts. - const reveal = sliceBetween(findBar, 'function foldToggleFor(', 'export function clearHighlights()'); - - assert.match(reveal, /\.header-fold-icon/); - assert.match(reveal, /\.callout-toggle/); - assert.match(reveal, /data-fold-target/); - assert.match(reveal, /dispatchEvent\(\s*new MouseEvent\('click', \{ bubbles: true \}\)/); - assert.doesNotMatch(findBar, /classList\.remove\(\s*['"]is-collapsed/); - assert.doesNotMatch(findBar, /classList\.toggle\(\s*['"]is-collapsed/); -}); - -test('nested folds are opened outermost first', () => { - const reveal = sliceBetween(findBar, 'function revealFoldsAround(', 'export function clearHighlights()'); - - assert.match(reveal, /while \(curr && curr !== root\)/); - assert.match(reveal, /collapsed\.reverse\(\)/); -}); - -test('the scroll is re-aimed once the fold height transition has settled', () => { - // styles.css animates `.foldable-content-wrapper` height for 0.25s, so the - // first scrollIntoView aims at a target that is still moving. - const setActive = sliceBetween(findBar, 'function setActive(', 'export function next()'); - - assert.match(setActive, /setTimeout\(/); - assert.match(setActive, /FOLD_TRANSITION_MS/); - assert.match(setActive, /isConnected/); - assert.match(findBar, /const FOLD_TRANSITION_MS = (\d+);/); - - const ms = Number(findBar.match(/const FOLD_TRANSITION_MS = (\d+);/)?.[1]); - assert.ok(ms >= 250, 'must outlast the 0.25s fold transition in styles.css'); -}); diff --git a/scripts/foldLayout.test.ts b/scripts/foldLayout.test.ts index 0a66505..ab930a8 100644 --- a/scripts/foldLayout.test.ts +++ b/scripts/foldLayout.test.ts @@ -19,6 +19,30 @@ test('fold wrapper animates an explicit measured height instead of a fractional assert.match(styles, /foldable-content-wrapper\.is-collapsed[\s\S]*height:\s*0/); }); +// Salvaged from `findCollapsedMatches.test.ts`, which was deleted for asserting +// the spelling of FindBar.svelte (it stayed green with `revealFoldsAround` made +// a no-op). This assertion is a different kind: it couples a TypeScript constant +// to a CSS duration in another file. Nothing else compares the two, and when +// FindBar's re-aim timer fires before the height transition settles the scroll +// lands on a target that is still moving — a defect that only shows up as "find +// sometimes scrolls to the wrong place". +test('the find-bar fold re-aim delay outlasts the CSS fold transition', () => { + const findBar = readFileSync('src/lib/components/FindBar.svelte', 'utf8'); + const styles = readFileSync('src/styles.css', 'utf8'); + + const declared = findBar.match(/const FOLD_TRANSITION_MS = (\d+);/); + assert.ok(declared, 'FindBar.svelte must declare the delay it waits for the fold to settle'); + + const expandedRule = styles.match(/\.foldable-content-wrapper\s*\{([^}]*)\}/)?.[1] || ''; + const transition = expandedRule.match(/transition:[^;]*?height\s+([\d.]+)s/); + assert.ok(transition, 'styles.css must animate the fold wrapper height'); + + assert.ok( + Number(declared[1]) >= Number(transition[1]) * 1000, + `FOLD_TRANSITION_MS (${declared[1]}ms) must outlast the ${transition[1]}s height transition`, + ); +}); + test('preview lifecycle starts and cleans up fold observation', () => { const viewer = readFileSync('src/lib/MarkdownViewer.svelte', 'utf8'); diff --git a/scripts/frontMatterDisclosure.test.ts b/scripts/frontMatterDisclosure.test.ts deleted file mode 100644 index 1e09b44..0000000 --- a/scripts/frontMatterDisclosure.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import { test } from 'node:test'; - -const viewerSource = readFileSync(new URL('../src/lib/MarkdownViewer.svelte', import.meta.url), 'utf8'); - -test('front matter panel uses native details disclosure so collapsed summary remains expandable', () => { - const panelStart = viewerSource.indexOf('frontmatter-panel'); - assert.notEqual(panelStart, -1); - - const panelSource = viewerSource.slice(panelStart - 160, panelStart + 900); - - assert.match(panelSource, /]*class="frontmatter-panel"/); - assert.match(panelSource, /]*class="frontmatter-summary"/); - assert.doesNotMatch(panelSource, /]*class="frontmatter-summary"/); -}); - -test('front matter panel is collapsed by default until the user opens it', () => { - assert.match(viewerSource, /frontMatterCollapsedByKey\[frontMatterPanelKey\]\s*\?\?\s*true/); - assert.match(viewerSource, /open=\{!isFrontMatterCollapsed\}/); -}); - -test('front matter list fields render as editable tags instead of one comma input', () => { - const listBranchStart = viewerSource.indexOf("field.kind === 'list'"); - assert.notEqual(listBranchStart, -1); - - const listBranch = viewerSource.slice(listBranchStart, listBranchStart + 4000); - - assert.match(listBranch, /class="frontmatter-tags"/); - assert.match(listBranch, /class="frontmatter-tag-remove"/); - assert.match(listBranch, /class="frontmatter-tag-add-button"/); - assert.match(listBranch, /startFrontMatterTagEdit/); - assert.match(listBranch, /value=\{getFrontMatterTagDraft\(field\)\}/); -}); diff --git a/scripts/i18nCoverage.test.ts b/scripts/i18nCoverage.test.ts index 7f011bc..2d691df 100644 --- a/scripts/i18nCoverage.test.ts +++ b/scripts/i18nCoverage.test.ts @@ -371,6 +371,56 @@ test('the window-tag editor’s Save button is translated', () => { } }); +// The two tests below moved here from `toolbarCustomizationWiring.test.ts`, +// which was deleted for asserting the spelling of `Settings.svelte` rather than +// any behaviour. These two were the exception: they import the dictionary and +// look keys up in it, and they are the only thing in the suite that fails when +// a *specific locale* silently falls back to English. The general rules above +// cannot see that — (1) only requires the key in English, and per-locale +// completeness is reported rather than enforced. These keys are the exception +// to that leniency because they label controls whose meaning is carried by the +// word alone: an untranslated "Move Up" on an icon button is unusable, not +// merely unpolished. + +/** The value `lang` itself defines for `key`, ignoring the English fallback. */ +function directTranslation(lang: LanguageCode, key: string): string | undefined { + return dictionaries.get(lang)!.get(key); +} + +test('interactive button labels are directly translated for every supported language', () => { + const interactiveLabelKeys = [ + 'common.close', + 'common.decrease', + 'common.increase', + 'toc.resizeTableOfContents', + 'settings.move', + 'settings.moveUp', + 'settings.moveDown', + 'settings.resetToolbar', + 'settings.toolbarOnBar', + 'settings.toolbarInMenu', + 'settings.resizeWindow', + ]; + + const missing = languages.flatMap((lang) => + interactiveLabelKeys + .filter((key) => directTranslation(lang, key) === undefined) + .map((key) => `${lang}:${key}`), + ); + + assert.deepEqual(missing, []); +}); + +test('Simplified Chinese directly translates the window organization labels', () => { + const keys = [ + 'menu.moveToWindow', 'menu.window', 'menu.mergeAllWindows', 'menu.setWindowTag', + 'menu.windowTagPlaceholder', 'menu.windowTagClear', 'menu.pinWindowTag', + 'menu.unpinWindowTag', 'toast.noOtherWindows', 'home.pinnedTags', 'home.pinnedFileCount', + ]; + + assert.deepEqual(keys.filter((key) => directTranslation('zh-CN', key) === undefined), []); +}); + test('every t() call resolves to a literal, a declared family, or a known indirection', () => { assert.deepEqual( [...indirectCalls].sort(), diff --git a/scripts/interruptedSessionRestore.test.ts b/scripts/interruptedSessionRestore.test.ts deleted file mode 100644 index cb7fd14..0000000 --- a/scripts/interruptedSessionRestore.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import test from 'node:test'; - -// The wiring behind the behaviour that `sessionRestoreResilience.test.ts` -// exercises: the breadcrumb key exists, it is written before the work and -// settled afterwards no matter how the pass ends, and an interrupted pass is -// never answered by deleting the snapshot. - -const viewer = readFileSync('src/lib/MarkdownViewer.svelte', 'utf8'); -const session = readFileSync('src/lib/sessions/windowSession.svelte.ts', 'utf8'); - -test('session restore records work in progress before restoring tabs', () => { - assert.match(viewer, /const RESTORE_IN_PROGRESS_KEY = 'markpad-window-restore-in-progress';/); - assert.match( - session, - /const progress: RestoreProgress = \{ running: true, pending: null, deferred, interruptions \};\s*\n\s*writeProgress\(progress\);/, - ); - // The record names the document being read, not just "a restore is running": - // that is what lets the next launch skip one document instead of all of them. - assert.match(session, /progress\.pending = tab\.path;\s*\n\s*writeProgress\(progress\);/); - assert.match(session, /finally \{\s*writeProgress\(\{ running: false,/s); -}); - -test('an interrupted restore keeps the snapshot instead of deleting it', () => { - const restore = session.slice(session.indexOf('async function restore'), session.indexOf('async function claimTransferredTab')); - assert.match(restore, /if \(previous\?\.running\)/); - // Deleting the whole snapshot is what made one document cost the user every - // tab they had open. Nothing in restore() may discard it. - assert.doesNotMatch(restore, /discardPersistedState/); - assert.doesNotMatch(restore, /clear_window_state/); - // Explicit exit is a different matter: the user chose it. - assert.match(session, /async function discardPersistedState/); - assert.match(session, /await invoke\('clear_window_state'\);/); - assert.match(viewer, /await windowSession\.discardPersistedState\(\);/); -}); diff --git a/scripts/macosPdfExport.test.ts b/scripts/macosPdfExport.test.ts index d563937..8d33f08 100644 --- a/scripts/macosPdfExport.test.ts +++ b/scripts/macosPdfExport.test.ts @@ -14,3 +14,39 @@ test('all Markpad webviews may invoke Tauri native printing', () => { 'macOS replaces window.print() with Tauri native printing, which requires this permission', ); }); + +// Salvaged from `windowsPdfExport.test.ts`, which was deleted for asserting the +// spelling of the Rust body (it stayed green with the `await` dropped from the +// call below). This part is the opposite kind of claim: a Tauri command name is +// a bare string on the JS side and an identifier inside a macro on the Rust +// side, so nothing — not tsc, not `svelte-check`, not `cargo check` — notices +// when one moves and the other does not. The failure is silent at build time +// and total at runtime: "Export PDF" does nothing at all. +test('both PDF commands the exporter invokes are registered with Tauri', () => { + const exporter = readFileSync('src/lib/utils/export.ts', 'utf8'); + const tauriLib = readFileSync('src-tauri/src/lib.rs', 'utf8'); + + const handlerStart = tauriLib.indexOf('tauri::generate_handler!['); + assert.notEqual(handlerStart, -1, 'lib.rs must keep a generate_handler! list'); + const handlerEnd = tauriLib.indexOf(']', handlerStart); + const registered = new Set( + tauriLib + .slice(handlerStart, handlerEnd) + .split(',') + .map((entry) => entry.trim().replace(/^.*::/, '')), + ); + + // Read the names off the exporter rather than restating them, so a renamed + // command is checked at its new name instead of quietly passing. + const invoked = [...exporter.matchAll(/invoke\('([a-z_]*pdf[a-z_]*)'/g)].map((m) => m[1]); + assert.deepEqual(invoked.sort(), ['export_pdf_windows', 'print_pdf']); + + for (const command of invoked) { + assert.ok(registered.has(command), `export.ts invokes '${command}', which lib.rs must register`); + assert.match( + tauriLib, + new RegExp(`#\\[tauri::command\\]\\n(?:async )?fn ${command}\\(`), + `${command} must be defined as a Tauri command`, + ); + } +}); diff --git a/scripts/openMultipleFiles.test.ts b/scripts/openMultipleFiles.test.ts deleted file mode 100644 index 3efbbd3..0000000 --- a/scripts/openMultipleFiles.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import test from 'node:test'; - -const viewer = readFileSync('src/lib/MarkdownViewer.svelte', 'utf8'); -const selectFile = viewer.slice(viewer.indexOf('async function selectFile'), viewer.indexOf('async function reloadFromDisk')); - -test('Open File accepts multiple documents and loads each selected path', () => { - assert.match(selectFile, /multiple: true/); - assert.match(selectFile, /const paths = Array\.isArray\(selected\) \? selected : \[selected\];/); - assert.match(selectFile, /for \(const path of paths\) await loadMarkdown\(path\);/); -}); diff --git a/scripts/previewRenderRevision.test.ts b/scripts/previewRenderRevision.test.ts deleted file mode 100644 index 64a3910..0000000 --- a/scripts/previewRenderRevision.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import test from 'node:test'; - -const viewer = readFileSync('src/lib/MarkdownViewer.svelte', 'utf8'); - -test('preview rendering uses a revision and cleans up its debounce timer', () => { - assert.match(viewer, /let previewRenderRevision = 0;/); - assert.match(viewer, /const renderRevision = \+\+previewRenderRevision;/); - assert.match(viewer, /return \(\) => clearTimeout\(timer\);/); -}); - -test('a completed preview render verifies its tab, content, and revision', () => { - assert.match(viewer, /previewRenderRevision !== renderRevision/); - assert.match(viewer, /tabManager\.activeTabId !== tabId/); - assert.match(viewer, /currentTab\?\.rawContent !== rawContent/); -}); diff --git a/scripts/previewScrollSync.test.ts b/scripts/previewScrollSync.test.ts deleted file mode 100644 index da147f5..0000000 --- a/scripts/previewScrollSync.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import { test } from 'node:test'; - -const markdownViewer = readFileSync('src/lib/MarkdownViewer.svelte', 'utf8'); -const editor = readFileSync('src/lib/components/Editor.svelte', 'utf8'); - -test('preview scroll sync uses pixel segment positions instead of source lines', () => { - assert.match(markdownViewer, /type ScrollSyncPosition = \{\s*section: 'frontmatter' \| 'body';\s*ratio: number;\s*\}/); - assert.match(markdownViewer, /function getPreviewFrontMatterScrollEnd\(target: HTMLElement\)/); - assert.match(markdownViewer, /function getPreviewScrollSyncPosition\(target: HTMLElement\)/); - assert.match(markdownViewer, /function scrollPreviewToSyncPosition\(position: ScrollSyncPosition\)/); - assert.match(markdownViewer, /function handleEditorScrollSync\(position: ScrollSyncPosition\)/); - assert.match(markdownViewer, /const position = getPreviewScrollSyncPosition\(target\);[\s\S]*editorPane\.syncScrollToPosition\(position\)/); - assert.doesNotMatch(markdownViewer, /editorPane\.syncScrollToLine\(anchor\.line, anchor\.ratio\)/); -}); - -test('editor emits and applies pixel segment scroll positions', () => { - assert.match(editor, /type ScrollSyncPosition = \{\s*section: 'frontmatter' \| 'body';\s*ratio: number;\s*\}/); - assert.match(editor, /onscrollsync\?: \(position: ScrollSyncPosition\) => void/); - assert.match(editor, /function getEditorFrontMatterScrollEnd\(\)/); - assert.match(editor, /function getEditorContentScrollMax\(\)/); - assert.match(editor, /function getEditorScrollSyncPosition\(\)/); - assert.match(editor, /export function syncScrollToPosition\(position: ScrollSyncPosition\)/); - assert.match(editor, /editor\.getContentHeight\(\) - layout\.height/); - assert.match(editor, /getEditorScrollSyncPosition\(\)[\s\S]*getEditorContentScrollMax\(\)/); - assert.match(editor, /syncScrollToPosition\(position: ScrollSyncPosition\)[\s\S]*getEditorContentScrollMax\(\)/); - assert.match(editor, /onscrollsync\?\.\(getEditorScrollSyncPosition\(\)\)/); - assert.doesNotMatch(editor, /onscrollsync\?\.\(position\.lineNumber, ratio\)/); - assert.doesNotMatch(editor, /export function syncScrollToLine/); -}); - -test('source-position anchors are retained only for saved tab anchor lines', () => { - assert.match(markdownViewer, /type PreviewScrollAnchor/); - assert.match(markdownViewer, /function getPreviewScrollAnchor\(target: HTMLElement\)/); - assert.match(markdownViewer, /const anchor = getPreviewScrollAnchor\(target\);[\s\S]*tabManager\.updateTabAnchorLine\(tabManager\.activeTabId, anchor\.line\)/); - assert.doesNotMatch(markdownViewer, /if \(!sourcepos\) break/); -}); diff --git a/scripts/reloadOpenToolbar.test.ts b/scripts/reloadOpenToolbar.test.ts deleted file mode 100644 index b7692d8..0000000 --- a/scripts/reloadOpenToolbar.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import { test } from 'node:test'; - -const markdownViewer = readFileSync('src/lib/MarkdownViewer.svelte', 'utf8'); -const titleBar = readFileSync('src/lib/components/TitleBar.svelte', 'utf8'); -const editor = readFileSync('src/lib/components/Editor.svelte', 'utf8'); -const tauriLib = readFileSync('src-tauri/src/lib.rs', 'utf8'); - -test('manual reload is wired through titlebar and F5 with dirty-state protection', () => { - assert.match(markdownViewer, /async function reloadFromDisk\(\)/); - assert.match(markdownViewer, /await canCloseTab\(activeId\)/); - assert.match(markdownViewer, /loadMarkdown\(tab\.path,\s*\{[\s\S]*preserveEditState:\s*true/); - assert.match(markdownViewer, /code === 'F5'[\s\S]*reloadFromDisk\(\)/); - assert.match(titleBar, /onreloadFromDisk/); - assert.match(titleBar, /id === 'reload'/); - assert.doesNotMatch(tauriLib, /menu-file-reload/); -}); - -test('preview-level open shortcut handles Ctrl/Cmd+O outside Monaco', () => { - assert.match(markdownViewer, /cmdOrCtrl[\s\S]*key === 'o'[\s\S]*selectFile\(\)/); -}); - -test('editor toolbar forwards Monaco actions and optional payloads', () => { - assert.match(markdownViewer, /import EditorToolbar from '\.\/components\/EditorToolbar\.svelte'/); - assert.match(markdownViewer, / editorPane\?\.runEditorAction\(actionId, payload\)\}/); - assert.match(editor, /export function runEditorAction\(actionId: string, payload\?: any\)/); - - for (const actionId of [ - 'fmt-bold', - 'fmt-italic', - 'fmt-underline', - 'fmt-inline-code', - 'fmt-code-block', - 'fmt-quote', - 'fmt-heading-1', - 'fmt-heading-2', - 'fmt-heading-3', - 'fmt-bullet-list', - 'fmt-numbered-list', - 'fmt-checklist', - 'fmt-link', - 'insert-table-simple', - ]) { - assert.match(editor, new RegExp(`id: "${actionId}"`)); - } -}); diff --git a/scripts/tabContextMenuIsolation.test.ts b/scripts/tabContextMenuIsolation.test.ts deleted file mode 100644 index ee95e39..0000000 --- a/scripts/tabContextMenuIsolation.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import test from 'node:test'; - -const contextMenu = readFileSync(new URL('../src/lib/components/ContextMenu.svelte', import.meta.url), 'utf8'); -const viewer = readFileSync(new URL('../src/lib/MarkdownViewer.svelte', import.meta.url), 'utf8'); -const tab = readFileSync(new URL('../src/lib/components/Tab.svelte', import.meta.url), 'utf8'); -const lib = readFileSync(new URL('../src-tauri/src/lib.rs', import.meta.url), 'utf8'); - -// Issue #356, first failure: right-clicking a second time while a tab menu is -// open produced a *different* menu. The open menu's overlay covers the whole -// viewport, so the second right-click lands on the overlay — which dismissed -// the tab menu but let the event keep bubbling to the document-level handler, -// which then opened the document context menu (Copy / Select All / Edit). -test('the context-menu overlay does not leak right-clicks to the document handler', () => { - assert.match( - contextMenu, - /class="context-menu-overlay"[^>]*oncontextmenu=\{\(e\) => \{ e\.preventDefault\(\); e\.stopPropagation\(\); onhide\(\); \}\}/, - ); -}); - -test('the document context menu is still reachable when no menu overlay is open', () => { - // Guard against "fixing" the leak by disabling the document menu outright. - assert.match(viewer, /oncontextmenu=\{handleContextMenu\}/); - assert.match(viewer, /function handleContextMenu\(e: MouseEvent\) \{/); -}); - -test('a tab right-click never reaches the tab-strip container menu', () => { - // Tab.svelte stops propagation itself; that is the other half of keeping - // exactly one menu per right-click. - assert.match( - tab, - /async function handleContextMenu\(e: MouseEvent\) \{\n\t\te\.preventDefault\(\);\n\t\te\.stopPropagation\(\);/, - ); -}); - -// Issue #356, second failure: "Move to New Window" did nothing on Windows and -// then froze the whole app (no menus, dead close button, force-kill required). -// `WebviewWindowBuilder::build()` deadlocks when called from a synchronous -// Tauri command on Windows/WebView2 — the main thread is blocked inside the -// command while WebView2 waits for that same thread to pump messages. -// See tauri-apps/tauri#12521. -test('create_transfer_window is async so window creation cannot deadlock the main thread', () => { - assert.match(lib, /#\[tauri::command\]\nasync fn create_transfer_window\(/); - assert.doesNotMatch(lib, /#\[tauri::command\]\nfn create_transfer_window\(/); -}); diff --git a/scripts/taskToggleMemory.test.ts b/scripts/taskToggleMemory.test.ts deleted file mode 100644 index 2084c5c..0000000 --- a/scripts/taskToggleMemory.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import test from 'node:test'; - -const session = readFileSync(new URL('../src/lib/sessions/documentSession.svelte.ts', import.meta.url), 'utf8'); -const markdownProcessing: string = readFileSync(new URL('../src/lib/utils/markdown.ts', import.meta.url), 'utf8'); -const viewer = readFileSync(new URL('../src/lib/MarkdownViewer.svelte', import.meta.url), 'utf8'); - -test('preview task toggles transform the active in-memory buffer before saving', () => { - const taskToggle = session.match(/async function toggleTaskCheckbox[\s\S]*?\n\t}\n\n\treturn/); - assert.ok(taskToggle); - assert.doesNotMatch(taskToggle[0], /read_file_content/); - assert.match(taskToggle[0], /const raw = tab\.rawContent;/); - assert.match(taskToggle[0], /getMarkdownBodyWithoutFrontMatter\(raw\)/); - assert.match(taskToggle[0], /body\.slice\(0, offset\)\.split\('\\n'\)\.length/); - assert.match(taskToggle[0], /(?:\[-\+\*\]|\\d\+\[\.\)\])/); - assert.match(taskToggle[0], /tabManager\.updateTabRawContent\(tab\.id, updated\);/); - assert.match(taskToggle[0], /await saveContent\(tab\.id\)/); -}); - -test('preview task toggles use the renderer source line instead of checkbox order', () => { - const viewerToggle = viewer.match(/async function toggleTaskCheckbox[\s\S]*?\n\t}\n\n/); - assert.ok(viewerToggle); - assert.match(viewerToggle[0], /closest\('li'\)\?\.getAttribute\('data-sourcepos'\)/); - assert.match(viewerToggle[0], /documentSession\.toggleTaskCheckbox\(sourceLine, nowChecked\)/); - assert.doesNotMatch(viewerToggle[0], /allBoxes/); - assert.match(viewer, /onchange=\{handleTaskCheckboxChange\}/); - assert.match(viewer, /const nowChecked = checkbox\.checked/); - assert.doesNotMatch(viewerToggle[0], /const nowChecked = !checkbox\.checked/); -}); - -test('preview task processing trusts the Markdown renderer task marker', () => { - const taskProcessing = markdownProcessing.match(/function processTaskItems[\s\S]*?\n}\n\nexport function processMarkdownHtml/); - assert.ok(taskProcessing); - assert.match(taskProcessing[0], /input\.hasAttribute\("data-task-checkbox"\)/); - assert.match(taskProcessing[0], /input\.setAttribute\("disabled", ""\)/); - assert.match(markdownProcessing, /input\.removeAttribute\("disabled"\)/); -}); diff --git a/scripts/tocEditorJump.test.ts b/scripts/tocEditorJump.test.ts deleted file mode 100644 index 440ec77..0000000 --- a/scripts/tocEditorJump.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import test from 'node:test'; - -const viewer = readFileSync('src/lib/MarkdownViewer.svelte', 'utf8'); -const editor = readFileSync('src/lib/components/Editor.svelte', 'utf8'); -const toc = readFileSync('src/lib/components/Toc.svelte', 'utf8'); - -test('ToC editor jumps use the clicked heading source line, not duplicate heading text', () => { - assert.match(toc, /onjump\?: \(id: string, text: string, sourceLine: number \| null\) => void;/); - assert.match(toc, /const \w+ = Number\(\w+\.dataset\.sourcepos\?\.match\(\/\^\(\\d\+\):\/\)\?\.\[1\]\)/); - assert.match(viewer, /onjump=\{\(id: string, text: string, sourceLine: number \| null\) => \{[\s\S]*?editorPane\.revealHeader\(sourceLine, text\);/); - assert.match(editor, /export function revealHeader\(sourceLine: number \| null, text: string\)/); - assert.match(editor, /const (\w+) = sourceLine \?\? 0;[\s\S]*?revealLineInCenterIfOutsideViewport\(\1,/); -}); diff --git a/scripts/toolbarCustomizationWiring.test.ts b/scripts/toolbarCustomizationWiring.test.ts deleted file mode 100644 index 64d82cd..0000000 --- a/scripts/toolbarCustomizationWiring.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import { test } from 'node:test'; - -import { getSupportedLanguages, translations, type LanguageCode } from '../src/lib/utils/i18n.js'; - -const markdownViewer = readFileSync('src/lib/MarkdownViewer.svelte', 'utf8'); -const settingsComponent = readFileSync('src/lib/components/Settings.svelte', 'utf8'); -const settingsStore = readFileSync('src/lib/stores/settings.svelte.ts', 'utf8'); -const titleBar = readFileSync('src/lib/components/TitleBar.svelte', 'utf8'); -const toastComponent = readFileSync('src/lib/components/Toast.svelte', 'utf8'); - -function getDirectTranslation(lang: LanguageCode, key: string): string | undefined { - let current: unknown = translations[lang]; - for (const part of key.split('.')) { - if (!current || typeof current !== 'object' || !(part in current)) return undefined; - current = (current as Record)[part]; - } - return typeof current === 'string' ? current : undefined; -} - -test('editor toolbar receives order and hidden configuration from settings', () => { - assert.match(markdownViewer, /toolbarOrder=\{settings\.editorToolbarOrder\}/); - assert.match(markdownViewer, /toolbarHidden=\{settings\.editorToolbarHidden\}/); -}); - -test('settings expose pointer-driven toolbar customization controls', () => { - assert.match(settingsComponent, /settings\.reorderEditorToolbarTool/); - assert.match(settingsComponent, /handleEditorToolbarDragPointerDown/); - assert.match(settingsComponent, /data-editor-toolbar-tool-id/); - assert.match(settingsComponent, /settings\.setEditorToolbarToolVisible/); - assert.match(settingsComponent, /settings\.resetEditorToolbar/); - assert.doesNotMatch(settingsComponent, /draggable="true"/); - assert.doesNotMatch(settingsComponent, /ondrag(start|over|end)|ondrop/); -}); - -test('settings separate editor toolbar and application titlebar toolbar customization', () => { - assert.match(settingsComponent, /activeCategory === 'toolbars'/); - assert.match(settingsComponent, /settings\.reorderTitlebarToolbarAction/); - assert.match(settingsComponent, /handleTitlebarToolbarDragPointerDown/); - assert.match(settingsComponent, /data-titlebar-toolbar-action-id/); - assert.match(settingsComponent, /settings\.setTitlebarToolbarActionVisible/); - assert.match(settingsComponent, /settings\.setTitlebarToolbarActionPlacement/); - assert.match(settingsComponent, /settings\.resetTitlebarToolbar/); - assert.match(settingsStore, /titlebar\.toolbarOrder/); - assert.match(settingsStore, /titlebar\.toolbarHidden/); - assert.match(settingsStore, /titlebar\.toolbarPlacement/); -}); - -test('titlebar applies user toolbar settings before rendering bar and more menu', () => { - assert.match(titleBar, /getConfiguredTitlebarToolbarIds/); - assert.match(titleBar, /settings\.titlebarToolbarOrder/); - assert.match(titleBar, /configuredActionIds\.barIds/); - assert.match(titleBar, /configuredActionIds\.menuIds/); - assert.doesNotMatch(titleBar, /const inlineIds/); -}); - -test('settings modal uses a dedicated resize handle instead of native CSS resize', () => { - assert.match(settingsComponent, /class="settings-resize-handle/); - assert.match(settingsComponent, /handleSettingsResizePointerDown/); - assert.doesNotMatch(settingsComponent, /resize:\s*both/); - assert.match(settingsComponent, /\.settings-modal[\s\S]*min-width:/); - assert.match(settingsComponent, /\.settings-modal[\s\S]*max-height:/); -}); - -test('settings modal starts dragging only from the non-interactive header surface', () => { - assert.match(settingsComponent, /handleSettingsModalDragPointerDown/); - assert.match(settingsComponent, /class="settings-modal"[\s\S]*onpointerdown=\{handleSettingsModalDragPointerDown\}/); - assert.match(settingsComponent, /closest\('\.settings-header'\)/); - assert.doesNotMatch(settingsComponent, /class="settings-header"[^>]*onpointerdown=/); - assert.match(settingsComponent, /isSettingsHeaderInteractiveTarget\(e\.target\)/); - - assert.match(settingsComponent, /settingsResizeHandles/); - for (const handleClass of ['resize-n', 'resize-ne', 'resize-e', 'resize-se', 'resize-s', 'resize-sw', 'resize-w', 'resize-nw']) { - assert.match(settingsComponent, new RegExp(`className: '${handleClass}'`)); - } -}); - -test('settings modal does not light-dismiss when clicking the backdrop', () => { - assert.match(settingsComponent, /aria-modal="true"/); - assert.doesNotMatch(settingsComponent, /class="settings-backdrop"[^>]*onclick=\{handleBackdropClick\}/); - assert.doesNotMatch(settingsComponent, /function handleBackdropClick[\s\S]*onclose\(\)/); -}); - -test('toolbar settings use collapsed accordions for application and editor toolbars', () => { - const accordionBlocks = Array.from(settingsComponent.matchAll(//g)); - - assert.equal(accordionBlocks.length, 2); - assert.match(accordionBlocks[0][0], /settings\.applicationToolbar/); - assert.match(accordionBlocks[1][0], /settings\.editorToolbar/); - assert.match(accordionBlocks[0][0], //); - assert.match(accordionBlocks[1][0], //); - assert.match(accordionBlocks[0][0], /class="toolbar-settings-chevron"/); - assert.match(accordionBlocks[1][0], /class="toolbar-settings-chevron"/); - for (const block of accordionBlocks) { - assert.doesNotMatch(block[0], /]*\sopen(?:[\s=>]|$)/); - } - assert.match(settingsComponent, /\.toolbar-settings\[open\]\s+\.toolbar-settings-chevron/); - assert.match(settingsComponent, /\.toolbar-settings-summary::-webkit-details-marker/); -}); - -test('interactive button labels are directly translated for every supported language', () => { - const interactiveLabelKeys = [ - 'common.close', - 'common.decrease', - 'common.increase', - 'toc.resizeTableOfContents', - 'settings.move', - 'settings.moveUp', - 'settings.moveDown', - 'settings.resetToolbar', - 'settings.toolbarOnBar', - 'settings.toolbarInMenu', - 'settings.resizeWindow', - ]; - - const missing = getSupportedLanguages().flatMap(({ code }) => - interactiveLabelKeys - .filter((key) => getDirectTranslation(code, key) === undefined) - .map((key) => `${code}:${key}`) - ); - - assert.deepEqual(missing, []); - assert.doesNotMatch(settingsComponent, /aria-label="(?:Close|Decrease|Increase)"/); - assert.doesNotMatch(settingsComponent, />\s*(?:Up|Down)\s* { - const keys = [ - 'menu.moveToWindow', 'menu.window', 'menu.mergeAllWindows', 'menu.setWindowTag', - 'menu.windowTagPlaceholder', 'menu.windowTagClear', 'menu.pinWindowTag', - 'menu.unpinWindowTag', 'toast.noOtherWindows', 'home.pinnedTags', 'home.pinnedFileCount', - ]; - assert.deepEqual(keys.filter((key) => getDirectTranslation('zh-CN', key) === undefined), []); - assert.doesNotMatch(readFileSync('src/lib/components/HomePage.svelte', 'utf8'), /\{tag\.files\.length\} files/); -}); - -test('top toolbar overflow no longer includes Open file location action', () => { - assert.doesNotMatch(titleBar, /list\.push\('open_loc'\)/); -}); diff --git a/scripts/viewerDisposal.test.ts b/scripts/viewerDisposal.test.ts deleted file mode 100644 index ca0a19e..0000000 --- a/scripts/viewerDisposal.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import test from 'node:test'; - -const viewer = readFileSync('src/lib/MarkdownViewer.svelte', 'utf8'); - -test('onMount initialization tracks and checks disposal around async work', () => { - assert.match(viewer, /let isDisposed = false;/); - assert.match(viewer, /if \(isDisposed\) return;/); - assert.match(viewer, /if \(!isDisposed && args\?\.length > 0\)/); - assert.match(viewer, /isDisposed = true;/); -}); - -test('listeners registered after disposal are released', () => { - assert.match(viewer, /if \(isDisposed\) \{\s*unlisteners\.forEach\(\(unlisten\) => unlisten\(\)\);/s); -}); diff --git a/scripts/windowOrganization.test.ts b/scripts/windowOrganization.test.ts index 15bb610..be20310 100644 --- a/scripts/windowOrganization.test.ts +++ b/scripts/windowOrganization.test.ts @@ -25,6 +25,22 @@ test('moving to an existing window uses the acknowledged transfer protocol', () assert.match(viewer, /invoke\('offer_tab_to_window', \{ targetLabel, token \}\)/); }); +// Salvaged from `tabContextMenuIsolation.test.ts`, which was deleted for pinning +// the exact text of two inline Svelte event handlers. This assertion is not +// about spelling: `WebviewWindowBuilder::build()` deadlocks when called from a +// *synchronous* Tauri command on Windows/WebView2, because the main thread is +// blocked inside the command while WebView2 waits for that same thread to pump +// messages (tauri-apps/tauri#12521). Both forms compile everywhere, and CI's +// `cargo test` runs on a host where the deadlock cannot happen, so dropping the +// `async` is invisible until "Move to New Window" freezes a Windows user's app +// hard enough to need a force-kill. Issue #356. +test('create_transfer_window is async so window creation cannot deadlock the main thread', () => { + const lib = readFileSync('src-tauri/src/lib.rs', 'utf8'); + + assert.match(lib, /#\[tauri::command\]\nasync fn create_transfer_window\(/); + assert.doesNotMatch(lib, /#\[tauri::command\]\nfn create_transfer_window\(/); +}); + test('window organization exposes move, merge, and carry actions', () => { assert.match(tab, /list_viewer_windows/); assert.match(tab, /menu-tab-move/); diff --git a/scripts/windowsPdfExport.test.ts b/scripts/windowsPdfExport.test.ts deleted file mode 100644 index 9205d04..0000000 --- a/scripts/windowsPdfExport.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import { test } from 'node:test'; - -const exporter = readFileSync('src/lib/utils/export.ts', 'utf8'); -const tauriLib = readFileSync('src-tauri/src/lib.rs', 'utf8'); - -test('Windows PDF export uses WebView2 settings that suppress print headers and footers', () => { - assert.match(exporter, /export async function exportAsPdf\(ctx: PdfExportContext\)/); - assert.match(exporter, /if \(ctx\.osType !== 'windows'\) \{\s*await invoke\('print_pdf'\);/); - assert.match(exporter, /filters: \[\{ name: 'PDF', extensions: \['pdf'\] \}\]/); - assert.match(exporter, /invoke\('export_pdf_windows', \{ path: selected \}\)/); - assert.match(tauriLib, /async fn export_pdf_windows\(/); - assert.match(tauriLib, /fn print_pdf\(window: tauri::WebviewWindow\) -> Result<\(\), String> \{\s*window\.print\(\)/); - assert.match(tauriLib, /save_file_content,\s*\n\s*export_pdf_windows,\s*\n\s*print_pdf,/); - assert.match(tauriLib, /SetShouldPrintHeaderAndFooter\(false\)/); - assert.match(tauriLib, /PrintToPdf\(/); - assert.match(tauriLib, /use webview2_com::\{\s*PrintToPdfCompletedHandler,/); - assert.doesNotMatch(tauriLib, /callback::PrintToPdfCompletedHandler/); - assert.match(tauriLib, /recv_timeout\(Duration::from_secs\(60\)\)/); -});