// src/editor/__tests__/table-grid.test.ts /** * Tests for the table-grid extension. * * Test groups: * 1. Alignment helpers (pure functions) * 2. Lezer node detection (decoration counts/shapes) * 3. Per-cell alignment classes * 4. Pipe hiding + separator replace * 5. Empty cells * 6. Edge cases (single col, header-only, malformed, long, header-with-fewer-cols, adjacent tables, mid-cell pipe) * 7. Grid stability across cursor movement (incl. click-to-position offset) * 8. Mode scoping (present in livePreview, absent without; decoration count > 0) * 9. Code/frontmatter exclusion (real frontmatter boundary case) * 10. Editing rebuild (alignment re-parse on doc change) */ import { describe, it, expect, afterEach } from 'vitest' import { EditorState } from '@codemirror/state' import { EditorView, type DecorationSet } from '@codemirror/view' import { markdown, markdownLanguage } from '@codemirror/lang-markdown' import { GFM } from '@lezer/markdown' import { tableGrid, parseSeparatorAlignment, normalizeAlignment, type TableAlignment, } from '../extensions/table-grid' import { markdownFrontmatterField } from '../extensions/markdown-context' function createView(doc: string, cursorPos?: number): EditorView { const pos = Math.min(cursorPos ?? doc.length, doc.length) return new EditorView({ state: EditorState.create({ doc, extensions: [ markdown({ base: markdownLanguage, extensions: [GFM], addKeymap: false }), markdownFrontmatterField, tableGrid, ], selection: { anchor: pos }, }), }) } interface DecoEntry { from: number to: number classes: string[] isLine: boolean isReplace: boolean isWidget: boolean widget: WidgetWithAlign | null widgetAlign: TableAlignment | null style: string | null } /** * Iterate the tableGrid plugin's decoration set and shape every entry. * Distinguishes line / mark / replace / widget by typed inspection of * the public Decoration.spec API. */ function getDecos(view: EditorView): DecoEntry[] { const out: DecoEntry[] = [] const plugin = view.plugin(tableGrid) if (!plugin) return out const decoSet = plugin.decorations as DecorationSet const iter = decoSet.iter() while (iter.value) { const value = iter.value as unknown as { spec?: { class?: string attributes?: { style?: string } widget?: WidgetWithAlign } } const spec = value.spec ?? {} const cls = spec.class ?? '' const classes = cls ? cls.split(/\s+/).filter(Boolean) : [] const widget = spec.widget const isWidget = widget !== undefined && iter.from === iter.to const isLine = iter.from === iter.to && !isWidget // Decoration.replace: nonzero range with no class and no widget. const isReplace = !isWidget && !isLine && classes.length === 0 out.push({ from: iter.from, to: iter.to, classes, isLine, isReplace, isWidget, widget: isWidget && widget ? (widget as WidgetWithAlign) : null, widgetAlign: isWidget && widget && 'align' in widget ? widget.align : null, style: spec.attributes?.style ?? null, }) iter.next() } return out } interface WidgetWithAlign { align: TableAlignment } afterEach(() => { document.body.innerHTML = '' }) // -------------------------------------------------------------------------- // Group 1: alignment helpers // -------------------------------------------------------------------------- describe('parseSeparatorAlignment', () => { it('plain dashes default to left', () => { expect(parseSeparatorAlignment('|---|---|')).toEqual(['left', 'left']) }) it('parses :--- as left, ---: as right, :---: as center', () => { expect(parseSeparatorAlignment('|:---|:---:|---:|')).toEqual([ 'left', 'center', 'right', ]) }) it('tolerates inner whitespace', () => { expect(parseSeparatorAlignment('| :--- | :---: | ---: |')).toEqual([ 'left', 'center', 'right', ]) }) it('handles single-column separator', () => { expect(parseSeparatorAlignment('|---:|')).toEqual(['right']) }) it('returns empty array for malformed (no pipes) input', () => { expect(parseSeparatorAlignment('---')).toEqual([]) }) }) describe('countHeaderColumns + normalizeAlignment', () => { it('normalizeAlignment pads short array with left', () => { expect(normalizeAlignment(['right'], 3)).toEqual(['right', 'left', 'left']) }) it('normalizeAlignment truncates long array', () => { expect(normalizeAlignment(['left', 'center', 'right'], 2)).toEqual([ 'left', 'center', ]) }) }) // -------------------------------------------------------------------------- // Group 2: Lezer node detection // -------------------------------------------------------------------------- describe('tableGrid — node detection', () => { it('basic 2-col 1-body table → 3 row lines, 1 separator-anchor mark, 4 cell marks, 6 pipe-hides (#269)', () => { const doc = '| H1 | H2 |\n|---|---|\n| a | b |\n' const view = createView(doc) const decos = getDecos(view) const lineDecos = decos.filter(d => d.isLine && d.classes.includes('cm-table-row')) expect(lineDecos).toHaveLength(3) // Per #269, the separator's `|---|---|` is wrapped by a // `Decoration.mark` carrying `cm-table-separator-anchor` rather // than a `Decoration.replace` — the source text stays in the DOM // as editable text so `caretRangeFromPoint` can find it. const replaces = decos.filter(d => d.isReplace) expect(replaces).toHaveLength(0) const sepAnchorMarks = decos.filter(d => d.classes.includes('cm-table-separator-anchor')) expect(sepAnchorMarks).toHaveLength(1) const cellMarks = decos.filter(d => d.classes.includes('cm-table-cell')) expect(cellMarks).toHaveLength(4) const pipeHides = decos.filter(d => d.classes.includes('cm-table-pipe-hidden')) // 3 pipes/row × 2 non-separator rows = 6. expect(pipeHides).toHaveLength(6) view.destroy() }) it('header line carries cm-table-header-row + cm-table-row-first', () => { const doc = '| H1 | H2 |\n|---|---|\n| a | b |\n' const view = createView(doc) const headerLine = getDecos(view).find(d => d.isLine && d.classes.includes('cm-table-header-row')) expect(headerLine).toBeDefined() expect(headerLine!.from).toBe(0) expect(headerLine!.classes).toContain('cm-table-row-first') view.destroy() }) it('final body row carries cm-table-row-last', () => { const doc = '| H |\n|---|\n| a |\n| b |\n' const view = createView(doc) const lasts = getDecos(view).filter(d => d.isLine && d.classes.includes('cm-table-row-last')) expect(lasts).toHaveLength(1) view.destroy() }) it('encodes column count via --cm-table-cols on every row line', () => { const doc = '| A | B | C |\n|---|---|---|\n| 1 | 2 | 3 |\n' const view = createView(doc) const styles = getDecos(view).filter(d => d.style).map(d => d.style!) expect(styles.length).toBeGreaterThan(0) expect(styles.every(s => s.includes('--cm-table-cols: 3'))).toBe(true) view.destroy() }) }) // -------------------------------------------------------------------------- // Group 3: per-cell alignment // -------------------------------------------------------------------------- describe('tableGrid — per-cell alignment', () => { function getCellAligns(view: EditorView): string[] { return getDecos(view) .filter(d => d.classes.includes('cm-table-cell')) .map(d => d.classes.find(c => c.startsWith('cm-table-cell-align-')) ?? '') } it('all left when separator is plain dashes', () => { const doc = '| A | B | C |\n|---|---|---|\n| 1 | 2 | 3 |\n' const view = createView(doc) const aligns = getCellAligns(view) expect(aligns).toHaveLength(6) expect(aligns.every(c => c === 'cm-table-cell-align-left')).toBe(true) view.destroy() }) it('center applies to that column in every row', () => { const doc = '| A | B |\n|---|:---:|\n| 1 | 2 |\n' const view = createView(doc) expect(getCellAligns(view)).toEqual([ 'cm-table-cell-align-left', 'cm-table-cell-align-center', 'cm-table-cell-align-left', 'cm-table-cell-align-center', ]) view.destroy() }) it('right applies to that column in every row', () => { const doc = '| A | B |\n|---|---:|\n| 1 | 2 |\n' const view = createView(doc) expect(getCellAligns(view)).toEqual([ 'cm-table-cell-align-left', 'cm-table-cell-align-right', 'cm-table-cell-align-left', 'cm-table-cell-align-right', ]) view.destroy() }) it('mixed alignment across all three flavors', () => { const doc = '| A | B | C |\n|:---|:---:|---:|\n| 1 | 2 | 3 |\n' const view = createView(doc) expect(getCellAligns(view)).toEqual([ 'cm-table-cell-align-left', 'cm-table-cell-align-center', 'cm-table-cell-align-right', 'cm-table-cell-align-left', 'cm-table-cell-align-center', 'cm-table-cell-align-right', ]) view.destroy() }) }) // -------------------------------------------------------------------------- // Group 4: pipe hiding + separator replace // -------------------------------------------------------------------------- describe('tableGrid — chrome hiding', () => { it('every intra-row pipe is exactly 1 char wide', () => { const doc = '| A | B |\n|---|---|\n| 1 | 2 |\n' const view = createView(doc) const pipes = getDecos(view).filter(d => d.classes.includes('cm-table-pipe-hidden')) expect(pipes).toHaveLength(6) pipes.forEach(p => { expect(p.to - p.from).toBe(1) expect(view.state.doc.sliceString(p.from, p.to)).toBe('|') }) view.destroy() }) it('separator anchor mark covers exactly the separator row content (#269)', () => { // Per #269 the separator's `|---|---|` is wrapped by a // `Decoration.mark` carrying `cm-table-separator-anchor` (was // previously a `Decoration.replace`). Keeping the source text in // the DOM is what makes the line caret-targetable. const doc = '| A | B |\n|---|---|\n| 1 | 2 |\n' const view = createView(doc) const replaces = getDecos(view).filter(d => d.isReplace) expect(replaces).toHaveLength(0) const sepMarks = getDecos(view).filter(d => d.classes.includes('cm-table-separator-anchor')) expect(sepMarks).toHaveLength(1) expect(view.state.doc.sliceString(sepMarks[0].from, sepMarks[0].to)) .toBe('|---|---|') view.destroy() }) it('separator pipes are NOT individually pipe-hidden (they are inside the separator-anchor range)', () => { const doc = '| A | B |\n|---|---|\n| 1 | 2 |\n' const view = createView(doc) const sepFrom = '| A | B |\n'.length const sepTo = sepFrom + '|---|---|'.length const pipes = getDecos(view).filter(d => d.classes.includes('cm-table-pipe-hidden')) expect(pipes.every(p => p.to <= sepFrom || p.from >= sepTo)).toBe(true) view.destroy() }) }) // -------------------------------------------------------------------------- // Group 5: empty cells // -------------------------------------------------------------------------- describe('tableGrid — empty cells', () => { it('empty leading cell with whitespace gap → cell-empty wrapping mark, NO widget', () => { // For "| | b |", the empty leading cell has a 3-char whitespace gap. // The wrapping cm-table-cell-empty mark provides the flex item; CSS // ::before adds nbsp for line height. No widget needed in this case. const doc = '| A | B |\n|---|---|\n| | b |\n' const view = createView(doc) const decos = getDecos(view) const widgets = decos.filter(d => d.isWidget) expect(widgets).toHaveLength(0) const cellMarks = decos.filter(d => d.classes.includes('cm-table-cell')) // Header has 2 inter-pipe cell marks; body has 1 empty-wrapping mark // (covers ' ') + 1 normal cell mark (covers ' b '). Total 4. expect(cellMarks).toHaveLength(4) const emptyMarks = cellMarks.filter(d => d.classes.includes('cm-table-cell-empty')) expect(emptyMarks).toHaveLength(1) expect(emptyMarks[0].classes).toContain('cm-table-cell-align-left') view.destroy() }) it('empty cell wrapping marks carry the column alignment', () => { const doc = '| A | B |\n|---|---:|\n| | |\n' const view = createView(doc) const decos = getDecos(view) // Both cells have a whitespace gap → 2 wrapping marks, no widgets. const widgets = decos.filter(d => d.isWidget) expect(widgets).toHaveLength(0) const emptyMarks = decos.filter(d => d.classes.includes('cm-table-cell') && d.classes.includes('cm-table-cell-empty'), ) expect(emptyMarks).toHaveLength(2) expect(emptyMarks[0].classes).toContain('cm-table-cell-align-left') expect(emptyMarks[1].classes).toContain('cm-table-cell-align-right') view.destroy() }) it('no-gap empty cell (consecutive pipes with no whitespace) emits exactly one EmptyCellWidget', () => { // For "|| b |" the leading cell has no inter-pipe content at all // (innerFrom === innerTo). A zero-length mark can't be emitted, so the // widget anchor IS the flex item. // // Empirically validated lezer AST for the body row "|| b |": // children = [TableDelim@20-21, TableDelim@21-22, TableCell@23-24, // TableDelim@25-26] // The walker produces: // - 1 EmptyCellWidget for the empty leading cell (gap between the // two consecutive TableDelimiters at offsets 20-21 and 21-22). // - 1 cm-table-cell mark for the second cell (covers ' b ', the // full inter-pipe range between TableDelim@21-22 and // TableDelim@25-26). // Plus the 2 cm-table-cell marks for the header row "| A | B |". // Plus the cm-table-cell-empty class is NOT applied to the widget; // it's a Decoration.widget, not a mark with a class. // (Review #22 strengthened this from "<= 1 widget" to exact-equality.) const doc = '| A | B |\n|---|---|\n|| b |\n' const view = createView(doc) const decos = getDecos(view) const widgets = decos.filter(d => d.isWidget) expect(widgets).toHaveLength(1) const cellMarks = decos.filter(d => d.classes.includes('cm-table-cell')) // 2 from header + 1 from the non-empty body cell = 3. expect(cellMarks).toHaveLength(3) view.destroy() }) it('EmptyCellWidget eq compares alignment across a rebuild', () => { const doc = '| A | B |\n|---|---|\n|| b |\n' const view = createView(doc) // A document change forces a decoration rebuild, so CM6 compares the // old and new EmptyCellWidget via eq (same alignment → DOM reused). view.dispatch({ changes: { from: 2, insert: 'x' } }) const anchor = view.dom.querySelector('.cm-table-cell-empty-anchor') expect(anchor).toBeTruthy() view.destroy() }) it('EmptyCellWidget ignoreEvent returns false (CM6 must handle events on the anchor)', () => { // The widget is not exported, so reach it through the decoration set — // the same pattern wikilink-widget.test.ts uses for its chip widget. const doc = '| A | B |\n|---|---|\n|| b |\n' const view = createView(doc) const widget = getDecos(view).find(d => d.isWidget)?.widget expect(widget).toBeDefined() // ignoreEvent() === false means the editor's own event handling is // allowed to run for events landing on the empty-cell anchor (the // anchor is caret-targetable text, not chrome). expect((widget as WidgetWithAlign & { ignoreEvent(): boolean }).ignoreEvent()).toBe(false) view.destroy() }) it('no-gap empty cell in the HEADER row emits a widget via the header push path', () => { // The header row "|| B |" has a zero-length leading cell, so the // header's pushWidget callback (not just the body rows') must fire. const doc = '|| B |\n|---|---|\n| 1 | 2 |\n' const view = createView(doc) const widgets = getDecos(view).filter(d => d.isWidget) expect(widgets).toHaveLength(1) expect(widgets[0].widgetAlign).toBe('left') view.destroy() }) it('falls back to the full document when visibleRanges is empty', () => { const doc = '| A | B |\n|---|---|\n| 1 | 2 |\n' const view = createView(doc) Object.defineProperty(view, 'visibleRanges', { value: [], configurable: true, }) view.dispatch({ changes: { from: 2, insert: 'x' } }) // The fallback range covers the whole doc, so the table still renders. const lineDecos = getDecos(view).filter(d => d.isLine && d.classes.includes('cm-table-row')) expect(lineDecos).toHaveLength(3) view.destroy() }) it('skips empty visible ranges without double-processing the table', () => { // A zero-width visible range at offset 5 falls INSIDE the table, and // lezer's iterate({from: 5, to: 5}) still enters the Table node — so // without the `rangeFrom >= rangeTo` guard the table would be // decorated twice (once per overlapping range). With the guard, the // empty range contributes nothing. const doc = '| A | B |\n|---|---|\n| 1 | 2 |\n' const view = createView(doc) Object.defineProperty(view, 'visibleRanges', { value: [{ from: 5, to: 5 }], configurable: true, }) view.dispatch({ changes: { from: 2, insert: 'x' } }) const lineDecos = getDecos(view).filter(d => d.isLine && d.classes.includes('cm-table-row')) expect(lineDecos).toHaveLength(0) view.destroy() }) }) // -------------------------------------------------------------------------- // Group 6: edge cases // -------------------------------------------------------------------------- describe('tableGrid — edge cases', () => { it('single-column table renders with --cm-table-cols: 1 on every row', () => { const doc = '| H |\n|---|\n| a |\n' const view = createView(doc) const styles = getDecos(view).filter(d => d.style).map(d => d.style!) expect(styles.length).toBeGreaterThan(0) expect(styles.every(s => s.includes('--cm-table-cols: 1'))).toBe(true) view.destroy() }) it('header-only table: header line carries BOTH row-first AND row-last', () => { const doc = '| H1 | H2 |\n|---|---|\n' const view = createView(doc) const decos = getDecos(view) const lineDecos = decos.filter(d => d.isLine && d.classes.includes('cm-table-row')) expect(lineDecos).toHaveLength(2) // header + separator const headerLine = decos.find(d => d.isLine && d.classes.includes('cm-table-header-row')) expect(headerLine!.classes).toContain('cm-table-row-first') expect(headerLine!.classes).toContain('cm-table-row-last') view.destroy() }) it('long table: 12 body rows produce 12 body-row line decorations', () => { const rows = Array.from({ length: 12 }, (_, i) => `| r${i} | v${i} |`).join('\n') const doc = `| H1 | H2 |\n|---|---|\n${rows}\n` const view = createView(doc) const bodyLines = getDecos(view).filter(d => d.isLine && d.classes.includes('cm-table-body-row')) expect(bodyLines).toHaveLength(12) view.destroy() }) it('header has fewer columns than separator: lezer-markdown rejects the table → no decorations', () => { // 2-col header, 3-col separator. Lezer GFM is strict: when header // column count != separator column count, the entire construct is // NOT parsed as a Table (it stays a Paragraph). The plan's original // expectation was that the renderer would truncate alignment to // header count — but that path only runs if a Table is parsed, and // here it isn't. The test asserts the actual observable behavior: // zero cell-mark decorations because tableGrid never sees a Table. const doc = '| A | B |\n|---|:--:|---:|\n| 1 | 2 |\n' const view = createView(doc) const cellMarks = getDecos(view) .filter(d => d.classes.includes('cm-table-cell')) expect(cellMarks).toHaveLength(0) view.destroy() }) it('header has fewer columns than separator declares: lezer rejects the table → no row decorations', () => { // Same scenario with a different separator width. Same Lezer // behavior: not parsed as Table. Confirmed by inspecting the syntax // tree: only Document/Paragraph nodes, no Table. const doc = '| A | B |\n|---|:---:|---:|\n| 1 | 2 |\n' const view = createView(doc) const lineDecos = getDecos(view) .filter(d => d.isLine && d.classes.includes('cm-table-row')) expect(lineDecos).toHaveLength(0) view.destroy() }) it('two adjacent tables: row-last on first table does NOT appear on second table header', () => { const doc = '| A | B |\n|---|---|\n| 1 | 2 |\n' + '\n' + '| C | D |\n|---|---|\n| 3 | 4 |\n' const view = createView(doc) const decos = getDecos(view) const firsts = decos.filter(d => d.isLine && d.classes.includes('cm-table-row-first')) const lasts = decos.filter(d => d.isLine && d.classes.includes('cm-table-row-last')) // One first per table, one last per table = 2 each. expect(firsts).toHaveLength(2) expect(lasts).toHaveLength(2) // Each first sits at the header line of its respective table; each last // at the body row line. No row carries both first AND last (the tables // each have body rows, so the header is not also the last). const both = decos.filter(d => d.isLine && d.classes.includes('cm-table-row-first') && d.classes.includes('cm-table-row-last'), ) expect(both).toHaveLength(0) view.destroy() }) it('typing `|` mid-cell: doc grows; plugin rebuilds decorations without crashing', () => { const doc = '| A | B |\n|---|---|\n| 1 | 2 |\n' const view = createView(doc) // Insert `|` inside the body cell containing "1" (offset right after "1"). const insertAt = doc.indexOf('| 1') + 3 expect(() => { view.dispatch({ changes: { from: insertAt, insert: '|' } }) }).not.toThrow() // Plugin should still expose decorations. const plugin = view.plugin(tableGrid) expect(plugin).not.toBeNull() // Stronger assertion (review #7): after the edit, the doc has 3 pipes // in the body row instead of 2. lezer-markdown's GFM parser may // either re-parse the row at a new column count (and tableGrid // re-emits with the new structure) or reject the row. Either way, // the decoration set must still be a valid RangeSet (iterable, no // throw on access) and the cell-mark wrapper invariant must hold: // for any cells that ARE emitted, each must cover its full // inter-pipe range (not split by the newly-inserted pipe). const decos = getDecos(view) // Smoke: iterating the decoration set must not throw and must // produce a non-error result. expect(Array.isArray(decos)).toBe(true) // For any cell marks that ARE emitted, their text must start and // end with whitespace (the inter-pipe-range invariant from D5). const cellMarks = decos.filter(d => d.classes.includes('cm-table-cell')) cellMarks.forEach(m => { const text = view.state.doc.sliceString(m.from, m.to) // Cell marks always span at least the surrounding whitespace; any // mark that is just '|' or contains '|' as boundary chars would // indicate the wrapper got split. expect(text.includes('|')).toBe(false) }) view.destroy() }) }) // -------------------------------------------------------------------------- // Group 7: row-layout stability across cursor movement // -------------------------------------------------------------------------- describe('tableGrid — row-layout stability', () => { function decoCount(view: EditorView): number { const plugin = view.plugin(tableGrid) if (!plugin) return 0 let n = 0 const iter = (plugin.decorations as DecorationSet).iter() while (iter.value) { n++; iter.next() } return n } /** * Serialize the decoration set to a stable string of * `from:to:classes|from:to:classes|...` for content-equality comparison * (review #8 strengthened this from a count-only comparison). */ function decoSignature(view: EditorView): string { return getDecos(view) .map(d => `${d.from}:${d.to}:${d.classes.sort().join(',')}`) .sort() .join('|') } it('decoration set is identical (positions + classes) regardless of initial cursor position', () => { const doc = '| A | B |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\n' const v1 = createView(doc, 0) const v2 = createView(doc, 12) const v3 = createView(doc, doc.length) const sigs = [decoSignature(v1), decoSignature(v2), decoSignature(v3)] // Stronger than count-equality: same positions + classes too. expect(sigs[0]).toBe(sigs[1]) expect(sigs[1]).toBe(sigs[2]) expect(sigs[0].length).toBeGreaterThan(0) // And count, for legacy parity with the original assertion. expect(decoCount(v1)).toBe(decoCount(v2)) expect(decoCount(v2)).toBe(decoCount(v3)) v1.destroy(); v2.destroy(); v3.destroy() }) it('moving the cursor inside the table does NOT change the decoration content', () => { const doc = '| A | B |\n|---|---|\n| 1 | 2 |\n' const view = createView(doc, doc.length) const before = decoSignature(view) view.dispatch({ selection: { anchor: 3 } }) expect(decoSignature(view)).toBe(before) view.dispatch({ selection: { anchor: view.state.doc.line(3).from + 2 } }) expect(decoSignature(view)).toBe(before) view.dispatch({ selection: { anchor: view.state.doc.line(2).from + 1 } }) expect(decoSignature(view)).toBe(before) view.destroy() }) it('typing a character inside a body cell inserts at the cursor offset', () => { // Asserts cursor geometry: typing `X` at offset N produces text with // `X` at offset N. Mirrors the spike R4 assertion but on the production // extension. const doc = '| A | B |\n|---|---|\n| 1 | 2 |\n' const cursor = doc.indexOf('| 1') + 2 // immediately before "1" const view = createView(doc, cursor) view.dispatch({ changes: { from: cursor, insert: 'X' } }) expect(view.state.doc.toString()).toContain('| X1 |') view.destroy() }) it('cell mark ranges cover surrounding whitespace (no anonymous flex items)', () => { // Production-level regression check that mirrors Spike R5: cell marks // span [prevDelim.to, nextDelim.from), so for a row "| a | b |" each // cell mark covers " a " and " b " (3 chars each), not just "a" / "b" // (1 char each). This is the explicit fix for the bare-whitespace // anonymous-flex-item bug under display: flex (and would equally apply // under display: grid). If a future refactor reverts to TableCell // ranges, this assertion fails. const doc = '| a | b |\n|---|---|\n' const view = createView(doc) const decos = getDecos(view) const cellMarks = decos.filter(d => d.classes.includes('cm-table-cell')) expect(cellMarks).toHaveLength(2) // Each cell mark spans 3 chars: ' a ' and ' b '. cellMarks.forEach(m => { expect(m.to - m.from).toBe(3) const text = view.state.doc.sliceString(m.from, m.to) expect(text.startsWith(' ')).toBe(true) expect(text.endsWith(' ')).toBe(true) }) view.destroy() }) }) // -------------------------------------------------------------------------- // Group 8: mode scoping // -------------------------------------------------------------------------- describe('tableGrid — mode scoping', () => { const SAMPLE = '| A | B |\n|---|---|\n| 1 | 2 |\n' function build(extensions: unknown[]): EditorView { return new EditorView({ state: EditorState.create({ doc: SAMPLE, extensions: [ markdown({ base: markdownLanguage, extensions: [GFM], addKeymap: false }), markdownFrontmatterField, ...(extensions as never[]), ], selection: { anchor: SAMPLE.length }, }), }) } it('tableGrid is present AND emits decorations when livePreview() is mounted', async () => { const { livePreview } = await import('../extensions/live-preview') const view = build([livePreview({})]) const plugin = view.plugin(tableGrid) expect(plugin).not.toBeNull() // Strong assertion (per adversary feedback #8): not just presence — // the plugin must actually be producing decorations. A future // regression that loads the plugin but produces zero decorations // (e.g. silent error swallowed in buildDecorations) fails here. let count = 0 const iter = (plugin!.decorations as DecorationSet).iter() while (iter.value) { count++; iter.next() } expect(count).toBeGreaterThan(0) view.destroy() }) it('tableGrid is ABSENT when livePreview() is NOT mounted (source mode shape)', () => { const view = build([]) expect(view.plugin(tableGrid)).toBeNull() view.destroy() }) }) // -------------------------------------------------------------------------- // Group 9: code/frontmatter exclusion // -------------------------------------------------------------------------- describe('tableGrid — exclusions', () => { it('does not produce row decorations for table syntax inside fenced code', () => { const doc = '```md\n| A | B |\n|---|---|\n| 1 | 2 |\n```\n\n' + '| X | Y |\n|---|---|\n| a | b |\n' const view = createView(doc) const lineDecos = getDecos(view).filter(d => d.isLine && d.classes.includes('cm-table-row')) // Only the real outside-fence table: header + separator + 1 body = 3. expect(lineDecos).toHaveLength(3) view.destroy() }) it('does not produce decorations on a doc with only a code-fenced table', () => { const doc = '```md\n| A | B |\n|---|---|\n| 1 | 2 |\n```\n' const view = createView(doc) expect(getDecos(view)).toHaveLength(0) view.destroy() }) it('frontmatter belt-and-suspenders: real boundary case', () => { // The adversary correctly noted that with a normal `---\n…\n---\n` block // Lezer doesn't parse a Table inside the YAML, so a naive "table inside // frontmatter" test is a tautology. This test exercises the boundary // case the exclusion exists for: a MALFORMED frontmatter (no closing // `---`) where Lezer might otherwise parse a Table inside what we // consider frontmatter range. parseFrontmatter requires a closing fence // so it returns null in this case; the test asserts the table OUTSIDE // the would-be frontmatter still renders normally — a regression check // that isRangeInFrontmatter(null) doesn't accidentally return true. const doc = '---\nkey: value\n\n| A | B |\n|---|---|\n| 1 | 2 |\n' const view = createView(doc) const lineDecos = getDecos(view).filter(d => d.isLine && d.classes.includes('cm-table-row')) expect(lineDecos).toHaveLength(3) view.destroy() }) it('code-context belt-and-suspenders: isRangeInCodeContext detects code regions for the table walker', async () => { // Note: lezer-markdown does NOT currently emit a Table inside FencedCode // (the first Group-9 test verifies this end-to-end: a code-fenced table // produces zero decorations). That test is a tautology w.r.t. the // production isRangeInCodeContext check because the Table never enters // the walker. This test documents the defense-in-depth: the helper used // by buildDecorations is real and correctly classifies code ranges. // If a future lezer regression starts emitting Table inside FencedCode, // the production code path would catch it via this same helper. const { isRangeInCodeContext } = await import('../extensions/markdown-context') const doc = 'plain text\n\n```md\n| A | B |\n|---|---|\n| 1 | 2 |\n```\n' const view = createView(doc) // The code-fenced table content lives inside the fenced range. Pick a // position inside the inner table-syntax line. const innerFrom = doc.indexOf('| A | B |') const innerTo = doc.indexOf('```\n', innerFrom) expect(innerFrom).toBeGreaterThan(0) expect(isRangeInCodeContext(view.state, innerFrom, innerTo)).toBe(true) // Sanity: a position inside the leading "plain text\n" prefix is NOT // in code context. expect(isRangeInCodeContext(view.state, 0, 5)).toBe(false) view.destroy() }) }) // -------------------------------------------------------------------------- // Group 10: rebuild on doc change // -------------------------------------------------------------------------- describe('tableGrid — rebuild on doc change', () => { function getCellAligns(view: EditorView): string[] { return getDecos(view) .filter(d => d.classes.includes('cm-table-cell')) .map(d => d.classes.find(c => c.startsWith('cm-table-cell-align-')) ?? '') } it('changing the separator row re-parses alignment', () => { const doc = '| A | B |\n|---|---|\n| 1 | 2 |\n' const view = createView(doc) expect(getCellAligns(view).every(c => c === 'cm-table-cell-align-left')) .toBe(true) const sepFrom = '| A | B |\n'.length view.dispatch({ changes: { from: sepFrom, to: sepFrom + '|---|---|'.length, insert: '|---|---:|', }, }) expect(getCellAligns(view)).toEqual([ 'cm-table-cell-align-left', 'cm-table-cell-align-right', 'cm-table-cell-align-left', 'cm-table-cell-align-right', ]) view.destroy() }) it('inserting a body row produces a new body-row decoration', () => { const doc = '| A | B |\n|---|---|\n| 1 | 2 |\n' const view = createView(doc) const before = getDecos(view).filter(d => d.isLine && d.classes.includes('cm-table-body-row')).length expect(before).toBe(1) view.dispatch({ changes: { from: view.state.doc.length, insert: '| 3 | 4 |\n' }, }) const after = getDecos(view).filter(d => d.isLine && d.classes.includes('cm-table-body-row')).length expect(after).toBe(2) view.destroy() }) it('removing the separator row removes all table decorations', () => { const doc = '| A | B |\n|---|---|\n| 1 | 2 |\n' const view = createView(doc) expect(getDecos(view).filter(d => d.isLine && d.classes.includes('cm-table-row')).length).toBeGreaterThan(0) const sepFrom = '| A | B |\n'.length view.dispatch({ changes: { from: sepFrom, to: sepFrom + '|---|---|\n'.length, insert: '', }, }) const after = getDecos(view).filter(d => d.isLine && d.classes.includes('cm-table-row')) expect(after).toHaveLength(0) view.destroy() }) }) // -------------------------------------------------------------------------- // Group 11: full-stack integration — Fallback A (extended) under livePreview // // Issue #13 from the round-1 review: Fallback A initially patched only // inline-format and code-marker decorations. The audit added cell-aware // suppression to wikilink + image-embed extensions (regex-based) and // defensive guards to heading + checkbox (syntax-tree-based, where Lezer // does not currently emit the relevant nodes inside TableCell but a // future parser change could). These tests mount the FULL livePreview() // stack and verify that wikilinks / images / inline marks inside cells // do not split the .cm-table-cell wrapper. // -------------------------------------------------------------------------- describe('tableGrid — full-stack Fallback A integration', () => { it('wikilink + table cell: suppression + DOM cell-wrapper integrity (livePreview with wikilink)', async () => { // ARCHITECTURAL PROPERTY (Spike R2 / Fallback A): // When a Decoration.replace from another extension overlaps a // cm-table-cell mark range, CM6 splits the wrapper into N sibling // spans, breaking the one-cell-equals-one-flex-item invariant. // This test verifies BOTH layers of the architecture: // (1) suppression — wikilink emits zero decorations inside cells. // (2) DOM integrity — exactly N rendered .cm-table-cell elements, // one per logical cell, regardless of whether other plugins // were tempted to overlap. // // Layer (2) is the load-bearing assertion. RangeSetBuilder always // produces one cm-table-cell SPEC per logical cell — that's not the // property at risk. The property is whether the rendered DOM still // shows exactly N direct .cm-table-cell elements after CM6's // mark-merging pass. If a future regression removes the wikilink // guard (or a new extension is added without a guard), the spec // count stays at N (unchanged) but the DOM count drops below N // (wrapper got split into siblings). DOM-level assertion catches that. const { livePreview } = await import('../extensions/live-preview') const doc = '| [[target]] | b |\n|---|---|\n| 1 | 2 |\n' const view = new EditorView({ state: EditorState.create({ doc, extensions: [ markdown({ base: markdownLanguage, extensions: [GFM], addKeymap: false }), markdownFrontmatterField, livePreview({ wikilink: { resolveLink: () => true } }), ], selection: { anchor: doc.length }, }), parent: document.body, }) view.requestMeasure() // Layer 1: the wikilink chip is not rendered (suppression works). const chips = view.dom.querySelectorAll('.cm-wikilink-chip') expect(chips).toHaveLength(0) // Layer 2: rendered DOM still has exactly N cell wrappers. // 2-col table × 2 non-separator rows = 4 cells. const cellEls = view.dom.querySelectorAll('.cm-table-cell') expect(cellEls).toHaveLength(4) view.destroy() }) it('image embed + table cell: suppression + DOM cell-wrapper integrity (livePreview with imageEmbed)', async () => { // See the wikilink test above for the architectural rationale. // Layer 1 (suppression): no .cm-image-embed[data-target] inside cell. // Layer 2 (DOM integrity): exactly 4 rendered .cm-table-cell elements. const { livePreview } = await import('../extensions/live-preview') const doc = '| ![[a.png]] | b |\n|---|---|\n| 1 | 2 |\n' const view = new EditorView({ state: EditorState.create({ doc, extensions: [ markdown({ base: markdownLanguage, extensions: [GFM], addKeymap: false }), markdownFrontmatterField, livePreview({ imageEmbed: { resolver: { resolveImageUrl: async () => 'blob:test' } }, }), ], selection: { anchor: doc.length }, }), parent: document.body, }) view.requestMeasure() const wrappers = view.dom.querySelectorAll('.cm-image-embed[data-target]') expect(wrappers).toHaveLength(0) const cellEls = view.dom.querySelectorAll('.cm-table-cell') expect(cellEls).toHaveLength(4) view.destroy() }) it('bold inside table cell: suppression + DOM cell-wrapper integrity (livePreview default config)', async () => { // Smoke test under the basic livePreview stack (no wikilink/image // config). Inline backticks inside table cells interact with // lezer-markdown's GFM table parser in ways that may suppress the // table parse entirely; coverage for Fallback A on `code` is in the // dedicated code-marker-decoration suppression tests. // // Layer 1 (decoration spec count): tableGrid emits 4 cm-table-cell // marks (1 per logical cell × 2 rows × 2 cols). // Layer 2 (DOM integrity): the rendered DOM also has exactly 4 // .cm-table-cell elements — i.e. the bold mark from // inlineFormatDecoration is suppressed (Fallback A) and therefore // does NOT split the wrapper into siblings. const { livePreview } = await import('../extensions/live-preview') const doc = '| **bold** | normal |\n|---|---|\n| 1 | 2 |\n' const view = new EditorView({ state: EditorState.create({ doc, extensions: [ markdown({ base: markdownLanguage, extensions: [GFM], addKeymap: false }), markdownFrontmatterField, livePreview({}), ], selection: { anchor: doc.length }, }), parent: document.body, }) view.requestMeasure() // Layer 1: spec count = 4 (verified via the tableGrid plugin). const tg = view.plugin(tableGrid) expect(tg).not.toBeNull() let cellSpecs = 0 const iter = (tg!.decorations as DecorationSet).iter() while (iter.value) { const cls = ((iter.value as unknown as { spec?: { class?: string } }).spec?.class ?? '') if (cls.includes('cm-table-cell ') || cls.endsWith('cm-table-cell') || /cm-table-cell\b/.test(cls)) { cellSpecs++ } iter.next() } expect(cellSpecs).toBe(4) // Layer 2: DOM has exactly 4 cm-table-cell elements. const cellEls = view.dom.querySelectorAll('.cm-table-cell') expect(cellEls).toHaveLength(4) view.destroy() }) // -------------------------------------------------------------------- // Round-2 review #1: explicit DOM-level integrity tests // // The tests above verify decoration suppression (correctly necessary, // but if a NEW decoration-emitting extension is added to livePreview() // without a TableCell guard, only a real DOM-level test would catch // the cell-wrapper split). These three tests are dedicated to the // DOM property — N rendered .cm-table-cell elements, one per logical // cell, under the full livePreview() stack. // // We use livePreview() with explicit configs to exercise the // production wiring path (wikilink, image-embed) as a future-proof // regression check against any new extension being added without a // TableCell guard. // -------------------------------------------------------------------- it('DOM integrity: 4 cm-table-cell elements rendered for a 2x2 table under default livePreview', async () => { const { livePreview } = await import('../extensions/live-preview') const doc = '| **bold** | b |\n|---|---|\n| 1 | 2 |\n' const view = new EditorView({ state: EditorState.create({ doc, extensions: [ markdown({ base: markdownLanguage, extensions: [GFM], addKeymap: false }), markdownFrontmatterField, livePreview({}), ], selection: { anchor: doc.length }, }), parent: document.body, }) view.requestMeasure() const cellEls = view.dom.querySelectorAll('.cm-table-cell') expect(cellEls).toHaveLength(4) view.destroy() }) it('DOM integrity: 4 cm-table-cell elements rendered for a 2x2 table with wikilinkExtension loaded', async () => { const { livePreview } = await import('../extensions/live-preview') const doc = '| [[link]] | b |\n|---|---|\n| 1 | 2 |\n' const view = new EditorView({ state: EditorState.create({ doc, extensions: [ markdown({ base: markdownLanguage, extensions: [GFM], addKeymap: false }), markdownFrontmatterField, livePreview({ wikilink: { resolveLink: () => true } }), ], selection: { anchor: doc.length }, }), parent: document.body, }) view.requestMeasure() const cellEls = view.dom.querySelectorAll('.cm-table-cell') expect(cellEls).toHaveLength(4) view.destroy() }) it('DOM integrity: 4 cm-table-cell elements rendered for a 2x2 table with imageEmbedWidget loaded', async () => { const { livePreview } = await import('../extensions/live-preview') const doc = '| ![[img.png]] | b |\n|---|---|\n| 1 | 2 |\n' const view = new EditorView({ state: EditorState.create({ doc, extensions: [ markdown({ base: markdownLanguage, extensions: [GFM], addKeymap: false }), markdownFrontmatterField, livePreview({ imageEmbed: { resolver: { resolveImageUrl: async () => 'blob:test' } }, }), ], selection: { anchor: doc.length }, }), parent: document.body, }) view.requestMeasure() const cellEls = view.dom.querySelectorAll('.cm-table-cell') expect(cellEls).toHaveLength(4) view.destroy() }) })