文件
mynode/packages/lib/services/whiteboard/whiteboard.test.ts
chen 301194963b
一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
feat: Initial clean release of CHM
2026-11-04 11:29:39 +08:00

199 行
8.1 KiB
TypeScript

import { hasWhiteboardFence, parseWhiteboard } from './parse';
import { newWhiteboardBody, serializeWhiteboard } from './serialize';
import { isInternalRef, RefKind, resolveFileRef } from './resolveRef';
import { Canvas } from './jsoncanvas';
import { resolveCanvasColor } from './presetColors';
import { ThemeAppearance } from '../../themes/type';
const sampleCanvas: Canvas = {
nodes: [
{ id: 'a', type: 'text', x: 0, y: 0, width: 200, height: 100, text: 'Hello' },
{ id: 'b', type: 'file', x: 300, y: 0, width: 200, height: 100, file: ':/0123456789abcdef0123456789abcdef' },
{ id: 'c', type: 'link', x: 0, y: 200, width: 200, height: 100, url: 'https://example.com' },
{ id: 'g', type: 'group', x: -50, y: -50, width: 600, height: 400, label: 'Phase 1' },
],
edges: [
{ id: 'e1', fromNode: 'a', fromSide: 'right', toNode: 'b', toSide: 'left', label: 'depends on' },
],
};
describe('whiteboard', () => {
test('parses a body with a jsoncanvas fence and preserves surrounding text', () => {
const before = '# My note\n\nSome intro text.\n\n';
const after = '\nA closing paragraph.\n';
const body = `${before}\`\`\`jsoncanvas\n${JSON.stringify(sampleCanvas)}\n\`\`\`${after}`;
const result = parseWhiteboard(body);
expect(result.hasCanvas).toBe(true);
expect(result.prefix).toBe(before);
expect(result.suffix).toBe(after.replace(/^\n/, '')); // leading newline is consumed by the fence terminator
expect(result.canvas.nodes).toHaveLength(4);
expect(result.canvas.edges).toHaveLength(1);
});
test('hasWhiteboardFence detects fence presence', () => {
expect(hasWhiteboardFence('plain note')).toBe(false);
expect(hasWhiteboardFence('```jsoncanvas\n{}\n```')).toBe(true);
expect(hasWhiteboardFence('# title\n\n```jsoncanvas\n{}\n```\n')).toBe(true);
});
test('returns empty canvas with hasCanvas=false when fence content is invalid JSON', () => {
const body = '```jsoncanvas\nthis is not json\n```';
const result = parseWhiteboard(body);
expect(result.hasCanvas).toBe(false);
expect(result.canvas.nodes).toHaveLength(0);
expect(result.canvas.edges).toHaveLength(0);
expect(result.parseError).toMatch(/^Invalid JSON: /);
});
test('reports a schema error when the JSON is valid but not a JSONCanvas object', () => {
const body = '```jsoncanvas\n[]\n```';
const result = parseWhiteboard(body);
expect(result.hasCanvas).toBe(false);
expect(result.parseError).toMatch(/JSONCanvas/);
});
test('parseError is null when there is no fence at all', () => {
expect(parseWhiteboard('').parseError).toBeNull();
expect(parseWhiteboard('plain note').parseError).toBeNull();
});
test('drops invalid nodes and edges that reference unknown nodes', () => {
const dirty = {
nodes: [
{ id: 'a', type: 'text', x: 0, y: 0, width: 100, height: 100, text: 'ok' },
{ id: 'b', type: 'text', x: 0, y: 0 }, // missing dimensions
{ type: 'text', x: 0, y: 0, width: 100, height: 100, text: 'no id' },
],
edges: [
{ id: 'e1', fromNode: 'a', toNode: 'a' },
{ id: 'e2', fromNode: 'a', toNode: 'ghost' },
],
};
const body = `\`\`\`jsoncanvas\n${JSON.stringify(dirty)}\n\`\`\``;
const result = parseWhiteboard(body);
expect(result.canvas.nodes.map(n => n.id)).toEqual(['a']);
expect(result.canvas.edges.map(e => e.id)).toEqual(['e1']);
});
test('round-trips: serialize then parse yields the same canvas', () => {
const body = serializeWhiteboard('', sampleCanvas);
const result = parseWhiteboard(body);
expect(result.hasCanvas).toBe(true);
expect(result.canvas).toEqual(sampleCanvas);
});
test('serialize replaces an existing fence in place and preserves prefix/suffix', () => {
const original = `Intro\n\n\`\`\`jsoncanvas\n${JSON.stringify({ nodes: [], edges: [] })}\n\`\`\`\n\nOutro\n`;
const updated = serializeWhiteboard(original, sampleCanvas);
const result = parseWhiteboard(updated);
expect(result.canvas).toEqual(sampleCanvas);
expect(result.prefix).toBe('Intro\n\n');
expect(updated.endsWith('Outro\n')).toBe(true);
});
test('serialize appends a fence when none exists', () => {
const before = '# Title\n\nSome content.';
const updated = serializeWhiteboard(before, sampleCanvas);
expect(updated.startsWith(before)).toBe(true);
expect(hasWhiteboardFence(updated)).toBe(true);
});
test('newWhiteboardBody produces a parseable empty canvas', () => {
const body = newWhiteboardBody();
const result = parseWhiteboard(body);
expect(result.hasCanvas).toBe(true);
expect(result.canvas.nodes).toEqual([]);
expect(result.canvas.edges).toEqual([]);
});
test('round-trips edge fromEnd / toEnd / sides without losing or flipping them', () => {
const canvas: Canvas = {
nodes: [
{ id: 'a', type: 'text', x: 0, y: 0, width: 100, height: 100, text: 'a' },
{ id: 'b', type: 'text', x: 200, y: 0, width: 100, height: 100, text: 'b' },
],
edges: [
{ id: 'forward', fromNode: 'a', toNode: 'b', toEnd: 'arrow' },
{ id: 'backward', fromNode: 'a', toNode: 'b', fromEnd: 'arrow', toEnd: 'none' },
{ id: 'both', fromNode: 'a', toNode: 'b', fromEnd: 'arrow', toEnd: 'arrow' },
{ id: 'sided', fromNode: 'a', toNode: 'b', fromSide: 'right', toSide: 'left' },
],
};
const body = serializeWhiteboard('', canvas);
const result = parseWhiteboard(body);
expect(result.hasCanvas).toBe(true);
expect(result.canvas.edges).toEqual(canvas.edges);
});
test('preserves group nodes through parse + serialize', () => {
// Joplin's editor doesn't render group nodes but must not silently
// drop them on save — other tools may contain them.
const canvas: Canvas = {
nodes: [
{ id: 'g', type: 'group', x: -50, y: -50, width: 400, height: 300, label: 'Phase 1', background: '#eee' },
{ id: 't', type: 'text', x: 0, y: 0, width: 100, height: 60, text: 'inside' },
],
edges: [],
};
const body = serializeWhiteboard('', canvas);
const result = parseWhiteboard(body);
expect(result.hasCanvas).toBe(true);
expect(result.canvas.nodes).toEqual(canvas.nodes);
});
test.each([
[':/0123456789abcdef0123456789abcdef', RefKind.Resource, '0123456789abcdef0123456789abcdef'],
['https://example.com/foo.pdf', RefKind.External, 'https://example.com/foo.pdf'],
['Notes/Foo.md', RefKind.External, 'Notes/Foo.md'],
])('resolveFileRef classifies %s', (value, kind, id) => {
const r = resolveFileRef(value);
expect(r.kind).toBe(kind);
expect(r.id).toBe(id);
});
test('resolveFileRef respects kindHint for internal refs', () => {
const r = resolveFileRef(':/0123456789abcdef0123456789abcdef', RefKind.Note);
expect(r.kind).toBe(RefKind.Note);
});
test('isInternalRef matches only proper :/ id strings', () => {
expect(isInternalRef(':/0123456789abcdef0123456789abcdef')).toBe(true);
expect(isInternalRef(':/short')).toBe(false);
expect(isInternalRef('not internal')).toBe(false);
});
test.each([
[undefined, ThemeAppearance.Light, undefined],
// Preset IDs resolve to different hex values per theme.
['1', ThemeAppearance.Light, '#d64545'],
['1', ThemeAppearance.Dark, '#e57373'],
// Arbitrary hex strings (the spec's escape hatch) pass through untouched.
['#abcdef', ThemeAppearance.Light, '#abcdef'],
['#abcdef', ThemeAppearance.Dark, '#abcdef'],
// Unknown non-preset strings also pass through — browsers will fall
// back to their default handling for unrecognised colours.
['not-a-preset', ThemeAppearance.Light, 'not-a-preset'],
])('resolveCanvasColor(%s, %s) → %s', (input, appearance, expected) => {
expect(resolveCanvasColor(input, appearance, 'stroke')).toBe(expected);
});
test('preset canvas color round-trips through serialize + parse on both nodes and edges', () => {
const colored: Canvas = {
nodes: [
{ id: 'a', type: 'text', x: 0, y: 0, width: 100, height: 100, text: 'a', color: '3' },
{ id: 'b', type: 'text', x: 200, y: 0, width: 100, height: 100, text: 'b' },
],
edges: [
{ id: 'e', fromNode: 'a', toNode: 'b', color: '4' },
],
};
const body = serializeWhiteboard('', colored);
const result = parseWhiteboard(body);
expect(result.hasCanvas).toBe(true);
expect(result.canvas.nodes.find(n => n.id === 'a')?.color).toBe('3');
expect(result.canvas.edges[0].color).toBe('4');
});
});