Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392// src/plugin/__tests__/editor-suggest.test.tsimport { describe, it, expect, vi } from 'vitest'import { EditorSuggest, PopoverSuggest, AbstractInputSuggest } from '../editor-suggest'import { Component } from '../component'import { Scope } from '../obsidian-stubs'import { Plugin } from '../plugin'import { createTestApp } from '../loader/__tests__/test-utils'import type { App } from '../app'import type { PluginManifest } from '../types'
class StubSuggest extends EditorSuggest<string> { onTrigger() { return null } async getSuggestions() { return [] as string[] } renderSuggestion() {} selectSuggestion() {}}
function mkManifest(id = 'test'): PluginManifest { return { id, name: 'Test', author: 'Test', version: '1.0.0', minAppVersion: '0.0.0', description: 'x', }}
describe('EditorSuggest base class', () => { it('does not extend Component (upstream Obsidian API parity)', async () => { const app = await createTestApp() const s = new StubSuggest(app) // Upstream PopoverSuggest implements interfaces, does not extend Component expect(s).not.toBeInstanceOf(Component) // load/unload are NOT exposed on EditorSuggest/PopoverSuggest expect((s as unknown as Record<string, unknown>)['load']).toBeUndefined() expect((s as unknown as Record<string, unknown>)['unload']).toBeUndefined() })
it('exposes app on the instance (Obsidian API parity)', async () => { const app = await createTestApp() const s = new StubSuggest(app) expect(s.app).toBe(app) })
it('forces subclasses to implement all four abstract methods', () => { expect(StubSuggest.prototype.onTrigger).toBeDefined() expect(StubSuggest.prototype.getSuggestions).toBeDefined() expect(StubSuggest.prototype.renderSuggestion).toBeDefined() expect(StubSuggest.prototype.selectSuggestion).toBeDefined() })
it('inheritance chain matches Obsidian: EditorSuggest extends PopoverSuggest', async () => { const app = await createTestApp() const s = new StubSuggest(app) expect(s).toBeInstanceOf(EditorSuggest) expect(s).toBeInstanceOf(PopoverSuggest) // PopoverSuggest implements interfaces, does not extend Component expect(s).not.toBeInstanceOf(Component) })
it('has limit property with default value of 100 (Obsidian API parity)', async () => { const app = await createTestApp() const s = new StubSuggest(app) expect(s.limit).toBe(100) // Subclasses can override the limit s.limit = 5 expect(s.limit).toBe(5) })
it('has setInstructions method (Obsidian API parity)', async () => { const app = await createTestApp() const s = new StubSuggest(app) expect(typeof s.setInstructions).toBe('function') // Method accepts Instruction array without throwing expect(() => s.setInstructions([{ command: '↵', purpose: 'to insert' }])).not.toThrow() })
it('PopoverSuggest is abstract; open()/close() throw "not implemented"', async () => { class MyPopover extends PopoverSuggest<string> { renderSuggestion() {} selectSuggestion() {} } const app = await createTestApp() const p = new MyPopover(app) expect(p.app).toBe(app) expect(() => p.open()).toThrow(/not implemented/) expect(() => p.close()).toThrow(/not implemented/) })
it('PopoverSuggest.scope is a Scope instance (Obsidian API parity)', async () => { class MyPopover extends PopoverSuggest<string> { renderSuggestion() {} selectSuggestion() {} } const app = await createTestApp() const p = new MyPopover(app) // Upstream: scope is a property of type Scope, not a throwing getter expect(p.scope).toBeDefined() expect(typeof p.scope.register).toBe('function') })
it('PopoverSuggest accepts custom scope in constructor', async () => { class MyPopover extends PopoverSuggest<string> { renderSuggestion() {} selectSuggestion() {} } const app = await createTestApp() const customScope = new Scope() const p = new MyPopover(app, customScope) expect(p.scope).toBe(customScope) })
it('PopoverSuggest creates new Scope for each instance when none provided', async () => { class MyPopover extends PopoverSuggest<string> { renderSuggestion() {} selectSuggestion() {} } const app = await createTestApp() const p1 = new MyPopover(app) const p2 = new MyPopover(app) // Each instance should get its own Scope expect(p1.scope).not.toBe(p2.scope) expect(p1.scope).toBeInstanceOf(Scope) expect(p2.scope).toBeInstanceOf(Scope) })})
describe('Workspace.registerEditorSuggest', () => { it('appends to editorSuggests in registration order', async () => { const app = await createTestApp() const a = new StubSuggest(app) const b = new StubSuggest(app) app.workspace.registerEditorSuggest(a) app.workspace.registerEditorSuggest(b) expect(app.workspace.editorSuggests).toEqual([a, b]) })
it('is idempotent (re-registering the same suggester is a no-op)', async () => { const app = await createTestApp() const a = new StubSuggest(app) app.workspace.registerEditorSuggest(a) app.workspace.registerEditorSuggest(a) expect(app.workspace.editorSuggests).toEqual([a]) })
it('unregisterEditorSuggest removes the suggester', async () => { const app = await createTestApp() const a = new StubSuggest(app) const b = new StubSuggest(app) app.workspace.registerEditorSuggest(a) app.workspace.registerEditorSuggest(b) app.workspace.unregisterEditorSuggest(a) expect(app.workspace.editorSuggests).toEqual([b]) })
it('unregisterEditorSuggest is a silent no-op when not registered', async () => { const app = await createTestApp() const a = new StubSuggest(app) expect(() => app.workspace.unregisterEditorSuggest(a)).not.toThrow() })
it('fires editor-suggest-registered / -unregistered events', async () => { const app = await createTestApp() const seen: string[] = [] // editor-suggest-* events are on app.reamsEvents, not workspace app.reamsEvents.on('editor-suggest-registered', () => seen.push('r')) app.reamsEvents.on('editor-suggest-unregistered', () => seen.push('u')) const a = new StubSuggest(app) app.workspace.registerEditorSuggest(a) app.workspace.unregisterEditorSuggest(a) expect(seen).toEqual(['r', 'u']) })
it('returns ReadonlyArray to prevent external mutation (F-VAULT-16)', async () => { // This test documents that editorSuggests is typed as ReadonlyArray, // which prevents callers from bypassing registerEditorSuggest's dedup // by directly calling .push(), .splice(), etc. // // The actual compile-time protection is enforced by TypeScript — // attempting `app.workspace.editorSuggests.push(x)` produces: // TS2339: Property 'push' does not exist on type 'readonly EditorSuggest<unknown>[]' // // At runtime, the array IS mutable (ReadonlyArray is a type-only construct), // but the public contract is that all mutations go through register/unregister. const app = await createTestApp() const arr = app.workspace.editorSuggests
// Type assertion: the getter returns ReadonlyArray, not mutable array void (arr as ReadonlyArray<unknown>)
// Verify the array is readable expect(arr.length).toBe(0) expect(arr.includes).toBeDefined() expect(arr.indexOf).toBeDefined() expect(arr[Symbol.iterator]).toBeDefined()
// Verify the array can be iterated const a = new StubSuggest(app) app.workspace.registerEditorSuggest(a) expect([...app.workspace.editorSuggests]).toEqual([a]) })})
class TestSuggestPlugin extends Plugin { suggest: StubSuggest | null = null onload() { this.suggest = new StubSuggest(this.app) this.registerEditorSuggest(this.suggest) }}
describe('Plugin.registerEditorSuggest', () => { it('delegates to workspace on load', async () => { const app: App = await createTestApp() const p = new TestSuggestPlugin(app, mkManifest()) p.load() expect(app.workspace.editorSuggests).toContain(p.suggest) })
it('unregisters from workspace on unload', async () => { const app: App = await createTestApp() const p = new TestSuggestPlugin(app, mkManifest()) p.load() p.unload() expect(app.workspace.editorSuggests).toEqual([]) })
it('no-ops when plugin is not loaded', async () => { const app: App = await createTestApp() const p = new TestSuggestPlugin(app, mkManifest()) const s = new StubSuggest(app) p.registerEditorSuggest(s) // not loaded expect(app.workspace.editorSuggests).toEqual([]) })
it('survives load → unload → reload', async () => { const app: App = await createTestApp() const p = new TestSuggestPlugin(app, mkManifest()) p.load() p.unload() p.load() expect(app.workspace.editorSuggests.length).toBe(1) })
it('cleans up alongside other plugin resources (commands, views)', async () => { const app: App = await createTestApp() class MultiPlugin extends Plugin { onload() { this.addCommand({ id: 'hello', name: 'Hello' }) this.registerEditorSuggest(new StubSuggest(this.app)) } } const p = new MultiPlugin(app, mkManifest('multi')) p.load() expect(app.commands.getCommand('multi:hello')).toBeDefined() expect(app.workspace.editorSuggests.length).toBe(1) p.unload() expect(app.commands.getCommand('multi:hello')).toBeUndefined() expect(app.workspace.editorSuggests).toEqual([]) })
it('is idempotent — re-registering the same suggester does not cause double cleanup', async () => { // F-VAULT-16: Plugin.registerEditorSuggest should also deduplicate its // internal tracking array so that unload doesn't call unregisterEditorSuggest // multiple times for the same suggester. const app: App = await createTestApp() class DoubleRegisterPlugin extends Plugin { suggest = new StubSuggest(this.app) onload() { this.registerEditorSuggest(this.suggest) this.registerEditorSuggest(this.suggest) // duplicate } } const p = new DoubleRegisterPlugin(app, mkManifest('double')) p.load() // Workspace should have deduplicated expect(app.workspace.editorSuggests.length).toBe(1) // Unload should not throw or call unregister twice p.unload() expect(app.workspace.editorSuggests.length).toBe(0) })})
describe('AbstractInputSuggest', () => { class TestInputSuggest extends AbstractInputSuggest<string> { renderSuggestion() {} protected getSuggestions() { return ['a', 'b', 'c'] } }
it('extends PopoverSuggest (upstream inheritance chain)', async () => { const app = await createTestApp() const input = document.createElement('input') const suggest = new TestInputSuggest(app, input) expect(suggest).toBeInstanceOf(PopoverSuggest) expect(suggest).toBeInstanceOf(AbstractInputSuggest) // Not an EditorSuggest - different subclass expect(suggest).not.toBeInstanceOf(EditorSuggest) })
it('has limit property with default value of 100', async () => { const app = await createTestApp() const input = document.createElement('input') const suggest = new TestInputSuggest(app, input) expect(suggest.limit).toBe(100) suggest.limit = 50 expect(suggest.limit).toBe(50) })
it('can close during an input component cleanup', async () => { const app = await createTestApp() const input = document.createElement('input') const suggest = new TestInputSuggest(app, input)
expect(() => suggest.close()).not.toThrow() })
it('setValue/getValue work with HTMLInputElement', async () => { const app = await createTestApp() const input = document.createElement('input') const suggest = new TestInputSuggest(app, input)
suggest.setValue('test value') expect(input.value).toBe('test value') expect(suggest.getValue()).toBe('test value') })
it('setValue/getValue work with contenteditable div', async () => { const app = await createTestApp() const div = document.createElement('div') div.contentEditable = 'true' const suggest = new TestInputSuggest(app, div)
suggest.setValue('editable content') expect(div.textContent).toBe('editable content') expect(suggest.getValue()).toBe('editable content') })
it('onSelect registers callback invoked by selectSuggestion', async () => { const app = await createTestApp() const input = document.createElement('input') const suggest = new TestInputSuggest(app, input) const callback = vi.fn()
suggest.onSelect(callback)
const mockEvt = new KeyboardEvent('keydown') suggest.selectSuggestion('selected-item', mockEvt)
expect(callback).toHaveBeenCalledWith('selected-item', mockEvt) })
it('onSelect returns this for chaining', async () => { const app = await createTestApp() const input = document.createElement('input') const suggest = new TestInputSuggest(app, input)
const result = suggest.onSelect(() => {}) expect(result).toBe(suggest) })
it('selectSuggestion does nothing without onSelect callback', async () => { const app = await createTestApp() const input = document.createElement('input') const suggest = new TestInputSuggest(app, input)
// Should not throw expect(() => suggest.selectSuggestion('value', new KeyboardEvent('keydown'))).not.toThrow() })
it('app property is accessible (inherited from PopoverSuggest)', async () => { const app = await createTestApp() const input = document.createElement('input') const suggest = new TestInputSuggest(app, input) expect(suggest.app).toBe(app) })
it('scope property is accessible (inherited from PopoverSuggest)', async () => { const app = await createTestApp() const input = document.createElement('input') const suggest = new TestInputSuggest(app, input) expect(suggest.scope).toBeDefined() expect(typeof suggest.scope.register).toBe('function') })})