// src/editor/extensions/__tests__/selection-validation.test.ts import { describe, test, expect } from 'vitest' import { EditorState, EditorSelection } from '@codemirror/state' import { markdown, markdownLanguage } from '@codemirror/lang-markdown' import { markdownFrontmatterField } from '../markdown-context' import { isPositionInsideFrontmatterWidget, adjustRangeOutsideFrontmatter, adjustSelectionOutsideFrontmatter, isSelectionSafeToRestore, getSafeSelectionForRestore, getInitialCursorPosition, createInitialSelection, } from '../selection-validation' /** * Create an EditorState with markdown language and frontmatter field support. */ function createState(doc: string, selection?: EditorSelection): EditorState { return EditorState.create({ doc, selection, extensions: [ markdown({ base: markdownLanguage }), markdownFrontmatterField, ], }) } const DOC_WITH_FRONTMATTER = `--- title: Test tags: [a, b] --- # Hello This is content after frontmatter. ` const DOC_WITHOUT_FRONTMATTER = `# Hello This is a document without frontmatter. ` describe('selection-validation', () => { describe('isPositionInsideFrontmatterWidget', () => { test('returns false for document without frontmatter', () => { const state = createState(DOC_WITHOUT_FRONTMATTER) expect(isPositionInsideFrontmatterWidget(state, 0)).toBe(false) expect(isPositionInsideFrontmatterWidget(state, 5)).toBe(false) }) test('returns true for positions inside frontmatter range', () => { const state = createState(DOC_WITH_FRONTMATTER) // Frontmatter is "---\ntitle: Test\ntags: [a, b]\n---\n" // which is 33 characters (endOffset = 33) expect(isPositionInsideFrontmatterWidget(state, 0)).toBe(true) expect(isPositionInsideFrontmatterWidget(state, 10)).toBe(true) expect(isPositionInsideFrontmatterWidget(state, 32)).toBe(true) }) test('returns false for positions at or after frontmatter endOffset', () => { const state = createState(DOC_WITH_FRONTMATTER) // endOffset is 33 (just past the closing ---\n) expect(isPositionInsideFrontmatterWidget(state, 33)).toBe(false) expect(isPositionInsideFrontmatterWidget(state, 34)).toBe(false) expect(isPositionInsideFrontmatterWidget(state, 50)).toBe(false) }) }) describe('adjustRangeOutsideFrontmatter', () => { test('returns original range for document without frontmatter', () => { const state = createState(DOC_WITHOUT_FRONTMATTER) const range = EditorSelection.range(0, 5) const adjusted = adjustRangeOutsideFrontmatter(state, range) expect(adjusted.anchor).toBe(0) expect(adjusted.head).toBe(5) }) test('adjusts anchor inside frontmatter to endOffset', () => { const state = createState(DOC_WITH_FRONTMATTER) const range = EditorSelection.range(0, 50) const adjusted = adjustRangeOutsideFrontmatter(state, range) expect(adjusted.anchor).toBe(33) // frontmatter endOffset expect(adjusted.head).toBe(50) }) test('adjusts head inside frontmatter to endOffset', () => { const state = createState(DOC_WITH_FRONTMATTER) const range = EditorSelection.range(50, 10) const adjusted = adjustRangeOutsideFrontmatter(state, range) expect(adjusted.anchor).toBe(50) expect(adjusted.head).toBe(33) // frontmatter endOffset }) test('adjusts both anchor and head when both inside frontmatter', () => { const state = createState(DOC_WITH_FRONTMATTER) const range = EditorSelection.range(0, 10) const adjusted = adjustRangeOutsideFrontmatter(state, range) expect(adjusted.anchor).toBe(33) expect(adjusted.head).toBe(33) }) test('returns original range when both outside frontmatter', () => { const state = createState(DOC_WITH_FRONTMATTER) const range = EditorSelection.range(40, 50) const adjusted = adjustRangeOutsideFrontmatter(state, range) expect(adjusted.anchor).toBe(40) expect(adjusted.head).toBe(50) }) }) describe('adjustSelectionOutsideFrontmatter', () => { test('returns original selection when no adjustment needed', () => { const state = createState(DOC_WITH_FRONTMATTER) const selection = EditorSelection.single(40) const adjusted = adjustSelectionOutsideFrontmatter(state, selection) expect(adjusted.main.anchor).toBe(40) expect(adjusted.main.head).toBe(40) }) test('adjusts all ranges in multi-range selection', () => { const state = createState(DOC_WITH_FRONTMATTER) const selection = EditorSelection.create([ EditorSelection.range(0, 0), EditorSelection.range(10, 20), EditorSelection.range(40, 50), ], 0) const adjusted = adjustSelectionOutsideFrontmatter(state, selection) // When multiple ranges adjust to the same position (33, 33), // EditorSelection.create merges them into a single range. // So we end up with 2 ranges instead of 3. expect(adjusted.ranges.length).toBe(2) expect(adjusted.ranges[0].anchor).toBe(33) expect(adjusted.ranges[0].head).toBe(33) expect(adjusted.ranges[1].anchor).toBe(40) expect(adjusted.ranges[1].head).toBe(50) }) test('preserves mainIndex', () => { const state = createState(DOC_WITH_FRONTMATTER) const selection = EditorSelection.create([ EditorSelection.range(0, 0), EditorSelection.range(40, 50), ], 1) const adjusted = adjustSelectionOutsideFrontmatter(state, selection) expect(adjusted.mainIndex).toBe(1) }) test('returns the selection unchanged for a document without frontmatter', () => { const state = createState(DOC_WITHOUT_FRONTMATTER) const selection = EditorSelection.create([ EditorSelection.range(2, 4), EditorSelection.range(10, 20), ], 1) const adjusted = adjustSelectionOutsideFrontmatter(state, selection) expect(adjusted).toBe(selection) }) }) describe('isSelectionSafeToRestore', () => { test('returns true for safe selection', () => { const state = createState(DOC_WITH_FRONTMATTER) const selection = EditorSelection.single(40) expect(isSelectionSafeToRestore(state, selection)).toBe(true) }) test('returns false for selection inside frontmatter', () => { const state = createState(DOC_WITH_FRONTMATTER) const selection = EditorSelection.single(10) expect(isSelectionSafeToRestore(state, selection)).toBe(false) }) test('returns false for selection with out-of-bounds positions', () => { const state = createState(DOC_WITH_FRONTMATTER) const docLen = state.doc.length const selection = EditorSelection.single(docLen + 100) expect(isSelectionSafeToRestore(state, selection)).toBe(false) }) test('returns false for negative positions', () => { const state = createState(DOC_WITH_FRONTMATTER) // Create a mock selection with negative position (shouldn't happen normally) const selection = EditorSelection.create([ EditorSelection.range(-1, 10), ]) expect(isSelectionSafeToRestore(state, selection)).toBe(false) }) test('treats a document without frontmatter as safe from offset zero', () => { const state = createState(DOC_WITHOUT_FRONTMATTER) const selection = EditorSelection.single(5) expect(isSelectionSafeToRestore(state, selection)).toBe(true) }) }) describe('getSafeSelectionForRestore', () => { test('returns original selection when already safe', () => { const state = createState(DOC_WITH_FRONTMATTER) const selection = EditorSelection.single(40) const safe = getSafeSelectionForRestore(state, selection) expect(safe).not.toBeNull() expect(safe!.main.anchor).toBe(40) expect(safe!.main.head).toBe(40) }) test('adjusts selection inside frontmatter', () => { const state = createState(DOC_WITH_FRONTMATTER) const selection = EditorSelection.single(0) const safe = getSafeSelectionForRestore(state, selection) expect(safe).not.toBeNull() expect(safe!.main.anchor).toBe(33) expect(safe!.main.head).toBe(33) }) test('clamps out-of-bounds selection to document length', () => { const state = createState(DOC_WITH_FRONTMATTER) const docLen = state.doc.length const selection = EditorSelection.single(docLen + 100) const safe = getSafeSelectionForRestore(state, selection) expect(safe).not.toBeNull() expect(safe!.main.anchor).toBe(docLen) expect(safe!.main.head).toBe(docLen) }) test('handles selection that needs both clamping and frontmatter adjustment', () => { const state = createState(DOC_WITH_FRONTMATTER) // Create a selection with one range in frontmatter, one out of bounds const docLen = state.doc.length const selection = EditorSelection.create([ EditorSelection.range(5, 10), EditorSelection.range(docLen + 50, docLen + 100), ], 0) const safe = getSafeSelectionForRestore(state, selection) expect(safe).not.toBeNull() expect(safe!.ranges[0].anchor).toBe(33) // adjusted for frontmatter expect(safe!.ranges[0].head).toBe(33) expect(safe!.ranges[1].anchor).toBe(docLen) // clamped to doc length expect(safe!.ranges[1].head).toBe(docLen) }) }) describe('getInitialCursorPosition', () => { test('returns 0 for document without frontmatter', () => { const state = createState(DOC_WITHOUT_FRONTMATTER) expect(getInitialCursorPosition(state)).toBe(0) }) test('returns endOffset for document with frontmatter', () => { const state = createState(DOC_WITH_FRONTMATTER) expect(getInitialCursorPosition(state)).toBe(33) }) }) describe('createInitialSelection', () => { test('creates cursor at 0 for document without frontmatter', () => { const state = createState(DOC_WITHOUT_FRONTMATTER) const selection = createInitialSelection(state) expect(selection.main.anchor).toBe(0) expect(selection.main.head).toBe(0) }) test('creates cursor after frontmatter for document with frontmatter', () => { const state = createState(DOC_WITH_FRONTMATTER) const selection = createInitialSelection(state) expect(selection.main.anchor).toBe(33) expect(selection.main.head).toBe(33) }) test('creates collapsed selection (cursor, not range)', () => { const state = createState(DOC_WITH_FRONTMATTER) const selection = createInitialSelection(state) expect(selection.main.empty).toBe(true) }) }) })