From 36b04db98969fc26aea7c1a5b350ff9a87d6e2b9 Mon Sep 17 00:00:00 2001 From: Leslie Owusu-Appiah Date: Sat, 23 May 2026 19:02:20 +0200 Subject: [PATCH] test: conformance tests for JSON Canvas spec + Canvas Candy baseline (closes #16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test count goes from 38/3 suites to 168/5 suites. Two new files: 1. src/core/__tests__/spec-conformance.test.ts - Edge side × end matrix round-trip: 4 (fromSide) × 4 (toSide) × 2 (fromEnd) × 2 (toEnd) = 64 combinations, each round-tripped through parseCanvas / serializeCanvas with deep equality on the result - Edge with all-optional fields omitted — verifies we don't fabricate default values - All six JSON Canvas preset colour codes ('1' through '6') preserved verbatim through round-trip on both nodes and edges - Hex colour preserved verbatim through round-trip - Full structural deep-equal on a representative mixed-type document - FileNode subpath round-trip - GroupNode background / backgroundStyle round-trip 2. src/renderer/extensions/__tests__/cssclasses.test.ts - hasCssClasses detection: text node, plain text, `cssclass` alias, non-text-node ignore - Frontmatter parsing: array form, comma-separated string, single string, `cssclass` (singular) alias, whitespace trimming, empty handling, displayText extraction - Class → RenderProps mapping for every baseline class: shapes (cc-shape-circle, parallelogram-left/right, cc-border-squared) fills (cc-card-fill / -transparent / -opaque / -nocolor) borders (cc-border-none / -dashed / -dotted / -double / -rounded / -dropshadow, single-side, additive multi-side) text alignment (cc-card-center, cc-callout-center) parametric: gradient {N}deg, rotate-card {N}, rotate-text {N} and trailing-l variant, non-matching pattern silently ignored - enrichNodes batch behaviour: non-text passthrough, plain-text reference-equality, frontmatter-but-no-cssclasses passthrough Verified - npm test: 168/168 across 5 suites - npm run typecheck: clean - npm run lint: exit 0 Notes - Tests live under src/core/__tests__/ and src/renderer/extensions/__tests__/ per jest.config.js's testMatch pattern. Co-locating Candy tests with the extension keeps the test surface organised by module. - Did not test the new updateXxx operation variants from #5 (PR #27) — those land separately and will get their own round of tests post-merge. Refs: #16 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/core/__tests__/spec-conformance.test.ts | 128 +++++++++ .../extensions/__tests__/cssclasses.test.ts | 257 ++++++++++++++++++ 2 files changed, 385 insertions(+) create mode 100644 src/core/__tests__/spec-conformance.test.ts create mode 100644 src/renderer/extensions/__tests__/cssclasses.test.ts diff --git a/src/core/__tests__/spec-conformance.test.ts b/src/core/__tests__/spec-conformance.test.ts new file mode 100644 index 0000000..e17407b --- /dev/null +++ b/src/core/__tests__/spec-conformance.test.ts @@ -0,0 +1,128 @@ +import {parseCanvas, serializeCanvas} from '../serialization'; +import type {CanvasEdge, EdgeSide, EdgeEnd, CanvasPresetColor} from '../types'; + +// JSON Canvas spec conformance — focused on round-trip stability across the +// full matrix of side / end combinations on edges, and across every preset +// colour plus a representative hex colour. Adds breadth on top of the +// hand-written cases in serialization.test.ts. +// +// References: +// https://jsoncanvas.org/spec/ +// https://github.com/obsidianmd/jsoncanvas + +const SIDES: EdgeSide[] = ['top', 'right', 'bottom', 'left']; +const ENDS: EdgeEnd[] = ['none', 'arrow']; +const PRESET_COLORS: CanvasPresetColor[] = ['1', '2', '3', '4', '5', '6']; + +function makeBaseEdge(overrides: Partial): CanvasEdge { + return {id: 'e', fromNode: 'a', toNode: 'b', ...overrides}; +} + +describe('JSON Canvas spec conformance', () => { + describe('edges — side × end matrix round-trip', () => { + // Cartesian product of all four EdgeSide values for fromSide and toSide + // crossed with all two EdgeEnd values for fromEnd and toEnd. 4 × 4 × 2 × 2 + // = 64 combinations. Each one round-trips losslessly. + for (const fromSide of SIDES) { + for (const toSide of SIDES) { + for (const fromEnd of ENDS) { + for (const toEnd of ENDS) { + const label = `fromSide=${fromSide} toSide=${toSide} fromEnd=${fromEnd} toEnd=${toEnd}`; + it(`round-trips ${label}`, () => { + const edge = makeBaseEdge({fromSide, toSide, fromEnd, toEnd}); + const json = serializeCanvas({edges: [edge]}); + const reparsed = parseCanvas(json); + expect(reparsed.edges).toEqual([edge]); + }); + } + } + } + } + + it('round-trips edges with omitted optional side/end fields', () => { + const edge = makeBaseEdge({}); + const json = serializeCanvas({edges: [edge]}); + const reparsed = parseCanvas(json); + expect(reparsed.edges![0]).toEqual(edge); + // Confirm we don't fabricate default values for omitted fields. + expect(reparsed.edges![0].fromSide).toBeUndefined(); + expect(reparsed.edges![0].toEnd).toBeUndefined(); + }); + }); + + describe('colors — preset + hex round-trip', () => { + for (const color of PRESET_COLORS) { + it(`preserves preset colour code "${color}" verbatim`, () => { + const json = JSON.stringify({ + nodes: [{id: 'n', type: 'text', x: 0, y: 0, width: 1, height: 1, text: '', color}], + }); + const doc = parseCanvas(json); + expect(doc.nodes![0].color).toBe(color); + // Round-trip: serialize, re-parse, colour stays a string code (not coerced to number). + const reparsed = parseCanvas(serializeCanvas(doc)); + expect(reparsed.nodes![0].color).toBe(color); + }); + } + + it('preserves a hex colour verbatim through round-trip', () => { + const json = JSON.stringify({ + nodes: [{id: 'n', type: 'text', x: 0, y: 0, width: 1, height: 1, text: '', color: '#FF5733'}], + }); + const doc = parseCanvas(json); + expect(doc.nodes![0].color).toBe('#FF5733'); + const reparsed = parseCanvas(serializeCanvas(doc)); + expect(reparsed.nodes![0].color).toBe('#FF5733'); + }); + + it('preserves a hex colour on an edge', () => { + const edge: CanvasEdge = {id: 'e', fromNode: 'a', toNode: 'b', color: '#00AAFF'}; + const doc = parseCanvas(serializeCanvas({edges: [edge]})); + expect(doc.edges![0].color).toBe('#00AAFF'); + }); + }); + + describe('round-trip property — full deep equality on representative shapes', () => { + // The existing serialization.test.ts checks lengths and a few field-level + // assertions on sample.canvas. These tests assert full structural equality + // so any regression that silently mutates a field surfaces immediately. + + it('mixed-type document with edges round-trips deep-equal', () => { + const original = { + nodes: [ + {id: 't1', type: 'text', x: 0, y: 0, width: 100, height: 50, text: 'hi'}, + {id: 'f1', type: 'file', x: 0, y: 100, width: 100, height: 50, file: 'a.md'}, + {id: 'l1', type: 'link', x: 100, y: 0, width: 100, height: 50, url: 'https://x.com'}, + {id: 'g1', type: 'group', x: -50, y: -50, width: 300, height: 300, label: 'Block', color: '4'}, + ], + edges: [ + {id: 'e1', fromNode: 't1', toNode: 'f1', fromSide: 'bottom', toSide: 'top', toEnd: 'arrow'}, + {id: 'e2', fromNode: 'l1', toNode: 'g1', color: '#123456', label: 'rel'}, + ], + }; + const reparsed = parseCanvas(serializeCanvas(parseCanvas(JSON.stringify(original)))); + expect(reparsed).toEqual(original); + }); + + it('FileNode subpath survives round-trip', () => { + const original = { + nodes: [{ + id: 'f', type: 'file', x: 0, y: 0, width: 1, height: 1, + file: 'doc.md', subpath: '#heading', + }], + }; + const reparsed = parseCanvas(serializeCanvas(parseCanvas(JSON.stringify(original)))); + expect(reparsed).toEqual(original); + }); + + it('GroupNode background fields survive round-trip', () => { + const original = { + nodes: [{ + id: 'g', type: 'group', x: 0, y: 0, width: 100, height: 100, + background: 'bg.png', backgroundStyle: 'ratio', + }], + }; + const reparsed = parseCanvas(serializeCanvas(parseCanvas(JSON.stringify(original)))); + expect(reparsed).toEqual(original); + }); + }); +}); diff --git a/src/renderer/extensions/__tests__/cssclasses.test.ts b/src/renderer/extensions/__tests__/cssclasses.test.ts new file mode 100644 index 0000000..bb6f0d8 --- /dev/null +++ b/src/renderer/extensions/__tests__/cssclasses.test.ts @@ -0,0 +1,257 @@ +import type {CanvasNode, TextNode} from '../../../core'; +import { + enrichNodes, + enrichTextNode, + hasCssClasses, + type RenderProps, +} from '../cssclasses'; + +// Canvas Candy conformance — frontmatter parsing variants and the full +// baseline class → RenderProps mapping. Pinning current behaviour so the +// renderer can refactor freely (or eventually port to Rust) without silent +// regressions. The Candy classes are an Obsidian community convention; see +// https://github.com/TfTHacker/obsidian-canvas-candy for the visual reference. + +function textNode(text: string, overrides: Partial = {}): TextNode { + return {id: 'n', type: 'text', x: 0, y: 0, width: 100, height: 100, text, ...overrides}; +} + +function withFrontmatter(classes: string | string[], body = ''): TextNode { + const yaml = Array.isArray(classes) + ? `cssclasses:\n${classes.map(c => ` - ${c}`).join('\n')}` + : `cssclasses: ${classes}`; + return textNode(`---\n${yaml}\n---\n${body}`); +} + +function enrich(classes: string | string[], body = ''): RenderProps { + const result = enrichTextNode(withFrontmatter(classes, body)); + if (!result) throw new Error('expected enrichment, got null'); + return result.renderProps; +} + +describe('hasCssClasses', () => { + it('returns true when any text node carries cssclasses', () => { + const nodes: CanvasNode[] = [ + textNode('plain text'), + withFrontmatter('cc-card-fill'), + ]; + expect(hasCssClasses(nodes)).toBe(true); + }); + + it('returns false for a canvas of plain text nodes', () => { + const nodes: CanvasNode[] = [textNode('# title'), textNode('paragraph')]; + expect(hasCssClasses(nodes)).toBe(false); + }); + + it('accepts the `cssclass` (singular) alias for detection', () => { + const node = textNode('---\ncssclass: cc-card-fill\n---\nbody'); + expect(hasCssClasses([node])).toBe(true); + }); + + it('ignores non-text nodes', () => { + const nodes: CanvasNode[] = [ + {id: 'l', type: 'link', x: 0, y: 0, width: 1, height: 1, url: 'https://x.com'}, + {id: 'g', type: 'group', x: 0, y: 0, width: 1, height: 1}, + ]; + expect(hasCssClasses(nodes)).toBe(false); + }); +}); + +describe('frontmatter parsing variants', () => { + it('accepts array form', () => { + const result = enrichTextNode(withFrontmatter(['cc-card-fill', 'cc-shape-circle'])); + expect(result!.cssClasses).toEqual(['cc-card-fill', 'cc-shape-circle']); + }); + + it('accepts comma-separated string form', () => { + const result = enrichTextNode(withFrontmatter('cc-card-fill, cc-shape-circle')); + expect(result!.cssClasses).toEqual(['cc-card-fill', 'cc-shape-circle']); + }); + + it('accepts single string form', () => { + const result = enrichTextNode(withFrontmatter('cc-card-fill')); + expect(result!.cssClasses).toEqual(['cc-card-fill']); + }); + + it('accepts the `cssclass` (singular) alias', () => { + const node = textNode('---\ncssclass: cc-card-fill\n---\nbody'); + const result = enrichTextNode(node); + expect(result!.cssClasses).toEqual(['cc-card-fill']); + }); + + it('trims entries and drops empties', () => { + const result = enrichTextNode(withFrontmatter(' cc-card-fill ,, , cc-shape-circle ')); + expect(result!.cssClasses).toEqual(['cc-card-fill', 'cc-shape-circle']); + }); + + it('returns null when frontmatter is absent', () => { + expect(enrichTextNode(textNode('no frontmatter here'))).toBeNull(); + }); + + it('returns null when cssclasses key is missing from frontmatter', () => { + expect(enrichTextNode(textNode('---\ntitle: Hello\n---\nbody'))).toBeNull(); + }); + + it('returns null when cssclasses is empty array', () => { + expect(enrichTextNode(withFrontmatter([]))).toBeNull(); + }); + + it('strips the frontmatter block from displayText', () => { + const result = enrichTextNode(withFrontmatter('cc-card-fill', 'Hello body')); + expect(result!.displayText).toBe('Hello body'); + }); + + it('preserves text after the frontmatter intact (no trailing newline)', () => { + const result = enrichTextNode(withFrontmatter('cc-card-fill', 'Line one\n\nLine two')); + expect(result!.displayText).toBe('Line one\n\nLine two'); + }); +}); + +describe('class → RenderProps mapping', () => { + describe('shapes', () => { + it('cc-shape-circle → shape: circle', () => { + expect(enrich('cc-shape-circle')).toEqual({shape: 'circle'}); + }); + + it('cc-shape-parallelogram-left → shape: parallelogram-left', () => { + expect(enrich('cc-shape-parallelogram-left')).toEqual({shape: 'parallelogram-left'}); + }); + + it('cc-shape-parallelogram-right → shape: parallelogram-right', () => { + expect(enrich('cc-shape-parallelogram-right')).toEqual({shape: 'parallelogram-right'}); + }); + + it('cc-border-squared → shape: rectangle (promotes default rounded-rect to a square)', () => { + expect(enrich('cc-border-squared')).toEqual({shape: 'rectangle'}); + }); + }); + + describe('card fill', () => { + it.each([ + ['cc-card-fill', {fill: true}], + ['cc-card-transparent', {transparent: true}], + ['cc-card-opaque', {opaque: true}], + ['cc-card-nocolor', {nocolor: true}], + ])('%s → %j', (cls, expected) => { + expect(enrich(cls)).toEqual(expected); + }); + }); + + describe('borders', () => { + it.each([ + ['cc-border-none', {borderStyle: 'none'}], + ['cc-border-dashed', {borderStyle: 'dashed'}], + ['cc-border-dotted', {borderStyle: 'dotted'}], + ['cc-border-double', {borderStyle: 'double'}], + ])('%s → %j', (cls, expected) => { + expect(enrich(cls)).toEqual(expected); + }); + + it('cc-border-rounded → pill: true (promotes default rounded-rect to a pill)', () => { + expect(enrich('cc-border-rounded')).toEqual({pill: true}); + }); + + it('cc-border-dropshadow → dropShadow: true', () => { + expect(enrich('cc-border-dropshadow')).toEqual({dropShadow: true}); + }); + + it('single-side border classes populate borderSides', () => { + expect(enrich('cc-border-top')).toEqual({borderSides: ['top']}); + expect(enrich('cc-border-bottom')).toEqual({borderSides: ['bottom']}); + expect(enrich('cc-border-left')).toEqual({borderSides: ['left']}); + expect(enrich('cc-border-right')).toEqual({borderSides: ['right']}); + }); + + it('multiple side classes are additive (insertion order preserved)', () => { + expect(enrich(['cc-border-top', 'cc-border-bottom'])).toEqual({ + borderSides: ['top', 'bottom'], + }); + expect(enrich(['cc-border-left', 'cc-border-right', 'cc-border-top'])).toEqual({ + borderSides: ['left', 'right', 'top'], + }); + }); + }); + + describe('text alignment', () => { + it('cc-card-center → textAlign: center', () => { + expect(enrich('cc-card-center')).toEqual({textAlign: 'center'}); + }); + + it('cc-callout-center → textAlign: center', () => { + expect(enrich('cc-callout-center')).toEqual({textAlign: 'center'}); + }); + }); + + describe('parametric classes (regex-matched)', () => { + it.each([0, 45, 90, 180, 270, 359])('cc-card-gradient-%ddeg → gradientDeg', deg => { + expect(enrich(`cc-card-gradient-${deg}deg`)).toEqual({gradientDeg: deg}); + }); + + it.each([0, 45, 90, 180, 359])('cc-rotate-card-%d → rotateCard', deg => { + expect(enrich(`cc-rotate-card-${deg}`)).toEqual({rotateCard: deg}); + }); + + it.each([0, 30, 90, 180])('cc-rotate-text-%d → rotateText', deg => { + expect(enrich(`cc-rotate-text-${deg}`)).toEqual({rotateText: deg}); + }); + + it('cc-rotate-text-{N}l (trailing `l` variant) → rotateText', () => { + expect(enrich('cc-rotate-text-45l')).toEqual({rotateText: 45}); + }); + + it('non-matching gradient pattern is silently ignored (no shape change)', () => { + // `-deg` suffix is required; missing it should not enter the gradient branch. + expect(enrich('cc-card-gradient-45')).toEqual({}); + }); + }); + + describe('combinations', () => { + it('multiple class kinds combine into a single RenderProps', () => { + expect(enrich(['cc-shape-circle', 'cc-card-fill', 'cc-card-gradient-90deg'])).toEqual({ + shape: 'circle', + fill: true, + gradientDeg: 90, + }); + }); + + it('unknown classes are silently ignored', () => { + expect(enrich(['cc-card-fill', 'cc-not-a-thing', 'rogue'])).toEqual({fill: true}); + }); + }); +}); + +describe('enrichNodes — batch behaviour', () => { + it('passes non-text nodes through unchanged', () => { + const nodes: CanvasNode[] = [ + {id: 'l', type: 'link', x: 0, y: 0, width: 1, height: 1, url: 'https://x.com'}, + {id: 'f', type: 'file', x: 0, y: 0, width: 1, height: 1, file: 'a.md'}, + {id: 'g', type: 'group', x: 0, y: 0, width: 1, height: 1}, + ]; + expect(enrichNodes(nodes)).toEqual(nodes); + }); + + it('enriches text nodes with cssclasses; leaves plain text nodes unchanged', () => { + const plain = textNode('just a paragraph'); + const styled = withFrontmatter('cc-card-fill', 'styled body'); + const result = enrichNodes([plain, styled]); + + // Plain text node passes through reference-equal (no allocation). + expect(result[0]).toBe(plain); + + // Styled text node gets enrichment fields. + const enriched = result[1] as typeof styled & { + displayText?: string; + cssClasses?: string[]; + renderProps?: RenderProps; + }; + expect(enriched.displayText).toBe('styled body'); + expect(enriched.cssClasses).toEqual(['cc-card-fill']); + expect(enriched.renderProps).toEqual({fill: true}); + }); + + it('returns text nodes with frontmatter-but-no-cssclasses unchanged', () => { + const node = textNode('---\ntitle: Hello\n---\nbody'); + const result = enrichNodes([node]); + expect(result[0]).toBe(node); + }); +});