chore(release): prepare v1.3.5

This commit is contained in:
Yuri Karamian
2026-08-17 16:50:14 +02:00
parent b253ec4121
commit ade146f9f3
35 changed files with 3973 additions and 3174 deletions
+164
View File
@@ -0,0 +1,164 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { Schema } from '@tiptap/pm/model';
import { EditorState, TextSelection } from '@tiptap/pm/state';
import { applyClearFormatting } from '../src/lib/editor/clearFormatting.ts';
const schema = new Schema({
nodes: {
doc: { content: 'block+' },
paragraph: { content: 'text*', group: 'block' },
heading: { attrs: { level: { default: 1 } }, content: 'text*', group: 'block' },
codeBlock: { content: 'text*', group: 'block', code: true },
blockquote: { content: 'block+', group: 'block' },
bulletList: { content: 'listItem+', group: 'block' },
listItem: { content: 'paragraph block*' },
taskList: { content: 'taskItem+', group: 'block' },
taskItem: { attrs: { checked: { default: false } }, content: 'paragraph block*' },
text: { group: 'inline' },
},
marks: {
bold: {},
italic: {},
strike: {},
underline: {},
highlight: { attrs: { color: { default: null } } },
code: {},
link: { attrs: { href: {} } },
wikiLink: { attrs: { path: { default: null } } },
},
});
const mark = (name, attrs = null) => schema.mark(name, attrs);
const text = (value, marks = []) => schema.text(value, marks);
const para = (...children) => schema.node('paragraph', null, children);
function clearedDoc(doc, from, to = from) {
const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, from, to) });
const tr = state.tr;
applyClearFormatting(schema, tr);
return state.apply(tr).doc;
}
function markSummary(doc) {
const summary = [];
doc.descendants((node) => {
if (node.isText) summary.push(`${node.text}${node.marks.map((m) => m.type.name).join('+') || 'none'}`);
return true;
});
return summary;
}
test('strips formatting marks from a selection but keeps link and wikiLink', () => {
// "bold"[1,5) "link"[5,9) "wiki"[9,13)
const doc = schema.node('doc', null, [
para(
text('bold', [mark('bold')]),
text('link', [mark('link', { href: 'https://example.com' })]),
text('wiki', [mark('wikiLink', { path: 'notes/wiki.md' })]),
),
]);
const result = clearedDoc(doc, 1, 13);
const nodes = [];
result.firstChild.forEach((node) => nodes.push(node));
assert.deepEqual(nodes[0].marks, []);
assert.deepEqual(nodes[1].marks.map((m) => m.type.name), ['link']);
assert.equal(nodes[1].marks[0].attrs.href, 'https://example.com');
assert.deepEqual(nodes[2].marks.map((m) => m.type.name), ['wikiLink']);
assert.equal(nodes[2].marks[0].attrs.path, 'notes/wiki.md');
});
test('empty selection clears the entire current line', () => {
// "one "[1,5) "two"[5,8) " three"[8,14), cursor inside "one" at 2
const doc = schema.node('doc', null, [
para(text('one '), text('two', [mark('bold')]), text(' three', [mark('italic')])),
]);
const result = clearedDoc(doc, 2);
assert.deepEqual(markSummary(result), ['one two three→none']);
});
test('cursor in a heading demotes it to a plain paragraph', () => {
const doc = schema.node('doc', null, [
schema.node('heading', { level: 2 }, [text('Title', [mark('bold')])]),
]);
const result = clearedDoc(doc, 3);
assert.equal(result.firstChild.type.name, 'paragraph');
assert.equal(result.firstChild.textContent, 'Title');
assert.deepEqual(markSummary(result), ['Title→none']);
});
test('cursor in a code block converts it to a paragraph', () => {
const doc = schema.node('doc', null, [schema.node('codeBlock', null, [text('const x = 1;')])]);
const result = clearedDoc(doc, 4);
assert.equal(result.firstChild.type.name, 'paragraph');
assert.equal(result.firstChild.textContent, 'const x = 1;');
});
test('cursor in a blockquote lifts the content out of the wrapper', () => {
const doc = schema.node('doc', null, [
schema.node('blockquote', null, [para(text('quoted', [mark('italic')]))]),
]);
const result = clearedDoc(doc, 3);
assert.equal(result.childCount, 1);
assert.equal(result.firstChild.type.name, 'paragraph');
assert.deepEqual(markSummary(result), ['quoted→none']);
});
test('nested blockquotes are fully unwrapped', () => {
const doc = schema.node('doc', null, [
schema.node('blockquote', null, [
schema.node('blockquote', null, [para(text('deep', [mark('bold')]))]),
]),
]);
const result = clearedDoc(doc, 3);
assert.equal(result.childCount, 1);
assert.equal(result.firstChild.type.name, 'paragraph');
assert.deepEqual(markSummary(result), ['deep→none']);
});
test('task list structure and checked state survive clearing', () => {
const doc = schema.node('doc', null, [
schema.node('taskList', null, [
schema.node('taskItem', { checked: true }, [para(text('done task', [mark('strike')]))]),
schema.node('taskItem', { checked: false }, [para(text('open task'))]),
]),
]);
// cursor inside "done task": taskList at 0, taskItem at 1, paragraph at 2, text from 3
const result = clearedDoc(doc, 5);
const list = result.firstChild;
assert.equal(list.type.name, 'taskList');
assert.equal(list.childCount, 2);
assert.equal(list.firstChild.type.name, 'taskItem');
assert.equal(list.firstChild.attrs.checked, true);
assert.equal(list.lastChild.attrs.checked, false);
assert.deepEqual(markSummary(result), ['done task→none', 'open task→none']);
});
test('partial selection keeps formatting outside the selection', () => {
// "abcd" all bold; clear only [2,4) = "bc"
const doc = schema.node('doc', null, [para(text('abcd', [mark('bold')]))]);
const result = clearedDoc(doc, 2, 4);
assert.deepEqual(markSummary(result), ['a→bold', 'bc→none', 'd→bold']);
});
test('multi-block selection demotes headings and strips marks in range', () => {
// heading "H1" [0,4), paragraph at 4 with "body" bold [5,9)
const doc = schema.node('doc', null, [
schema.node('heading', { level: 1 }, [text('H1')]),
para(text('body', [mark('bold')])),
]);
const result = clearedDoc(doc, 2, 7);
assert.equal(result.firstChild.type.name, 'paragraph');
// marks removed only up to position 7: "bo" cleared, "dy" still bold
assert.deepEqual(markSummary(result), ['H1→none', 'bo→none', 'dy→bold']);
});
+95
View File
@@ -0,0 +1,95 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { Schema } from '@tiptap/pm/model';
import { EditorState, Plugin, TextSelection } from '@tiptap/pm/state';
import { createCodeBlockInputScrollPlugin } from '../src/lib/editor/extensions/codeBlockInputScroll.ts';
const schema = new Schema({
nodes: {
doc: { content: 'block+' },
paragraph: { content: 'text*', group: 'block' },
codeBlock: {
attrs: { language: { default: null } },
content: 'text*',
group: 'block',
code: true,
},
text: { group: 'inline' },
},
});
const inputRulesPlugin = new Plugin({ isInputRules: true });
function textBlock(type, text, attrs = null) {
return schema.node(type, attrs, text ? [schema.text(text)] : undefined);
}
function stateWithSelection(type, text, options = {}) {
const { attrs = null, from = text.length + 1, to = from } = options;
const doc = schema.node('doc', null, [textBlock(type, text, attrs)]);
return EditorState.create({
doc,
selection: TextSelection.create(doc, from, to),
plugins: [inputRulesPlugin, createCodeBlockInputScrollPlugin()],
});
}
function convertToCodeBlock(state, language = null, { inputRule = true } = {}) {
const codeBlock = textBlock('codeBlock', '', { language });
const tr = state.tr.replaceWith(0, state.doc.content.size, codeBlock);
tr.setSelection(TextSelection.create(tr.doc, 1));
if (inputRule) {
tr.setMeta(inputRulesPlugin, {
transform: tr,
from: state.selection.from,
to: state.selection.to,
text: '\n',
});
}
return state.applyTransaction(tr);
}
function assertAppendedScroll(result) {
assert.equal(result.transactions.length, 2);
assert.equal(result.transactions[0].scrolledIntoView, false);
assert.equal(result.transactions[1].scrolledIntoView, true);
assert.equal(result.state.selection.$from.parent.type.name, 'codeBlock');
}
function assertNoAppendedScroll(result) {
assert.equal(result.transactions.length, 1);
assert.equal(result.transactions[0].scrolledIntoView, false);
}
test('appends a scroll request for backtick and tilde input-rule transitions', () => {
assertAppendedScroll(convertToCodeBlock(stateWithSelection('paragraph', '```')));
assertAppendedScroll(convertToCodeBlock(stateWithSelection('paragraph', '~~~rust'), 'rust'));
});
test('does not append a scroll request for non-fence paragraph conversions', () => {
assertNoAppendedScroll(convertToCodeBlock(stateWithSelection('paragraph', 'ordinary text')));
assertNoAppendedScroll(convertToCodeBlock(stateWithSelection('paragraph', '```TypeScript')));
assertNoAppendedScroll(convertToCodeBlock(stateWithSelection('paragraph', '````')));
});
test('does not append a scroll request for toolbar conversions or note loads', () => {
assertNoAppendedScroll(
convertToCodeBlock(stateWithSelection('paragraph', '```'), null, { inputRule: false })
);
assertNoAppendedScroll(
convertToCodeBlock(stateWithSelection('paragraph', '~~~rust'), 'rust', { inputRule: false })
);
});
test('does not append a scroll request for a non-empty fence selection', () => {
const state = stateWithSelection('paragraph', '```', { from: 1, to: 4 });
assertNoAppendedScroll(convertToCodeBlock(state));
});
test('does not append a scroll request for a transaction already in a code block', () => {
const state = stateWithSelection('codeBlock', '```');
const tr = state.tr.insertText('x');
assertNoAppendedScroll(state.applyTransaction(tr));
});
+27
View File
@@ -0,0 +1,27 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
const editor = await readFile(
new URL('../src/lib/components/Editor.svelte', import.meta.url),
'utf8'
);
test('Note Info owns the only open-note trash action', () => {
assert.doesNotMatch(editor, /editor-trash-btn/);
assert.match(editor, /class="info-trash-btn"[\s\S]{0,300}onclick=\{moveOpenNoteToTrash\}/);
assert.match(editor, /class="info-trash-btn"[\s\S]{0,500}Move to Trash/);
assert.equal(editor.match(/onclick=\{moveOpenNoteToTrash\}/g)?.length, 1);
});
test('Note Info hides the trash action when moving the note is unavailable', () => {
assert.match(
editor,
/\{#if onMoveToTrash && \$viewMode !== 'trash'\}[\s\S]{0,200}class="info-section info-actions"/
);
});
test('Note Info stays open when clicking another note', () => {
assert.doesNotMatch(editor, /onInfoClickAway/);
assert.match(editor, /class="info-close-btn" onclick=\{\(\) => showInfo = false\}/);
});
+80
View File
@@ -0,0 +1,80 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import { Schema } from '@tiptap/pm/model';
import { transformWithEsbuild } from 'vite';
const source = await readFile(
new URL('../src/lib/editor/mixedLists.ts', import.meta.url),
'utf8'
);
const { code } = await transformWithEsbuild(source, 'mixedLists.ts', {
loader: 'ts',
format: 'esm',
target: 'esnext'
});
const { convertListNode } = await import(
`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`
);
const schema = new Schema({
nodes: {
doc: { content: 'block+' },
paragraph: { content: 'text*', group: 'block' },
text: { group: 'inline' },
bulletList: { content: 'listItem+', group: 'block' },
listItem: { content: 'paragraph block*' },
taskList: { content: 'taskItem+', group: 'block' },
taskItem: {
attrs: { checked: { default: false } },
content: 'paragraph block*'
}
}
});
function paragraph(text) {
return schema.node('paragraph', null, [schema.text(text)]);
}
test('converts every task item in a list to a bullet item', () => {
const sourceList = schema.node('taskList', null, [
schema.node('taskItem', { checked: true }, [paragraph('First')]),
schema.node('taskItem', { checked: false }, [paragraph('Second')])
]);
const converted = convertListNode(schema, sourceList, 'bulletList');
assert.equal(converted.type.name, 'bulletList');
assert.deepEqual(
converted.content.content.map((item) => item.type.name),
['listItem', 'listItem']
);
assert.equal(converted.textContent, 'FirstSecond');
assert.equal(sourceList.type.name, 'taskList');
});
test('converts bullet items to unchecked tasks without dropping nested blocks', () => {
const nestedBullets = schema.node('bulletList', null, [
schema.node('listItem', null, [paragraph('Nested note')])
]);
const sourceList = schema.node('bulletList', null, [
schema.node('listItem', null, [paragraph('Parent'), nestedBullets])
]);
const converted = convertListNode(schema, sourceList, 'taskList');
const taskItem = converted.firstChild;
assert.equal(converted.type.name, 'taskList');
assert.equal(taskItem.type.name, 'taskItem');
assert.equal(taskItem.attrs.checked, false);
assert.equal(taskItem.lastChild.type.name, 'bulletList');
assert.equal(taskItem.textContent, 'ParentNested note');
});
test('leaves a list alone when it already has the requested type', () => {
const bullets = schema.node('bulletList', null, [
schema.node('listItem', null, [paragraph('Unchanged')])
]);
assert.equal(convertListNode(schema, bullets, 'bulletList'), null);
});
+28
View File
@@ -0,0 +1,28 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import { transformWithEsbuild } from 'vite';
const source = await readFile(
new URL('../src/lib/utils/note-drag.ts', import.meta.url),
'utf8'
);
const { code } = await transformWithEsbuild(source, 'note-drag.ts', {
loader: 'ts',
format: 'esm',
target: 'esnext'
});
const noteDrag = await import(`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`);
test('round-trips every selected note path in a drag payload', () => {
const paths = ['/vault/Alpha.md', '/vault/Projects/Beta.md'];
assert.deepEqual(noteDrag.decodeNoteDragPaths(noteDrag.encodeNoteDragPaths(paths)), paths);
});
test('keeps single-note and Windows path payloads compatible', () => {
assert.deepEqual(noteDrag.decodeNoteDragPaths('/vault/Alpha.md'), ['/vault/Alpha.md']);
assert.deepEqual(
noteDrag.decodeNoteDragPaths('C:\\Vault\\Alpha.md\r\nC:\\Vault\\Beta.md'),
['C:\\Vault\\Alpha.md', 'C:\\Vault\\Beta.md']
);
});
+117
View File
@@ -0,0 +1,117 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import { transformWithEsbuild } from 'vite';
const source = await readFile(
new URL('../src/lib/utils/paths.ts', import.meta.url),
'utf8'
);
const { code } = await transformWithEsbuild(source, 'paths.ts', {
loader: 'ts',
format: 'esm',
target: 'esnext'
});
const {
assetSourceToMarkdown,
assetUrlToLocalPath,
normalizeLocalAssetPath,
resolvePathFromFile,
resolveVaultFilePath
} = await import(
`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`
);
test('resolves note links relative to Windows note paths', () => {
assert.equal(
resolvePathFromFile('C:\\Vault\\Current.md', './Target.md'),
'C:/Vault/Target.md'
);
assert.equal(
resolvePathFromFile('C:\\Vault\\Folder\\Current.md', '../Target.md'),
'C:/Vault/Target.md'
);
assert.equal(
resolvePathFromFile('\\\\server\\share\\Folder\\Current.md', '../Target.md'),
'//server/share/Target.md'
);
});
test('preserves Linux relative note-link resolution', () => {
assert.equal(
resolvePathFromFile('/vault/folder/Current.md', '../Target.md'),
'/vault/Target.md'
);
});
test('converts Windows asset URLs back to portable attachment paths', () => {
assert.equal(
normalizeLocalAssetPath('/C:\\Users\\user\\Vault\\image.png'),
'C:/Users/user/Vault/image.png'
);
assert.equal(
assetSourceToMarkdown(
'http://asset.localhost/C%3A%5CUsers%5Cuser%5CVault%5C.helixnotes%5Cattachments%5Cimage.png',
'C:\\Users\\user\\Vault\\Note.md',
'C:\\Users\\user\\Vault'
),
'.helixnotes/attachments/image.png'
);
});
test('preserves portable asset paths on Linux and macOS', () => {
assert.equal(
assetSourceToMarkdown(
'asset://localhost/%2Fhome%2Fuser%2FVault%2F.helixnotes%2Fattachments%2Fimage.png',
'/home/user/Vault/Note.md',
'/home/user/Vault'
),
'.helixnotes/attachments/image.png'
);
assert.equal(
assetSourceToMarkdown(
'asset://localhost/%2FUsers%2Fuser%2FVault%2Fassets%2Fimage.png',
'/Users/user/Vault/notes/Note.md',
'/Users/user/Vault'
),
'../assets/image.png'
);
assert.equal(
assetSourceToMarkdown('../assets/image.png', '/vault/notes/Note.md', '/vault'),
'../assets/image.png'
);
});
test('decodes local asset URLs without damaging platform roots', () => {
assert.equal(
assetUrlToLocalPath('http://asset.localhost/C%3A%5CUsers%5Cuser%5CVault%5Cimage.png'),
'C:/Users/user/Vault/image.png'
);
assert.equal(
assetUrlToLocalPath('asset://localhost/%2Fhome%2Fuser%2FVault%2Fimage.png'),
'/home/user/Vault/image.png'
);
assert.equal(
assetUrlToLocalPath('http://asset.localhost/%5C%5Cserver%5Cshare%5CVault%5Cimage.png'),
'//server/share/Vault/image.png'
);
});
test('resolves vault files consistently across platforms', () => {
assert.equal(
resolveVaultFilePath('../assets/image.png', 'C:\\Vault\\notes\\Note.md', 'C:\\Vault'),
'C:/Vault/assets/image.png'
);
assert.equal(
resolveVaultFilePath('.helixnotes/attachments/image.png', 'C:\\Vault\\notes\\Note.md', 'C:\\Vault'),
'C:/Vault/.helixnotes/attachments/image.png'
);
assert.equal(
resolveVaultFilePath('../assets/image.png', '/vault/notes/Note.md', '/vault'),
'/vault/assets/image.png'
);
assert.equal(
resolveVaultFilePath('../assets/image.png', '\\\\server\\share\\Vault\\notes\\Note.md', '\\\\server\\share\\Vault'),
'//server/share/Vault/assets/image.png'
);
});
+120
View File
@@ -0,0 +1,120 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import { transformWithEsbuild } from 'vite';
const source = await readFile(
new URL('../src/lib/utils/note-switcher.ts', import.meta.url),
'utf8'
);
const { code } = await transformWithEsbuild(source, 'note-switcher.ts', {
loader: 'ts',
format: 'esm',
target: 'esnext'
});
const { buildNoteSwitcherRequestPaths, buildNoteSwitcherSections } = await import(
`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`
);
const note = (path, title, relativePath) => ({ path, title, relativePath });
test('requests the current note first, then unique history from newest to oldest', () => {
const current = '/vault/Current.md';
const recent = '/vault/Recent.md';
const older = '/vault/Older.md';
assert.deepEqual(
buildNoteSwitcherRequestPaths(current, [older, recent, recent, current]),
[current, recent, older]
);
});
test('puts the current note first, then unique history from newest to oldest up to the recent limit', () => {
const current = note('/vault/Current.md', 'Current', 'Current.md');
const alpha = note('/vault/Projects/Alpha.md', 'Alpha', 'Projects/Alpha.md');
const beta = note('/vault/Projects/Beta.md', 'Beta', 'Projects/Beta.md');
const sections = buildNoteSwitcherSections({
currentPath: current.path,
historyPaths: [beta.path, alpha.path, alpha.path, current.path],
knownNotes: [current, alpha, beta],
quickAccessNotes: [],
recentLimit: 3
});
assert.deepEqual(sections.recent, [
{ path: current.path, title: 'Current', folder: 'Unfiled', current: true },
{ path: alpha.path, title: 'Alpha', folder: 'Projects', current: false },
{ path: beta.path, title: 'Beta', folder: 'Projects', current: false }
]);
});
test('excludes stale, unknown, and external paths that are absent from the known-note inventory', () => {
const current = note('/vault/Current.md', 'Current', 'Current.md');
const known = note('/vault/Notes/Known.md', 'Known', 'Notes/Known.md');
const sections = buildNoteSwitcherSections({
currentPath: current.path,
historyPaths: [
'/vault/Deleted.md',
known.path,
'/home/user/Downloads/External.md'
],
knownNotes: [current, known],
quickAccessNotes: [
note('/vault/Missing-Quick.md', 'Missing Quick', 'Missing-Quick.md'),
note('/home/user/Downloads/Pinned.md', 'External Quick', 'Pinned.md')
]
});
assert.deepEqual(sections, {
recent: [
{ path: current.path, title: 'Current', folder: 'Unfiled', current: true },
{ path: known.path, title: 'Known', folder: 'Notes', current: false }
],
quickAccess: []
});
});
test('keeps Quick Access order, removes Recent overlap, and derives root and nested folder labels', () => {
const root = note('/vault/Root.md', 'Root', 'Root.md');
const recentNested = note(
'/vault/Projects/Helix/Plan.md',
'Plan',
'Projects/Helix/Plan.md'
);
const quickRoot = note('/vault/Scratch.md', 'Scratch', 'Scratch.md');
const quickNested = note(
'/vault/Areas/Reading/Queue.md',
'Reading Queue',
'Areas/Reading/Queue.md'
);
const sections = buildNoteSwitcherSections({
currentPath: root.path,
historyPaths: [recentNested.path],
knownNotes: [root, recentNested, quickRoot, quickNested],
quickAccessNotes: [quickRoot, root, quickNested, recentNested]
});
assert.deepEqual(sections, {
recent: [
{ path: root.path, title: 'Root', folder: 'Unfiled', current: true },
{
path: recentNested.path,
title: 'Plan',
folder: 'Projects/Helix',
current: false
}
],
quickAccess: [
{ path: quickRoot.path, title: 'Scratch', folder: 'Unfiled', current: false },
{
path: quickNested.path,
title: 'Reading Queue',
folder: 'Areas/Reading',
current: false
}
]
});
});
+33
View File
@@ -0,0 +1,33 @@
import assert from 'node:assert/strict';
import test from 'node:test';
const source = await import(new URL('../src/lib/utils/notebook-icons.ts', import.meta.url));
const {
NOTEBOOK_ICON_OPTIONS,
decodeBuiltinNotebookIcon,
encodeBuiltinNotebookIcon,
normalizeNotebookIconKey
} = source;
test('built-in notebook icons have stable unique storage values', () => {
assert.equal(NOTEBOOK_ICON_OPTIONS.length, 16);
assert.equal(new Set(NOTEBOOK_ICON_OPTIONS.map(({ id }) => id)).size, NOTEBOOK_ICON_OPTIONS.length);
for (const { id } of NOTEBOOK_ICON_OPTIONS) {
const stored = encodeBuiltinNotebookIcon(id);
assert.equal(stored, `builtin:${id}`);
assert.equal(decodeBuiltinNotebookIcon(stored), id);
}
});
test('custom paths and unknown built-in values remain outside the icon codec', () => {
assert.equal(decodeBuiltinNotebookIcon('.helixnotes/attachments/notebook-icon.png'), null);
assert.equal(decodeBuiltinNotebookIcon('builtin:unknown'), null);
assert.equal(decodeBuiltinNotebookIcon(null), null);
});
test('normalizes notebook icon keys to portable vault-relative paths', () => {
assert.equal(normalizeNotebookIconKey('Projects/Client'), 'Projects/Client');
assert.equal(normalizeNotebookIconKey(String.raw`Projects\Client`), 'Projects/Client');
});
+31
View File
@@ -0,0 +1,31 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
const sidebar = await readFile(
new URL('../src/lib/components/Sidebar.svelte', import.meta.url),
'utf8'
);
test('mobile notebook rows expose an accessible actions button', () => {
assert.match(sidebar, /\{#if isMobile\}[\s\S]{0,500}class="notebook-actions-btn"/);
assert.match(sidebar, /aria-label=\{`Actions for \$\{nb\.name\}`\}/);
assert.match(sidebar, /onclick=\{\(e\) => openNotebookMenu\(e, nb\)\}/);
});
test('the notebook context menu receives its mobile styling outside the sidebar', () => {
assert.match(sidebar, /class="context-menu" class:mobile=\{isMobile\}/);
assert.match(sidebar, /\.context-menu\.mobile\s*\{/);
});
test('mobile action sheets stay inside every safe area and prevent tap-through', () => {
assert.match(sidebar, /class="context-menu-backdrop"/);
assert.match(sidebar, /safe-area-inset-left/);
assert.match(sidebar, /safe-area-inset-right/);
assert.match(sidebar, /safe-area-inset-top/);
assert.match(sidebar, /safe-area-inset-bottom/);
});
test('the actions button remains inside the notebook manual-sort drop target', () => {
assert.match(sidebar, /<div class="notebook-row" data-nb-path=\{nb\.path\}>/);
});
+60
View File
@@ -0,0 +1,60 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import { transformWithEsbuild } from 'vite';
const source = await readFile(
new URL('../src/lib/utils/startup-view.ts', import.meta.url),
'utf8'
);
const { code } = await transformWithEsbuild(source, 'startup-view.ts', {
loader: 'ts',
format: 'esm',
target: 'esnext'
});
const startup = await import(`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`);
test('normalizes every supported default view and falls back to All Notes', () => {
for (const view of ['all', 'quickaccess', 'tasks', 'daily']) {
assert.equal(startup.normalizeStartupView(view), view);
}
assert.equal(startup.normalizeStartupView('notebook'), 'all');
assert.equal(startup.normalizeStartupView(undefined), 'all');
});
test('uses the configured default when session restoration is disabled', () => {
assert.deepEqual(startup.resolveStartupTarget({
startupView: 'tasks',
restoreLastSession: false,
lastViewMode: 'daily',
lastNotebook: null,
lastTag: null
}), { mode: 'tasks' });
});
test('restores a supported previous list when restoration is enabled', () => {
assert.deepEqual(startup.resolveStartupTarget({
startupView: 'daily',
restoreLastSession: true,
lastViewMode: 'quickaccess',
lastNotebook: null,
lastTag: null
}), { mode: 'quickaccess' });
});
test('restores notebook and tag identifiers, otherwise uses the configured default', () => {
assert.deepEqual(startup.resolveStartupTarget({
startupView: 'all',
restoreLastSession: true,
lastViewMode: 'notebook',
lastNotebook: 'Projects',
lastTag: null
}), { mode: 'notebook', notebookPath: 'Projects' });
assert.deepEqual(startup.resolveStartupTarget({
startupView: 'tasks',
restoreLastSession: true,
lastViewMode: 'tag',
lastNotebook: null,
lastTag: null
}), { mode: 'tasks' });
});
+24
View File
@@ -0,0 +1,24 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
const appLayout = await readFile(
new URL('../src/lib/components/AppLayout.svelte', import.meta.url),
'utf8'
);
test('the theme shortcut recognizes a resolved custom dark theme', () => {
assert.match(
appLayout,
/\$customThemes\.find\(theme => theme\.id === \$resolvedTheme\)/
);
assert.match(
appLayout,
/darkThemes\.includes\(\$resolvedTheme\) \|\| \(customTheme\?\.is_dark \?\? false\)/
);
});
test('AppLayout leaves root theme application to the root layout', () => {
assert.doesNotMatch(appLayout, /function applyTheme\(/);
assert.doesNotMatch(appLayout, /applyTheme\(\$theme\)/);
});