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); + }); +});