import { describe, it, expect } from 'vitest'; import { EditorState, type StateCommand } from '@codemirror/state'; import { sinkListItem, liftListItem, continueListItem } from './listKeymap'; /** Run a list-indent command headlessly and return the resulting doc + caret. */ function run(cmd: StateCommand, doc: string, cursor: number) { const state = EditorState.create({ doc, selection: { anchor: cursor } }); let tr: ReturnType | undefined; const ok = cmd({ state, dispatch: (t) => (tr = t) }); return { ok, doc: tr ? tr.newDoc.toString() : doc, head: tr ? tr.newSelection.main.head : cursor, }; } describe('continueListItem (Enter)', () => { it('continues a bullet marker with a trailing space, tightly', () => { const r = run(continueListItem, '- a', 3); expect(r.ok).toBe(true); expect(r.doc).toBe('- a\n- '); // no blank line between items }); it('auto-increments an ordered list, tightly', () => { const r = run(continueListItem, '1. a', 4); expect(r.doc).toBe('1. a\n2. '); }); it('continues at the same nested indent and increments', () => { const r = run(continueListItem, '1. a\n 1. b', 12); expect(r.doc).toBe('1. a\n 1. b\n 2. '); }); it('keeps a multi-item list tight (no blank line inserted)', () => { // The bug: the old library command inserted a blank line here. const r = run(continueListItem, '1. a\n2. b', 9); expect(r.doc).toBe('1. a\n2. b\n3. '); }); it('splits content after the cursor onto the continued line', () => { const r = run(continueListItem, '1. hello world', 9); // after "hello " expect(r.doc).toBe('1. hello \n2. world'); }); it('exits the list on an empty item', () => { expect(run(continueListItem, '- ', 2).doc).toBe(''); expect(run(continueListItem, '1. ', 3).doc).toBe(''); }); it('falls through (returns false) outside a list item', () => { expect(run(continueListItem, 'plain', 5).ok).toBe(false); }); }); describe('sinkListItem (Tab)', () => { it('nests an ordered item under its sibling and resets it to 1', () => { // cursor at end of "2. b" const r = run(sinkListItem, '1. a\n2. b', 9); expect(r.ok).toBe(true); expect(r.doc).toBe('1. a\n 1. b'); }); it('closes the gap it leaves in the parent list', () => { const r = run(sinkListItem, '1. a\n2. b\n3. c', 9); // sink "b" expect(r.doc).toBe('1. a\n 1. b\n2. c'); }); it('nests a bullet item without touching a marker number', () => { const r = run(sinkListItem, '- a\n- b', 7); // end of "- b" expect(r.doc).toBe('- a\n - b'); }); it('falls through on the first item of a list (nothing to nest under)', () => { const r = run(sinkListItem, '1. a', 4); expect(r.ok).toBe(false); // default Tab handles it expect(r.doc).toBe('1. a'); }); it('falls through (returns false) outside a list item', () => { const r = run(sinkListItem, 'plain text', 5); expect(r.ok).toBe(false); }); }); describe('liftListItem (Shift-Tab)', () => { it('outdents a nested ordered item and continues the parent numbering', () => { const r = run(liftListItem, '1. a\n 1. b', 12); // end of nested "1. b" expect(r.ok).toBe(true); expect(r.doc).toBe('1. a\n2. b'); }); it('increments following parent siblings when an item rejoins', () => { const r = run(liftListItem, '1. a\n 1. b\n2. c', 12); // lift "b" expect(r.doc).toBe('1. a\n2. b\n3. c'); }); it('restarts the siblings left behind in the nested list', () => { const r = run(liftListItem, '1. a\n 1. b\n 2. c', 12); // lift "b" expect(r.doc).toBe('1. a\n2. b\n 1. c'); }); it('falls through (returns false) at top level', () => { const r = run(liftListItem, '1. a', 4); expect(r.ok).toBe(false); }); });