feat(desktop): embed BoosAPI Desktop client as a subproject
Vendor the rebranded BoosAPI Desktop client (Tauri + Vue) into metapi as a monorepo subdirectory under desktop/. Source only — node_modules, dist, Cargo target and the upstream .git history are excluded. desktop/ keeps its own independent Cargo workspace (desktop/src-tauri with crates/*); it is not part of the backend boosapi crate, so a `cargo check` at the metapi root still compiles only the backend. Build the desktop app from desktop/src-tauri per desktop/README.md. Note: app icons are still placeholder assets pending the final BoosAPI logo. The updater signing keys live outside the repo and are not included.
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../src/monaco-shim', () => {
|
||||
class Range {
|
||||
constructor(
|
||||
public lineNumber: number,
|
||||
public column: number,
|
||||
public endLineNumber: number,
|
||||
public endColumn: number,
|
||||
) {}
|
||||
}
|
||||
const editor = {
|
||||
EditorOption: { lineHeight: 16, readOnly: 0 },
|
||||
ScrollType: { Smooth: 1 },
|
||||
setModelLanguage: vi.fn(),
|
||||
}
|
||||
return {
|
||||
default: { editor, Range, languages: { getLanguages: () => [] } },
|
||||
editor,
|
||||
Range,
|
||||
languages: { getLanguages: () => [] },
|
||||
}
|
||||
})
|
||||
|
||||
import { DiffEditorManager } from '../src/core/DiffEditorManager'
|
||||
import { EditorManager } from '../src/core/EditorManager'
|
||||
|
||||
const EDITOR_RAF_KINDS = [
|
||||
'maybe-scroll',
|
||||
'reveal',
|
||||
'maybe-resume',
|
||||
'content-size-change',
|
||||
'sync-last-known',
|
||||
'immediate-reveal',
|
||||
'update',
|
||||
'append',
|
||||
] as const
|
||||
|
||||
const DIFF_RAF_KINDS = [
|
||||
'sync-diff-presentation',
|
||||
'capture-diff-unchanged-state',
|
||||
'restore-diff-unchanged-state',
|
||||
'patch-diff-unchanged-regions',
|
||||
'maybe-scroll-diff',
|
||||
'revealDiff',
|
||||
'immediate-reveal-diff',
|
||||
'maybe-resume-diff',
|
||||
'content-size-change-diff',
|
||||
'sync-last-known-modified',
|
||||
'diff',
|
||||
'appendDiff',
|
||||
] as const
|
||||
|
||||
function createEditorManager() {
|
||||
return new EditorManager(
|
||||
{ readOnly: true } as any,
|
||||
600,
|
||||
'600px',
|
||||
true,
|
||||
true,
|
||||
32,
|
||||
2,
|
||||
75,
|
||||
)
|
||||
}
|
||||
|
||||
function createDiffEditorManager() {
|
||||
return new DiffEditorManager(
|
||||
{ readOnly: true } as any,
|
||||
600,
|
||||
'600px',
|
||||
true,
|
||||
true,
|
||||
32,
|
||||
2,
|
||||
true,
|
||||
75,
|
||||
)
|
||||
}
|
||||
|
||||
function stubRafScheduler(instance: any) {
|
||||
const cancel = vi.fn()
|
||||
instance.rafScheduler = { schedule: vi.fn(), cancel }
|
||||
return cancel
|
||||
}
|
||||
|
||||
describe('EditorManager cleanup semantics', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('cleanup cancels every scheduled RAF kind and pending timers', () => {
|
||||
const manager = createEditorManager()
|
||||
const cancel = stubRafScheduler(manager)
|
||||
const revealDebounceHandle = 101
|
||||
const revealIdleHandle = 102
|
||||
const suppressionHandle = 103
|
||||
const throttleHandle = 104
|
||||
manager['revealDebounceId'] = revealDebounceHandle
|
||||
manager['revealIdleTimerId'] = revealIdleHandle
|
||||
manager['scrollWatcherSuppressionTimer'] = suppressionHandle
|
||||
manager['updateThrottleTimer'] = throttleHandle
|
||||
manager['appendBuffer'].push('x')
|
||||
manager['appendBufferScheduled'] = true
|
||||
const clearSpy = vi.spyOn(globalThis, 'clearTimeout').mockImplementation(() => {})
|
||||
|
||||
manager.cleanup()
|
||||
|
||||
for (const kind of EDITOR_RAF_KINDS)
|
||||
expect(cancel).toHaveBeenCalledWith(kind)
|
||||
expect(clearSpy).toHaveBeenCalledWith(revealDebounceHandle)
|
||||
expect(clearSpy).toHaveBeenCalledWith(revealIdleHandle)
|
||||
expect(clearSpy).toHaveBeenCalledWith(suppressionHandle)
|
||||
expect(clearSpy).toHaveBeenCalledWith(throttleHandle)
|
||||
expect(manager['revealDebounceId']).toBeNull()
|
||||
expect(manager['revealIdleTimerId']).toBeNull()
|
||||
expect(manager['scrollWatcherSuppressionTimer']).toBeNull()
|
||||
expect(manager['updateThrottleTimer']).toBeNull()
|
||||
expect(manager['appendBufferScheduled']).toBe(false)
|
||||
expect(manager['appendBuffer']).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('safeClean mirrors cleanup for RAFs, timers, and append state', () => {
|
||||
const manager = createEditorManager()
|
||||
const cancel = stubRafScheduler(manager)
|
||||
const revealDebounceHandle = 201
|
||||
const revealIdleHandle = 202
|
||||
const suppressionHandle = 203
|
||||
const throttleHandle = 204
|
||||
manager['revealDebounceId'] = revealDebounceHandle
|
||||
manager['revealIdleTimerId'] = revealIdleHandle
|
||||
manager['scrollWatcherSuppressionTimer'] = suppressionHandle
|
||||
manager['updateThrottleTimer'] = throttleHandle
|
||||
manager['appendBuffer'].push('x')
|
||||
manager['appendBufferScheduled'] = true
|
||||
const clearSpy = vi.spyOn(globalThis, 'clearTimeout').mockImplementation(() => {})
|
||||
|
||||
manager.safeClean()
|
||||
|
||||
for (const kind of EDITOR_RAF_KINDS)
|
||||
expect(cancel).toHaveBeenCalledWith(kind)
|
||||
expect(clearSpy).toHaveBeenCalledWith(revealDebounceHandle)
|
||||
expect(clearSpy).toHaveBeenCalledWith(revealIdleHandle)
|
||||
expect(clearSpy).toHaveBeenCalledWith(suppressionHandle)
|
||||
expect(clearSpy).toHaveBeenCalledWith(throttleHandle)
|
||||
expect(manager['revealDebounceId']).toBeNull()
|
||||
expect(manager['revealIdleTimerId']).toBeNull()
|
||||
expect(manager['scrollWatcherSuppressionTimer']).toBeNull()
|
||||
expect(manager['updateThrottleTimer']).toBeNull()
|
||||
expect(manager['appendBufferScheduled']).toBe(false)
|
||||
expect(manager['appendBuffer']).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DiffEditorManager cleanup semantics', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('cleanup clears diff RAF queues, timers, and append buffers', () => {
|
||||
const manager = createDiffEditorManager()
|
||||
const cancel = stubRafScheduler(manager)
|
||||
const revealDebounceHandle = 301
|
||||
const revealIdleHandle = 302
|
||||
const suppressionHandle = 303
|
||||
const appendThrottleHandle = 304
|
||||
const hideHandle = 305
|
||||
manager['revealDebounceIdDiff'] = revealDebounceHandle
|
||||
manager['revealIdleTimerIdDiff'] = revealIdleHandle
|
||||
manager['diffScrollWatcherSuppressionTimer'] = suppressionHandle
|
||||
manager['appendFlushThrottleTimerDiff'] = appendThrottleHandle
|
||||
manager['diffHunkHideTimer'] = hideHandle
|
||||
manager['appendBufferOriginalDiff'].push('left')
|
||||
manager['appendBufferModifiedDiff'].push('right')
|
||||
manager['appendBufferDiffScheduled'] = true
|
||||
const clearSpy = vi.spyOn(globalThis, 'clearTimeout').mockImplementation(() => {})
|
||||
|
||||
manager.cleanup()
|
||||
|
||||
for (const kind of DIFF_RAF_KINDS)
|
||||
expect(cancel).toHaveBeenCalledWith(kind)
|
||||
expect(clearSpy).toHaveBeenCalledWith(revealDebounceHandle)
|
||||
expect(clearSpy).toHaveBeenCalledWith(revealIdleHandle)
|
||||
expect(clearSpy).toHaveBeenCalledWith(suppressionHandle)
|
||||
expect(clearSpy).toHaveBeenCalledWith(appendThrottleHandle)
|
||||
expect(clearSpy).toHaveBeenCalledWith(hideHandle)
|
||||
expect(manager['revealDebounceIdDiff']).toBeNull()
|
||||
expect(manager['revealIdleTimerIdDiff']).toBeNull()
|
||||
expect(manager['diffScrollWatcherSuppressionTimer']).toBeNull()
|
||||
expect(manager['appendFlushThrottleTimerDiff']).toBeNull()
|
||||
expect(manager['diffHunkHideTimer']).toBeNull()
|
||||
expect(manager['appendBufferDiffScheduled']).toBe(false)
|
||||
expect(manager['appendBufferOriginalDiff']).toHaveLength(0)
|
||||
expect(manager['appendBufferModifiedDiff']).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('safeClean also cancels diff RAFs, timers, and append buffers', () => {
|
||||
const manager = createDiffEditorManager()
|
||||
const cancel = stubRafScheduler(manager)
|
||||
const revealDebounceHandle = 401
|
||||
const revealIdleHandle = 402
|
||||
const suppressionHandle = 403
|
||||
const appendThrottleHandle = 404
|
||||
const hideHandle = 405
|
||||
manager['revealDebounceIdDiff'] = revealDebounceHandle
|
||||
manager['revealIdleTimerIdDiff'] = revealIdleHandle
|
||||
manager['diffScrollWatcherSuppressionTimer'] = suppressionHandle
|
||||
manager['appendFlushThrottleTimerDiff'] = appendThrottleHandle
|
||||
manager['diffHunkHideTimer'] = hideHandle
|
||||
manager['appendBufferOriginalDiff'].push('left')
|
||||
manager['appendBufferModifiedDiff'].push('right')
|
||||
manager['appendBufferDiffScheduled'] = true
|
||||
const clearSpy = vi.spyOn(globalThis, 'clearTimeout').mockImplementation(() => {})
|
||||
|
||||
manager.safeClean()
|
||||
|
||||
for (const kind of DIFF_RAF_KINDS)
|
||||
expect(cancel).toHaveBeenCalledWith(kind)
|
||||
expect(clearSpy).toHaveBeenCalledWith(revealDebounceHandle)
|
||||
expect(clearSpy).toHaveBeenCalledWith(revealIdleHandle)
|
||||
expect(clearSpy).toHaveBeenCalledWith(suppressionHandle)
|
||||
expect(clearSpy).toHaveBeenCalledWith(appendThrottleHandle)
|
||||
expect(clearSpy).toHaveBeenCalledWith(hideHandle)
|
||||
expect(manager['revealDebounceIdDiff']).toBeNull()
|
||||
expect(manager['revealIdleTimerIdDiff']).toBeNull()
|
||||
expect(manager['diffScrollWatcherSuppressionTimer']).toBeNull()
|
||||
expect(manager['appendFlushThrottleTimerDiff']).toBeNull()
|
||||
expect(manager['diffHunkHideTimer']).toBeNull()
|
||||
expect(manager['appendBufferDiffScheduled']).toBe(false)
|
||||
expect(manager['appendBufferOriginalDiff']).toHaveLength(0)
|
||||
expect(manager['appendBufferModifiedDiff']).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,316 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
function deferred<T = unknown>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
async function loadUseMonacoHarness(options: Record<string, any> = {}) {
|
||||
vi.resetModules()
|
||||
|
||||
const registerCalls: Array<{
|
||||
themes: any
|
||||
languages: any
|
||||
deferred: ReturnType<typeof deferred>
|
||||
}> = []
|
||||
const editorManagers: any[] = []
|
||||
const diffManagers: any[] = []
|
||||
|
||||
vi.doMock('../src/utils/registerMonacoThemes', () => {
|
||||
return {
|
||||
registerMonacoThemes: vi.fn((themes: any, languages: any) => {
|
||||
const call = {
|
||||
themes,
|
||||
languages,
|
||||
deferred: deferred(),
|
||||
}
|
||||
registerCalls.push(call)
|
||||
return call.deferred.promise
|
||||
}),
|
||||
clearHighlighterCache: vi.fn(),
|
||||
getOrCreateHighlighter: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.doMock('../src/core/EditorManager', () => {
|
||||
return {
|
||||
EditorManager: class {
|
||||
cleanup = vi.fn()
|
||||
safeClean = vi.fn()
|
||||
appendCode = vi.fn((appendText: string) => {
|
||||
this.code += appendText
|
||||
})
|
||||
updateCode = vi.fn((code: string) => {
|
||||
this.code = code
|
||||
})
|
||||
setLanguage = vi.fn()
|
||||
code = ''
|
||||
throttleMs = 50
|
||||
editorView = {
|
||||
dispose: vi.fn(),
|
||||
getModel: vi.fn(() => ({
|
||||
getValue: () => this.code,
|
||||
getLanguageId: () => 'javascript',
|
||||
})),
|
||||
}
|
||||
createEditor = vi.fn(async (
|
||||
_container: HTMLElement,
|
||||
code: string,
|
||||
_language: string,
|
||||
_theme: string,
|
||||
) => {
|
||||
this.code = code
|
||||
return this.editorView
|
||||
})
|
||||
|
||||
constructor(...args: any[]) {
|
||||
this.throttleMs = args[8] ?? 50
|
||||
editorManagers.push(this)
|
||||
}
|
||||
|
||||
getEditorView() {
|
||||
return this.editorView
|
||||
}
|
||||
|
||||
getCode() {
|
||||
return this.code
|
||||
}
|
||||
|
||||
setUpdateThrottleMs(ms: number) {
|
||||
this.throttleMs = ms
|
||||
}
|
||||
|
||||
getUpdateThrottleMs() {
|
||||
return this.throttleMs
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.doMock('../src/core/DiffEditorManager', () => {
|
||||
return {
|
||||
DiffEditorManager: class {
|
||||
cleanup = vi.fn()
|
||||
safeClean = vi.fn()
|
||||
notifyThemeChange = vi.fn()
|
||||
updateDiff = vi.fn()
|
||||
updateOriginal = vi.fn()
|
||||
updateModified = vi.fn()
|
||||
appendOriginal = vi.fn()
|
||||
appendModified = vi.fn()
|
||||
refreshDiffPresentation = vi.fn()
|
||||
setLanguage = vi.fn()
|
||||
diffEditorView = { dispose: vi.fn() }
|
||||
models = { original: null, modified: null }
|
||||
createDiffEditor = vi.fn(async () => this.diffEditorView)
|
||||
|
||||
constructor() {
|
||||
diffManagers.push(this)
|
||||
}
|
||||
|
||||
getDiffEditorView() {
|
||||
return this.diffEditorView
|
||||
}
|
||||
|
||||
getDiffModels() {
|
||||
return this.models
|
||||
}
|
||||
|
||||
async setDiffModels(models: any) {
|
||||
this.models = models
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.doMock('../src/monaco-shim', () => {
|
||||
const setTheme = vi.fn()
|
||||
const setModelLanguage = vi.fn()
|
||||
const editor = {
|
||||
setTheme,
|
||||
setModelLanguage,
|
||||
EditorOption: { lineHeight: 16 },
|
||||
ScrollType: { Smooth: 1 },
|
||||
}
|
||||
const languages = {
|
||||
getLanguages: () => [{ id: 'javascript' }],
|
||||
register: vi.fn(),
|
||||
}
|
||||
const Range = class {
|
||||
constructor(
|
||||
public startLineNumber: number,
|
||||
public startColumn: number,
|
||||
public endLineNumber: number,
|
||||
public endColumn: number,
|
||||
) {}
|
||||
}
|
||||
return {
|
||||
default: { editor, languages, Range, ScrollType: { Smooth: 1 } },
|
||||
editor,
|
||||
languages,
|
||||
Range,
|
||||
ScrollType: { Smooth: 1 },
|
||||
}
|
||||
})
|
||||
|
||||
const base = await import('../src/index.base')
|
||||
const monaco = await import('../src/monaco-shim')
|
||||
|
||||
const api = base.useMonaco({
|
||||
themes: ['vitesse-dark', 'vitesse-light'],
|
||||
languages: ['javascript'],
|
||||
...options,
|
||||
})
|
||||
|
||||
return {
|
||||
api,
|
||||
monaco,
|
||||
registerCalls,
|
||||
editorManagers,
|
||||
diffManagers,
|
||||
}
|
||||
}
|
||||
|
||||
describe('useMonaco create lifecycle', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('replays the latest queued update after createEditor commits', async () => {
|
||||
const { api, registerCalls, editorManagers } = await loadUseMonacoHarness()
|
||||
const container = {} as HTMLElement
|
||||
|
||||
const createPromise = api.createEditor(container, 'initial', 'javascript')
|
||||
expect(registerCalls).toHaveLength(1)
|
||||
|
||||
api.updateCode('queued', 'javascript')
|
||||
|
||||
registerCalls[0].deferred.resolve(null)
|
||||
await createPromise
|
||||
|
||||
expect(editorManagers).toHaveLength(1)
|
||||
expect(editorManagers[0].updateCode).toHaveBeenCalledWith('queued', 'javascript')
|
||||
})
|
||||
|
||||
it('rejects superseded createEditor requests and disposes request-local resources', async () => {
|
||||
const firstDispose = vi.fn()
|
||||
const secondDispose = vi.fn()
|
||||
let beforeCreateCount = 0
|
||||
|
||||
const { api, registerCalls } = await loadUseMonacoHarness({
|
||||
onBeforeCreate: () => {
|
||||
beforeCreateCount += 1
|
||||
return [{ dispose: beforeCreateCount === 1 ? firstDispose : secondDispose }]
|
||||
},
|
||||
})
|
||||
|
||||
const firstPromise = api.createEditor({} as HTMLElement, 'first', 'javascript')
|
||||
expect(registerCalls).toHaveLength(1)
|
||||
|
||||
const secondPromise = api.createEditor({} as HTMLElement, 'second', 'javascript')
|
||||
expect(registerCalls).toHaveLength(2)
|
||||
expect(firstDispose).toHaveBeenCalledTimes(1)
|
||||
expect(secondDispose).not.toHaveBeenCalled()
|
||||
|
||||
registerCalls[1].deferred.resolve(null)
|
||||
await secondPromise
|
||||
|
||||
registerCalls[0].deferred.resolve(null)
|
||||
await expect(firstPromise).rejects.toMatchObject({
|
||||
message: 'Editor creation was superseded',
|
||||
code: 'STREAM_MONACO_CREATE_SUPERSEDED',
|
||||
})
|
||||
|
||||
expect(secondDispose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cleanupEditor cancels an in-flight create and disposes pending resources', async () => {
|
||||
const dispose = vi.fn()
|
||||
const { api, registerCalls, editorManagers } = await loadUseMonacoHarness({
|
||||
onBeforeCreate: () => [{ dispose }],
|
||||
})
|
||||
|
||||
const createPromise = api.createEditor({} as HTMLElement, 'code', 'javascript')
|
||||
expect(registerCalls).toHaveLength(1)
|
||||
|
||||
api.cleanupEditor()
|
||||
|
||||
expect(dispose).toHaveBeenCalledTimes(1)
|
||||
expect(editorManagers).toHaveLength(0)
|
||||
|
||||
registerCalls[0].deferred.resolve(null)
|
||||
await expect(createPromise).rejects.toMatchObject({
|
||||
message: 'Editor creation was superseded',
|
||||
code: 'STREAM_MONACO_CREATE_SUPERSEDED',
|
||||
})
|
||||
})
|
||||
|
||||
it('stabilizes createEditor on the latest requested theme during registration', async () => {
|
||||
const { api, registerCalls, editorManagers } = await loadUseMonacoHarness()
|
||||
const container = {} as HTMLElement
|
||||
|
||||
const createPromise = api.createEditor(container, 'code', 'javascript')
|
||||
expect(registerCalls).toHaveLength(1)
|
||||
|
||||
const setThemePromise = api.setTheme('vitesse-light')
|
||||
expect(registerCalls).toHaveLength(2)
|
||||
|
||||
registerCalls[0].deferred.resolve(null)
|
||||
for (let i = 0; i < 5 && registerCalls.length < 3; i++) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
expect(registerCalls).toHaveLength(3)
|
||||
|
||||
registerCalls[1].deferred.resolve(null)
|
||||
registerCalls[2].deferred.resolve(null)
|
||||
|
||||
await Promise.all([createPromise, setThemePromise])
|
||||
|
||||
expect(editorManagers).toHaveLength(1)
|
||||
expect(editorManagers[0].createEditor).toHaveBeenCalledWith(
|
||||
container,
|
||||
'code',
|
||||
'javascript',
|
||||
'vitesse-light',
|
||||
)
|
||||
})
|
||||
|
||||
it('safeClean does not cancel an in-flight create and cleanupEditor disposes committed resources', async () => {
|
||||
const dispose = vi.fn()
|
||||
const { api, registerCalls, editorManagers } = await loadUseMonacoHarness({
|
||||
onBeforeCreate: () => [{ dispose }],
|
||||
})
|
||||
|
||||
const createPromise = api.createEditor({} as HTMLElement, 'code', 'javascript')
|
||||
expect(registerCalls).toHaveLength(1)
|
||||
|
||||
api.safeClean()
|
||||
expect(dispose).not.toHaveBeenCalled()
|
||||
|
||||
registerCalls[0].deferred.resolve(null)
|
||||
await createPromise
|
||||
|
||||
expect(editorManagers).toHaveLength(1)
|
||||
expect(editorManagers[0].safeClean).not.toHaveBeenCalled()
|
||||
expect(dispose).not.toHaveBeenCalled()
|
||||
|
||||
api.safeClean()
|
||||
expect(editorManagers[0].safeClean).toHaveBeenCalledTimes(1)
|
||||
expect(dispose).not.toHaveBeenCalled()
|
||||
|
||||
api.cleanupEditor()
|
||||
expect(editorManagers[0].cleanup).toHaveBeenCalledTimes(1)
|
||||
expect(dispose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,326 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
function installRafMocks() {
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: any) => {
|
||||
return setTimeout(() => cb(Date.now()), 0) as unknown as number
|
||||
})
|
||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => {
|
||||
clearTimeout(id as unknown as ReturnType<typeof setTimeout>)
|
||||
})
|
||||
}
|
||||
|
||||
async function loadDiffEditorManager() {
|
||||
vi.resetModules()
|
||||
|
||||
vi.doMock('../src/monaco-shim', () => {
|
||||
class Range {
|
||||
constructor(
|
||||
public startLineNumber: number,
|
||||
public startColumn: number,
|
||||
public endLineNumber: number,
|
||||
public endColumn: number,
|
||||
) {}
|
||||
}
|
||||
|
||||
function createModel(initialValue: string, initialLanguage: string) {
|
||||
let value = initialValue
|
||||
let languageId = initialLanguage
|
||||
const contentSizeListeners = new Set<() => void>()
|
||||
const contentChangeListeners = new Set<() => void>()
|
||||
|
||||
function lines() {
|
||||
return value.split('\n')
|
||||
}
|
||||
|
||||
function getOffsetAt(lineNumber: number, column: number) {
|
||||
const parts = lines()
|
||||
let offset = 0
|
||||
for (let i = 0; i < lineNumber - 1; i++)
|
||||
offset += (parts[i] ?? '').length + 1
|
||||
return offset + column - 1
|
||||
}
|
||||
|
||||
function emitChange() {
|
||||
contentChangeListeners.forEach(listener => listener())
|
||||
contentSizeListeners.forEach(listener => listener())
|
||||
}
|
||||
|
||||
return {
|
||||
getValue() {
|
||||
return value
|
||||
},
|
||||
setValue(next: string) {
|
||||
value = next
|
||||
emitChange()
|
||||
},
|
||||
getLineCount() {
|
||||
return lines().length
|
||||
},
|
||||
getLineMaxColumn(lineNumber: number) {
|
||||
return (lines()[lineNumber - 1] ?? '').length + 1
|
||||
},
|
||||
getPositionAt(offset: number) {
|
||||
const consumed = value.slice(0, offset).split('\n')
|
||||
return {
|
||||
lineNumber: consumed.length,
|
||||
column: consumed[consumed.length - 1].length + 1,
|
||||
}
|
||||
},
|
||||
getLanguageId() {
|
||||
return languageId
|
||||
},
|
||||
setLanguageId(next: string) {
|
||||
languageId = next
|
||||
},
|
||||
applyEdits(edits: Array<{ range: Range, text: string }>) {
|
||||
for (const edit of edits) {
|
||||
const start = getOffsetAt(
|
||||
edit.range.startLineNumber,
|
||||
edit.range.startColumn,
|
||||
)
|
||||
const end = getOffsetAt(
|
||||
edit.range.endLineNumber,
|
||||
edit.range.endColumn,
|
||||
)
|
||||
value = value.slice(0, start) + edit.text + value.slice(end)
|
||||
}
|
||||
emitChange()
|
||||
},
|
||||
onDidContentSizeChange(listener: () => void) {
|
||||
contentSizeListeners.add(listener)
|
||||
return {
|
||||
dispose() {
|
||||
contentSizeListeners.delete(listener)
|
||||
},
|
||||
}
|
||||
},
|
||||
onDidChangeContent(listener: () => void) {
|
||||
contentChangeListeners.add(listener)
|
||||
return {
|
||||
dispose() {
|
||||
contentChangeListeners.delete(listener)
|
||||
},
|
||||
}
|
||||
},
|
||||
dispose() {},
|
||||
}
|
||||
}
|
||||
|
||||
function createCodeEditor(initialModel: any) {
|
||||
let model = initialModel
|
||||
let scrollTop = 0
|
||||
const scrollListeners = new Set<(e: any) => void>()
|
||||
const domNode = {
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
}
|
||||
|
||||
const api: any = {
|
||||
revealCalls: [] as Array<{ line: number }>,
|
||||
getModel() {
|
||||
return model
|
||||
},
|
||||
setModel(next: any) {
|
||||
model = next
|
||||
},
|
||||
getOption(option: string) {
|
||||
if (option === editor.EditorOption.lineHeight)
|
||||
return 20
|
||||
if (option === editor.EditorOption.readOnly)
|
||||
return true
|
||||
return undefined
|
||||
},
|
||||
getLayoutInfo() {
|
||||
return { height: 40 }
|
||||
},
|
||||
getScrollTop() {
|
||||
return scrollTop
|
||||
},
|
||||
getScrollHeight() {
|
||||
return model.getLineCount() * 20
|
||||
},
|
||||
setScrollTop(next: number) {
|
||||
scrollTop = next
|
||||
scrollListeners.forEach(listener => listener({ scrollTop }))
|
||||
},
|
||||
onDidContentSizeChange(listener: () => void) {
|
||||
return model.onDidContentSizeChange(listener)
|
||||
},
|
||||
onDidChangeModelContent(listener: () => void) {
|
||||
return model.onDidChangeContent(listener)
|
||||
},
|
||||
onDidScrollChange(listener: (e: any) => void) {
|
||||
scrollListeners.add(listener)
|
||||
return {
|
||||
dispose() {
|
||||
scrollListeners.delete(listener)
|
||||
},
|
||||
}
|
||||
},
|
||||
revealLine(line: number) {
|
||||
api.revealCalls.push({ line })
|
||||
scrollTop = Math.max(0, api.getScrollHeight() - api.getLayoutInfo().height)
|
||||
},
|
||||
revealLineInCenter(line: number) {
|
||||
api.revealLine(line)
|
||||
},
|
||||
revealLineInCenterIfOutsideViewport(line: number) {
|
||||
api.revealLine(line)
|
||||
},
|
||||
getDomNode() {
|
||||
return domNode as any
|
||||
},
|
||||
dispose() {},
|
||||
}
|
||||
|
||||
return api
|
||||
}
|
||||
|
||||
const editor = {
|
||||
EditorOption: {
|
||||
lineHeight: 'lineHeight',
|
||||
readOnly: 'readOnly',
|
||||
},
|
||||
ScrollType: {
|
||||
Immediate: 0,
|
||||
Smooth: 1,
|
||||
},
|
||||
createModel: vi.fn((value: string, language: string) =>
|
||||
createModel(value, language)),
|
||||
createDiffEditor: vi.fn(() => {
|
||||
const originalEditor = createCodeEditor(createModel('', 'plaintext'))
|
||||
const modifiedEditor = createCodeEditor(createModel('', 'plaintext'))
|
||||
const updateDiffListeners = new Set<() => void>()
|
||||
|
||||
return {
|
||||
setModel(pair: { original: any, modified: any }) {
|
||||
originalEditor.setModel(pair.original)
|
||||
modifiedEditor.setModel(pair.modified)
|
||||
updateDiffListeners.forEach(listener => listener())
|
||||
},
|
||||
getOriginalEditor() {
|
||||
return originalEditor
|
||||
},
|
||||
getModifiedEditor() {
|
||||
return modifiedEditor
|
||||
},
|
||||
onDidUpdateDiff(listener: () => void) {
|
||||
updateDiffListeners.add(listener)
|
||||
return {
|
||||
dispose() {
|
||||
updateDiffListeners.delete(listener)
|
||||
},
|
||||
}
|
||||
},
|
||||
updateOptions() {},
|
||||
dispose() {},
|
||||
}
|
||||
}),
|
||||
setTheme: vi.fn(),
|
||||
setModelLanguage: vi.fn((model: any, language: string) => {
|
||||
model.setLanguageId(language)
|
||||
}),
|
||||
}
|
||||
|
||||
return {
|
||||
default: { editor, Range, ScrollType: editor.ScrollType },
|
||||
editor,
|
||||
Range,
|
||||
ScrollType: editor.ScrollType,
|
||||
}
|
||||
})
|
||||
|
||||
return await import('../src/core/DiffEditorManager')
|
||||
}
|
||||
|
||||
async function createManager() {
|
||||
const { DiffEditorManager } = await loadDiffEditorManager()
|
||||
const manager = new DiffEditorManager(
|
||||
{
|
||||
readOnly: true,
|
||||
hideUnchangedRegions: false,
|
||||
diffHunkActionsOnHover: false,
|
||||
} as any,
|
||||
40,
|
||||
'40px',
|
||||
true,
|
||||
true,
|
||||
0,
|
||||
0,
|
||||
true,
|
||||
)
|
||||
|
||||
;(manager as any).disposeDiffPresentationTracking = () => {}
|
||||
;(manager as any).setupDiffUnchangedRegionEnhancements = () => {}
|
||||
;(manager as any).setupDiffHunkInteractions = () => {}
|
||||
;(manager as any).applyDiffRootAppearanceClass = () => {}
|
||||
;(manager as any).scheduleSyncDiffPresentationDecorations = () => {}
|
||||
|
||||
const container = {
|
||||
style: {},
|
||||
innerHTML: '',
|
||||
} as any
|
||||
|
||||
await manager.createDiffEditor(
|
||||
container,
|
||||
'a\nb\nc',
|
||||
'a\nb\nc',
|
||||
'javascript',
|
||||
'test-theme',
|
||||
)
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
return {
|
||||
manager: manager as any,
|
||||
modifiedEditor: (manager as any).diffEditorView.getModifiedEditor() as any,
|
||||
}
|
||||
}
|
||||
|
||||
describe('DiffEditorManager auto-scroll pause', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-04-01T10:00:00.000Z'))
|
||||
installRafMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('keeps diff auto-scroll paused after the user scrolls up', async () => {
|
||||
const { manager, modifiedEditor } = await createManager()
|
||||
|
||||
expect(modifiedEditor.revealCalls).toHaveLength(1)
|
||||
|
||||
modifiedEditor.setScrollTop(0)
|
||||
expect(manager.shouldAutoScrollDiff).toBe(false)
|
||||
|
||||
manager.appendModified('\nd')
|
||||
await vi.runAllTimersAsync()
|
||||
expect(manager.shouldAutoScrollDiff).toBe(false)
|
||||
|
||||
const revealCountAfterPause = modifiedEditor.revealCalls.length
|
||||
manager.appendModified('\ne')
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(manager.shouldAutoScrollDiff).toBe(false)
|
||||
expect(modifiedEditor.revealCalls).toHaveLength(revealCountAfterPause)
|
||||
})
|
||||
|
||||
it('does not let delayed diff reveals resume auto-scroll after pause', async () => {
|
||||
const { manager } = await createManager()
|
||||
|
||||
manager.shouldAutoScrollDiff = false
|
||||
manager.revealTicketDiff = 1
|
||||
manager.performRevealDiffTicketed(4, 1)
|
||||
await vi.runAllTimersAsync()
|
||||
expect(manager.shouldAutoScrollDiff).toBe(false)
|
||||
|
||||
manager.shouldAutoScrollDiff = false
|
||||
manager.revealTicketDiff = 2
|
||||
manager.performImmediateRevealDiff(4, 2)
|
||||
expect(manager.shouldAutoScrollDiff).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,557 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
function installRafMocks() {
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: any) => {
|
||||
return setTimeout(() => cb(Date.now()), 0) as unknown as number
|
||||
})
|
||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => {
|
||||
clearTimeout(id as unknown as ReturnType<typeof setTimeout>)
|
||||
})
|
||||
}
|
||||
|
||||
async function loadDiffEditorManager() {
|
||||
vi.resetModules()
|
||||
|
||||
vi.doMock('../src/monaco-shim', () => {
|
||||
class Range {
|
||||
constructor(
|
||||
public startLineNumber: number,
|
||||
public startColumn: number,
|
||||
public endLineNumber: number,
|
||||
public endColumn: number,
|
||||
) {}
|
||||
}
|
||||
|
||||
function createModel(initialValue: string, initialLanguage: string) {
|
||||
let value = initialValue
|
||||
let languageId = initialLanguage
|
||||
let alternativeVersionId = 1
|
||||
const contentSizeListeners = new Set<() => void>()
|
||||
const contentChangeListeners = new Set<() => void>()
|
||||
|
||||
function lines() {
|
||||
return value.split('\n')
|
||||
}
|
||||
|
||||
function getOffsetAt(lineNumber: number, column: number) {
|
||||
const parts = lines()
|
||||
let offset = 0
|
||||
for (let i = 0; i < lineNumber - 1; i++)
|
||||
offset += (parts[i] ?? '').length + 1
|
||||
return offset + column - 1
|
||||
}
|
||||
|
||||
function emitChange() {
|
||||
alternativeVersionId += 1
|
||||
contentChangeListeners.forEach(listener => listener())
|
||||
contentSizeListeners.forEach(listener => listener())
|
||||
}
|
||||
|
||||
return {
|
||||
getValue() {
|
||||
return value
|
||||
},
|
||||
setValue(next: string) {
|
||||
value = next
|
||||
emitChange()
|
||||
},
|
||||
getLineCount() {
|
||||
return lines().length
|
||||
},
|
||||
getLineMaxColumn(lineNumber: number) {
|
||||
return (lines()[lineNumber - 1] ?? '').length + 1
|
||||
},
|
||||
getPositionAt(offset: number) {
|
||||
const consumed = value.slice(0, offset).split('\n')
|
||||
return {
|
||||
lineNumber: consumed.length,
|
||||
column: consumed[consumed.length - 1].length + 1,
|
||||
}
|
||||
},
|
||||
getLanguageId() {
|
||||
return languageId
|
||||
},
|
||||
getAlternativeVersionId() {
|
||||
return alternativeVersionId
|
||||
},
|
||||
setLanguageId(next: string) {
|
||||
languageId = next
|
||||
},
|
||||
applyEdits(edits: Array<{ range: Range, text: string }>) {
|
||||
for (const edit of edits) {
|
||||
const start = getOffsetAt(
|
||||
edit.range.startLineNumber,
|
||||
edit.range.startColumn,
|
||||
)
|
||||
const end = getOffsetAt(
|
||||
edit.range.endLineNumber,
|
||||
edit.range.endColumn,
|
||||
)
|
||||
value = value.slice(0, start) + edit.text + value.slice(end)
|
||||
}
|
||||
emitChange()
|
||||
},
|
||||
onDidContentSizeChange(listener: () => void) {
|
||||
contentSizeListeners.add(listener)
|
||||
return {
|
||||
dispose() {
|
||||
contentSizeListeners.delete(listener)
|
||||
},
|
||||
}
|
||||
},
|
||||
onDidChangeContent(listener: () => void) {
|
||||
contentChangeListeners.add(listener)
|
||||
return {
|
||||
dispose() {
|
||||
contentChangeListeners.delete(listener)
|
||||
},
|
||||
}
|
||||
},
|
||||
dispose() {},
|
||||
}
|
||||
}
|
||||
|
||||
function createCodeEditor(initialModel: any) {
|
||||
let model = initialModel
|
||||
return {
|
||||
getModel() {
|
||||
return model
|
||||
},
|
||||
setModel(next: any) {
|
||||
model = next
|
||||
},
|
||||
getOption(option: string) {
|
||||
if (option === editor.EditorOption.lineHeight)
|
||||
return 20
|
||||
if (option === editor.EditorOption.readOnly)
|
||||
return true
|
||||
return undefined
|
||||
},
|
||||
getLayoutInfo() {
|
||||
return { height: 200 }
|
||||
},
|
||||
getScrollTop() {
|
||||
return 0
|
||||
},
|
||||
getScrollHeight() {
|
||||
return model.getLineCount() * 20
|
||||
},
|
||||
setScrollTop() {},
|
||||
onDidContentSizeChange(listener: () => void) {
|
||||
return model.onDidContentSizeChange(listener)
|
||||
},
|
||||
onDidChangeModelContent(listener: () => void) {
|
||||
return model.onDidChangeContent(listener)
|
||||
},
|
||||
onDidScrollChange() {
|
||||
return { dispose() {} }
|
||||
},
|
||||
revealLine() {},
|
||||
revealLineInCenter() {},
|
||||
revealLineInCenterIfOutsideViewport() {},
|
||||
getDomNode() {
|
||||
return {
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
} as any
|
||||
},
|
||||
deltaDecorations() {
|
||||
return []
|
||||
},
|
||||
updateOptions() {},
|
||||
layout() {},
|
||||
dispose() {},
|
||||
}
|
||||
}
|
||||
|
||||
const editor = {
|
||||
EditorOption: {
|
||||
lineHeight: 'lineHeight',
|
||||
readOnly: 'readOnly',
|
||||
},
|
||||
ScrollType: {
|
||||
Immediate: 0,
|
||||
Smooth: 1,
|
||||
},
|
||||
createModel: vi.fn((value: string, language: string) =>
|
||||
createModel(value, language)),
|
||||
createDiffEditor: vi.fn(() => {
|
||||
const originalEditor = createCodeEditor(createModel('', 'plaintext'))
|
||||
const modifiedEditor = createCodeEditor(createModel('', 'plaintext'))
|
||||
const updateDiffListeners = new Set<() => void>()
|
||||
|
||||
return {
|
||||
__emitUpdateDiff() {
|
||||
updateDiffListeners.forEach(listener => listener())
|
||||
},
|
||||
setModel(pair: { original: any, modified: any }) {
|
||||
originalEditor.setModel(pair.original)
|
||||
modifiedEditor.setModel(pair.modified)
|
||||
updateDiffListeners.forEach(listener => listener())
|
||||
},
|
||||
getOriginalEditor() {
|
||||
return originalEditor
|
||||
},
|
||||
getModifiedEditor() {
|
||||
return modifiedEditor
|
||||
},
|
||||
onDidUpdateDiff(listener: () => void) {
|
||||
updateDiffListeners.add(listener)
|
||||
return {
|
||||
dispose() {
|
||||
updateDiffListeners.delete(listener)
|
||||
},
|
||||
}
|
||||
},
|
||||
getLineChanges() {
|
||||
return []
|
||||
},
|
||||
updateOptions() {},
|
||||
getContainerDomNode() {
|
||||
return null as any
|
||||
},
|
||||
dispose() {},
|
||||
}
|
||||
}),
|
||||
setTheme: vi.fn(),
|
||||
setModelLanguage: vi.fn((model: any, language: string) => {
|
||||
model.setLanguageId(language)
|
||||
}),
|
||||
}
|
||||
|
||||
return {
|
||||
default: { editor, Range, ScrollType: editor.ScrollType },
|
||||
editor,
|
||||
Range,
|
||||
ScrollType: editor.ScrollType,
|
||||
}
|
||||
})
|
||||
|
||||
return await import('../src/core/DiffEditorManager')
|
||||
}
|
||||
|
||||
async function createManager(options: Record<string, unknown>) {
|
||||
const { DiffEditorManager } = await loadDiffEditorManager()
|
||||
const manager = new DiffEditorManager(
|
||||
{
|
||||
readOnly: true,
|
||||
hideUnchangedRegions: false,
|
||||
diffHunkActionsOnHover: false,
|
||||
...options,
|
||||
} as any,
|
||||
400,
|
||||
'400px',
|
||||
true,
|
||||
true,
|
||||
0,
|
||||
0,
|
||||
true,
|
||||
)
|
||||
|
||||
;(manager as any).disposeDiffPresentationTracking = () => {}
|
||||
;(manager as any).setupDiffUnchangedRegionEnhancements = () => {}
|
||||
;(manager as any).setupDiffHunkInteractions = () => {}
|
||||
;(manager as any).applyDiffRootAppearanceClass = () => {}
|
||||
;(manager as any).scheduleSyncDiffPresentationDecorations = () => {}
|
||||
|
||||
const container = {
|
||||
style: {} as Record<string, string>,
|
||||
classList: {
|
||||
add() {},
|
||||
remove() {},
|
||||
toggle() {},
|
||||
contains() { return false },
|
||||
},
|
||||
innerHTML: '',
|
||||
querySelector() {
|
||||
return null
|
||||
},
|
||||
querySelectorAll() {
|
||||
return []
|
||||
},
|
||||
getBoundingClientRect() {
|
||||
return { width: 1200, height: 200 }
|
||||
},
|
||||
clientWidth: 1200,
|
||||
} as any
|
||||
|
||||
await manager.createDiffEditor(
|
||||
container,
|
||||
'line 1\nline 2\n',
|
||||
'line 1\nline 2\n',
|
||||
'typescript',
|
||||
'vs-dark',
|
||||
)
|
||||
|
||||
return manager as any
|
||||
}
|
||||
|
||||
function waitForAsyncWork() {
|
||||
return new Promise(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
|
||||
describe('DiffEditorManager inline streaming updates', () => {
|
||||
beforeEach(() => {
|
||||
installRafMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('defers inline diff tail appends so both sides flush together', async () => {
|
||||
const manager = await createManager({ renderSideBySide: false })
|
||||
const appendOriginalSpy = vi.spyOn(manager, 'appendOriginal')
|
||||
const appendModifiedSpy = vi.spyOn(manager, 'appendModified')
|
||||
|
||||
manager.updateDiff(
|
||||
'line 1\nline 2\nold value\n',
|
||||
'line 1\nline 2\nnew value\n',
|
||||
'typescript',
|
||||
)
|
||||
|
||||
expect(appendOriginalSpy).not.toHaveBeenCalled()
|
||||
expect(appendModifiedSpy).not.toHaveBeenCalled()
|
||||
|
||||
await waitForAsyncWork()
|
||||
|
||||
const { original, modified } = manager.getDiffModels()
|
||||
expect(original.getValue()).toBe('line 1\nline 2\nold value\n')
|
||||
expect(modified.getValue()).toBe('line 1\nline 2\nnew value\n')
|
||||
manager.cleanup()
|
||||
})
|
||||
|
||||
it('keeps side-by-side diff tail append optimization', async () => {
|
||||
const manager = await createManager({ renderSideBySide: true, useInlineViewWhenSpaceIsLimited: false })
|
||||
const appendOriginalSpy = vi.spyOn(manager, 'appendOriginal')
|
||||
const appendModifiedSpy = vi.spyOn(manager, 'appendModified')
|
||||
|
||||
manager.updateDiff(
|
||||
'line 1\nline 2\nold value\n',
|
||||
'line 1\nline 2\nnew value\n',
|
||||
'typescript',
|
||||
)
|
||||
|
||||
expect(appendOriginalSpy).toHaveBeenCalledOnce()
|
||||
expect(appendModifiedSpy).toHaveBeenCalledOnce()
|
||||
await waitForAsyncWork()
|
||||
manager.cleanup()
|
||||
})
|
||||
|
||||
it('keeps side-by-side diff streaming appends byte-for-byte aligned', async () => {
|
||||
const manager = await createManager({ renderSideBySide: true, useInlineViewWhenSpaceIsLimited: false })
|
||||
const initial = 'line 1\nline 2\n'
|
||||
const originalTail = `{
|
||||
".c": "C",
|
||||
".cpp": "C++"
|
||||
}`
|
||||
const modifiedTail = `{
|
||||
".c": "C",
|
||||
".cpp": "C++",
|
||||
".cc": "C++"
|
||||
}`
|
||||
|
||||
for (let i = 1; i <= modifiedTail.length; i++) {
|
||||
const expectedOriginal = initial + originalTail.slice(0, Math.min(i, originalTail.length))
|
||||
const expectedModified = initial + modifiedTail.slice(0, i)
|
||||
manager.updateDiff(
|
||||
expectedOriginal,
|
||||
expectedModified,
|
||||
'json',
|
||||
)
|
||||
await waitForAsyncWork()
|
||||
const { original, modified } = manager.getDiffModels()
|
||||
expect(expectedOriginal.startsWith(original.getValue())).toBe(true)
|
||||
expect(expectedModified.startsWith(modified.getValue())).toBe(true)
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 80))
|
||||
|
||||
const { original, modified } = manager.getDiffModels()
|
||||
expect(original.getValue()).toBe(initial + originalTail)
|
||||
expect(modified.getValue()).toBe(initial + modifiedTail)
|
||||
manager.cleanup()
|
||||
})
|
||||
|
||||
it('flushes pending replacement updates before refreshing diff presentation', async () => {
|
||||
const manager = await createManager({ renderSideBySide: false })
|
||||
;(manager as any).lastContainer.style.removeProperty = vi.fn()
|
||||
|
||||
manager.updateDiff(
|
||||
'line 1\nold value\n',
|
||||
'line 1\nnew value\n',
|
||||
'typescript',
|
||||
)
|
||||
|
||||
;(manager as any).refreshDiffPresentation()
|
||||
|
||||
const { original, modified } = manager.getDiffModels()
|
||||
expect(original.getValue()).toBe('line 1\nold value\n')
|
||||
expect(modified.getValue()).toBe('line 1\nnew value\n')
|
||||
manager.cleanup()
|
||||
})
|
||||
|
||||
it('flushes buffered tail appends before refreshing diff presentation', async () => {
|
||||
const manager = await createManager({ renderSideBySide: true, useInlineViewWhenSpaceIsLimited: false })
|
||||
;(manager as any).lastContainer.style.removeProperty = vi.fn()
|
||||
|
||||
manager.updateDiff(
|
||||
'line 1\nline 2\nold value\n',
|
||||
'line 1\nline 2\nnew value\n',
|
||||
'typescript',
|
||||
)
|
||||
|
||||
;(manager as any).refreshDiffPresentation()
|
||||
|
||||
const { original, modified } = manager.getDiffModels()
|
||||
expect(original.getValue()).toBe('line 1\nline 2\nold value\n')
|
||||
expect(modified.getValue()).toBe('line 1\nline 2\nnew value\n')
|
||||
manager.cleanup()
|
||||
})
|
||||
|
||||
it('eagerly grows the diff container before content overflows the inline diff viewport', async () => {
|
||||
const manager = await createManager({ renderSideBySide: false })
|
||||
const scheduleLayoutSpy = vi.spyOn(
|
||||
manager,
|
||||
'scheduleSyncDiffEditorLayoutToContainer' as any,
|
||||
)
|
||||
|
||||
;(manager as any).lastContainer = {
|
||||
style: { height: '91px' },
|
||||
classList: {
|
||||
add() {},
|
||||
remove() {},
|
||||
contains() { return false },
|
||||
toggle() {},
|
||||
},
|
||||
getBoundingClientRect() {
|
||||
return { width: 1200, height: 91 }
|
||||
},
|
||||
clientHeight: 91,
|
||||
querySelectorAll() {
|
||||
return []
|
||||
},
|
||||
}
|
||||
vi.spyOn(manager, 'computedHeight' as any).mockReturnValue(109)
|
||||
|
||||
;(manager as any).eagerlyGrowDiffContainerHeight()
|
||||
|
||||
expect((manager as any).lastContainer.style.height).toBe('109px')
|
||||
expect(scheduleLayoutSpy).toHaveBeenCalledOnce()
|
||||
manager.cleanup()
|
||||
})
|
||||
|
||||
it('does not force a large min-height for inline diff containers', async () => {
|
||||
const manager = await createManager({ renderSideBySide: false })
|
||||
|
||||
expect((manager as any).lastContainer.style.minHeight || '').toBe('')
|
||||
|
||||
manager.cleanup()
|
||||
})
|
||||
|
||||
it('does not clear fallback inline delete zones directly from onDidUpdateDiff', async () => {
|
||||
const manager = await createManager({ renderSideBySide: false })
|
||||
const clearSpy = vi.spyOn(manager, 'clearFallbackInlineDeletedZones' as any)
|
||||
|
||||
;(manager as any).diffEditorView.__emitUpdateDiff()
|
||||
|
||||
expect(clearSpy).not.toHaveBeenCalled()
|
||||
manager.cleanup()
|
||||
})
|
||||
|
||||
it('keeps inline fallback decorations until native delete nodes are present', async () => {
|
||||
const manager = await createManager({ renderSideBySide: false })
|
||||
const keepFallbackSpy = vi.spyOn(manager, 'syncFallbackInlineDeletedZones' as any)
|
||||
const clearFallbackSpy = vi.spyOn(
|
||||
manager,
|
||||
'clearFallbackDiffDecorations' as any,
|
||||
)
|
||||
vi.spyOn(manager, 'hasFreshNativeDiffResult' as any).mockReturnValue(true)
|
||||
vi.spyOn(manager, 'getEffectiveLineChanges' as any).mockReturnValue([
|
||||
{
|
||||
originalStartLineNumber: 2,
|
||||
originalEndLineNumber: 2,
|
||||
modifiedStartLineNumber: 2,
|
||||
modifiedEndLineNumber: 2,
|
||||
charChanges: [],
|
||||
},
|
||||
])
|
||||
;(manager as any).diffEditorView.getOriginalEditor().getLayoutInfo = () => ({
|
||||
width: 0,
|
||||
height: 200,
|
||||
})
|
||||
|
||||
;(manager as any).syncDiffPresentationDecorations()
|
||||
|
||||
expect(keepFallbackSpy).toHaveBeenCalledOnce()
|
||||
expect(clearFallbackSpy).not.toHaveBeenCalled()
|
||||
manager.cleanup()
|
||||
})
|
||||
|
||||
it('does not recreate identical inline fallback delete zones', async () => {
|
||||
const manager = await createManager({ renderSideBySide: false })
|
||||
const originalDocument = (globalThis as any).document
|
||||
;(globalThis as any).document = {
|
||||
createElement() {
|
||||
return {
|
||||
className: '',
|
||||
style: {} as Record<string, string>,
|
||||
children: [] as any[],
|
||||
append(...nodes: any[]) {
|
||||
this.children.push(...nodes)
|
||||
},
|
||||
setAttribute() {},
|
||||
textContent: '',
|
||||
}
|
||||
},
|
||||
}
|
||||
try {
|
||||
const addedZones: string[] = []
|
||||
const removedZones: string[] = []
|
||||
const modifiedEditor = (manager as any).diffEditorView.getModifiedEditor()
|
||||
|
||||
modifiedEditor.changeViewZones = (callback: (accessor: {
|
||||
addZone: (zone: any) => string
|
||||
removeZone: (id: string) => void
|
||||
}) => void) => {
|
||||
callback({
|
||||
addZone() {
|
||||
const id = `zone-${addedZones.length + 1}`
|
||||
addedZones.push(id)
|
||||
return id
|
||||
},
|
||||
removeZone(id: string) {
|
||||
removedZones.push(id)
|
||||
},
|
||||
})
|
||||
}
|
||||
modifiedEditor.layout = vi.fn()
|
||||
modifiedEditor.render = vi.fn()
|
||||
|
||||
vi.spyOn(manager, 'hasFreshNativeDiffResult' as any).mockReturnValue(false)
|
||||
vi.spyOn(manager, 'getEffectiveLineChanges' as any).mockReturnValue([
|
||||
{
|
||||
originalStartLineNumber: 2,
|
||||
originalEndLineNumber: 2,
|
||||
modifiedStartLineNumber: 2,
|
||||
modifiedEndLineNumber: 2,
|
||||
charChanges: [],
|
||||
},
|
||||
])
|
||||
;(manager as any).diffEditorView.getOriginalEditor().getLayoutInfo = () => ({
|
||||
width: 0,
|
||||
height: 200,
|
||||
})
|
||||
|
||||
;(manager as any).syncDiffPresentationDecorations()
|
||||
;(manager as any).syncDiffPresentationDecorations()
|
||||
|
||||
expect(addedZones).toHaveLength(1)
|
||||
expect(removedZones).toHaveLength(0)
|
||||
manager.cleanup()
|
||||
}
|
||||
finally {
|
||||
;(globalThis as any).document = originalDocument
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,762 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../src/monaco-shim', () => {
|
||||
class Range {
|
||||
constructor(
|
||||
public startLineNumber: number,
|
||||
public startColumn: number,
|
||||
public endLineNumber: number,
|
||||
public endColumn: number,
|
||||
) {}
|
||||
}
|
||||
|
||||
return {
|
||||
default: { editor: { EditorOption: { lineHeight: 'lineHeight' } }, Range },
|
||||
editor: { EditorOption: { lineHeight: 'lineHeight' } },
|
||||
Range,
|
||||
}
|
||||
})
|
||||
|
||||
import { DiffEditorManager } from '../src/core/DiffEditorManager'
|
||||
|
||||
function createClassList() {
|
||||
const classes = new Set<string>()
|
||||
return {
|
||||
classList: {
|
||||
toggle(name: string, force?: boolean) {
|
||||
if (force)
|
||||
classes.add(name)
|
||||
else classes.delete(name)
|
||||
},
|
||||
remove(...names: string[]) {
|
||||
names.forEach(name => classes.delete(name))
|
||||
},
|
||||
contains(name: string) {
|
||||
return classes.has(name)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createToggleClassList(initial: string[] = []) {
|
||||
const classes = new Set(initial)
|
||||
return {
|
||||
classList: {
|
||||
add(...names: string[]) {
|
||||
names.forEach(name => classes.add(name))
|
||||
},
|
||||
remove(...names: string[]) {
|
||||
names.forEach(name => classes.delete(name))
|
||||
},
|
||||
toggle(name: string, force?: boolean) {
|
||||
if (force === undefined) {
|
||||
if (classes.has(name)) {
|
||||
classes.delete(name)
|
||||
return false
|
||||
}
|
||||
classes.add(name)
|
||||
return true
|
||||
}
|
||||
if (force)
|
||||
classes.add(name)
|
||||
else classes.delete(name)
|
||||
return force
|
||||
},
|
||||
contains(name: string) {
|
||||
return classes.has(name)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createPresentationHarness(
|
||||
preserveNativeDiffDecorationsOnStaleAppend: boolean,
|
||||
) {
|
||||
const manager = new DiffEditorManager(
|
||||
{ readOnly: true } as any,
|
||||
600,
|
||||
'600px',
|
||||
true,
|
||||
true,
|
||||
32,
|
||||
2,
|
||||
true,
|
||||
75,
|
||||
)
|
||||
|
||||
const { classList } = createClassList()
|
||||
const originalDeltaDecorations = vi.fn((_: string[], next: unknown[]) =>
|
||||
next.map((_, index) => `original-${index}`))
|
||||
const modifiedDeltaDecorations = vi.fn((_: string[], next: unknown[]) =>
|
||||
next.map((_, index) => `modified-${index}`))
|
||||
|
||||
;(manager as any).lastContainer = { classList }
|
||||
;(manager as any).isDiffInlineMode = () => false
|
||||
;(manager as any).diffComputedVersions = { original: 1, modified: 1 }
|
||||
;(manager as any).preserveNativeDiffDecorationsOnStaleAppend
|
||||
= preserveNativeDiffDecorationsOnStaleAppend
|
||||
;(manager as any).originalModel = {
|
||||
getAlternativeVersionId: () => 2,
|
||||
getValue: () => 'const value = 1\nreturn value',
|
||||
}
|
||||
;(manager as any).modifiedModel = {
|
||||
getAlternativeVersionId: () => 2,
|
||||
getValue: () => 'const value = 2\nreturn value\nconsole.log(value)',
|
||||
}
|
||||
;(manager as any).diffEditorView = {
|
||||
getLineChanges: () => [
|
||||
{
|
||||
originalStartLineNumber: 1,
|
||||
originalEndLineNumber: 1,
|
||||
modifiedStartLineNumber: 1,
|
||||
modifiedEndLineNumber: 1,
|
||||
charChanges: [],
|
||||
},
|
||||
],
|
||||
getOriginalEditor: () => ({
|
||||
deltaDecorations: originalDeltaDecorations,
|
||||
}),
|
||||
getModifiedEditor: () => ({
|
||||
deltaDecorations: modifiedDeltaDecorations,
|
||||
}),
|
||||
}
|
||||
|
||||
return {
|
||||
manager,
|
||||
classList,
|
||||
originalDeltaDecorations,
|
||||
modifiedDeltaDecorations,
|
||||
}
|
||||
}
|
||||
|
||||
describe('DiffEditorManager diff presentation', () => {
|
||||
it('forces glyph margin when unchanged-region folding is enabled', () => {
|
||||
const manager = new DiffEditorManager(
|
||||
{ readOnly: true, glyphMargin: false } as any,
|
||||
600,
|
||||
'600px',
|
||||
true,
|
||||
true,
|
||||
32,
|
||||
2,
|
||||
true,
|
||||
75,
|
||||
)
|
||||
|
||||
const options = (manager as any).resolveDiffPresentationEditorOptions({
|
||||
enabled: true,
|
||||
})
|
||||
const disabledOptions = (manager as any).resolveDiffPresentationEditorOptions({
|
||||
enabled: false,
|
||||
})
|
||||
|
||||
expect(options.glyphMargin).toBe(true)
|
||||
expect(disabledOptions.glyphMargin).toBe(false)
|
||||
})
|
||||
|
||||
it('hides the native fold glyph that overlaps the folded inline summary row', () => {
|
||||
const manager = new DiffEditorManager(
|
||||
{ readOnly: true } as any,
|
||||
600,
|
||||
'600px',
|
||||
true,
|
||||
true,
|
||||
32,
|
||||
2,
|
||||
true,
|
||||
75,
|
||||
)
|
||||
|
||||
const modifiedCenter = {
|
||||
...createToggleClassList(),
|
||||
getBoundingClientRect: () => ({
|
||||
top: 120,
|
||||
height: 32,
|
||||
width: 220,
|
||||
}),
|
||||
}
|
||||
const overlappingGlyph = {
|
||||
...createToggleClassList(),
|
||||
getBoundingClientRect: () => ({
|
||||
top: 127,
|
||||
height: 18,
|
||||
}),
|
||||
}
|
||||
const distantGlyph = {
|
||||
...createToggleClassList(),
|
||||
getBoundingClientRect: () => ({
|
||||
top: 180,
|
||||
height: 18,
|
||||
}),
|
||||
}
|
||||
|
||||
;(manager as any).lastContainer = {
|
||||
querySelectorAll(selector: string) {
|
||||
if (selector === '.diff-hidden-lines .center')
|
||||
return [modifiedCenter]
|
||||
if (selector === '.editor.modified .diff-hidden-lines .center')
|
||||
return [modifiedCenter]
|
||||
if (selector === '.diff-hidden-lines .top, .diff-hidden-lines .bottom')
|
||||
return []
|
||||
if (selector === '.fold-unchanged')
|
||||
return [overlappingGlyph, distantGlyph]
|
||||
return []
|
||||
},
|
||||
}
|
||||
;(manager as any).applyDiffRootAppearanceClass = vi.fn()
|
||||
;(manager as any).syncDiffUnchangedViewZoneHeights = vi.fn(() => false)
|
||||
;(manager as any).patchDiffUnchangedCenter = vi.fn()
|
||||
;(manager as any).clearDiffUnchangedBridgeSources = vi.fn()
|
||||
;(manager as any).renderMergedDiffUnchangedBridge = vi.fn(() => null)
|
||||
;(manager as any).pruneDiffUnchangedBridgeEntries = vi.fn()
|
||||
;(manager as any).syncDiffUnchangedOverlayScrollBaseline = vi.fn()
|
||||
;(manager as any).patchDiffUnchangedFoldGlyph = vi.fn()
|
||||
|
||||
;(manager as any).scanAndPatchDiffUnchangedRegions()
|
||||
|
||||
expect(
|
||||
overlappingGlyph.classList.contains('stream-monaco-fold-unchanged-hidden'),
|
||||
).toBe(true)
|
||||
expect(
|
||||
distantGlyph.classList.contains('stream-monaco-fold-unchanged-hidden'),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('does not overwrite persisted unchanged state while folding is disabled', () => {
|
||||
const manager = new DiffEditorManager(
|
||||
{ readOnly: true } as any,
|
||||
600,
|
||||
'600px',
|
||||
true,
|
||||
true,
|
||||
32,
|
||||
2,
|
||||
true,
|
||||
75,
|
||||
)
|
||||
|
||||
;(manager as any).diffPersistedUnchangedModelState = { collapsed: true }
|
||||
;(manager as any).diffHideUnchangedRegionsResolved = { enabled: false }
|
||||
;(manager as any).diffEditorView = {
|
||||
saveViewState: () => ({
|
||||
modelState: { expanded: true },
|
||||
}),
|
||||
}
|
||||
|
||||
;(manager as any).capturePersistedDiffUnchangedState()
|
||||
|
||||
expect((manager as any).diffPersistedUnchangedModelState).toEqual({
|
||||
collapsed: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('restores the previous unchanged state instead of collapsing all context', () => {
|
||||
const manager = new DiffEditorManager(
|
||||
{ readOnly: true } as any,
|
||||
600,
|
||||
'600px',
|
||||
true,
|
||||
true,
|
||||
32,
|
||||
2,
|
||||
true,
|
||||
75,
|
||||
)
|
||||
const restoreViewState = vi.fn()
|
||||
|
||||
;(manager as any).diffPreviousUnchangedModelState = {
|
||||
hiddenAreas: [{ startLineNumber: 7, endLineNumber: 25 }],
|
||||
}
|
||||
;(manager as any).diffEditorView = {
|
||||
saveViewState: () => ({
|
||||
original: { scrollTop: 10 },
|
||||
modified: { scrollTop: 10 },
|
||||
modelState: { hiddenAreas: [] },
|
||||
}),
|
||||
restoreViewState,
|
||||
}
|
||||
;(manager as any).applyPendingDiffScrollRestore = vi.fn()
|
||||
|
||||
const restored = (manager as any).restorePreviousDiffUnchangedState()
|
||||
|
||||
expect(restored).toBe(true)
|
||||
expect(restoreViewState).toHaveBeenCalledWith({
|
||||
original: { scrollTop: 10 },
|
||||
modified: { scrollTop: 10 },
|
||||
modelState: { hiddenAreas: [{ startLineNumber: 7, endLineNumber: 25 }] },
|
||||
})
|
||||
expect((manager as any).diffPersistedUnchangedModelState).toEqual({
|
||||
hiddenAreas: [{ startLineNumber: 7, endLineNumber: 25 }],
|
||||
})
|
||||
})
|
||||
|
||||
it('recomputes unchanged-region folding without restoring the stale model state', () => {
|
||||
const manager = new DiffEditorManager(
|
||||
{ readOnly: true } as any,
|
||||
600,
|
||||
'600px',
|
||||
true,
|
||||
true,
|
||||
32,
|
||||
2,
|
||||
true,
|
||||
75,
|
||||
)
|
||||
const setDiffModels = vi.fn()
|
||||
|
||||
;(manager as any).originalModel = { getValue: () => 'a' }
|
||||
;(manager as any).modifiedModel = { getValue: () => 'b' }
|
||||
;(manager as any).diffEditorView = {
|
||||
updateOptions: vi.fn(),
|
||||
}
|
||||
;(manager as any).diffHideUnchangedRegionsDeferred = false
|
||||
;(manager as any).diffHideUnchangedRegionsResolved = { enabled: false }
|
||||
;(manager as any).resolveDiffHideUnchangedRegionsOption = () => ({ enabled: true })
|
||||
;(manager as any).resolveDiffPresentationEditorOptions = () => ({})
|
||||
;(manager as any).withLockedDiffScrollPosition = (fn: () => void) => fn()
|
||||
;(manager as any).diffHeightManager = { update: vi.fn() }
|
||||
;(manager as any).applyDiffRootAppearanceClass = vi.fn()
|
||||
;(manager as any).schedulePatchDiffUnchangedRegionsAfterInteraction = vi.fn()
|
||||
;(manager as any).repositionDiffHunkNodes = vi.fn()
|
||||
;(manager as any).setDiffModels = setDiffModels
|
||||
|
||||
;(manager as any).refreshDiffPresentation()
|
||||
|
||||
expect(setDiffModels).toHaveBeenCalledWith(
|
||||
{
|
||||
original: (manager as any).originalModel,
|
||||
modified: (manager as any).modifiedModel,
|
||||
},
|
||||
{
|
||||
preserveViewState: true,
|
||||
preserveModelState: false,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves scroll state without restoring the saved model state when requested', async () => {
|
||||
const manager = new DiffEditorManager(
|
||||
{ readOnly: true } as any,
|
||||
600,
|
||||
'600px',
|
||||
true,
|
||||
true,
|
||||
32,
|
||||
2,
|
||||
true,
|
||||
75,
|
||||
)
|
||||
const restoreViewState = vi.fn()
|
||||
const setModel = vi.fn()
|
||||
const waitForDiff = vi.fn(async () => {})
|
||||
const preparedViewModel = {
|
||||
waitForDiff,
|
||||
dispose: vi.fn(),
|
||||
}
|
||||
const originalModel = {
|
||||
getValue: () => 'const a = 1',
|
||||
getLineCount: () => 1,
|
||||
getLanguageId: () => 'ts',
|
||||
}
|
||||
const modifiedModel = {
|
||||
getValue: () => 'const b = 2',
|
||||
getLineCount: () => 1,
|
||||
getLanguageId: () => 'ts',
|
||||
}
|
||||
|
||||
;(manager as any).originalModel = originalModel
|
||||
;(manager as any).modifiedModel = modifiedModel
|
||||
;(manager as any).originalModelOwned = false
|
||||
;(manager as any).modifiedModelOwned = false
|
||||
;(manager as any).captureDiffScrollPosition = () => null
|
||||
;(manager as any).captureModifiedViewportAnchor = () => null
|
||||
;(manager as any).queuePendingDiffScrollRestore = vi.fn()
|
||||
;(manager as any).withLockedDiffScrollPosition = (fn: () => void) => fn()
|
||||
;(manager as any).flushOriginalAppendBufferSync = vi.fn()
|
||||
;(manager as any).flushModifiedAppendBufferSync = vi.fn()
|
||||
;(manager as any).computedHeight = () => 0
|
||||
;(manager as any).getEffectiveLineChanges = () => []
|
||||
;(manager as any).hideDiffHunkActions = vi.fn()
|
||||
;(manager as any).clearDiffUnchangedBridgeOverlay = vi.fn()
|
||||
;(manager as any).syncDiffUnchangedViewZoneHeights = vi.fn()
|
||||
;(manager as any).refreshDiffPresentation = vi.fn()
|
||||
;(manager as any).scheduleSyncDiffPresentationDecorations = vi.fn()
|
||||
;(manager as any).applyPendingDiffScrollRestore = vi.fn()
|
||||
;(manager as any).scheduleRestoreModifiedViewportAnchor = vi.fn()
|
||||
;(manager as any).disposePreviousDiffModel = vi.fn()
|
||||
;(manager as any).diffEditorView = {
|
||||
createViewModel: vi.fn(() => preparedViewModel),
|
||||
setModel,
|
||||
saveViewState: vi.fn(() => ({
|
||||
original: { scrollTop: 10 },
|
||||
modified: { scrollTop: 10 },
|
||||
modelState: { hiddenAreas: [{ startLineNumber: 4, endLineNumber: 20 }] },
|
||||
})),
|
||||
restoreViewState,
|
||||
getModifiedEditor: () => ({
|
||||
getScrollHeight: () => 0,
|
||||
getOption: () => 18,
|
||||
}),
|
||||
}
|
||||
|
||||
await (manager as any).setDiffModels(
|
||||
{
|
||||
original: originalModel,
|
||||
modified: modifiedModel,
|
||||
},
|
||||
{
|
||||
preserveViewState: true,
|
||||
preserveModelState: false,
|
||||
},
|
||||
)
|
||||
|
||||
expect(waitForDiff).toHaveBeenCalled()
|
||||
expect(setModel).toHaveBeenCalled()
|
||||
expect(restoreViewState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps native diff blocks visible while stale during tail appends', () => {
|
||||
const {
|
||||
manager,
|
||||
classList,
|
||||
originalDeltaDecorations,
|
||||
modifiedDeltaDecorations,
|
||||
} = createPresentationHarness(true)
|
||||
|
||||
;(manager as any).syncDiffPresentationDecorations()
|
||||
|
||||
expect(classList.contains('stream-monaco-diff-native-stale')).toBe(false)
|
||||
expect(originalDeltaDecorations).toHaveBeenCalledOnce()
|
||||
expect(modifiedDeltaDecorations).toHaveBeenCalledOnce()
|
||||
expect(originalDeltaDecorations.mock.calls[0][1]).not.toHaveLength(0)
|
||||
expect(modifiedDeltaDecorations.mock.calls[0][1]).not.toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps native diff blocks visible when only the DOM still has stale native markers', () => {
|
||||
const { manager, classList } = createPresentationHarness(true)
|
||||
|
||||
;(manager as any).diffEditorView.getLineChanges = () => null
|
||||
;(manager as any).lastContainer.querySelector = vi.fn(() => ({}))
|
||||
|
||||
;(manager as any).syncDiffPresentationDecorations()
|
||||
|
||||
expect(classList.contains('stream-monaco-diff-native-stale')).toBe(false)
|
||||
})
|
||||
|
||||
it('still hides native diff blocks for non-append stale updates', () => {
|
||||
const { manager, classList } = createPresentationHarness(false)
|
||||
|
||||
;(manager as any).syncDiffPresentationDecorations()
|
||||
|
||||
expect(classList.contains('stream-monaco-diff-native-stale')).toBe(true)
|
||||
})
|
||||
|
||||
it('adds fallback inline delete zones while native inline diff is stale', () => {
|
||||
const createdNodes: Array<{
|
||||
className: string
|
||||
style: Record<string, string>
|
||||
children: any[]
|
||||
append: (...nodes: any[]) => void
|
||||
setAttribute: () => void
|
||||
textContent: string
|
||||
}> = []
|
||||
const originalDocument = (globalThis as any).document
|
||||
;(globalThis as any).document = {
|
||||
createElement() {
|
||||
const node = {
|
||||
className: '',
|
||||
style: {} as Record<string, string>,
|
||||
children: [] as any[],
|
||||
append(...next: any[]) {
|
||||
this.children.push(...next)
|
||||
},
|
||||
setAttribute() {},
|
||||
textContent: '',
|
||||
}
|
||||
createdNodes.push(node)
|
||||
return node
|
||||
},
|
||||
}
|
||||
|
||||
try {
|
||||
const { manager, classList } = createPresentationHarness(true)
|
||||
const addedZones: any[] = []
|
||||
const removedZoneIds: string[] = []
|
||||
const applyFontInfo = vi.fn()
|
||||
|
||||
;(manager as any).isDiffInlineMode = () => true
|
||||
;(manager as any).clearFallbackInlineDeletedZones = DiffEditorManager.prototype[
|
||||
'clearFallbackInlineDeletedZones'
|
||||
]
|
||||
;(manager as any).originalModel = {
|
||||
getAlternativeVersionId: () => 2,
|
||||
getValue: () => 'const value = 1\nreturn value',
|
||||
getLineContent: (line: number) =>
|
||||
['const value = 1', 'return value'][line - 1] ?? '',
|
||||
}
|
||||
;(manager as any).modifiedModel = {
|
||||
getAlternativeVersionId: () => 2,
|
||||
getValue: () => 'const value = 2\nreturn value',
|
||||
getLineCount: () => 2,
|
||||
}
|
||||
;(manager as any).diffEditorView = {
|
||||
getLineChanges: () => null,
|
||||
getOriginalEditor: () => ({
|
||||
deltaDecorations: vi.fn((_: string[], next: unknown[]) =>
|
||||
next.map((_, index) => `original-${index}`)),
|
||||
}),
|
||||
getModifiedEditor: () => ({
|
||||
deltaDecorations: vi.fn((_: string[], next: unknown[]) =>
|
||||
next.map((_, index) => `modified-${index}`)),
|
||||
getModel: () => (manager as any).modifiedModel,
|
||||
getOption: () => 20,
|
||||
applyFontInfo,
|
||||
changeViewZones(callback: (accessor: {
|
||||
addZone: (zone: any) => string
|
||||
removeZone: (id: string) => void
|
||||
}) => void) {
|
||||
callback({
|
||||
addZone(zone) {
|
||||
addedZones.push(zone)
|
||||
return `zone-${addedZones.length}`
|
||||
},
|
||||
removeZone(id) {
|
||||
removedZoneIds.push(id)
|
||||
},
|
||||
})
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
;(manager as any).syncDiffPresentationDecorations()
|
||||
|
||||
expect(classList.contains('stream-monaco-diff-native-stale')).toBe(true)
|
||||
expect(addedZones).toHaveLength(1)
|
||||
expect(addedZones[0].afterLineNumber).toBe(0)
|
||||
expect(addedZones[0].heightInLines).toBe(1)
|
||||
expect(addedZones[0].domNode.className).toBe(
|
||||
'stream-monaco-fallback-inline-delete-zone',
|
||||
)
|
||||
expect(addedZones[0].domNode.children).toHaveLength(1)
|
||||
expect(addedZones[0].domNode.children[0].textContent).toBe(
|
||||
'const value = 1',
|
||||
)
|
||||
expect(applyFontInfo).toHaveBeenCalledWith(addedZones[0].domNode)
|
||||
expect(applyFontInfo).toHaveBeenCalledWith(addedZones[0].marginDomNode)
|
||||
expect(removedZoneIds).toHaveLength(0)
|
||||
expect(createdNodes.length).toBeGreaterThan(0)
|
||||
}
|
||||
finally {
|
||||
;(globalThis as any).document = originalDocument
|
||||
}
|
||||
})
|
||||
|
||||
it('clears fallback inline delete zones as soon as native inline delete zones appear', () => {
|
||||
const originalDocument = (globalThis as any).document
|
||||
const originalHTMLElement = (globalThis as any).HTMLElement
|
||||
class FakeElement {
|
||||
constructor(public className: string) {}
|
||||
|
||||
matches(selector: string) {
|
||||
return selector.split(',').some((entry) => {
|
||||
const parts = entry.trim().split('.').filter(Boolean)
|
||||
return parts.every(part => this.className.split(/\s+/).includes(part))
|
||||
})
|
||||
}
|
||||
|
||||
querySelector() {
|
||||
return null
|
||||
}
|
||||
}
|
||||
;(globalThis as any).HTMLElement = FakeElement
|
||||
;(globalThis as any).document = {
|
||||
createElement() {
|
||||
return {
|
||||
className: '',
|
||||
style: {} as Record<string, string>,
|
||||
children: [] as any[],
|
||||
append() {},
|
||||
setAttribute() {},
|
||||
textContent: '',
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
try {
|
||||
const { manager, classList } = createPresentationHarness(true)
|
||||
const addedZones: any[] = []
|
||||
|
||||
;(manager as any).isDiffInlineMode = () => true
|
||||
;(manager as any).inlineDiffStreamingPresentationActive = true
|
||||
;(manager as any).hasFreshNativeDiffResult = () => false
|
||||
;(manager as any).clearFallbackInlineDeletedZones = DiffEditorManager.prototype[
|
||||
'clearFallbackInlineDeletedZones'
|
||||
]
|
||||
;(manager as any).lastContainer = {
|
||||
classList,
|
||||
querySelectorAll(selector: string) {
|
||||
if (selector.includes('.editor.modified .view-zones [monaco-view-zone]'))
|
||||
return [new FakeElement('view-lines line-delete monaco-mouse-cursor-text')]
|
||||
if (selector.includes('.editor.modified .margin-view-zones [monaco-view-zone]'))
|
||||
return [new FakeElement('inline-deleted-margin-view-zone')]
|
||||
return []
|
||||
},
|
||||
querySelector: vi.fn(() => null),
|
||||
}
|
||||
;(manager as any).originalModel = {
|
||||
getAlternativeVersionId: () => 2,
|
||||
getValue: () => 'const value = 1\nreturn value',
|
||||
getLineContent: (line: number) =>
|
||||
['const value = 1', 'return value'][line - 1] ?? '',
|
||||
}
|
||||
;(manager as any).modifiedModel = {
|
||||
getAlternativeVersionId: () => 2,
|
||||
getValue: () => 'const value = 2\nreturn value',
|
||||
getLineCount: () => 2,
|
||||
}
|
||||
;(manager as any).diffEditorView = {
|
||||
getLineChanges: () => null,
|
||||
getOriginalEditor: () => ({
|
||||
deltaDecorations: vi.fn((_: string[], next: unknown[]) =>
|
||||
next.map((_, index) => `original-${index}`)),
|
||||
}),
|
||||
getModifiedEditor: () => ({
|
||||
deltaDecorations: vi.fn((_: string[], next: unknown[]) =>
|
||||
next.map((_, index) => `modified-${index}`)),
|
||||
getModel: () => (manager as any).modifiedModel,
|
||||
getOption: () => 20,
|
||||
changeViewZones(callback: (accessor: {
|
||||
addZone: (zone: any) => string
|
||||
removeZone: (id: string) => void
|
||||
}) => void) {
|
||||
callback({
|
||||
addZone(zone) {
|
||||
addedZones.push(zone)
|
||||
return `zone-${addedZones.length}`
|
||||
},
|
||||
removeZone() {},
|
||||
})
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
;(manager as any).syncDiffPresentationDecorations()
|
||||
|
||||
expect(classList.contains('stream-monaco-diff-inline-native-ready')).toBe(true)
|
||||
expect(addedZones).toHaveLength(0)
|
||||
}
|
||||
finally {
|
||||
;(globalThis as any).document = originalDocument
|
||||
;(globalThis as any).HTMLElement = originalHTMLElement
|
||||
}
|
||||
})
|
||||
|
||||
it('reuses native inline delete wrappers instead of adding another fallback zone', () => {
|
||||
class FakeElement {
|
||||
className = ''
|
||||
style: Record<string, string> = {}
|
||||
children: any[] = []
|
||||
attributes = new Map<string, string>()
|
||||
|
||||
constructor(
|
||||
private readonly selectorMap: Record<string, unknown> = {},
|
||||
) {}
|
||||
|
||||
querySelector(selector: string) {
|
||||
return this.selectorMap[selector] ?? null
|
||||
}
|
||||
|
||||
append(node: any) {
|
||||
this.children.push(node)
|
||||
node.parentElement = this
|
||||
}
|
||||
|
||||
removeChild(node: any) {
|
||||
this.children = this.children.filter(child => child !== node)
|
||||
}
|
||||
|
||||
setAttribute(name: string, value: string) {
|
||||
this.attributes.set(name, value)
|
||||
}
|
||||
|
||||
getAttribute(name: string) {
|
||||
return this.attributes.get(name) ?? null
|
||||
}
|
||||
}
|
||||
|
||||
const originalDocument = (globalThis as any).document
|
||||
const originalHTMLElement = (globalThis as any).HTMLElement
|
||||
;(globalThis as any).HTMLElement = FakeElement
|
||||
;(globalThis as any).document = {
|
||||
createElement() {
|
||||
return new FakeElement()
|
||||
},
|
||||
}
|
||||
|
||||
try {
|
||||
const { manager } = createPresentationHarness(true)
|
||||
const applyFontInfo = vi.fn()
|
||||
const nativeDeleteMarker = new FakeElement()
|
||||
nativeDeleteMarker.className = 'view-lines line-delete monaco-mouse-cursor-text'
|
||||
const nativeMarginMarker = new FakeElement()
|
||||
nativeMarginMarker.className = 'inline-deleted-margin-view-zone'
|
||||
const viewWrapper = new FakeElement({
|
||||
'.view-lines.line-delete': nativeDeleteMarker,
|
||||
})
|
||||
viewWrapper.style.top = '54px'
|
||||
viewWrapper.setAttribute('monaco-view-zone', 'native-1')
|
||||
const marginWrapper = new FakeElement({
|
||||
'.inline-deleted-margin-view-zone': nativeMarginMarker,
|
||||
})
|
||||
marginWrapper.style.top = '54px'
|
||||
marginWrapper.setAttribute('monaco-view-zone', 'native-1')
|
||||
|
||||
;(manager as any).lastContainer = {
|
||||
querySelectorAll(selector: string) {
|
||||
if (selector.includes('.editor.modified .view-zones [monaco-view-zone]'))
|
||||
return [viewWrapper]
|
||||
if (selector.includes('.editor.modified .margin-view-zones [monaco-view-zone]'))
|
||||
return [marginWrapper]
|
||||
return []
|
||||
},
|
||||
}
|
||||
;(manager as any).diffEditorView = {
|
||||
getModifiedEditor: () => ({
|
||||
getModel: () => ({
|
||||
getLineCount: () => 4,
|
||||
}),
|
||||
getOption: () => 18,
|
||||
applyFontInfo,
|
||||
changeViewZones: vi.fn(),
|
||||
}),
|
||||
}
|
||||
;(manager as any).originalModel = {
|
||||
getLineContent: () => '"version": "0.0.49",',
|
||||
}
|
||||
;(manager as any).isDiffInlineMode = () => true
|
||||
|
||||
;(manager as any).syncFallbackInlineDeletedZones([
|
||||
{
|
||||
originalStartLineNumber: 1,
|
||||
originalEndLineNumber: 1,
|
||||
modifiedStartLineNumber: 4,
|
||||
modifiedEndLineNumber: 4,
|
||||
charChanges: [],
|
||||
},
|
||||
])
|
||||
|
||||
expect(viewWrapper.children).toHaveLength(1)
|
||||
expect(viewWrapper.children[0].className).toBe(
|
||||
'stream-monaco-fallback-inline-delete-zone',
|
||||
)
|
||||
expect(marginWrapper.children).toHaveLength(1)
|
||||
expect(marginWrapper.children[0].className).toBe(
|
||||
'stream-monaco-fallback-inline-delete-margin',
|
||||
)
|
||||
}
|
||||
finally {
|
||||
;(globalThis as any).document = originalDocument
|
||||
;(globalThis as any).HTMLElement = originalHTMLElement
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,176 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../src/monaco-shim', () => {
|
||||
class Range {
|
||||
constructor(
|
||||
public startLineNumber: number,
|
||||
public startColumn: number,
|
||||
public endLineNumber: number,
|
||||
public endColumn: number,
|
||||
) {}
|
||||
}
|
||||
|
||||
return {
|
||||
default: {
|
||||
Range,
|
||||
editor: {
|
||||
EditorOption: {
|
||||
lineHeight: 'lineHeight',
|
||||
},
|
||||
},
|
||||
},
|
||||
Range,
|
||||
editor: {
|
||||
EditorOption: {
|
||||
lineHeight: 'lineHeight',
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
import {
|
||||
applyDefaultDiffHunkAction,
|
||||
findLineChangeByHoverLine,
|
||||
hasModifiedLines,
|
||||
hasOriginalLines,
|
||||
inferInlineDiffHunkHoverSide,
|
||||
positionDiffHunkNode,
|
||||
} from '../src/core/diffHunk'
|
||||
|
||||
function createModel(lines: string[]) {
|
||||
const value = () => lines.join('\n')
|
||||
return {
|
||||
getLineCount() {
|
||||
return lines.length
|
||||
},
|
||||
getLineMaxColumn(line: number) {
|
||||
return (lines[line - 1] ?? '').length + 1
|
||||
},
|
||||
getValueInRange(range: any) {
|
||||
const start = range.startLineNumber - 1
|
||||
const end = range.endLineNumber - 1
|
||||
const selected = lines.slice(start, Math.max(start, end))
|
||||
if (range.endColumn !== 1 && end < lines.length)
|
||||
selected.push(lines[end] ?? '')
|
||||
return `${selected.join('\n')}${selected.length > 0 ? '\n' : ''}`
|
||||
},
|
||||
applyEdits: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
describe('diffHunk helpers', () => {
|
||||
it('detects whether a hunk has original or modified lines', () => {
|
||||
const insertion = {
|
||||
originalStartLineNumber: 0,
|
||||
originalEndLineNumber: 0,
|
||||
modifiedStartLineNumber: 3,
|
||||
modifiedEndLineNumber: 4,
|
||||
} as any
|
||||
|
||||
expect(hasOriginalLines(insertion)).toBe(false)
|
||||
expect(hasModifiedLines(insertion)).toBe(true)
|
||||
})
|
||||
|
||||
it('finds the nearest hovered line change within tolerance', () => {
|
||||
const changes = [
|
||||
{
|
||||
originalStartLineNumber: 2,
|
||||
originalEndLineNumber: 3,
|
||||
modifiedStartLineNumber: 2,
|
||||
modifiedEndLineNumber: 3,
|
||||
},
|
||||
{
|
||||
originalStartLineNumber: 10,
|
||||
originalEndLineNumber: 10,
|
||||
modifiedStartLineNumber: 12,
|
||||
modifiedEndLineNumber: 12,
|
||||
},
|
||||
] as any
|
||||
|
||||
expect(findLineChangeByHoverLine(changes, 'modified', 3)).toBe(changes[0])
|
||||
expect(findLineChangeByHoverLine(changes, 'modified', 14)).toBe(changes[1])
|
||||
expect(findLineChangeByHoverLine(changes, 'modified', 20)).toBeNull()
|
||||
})
|
||||
|
||||
it('infers inline hover side from DOM hint or hover line fallback', () => {
|
||||
const change = {
|
||||
originalStartLineNumber: 4,
|
||||
originalEndLineNumber: 5,
|
||||
modifiedStartLineNumber: 6,
|
||||
modifiedEndLineNumber: 7,
|
||||
} as any
|
||||
|
||||
const deletedTarget = {
|
||||
closest(selector: string) {
|
||||
return selector.includes('line-delete') ? {} : null
|
||||
},
|
||||
} as HTMLElement
|
||||
const insertedTarget = {
|
||||
closest(selector: string) {
|
||||
return selector.includes('line-insert') ? {} : null
|
||||
},
|
||||
} as HTMLElement
|
||||
|
||||
expect(inferInlineDiffHunkHoverSide(change, 5, deletedTarget)).toBe('upper')
|
||||
expect(inferInlineDiffHunkHoverSide(change, 7, insertedTarget)).toBe('lower')
|
||||
expect(inferInlineDiffHunkHoverSide(change, 5, null)).toBe('upper')
|
||||
expect(inferInlineDiffHunkHoverSide(change, 6, null)).toBe('lower')
|
||||
})
|
||||
|
||||
it('applies the default lower-stage action by copying modified lines into original', () => {
|
||||
const originalModel = createModel(['a', 'b'])
|
||||
const modifiedModel = createModel(['a', 'b', 'c'])
|
||||
const lineChange = {
|
||||
originalStartLineNumber: 0,
|
||||
originalEndLineNumber: 0,
|
||||
modifiedStartLineNumber: 3,
|
||||
modifiedEndLineNumber: 3,
|
||||
} as any
|
||||
|
||||
applyDefaultDiffHunkAction({
|
||||
action: 'stage',
|
||||
side: 'lower',
|
||||
lineChange,
|
||||
originalModel: originalModel as any,
|
||||
modifiedModel: modifiedModel as any,
|
||||
})
|
||||
|
||||
expect(originalModel.applyEdits).toHaveBeenCalledTimes(1)
|
||||
expect(originalModel.applyEdits.mock.calls[0][0][0].text).toBe('c\n')
|
||||
})
|
||||
|
||||
it('positions a hunk node within the editor host', () => {
|
||||
const node = {
|
||||
offsetWidth: 120,
|
||||
offsetHeight: 24,
|
||||
style: {
|
||||
transform: '',
|
||||
display: '',
|
||||
},
|
||||
} as unknown as HTMLDivElement
|
||||
const editor = {
|
||||
getContainerDomNode() {
|
||||
return {
|
||||
offsetLeft: 10,
|
||||
clientWidth: 400,
|
||||
offsetTop: 20,
|
||||
clientHeight: 200,
|
||||
}
|
||||
},
|
||||
getTopForLineNumber(line: number) {
|
||||
return line * 20
|
||||
},
|
||||
getScrollTop() {
|
||||
return 20
|
||||
},
|
||||
getOption() {
|
||||
return 20
|
||||
},
|
||||
} as any
|
||||
|
||||
positionDiffHunkNode(node, editor, 5)
|
||||
|
||||
expect(node.style.display).toBe('flex')
|
||||
expect(node.style.transform).toContain('translate(')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,203 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
buildDiffHunkMetadataLabel,
|
||||
formatDiffUnchangedCountLabel,
|
||||
measureDiffUnchangedSurroundingLines,
|
||||
resolveDiffMetadataLabel,
|
||||
resolveDiffUnchangedMergeRole,
|
||||
resolveDiffUnchangedRevealLayout,
|
||||
resolveDiffUnchangedSummaryLabel,
|
||||
} from '../src/core/diffUnchanged'
|
||||
|
||||
function createRect(top: number, bottom: number, left = 0, width = 20) {
|
||||
return {
|
||||
top,
|
||||
bottom,
|
||||
left,
|
||||
width,
|
||||
height: bottom - top,
|
||||
right: left + width,
|
||||
}
|
||||
}
|
||||
|
||||
function createLineNode(lineNumber: number, top: number) {
|
||||
return {
|
||||
textContent: String(lineNumber),
|
||||
getBoundingClientRect() {
|
||||
return createRect(top, top + 10)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('diffUnchanged helpers', () => {
|
||||
it('formats unchanged count labels', () => {
|
||||
expect(formatDiffUnchangedCountLabel('12 hidden lines')).toBe('12 unmodified lines')
|
||||
expect(formatDiffUnchangedCountLabel('1 hidden line')).toBe('1 unmodified line')
|
||||
})
|
||||
|
||||
it('measures surrounding visible lines around a hidden region', () => {
|
||||
const editorRoot = {
|
||||
querySelectorAll() {
|
||||
return [
|
||||
createLineNode(2, 10),
|
||||
createLineNode(3, 20),
|
||||
createLineNode(9, 80),
|
||||
createLineNode(10, 90),
|
||||
]
|
||||
},
|
||||
}
|
||||
const primaryNode = {
|
||||
closest(selector: string) {
|
||||
return selector.includes('.editor.modified') ? editorRoot : null
|
||||
},
|
||||
getBoundingClientRect() {
|
||||
return createRect(40, 60)
|
||||
},
|
||||
}
|
||||
|
||||
expect(measureDiffUnchangedSurroundingLines(primaryNode as any)).toEqual({
|
||||
previousVisibleLine: 3,
|
||||
nextVisibleLine: 9,
|
||||
})
|
||||
})
|
||||
|
||||
it('builds and resolves metadata labels from line changes', () => {
|
||||
const primaryNode = {
|
||||
closest() {
|
||||
return {
|
||||
querySelectorAll() {
|
||||
return [createLineNode(6, 90)]
|
||||
},
|
||||
}
|
||||
},
|
||||
getBoundingClientRect() {
|
||||
return createRect(40, 60)
|
||||
},
|
||||
}
|
||||
const lineChange = {
|
||||
originalStartLineNumber: 4,
|
||||
originalEndLineNumber: 5,
|
||||
modifiedStartLineNumber: 6,
|
||||
modifiedEndLineNumber: 8,
|
||||
}
|
||||
|
||||
expect(
|
||||
buildDiffHunkMetadataLabel(lineChange as any, {
|
||||
contextLineCount: 2,
|
||||
originalTotalLines: 20,
|
||||
modifiedTotalLines: 24,
|
||||
}),
|
||||
).toEqual({
|
||||
originalStart: 2,
|
||||
modifiedStart: 4,
|
||||
label: '@@ -2,6 +4,7 @@',
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveDiffMetadataLabel({
|
||||
lineChanges: [lineChange as any],
|
||||
pairIndex: 0,
|
||||
primaryNode: primaryNode as any,
|
||||
contextLineCount: 2,
|
||||
originalTotalLines: 20,
|
||||
modifiedTotalLines: 24,
|
||||
}),
|
||||
).toBe('@@ -2,6 +4,7 @@')
|
||||
|
||||
expect(
|
||||
resolveDiffUnchangedSummaryLabel({
|
||||
countText: '3 unmodified lines',
|
||||
unchangedRegionStyle: 'metadata',
|
||||
lineChanges: [lineChange as any],
|
||||
pairIndex: 0,
|
||||
primaryNode: primaryNode as any,
|
||||
contextLineCount: 2,
|
||||
originalTotalLines: 20,
|
||||
modifiedTotalLines: 24,
|
||||
}),
|
||||
).toBe('@@ -2,6 +4,7 @@')
|
||||
|
||||
expect(
|
||||
resolveDiffUnchangedSummaryLabel({
|
||||
countText: '3 unmodified lines',
|
||||
unchangedRegionStyle: 'line-info',
|
||||
lineChanges: [lineChange as any],
|
||||
pairIndex: 0,
|
||||
primaryNode: primaryNode as any,
|
||||
contextLineCount: 2,
|
||||
originalTotalLines: 20,
|
||||
modifiedTotalLines: 24,
|
||||
}),
|
||||
).toBe('3 unmodified lines')
|
||||
})
|
||||
|
||||
it('resolves reveal handle layout at file boundaries', () => {
|
||||
const primaryNode = {
|
||||
closest() {
|
||||
return {
|
||||
querySelectorAll() {
|
||||
return [createLineNode(1, 90)]
|
||||
},
|
||||
}
|
||||
},
|
||||
getBoundingClientRect() {
|
||||
return createRect(40, 60)
|
||||
},
|
||||
}
|
||||
|
||||
expect(
|
||||
resolveDiffUnchangedRevealLayout({
|
||||
primaryNode: primaryNode as any,
|
||||
countText: '3 hidden lines',
|
||||
pairIndex: 0,
|
||||
pairCount: 2,
|
||||
modelLineCount: 10,
|
||||
}),
|
||||
).toEqual({
|
||||
showTopHandle: false,
|
||||
showBottomHandle: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves unchanged merge role from editor host centers', () => {
|
||||
class HTMLElementMock {}
|
||||
const previousHTMLElement = globalThis.HTMLElement
|
||||
globalThis.HTMLElement = HTMLElementMock as any
|
||||
|
||||
const node = Object.assign(new HTMLElementMock(), {
|
||||
getBoundingClientRect() {
|
||||
return createRect(0, 20, 40, 20)
|
||||
},
|
||||
})
|
||||
const diffRoot = Object.assign(new HTMLElementMock(), {
|
||||
getBoundingClientRect() {
|
||||
return createRect(0, 20, 0, 200)
|
||||
},
|
||||
})
|
||||
const originalHost = Object.assign(new HTMLElementMock(), {
|
||||
getBoundingClientRect() {
|
||||
return createRect(0, 20, 20, 40)
|
||||
},
|
||||
})
|
||||
const modifiedHost = Object.assign(new HTMLElementMock(), {
|
||||
getBoundingClientRect() {
|
||||
return createRect(0, 20, 120, 40)
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
expect(
|
||||
resolveDiffUnchangedMergeRole({
|
||||
node: node as any,
|
||||
diffRoot: diffRoot as any,
|
||||
originalHost: originalHost as any,
|
||||
modifiedHost: modifiedHost as any,
|
||||
}),
|
||||
).toBe('secondary')
|
||||
}
|
||||
finally {
|
||||
globalThis.HTMLElement = previousHTMLElement
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,229 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
activateDiffUnchangedExpandAction,
|
||||
bindDiffUnchangedRevealButtonAction,
|
||||
collectDiffUnchangedViewZoneIds,
|
||||
findDiffUnchangedActivationAction,
|
||||
findDiffUnchangedExpandAction,
|
||||
resolveDiffUnchangedWheelScrollTarget,
|
||||
resolveDiffUnchangedViewZoneHeight,
|
||||
shouldHandleDiffUnchangedCenterClick,
|
||||
shouldHandleDiffUnchangedWheel,
|
||||
shouldIgnoreDiffUnchangedCenterClickTarget,
|
||||
} from '../src/core/diffUnchangedDom'
|
||||
|
||||
describe('diffUnchangedDom helpers', () => {
|
||||
it('resolves unchanged view-zone heights from style', () => {
|
||||
expect(resolveDiffUnchangedViewZoneHeight('simple')).toBe(28)
|
||||
expect(resolveDiffUnchangedViewZoneHeight('line-info')).toBe(32)
|
||||
expect(resolveDiffUnchangedViewZoneHeight('line-info-basic')).toBe(32)
|
||||
expect(resolveDiffUnchangedViewZoneHeight('metadata')).toBe(32)
|
||||
})
|
||||
|
||||
it('collects visible diff unchanged view-zone ids that align with widgets', () => {
|
||||
const editorRoot = {
|
||||
querySelectorAll(selector: string) {
|
||||
if (selector === '.diff-hidden-lines-widget') {
|
||||
return [
|
||||
{ style: { top: '20' } },
|
||||
{ style: { top: '-100001' } },
|
||||
]
|
||||
}
|
||||
|
||||
if (
|
||||
selector
|
||||
=== '.view-zones > div[monaco-view-zone][monaco-visible-view-zone="true"]'
|
||||
) {
|
||||
return [
|
||||
{
|
||||
style: { top: '120', height: '32' },
|
||||
getAttribute(name: string) {
|
||||
return name === 'monaco-view-zone' ? 'zone-a' : null
|
||||
},
|
||||
},
|
||||
{
|
||||
style: { top: '120', height: '0' },
|
||||
getAttribute(name: string) {
|
||||
return name === 'monaco-view-zone' ? 'zone-b' : null
|
||||
},
|
||||
},
|
||||
{
|
||||
style: { top: '160', height: '32' },
|
||||
getAttribute(name: string) {
|
||||
return name === 'monaco-view-zone' ? 'zone-c' : null
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
return []
|
||||
},
|
||||
}
|
||||
|
||||
expect(
|
||||
collectDiffUnchangedViewZoneIds(editorRoot as any, 100),
|
||||
).toEqual(['zone-a'])
|
||||
})
|
||||
|
||||
it('finds expand and activation actions from unchanged widgets', () => {
|
||||
const expandAction = { kind: 'expand' }
|
||||
const fallbackAction = { kind: 'fallback' }
|
||||
|
||||
const centerNode = {
|
||||
querySelector(selector: string) {
|
||||
return selector === 'a' ? expandAction : null
|
||||
},
|
||||
}
|
||||
const primaryNode = {
|
||||
querySelector(selector: string) {
|
||||
return selector === 'a, button' ? fallbackAction : null
|
||||
},
|
||||
}
|
||||
|
||||
expect(findDiffUnchangedExpandAction(centerNode as any)).toBe(expandAction)
|
||||
expect(
|
||||
findDiffUnchangedActivationAction(null, primaryNode as any),
|
||||
).toBe(fallbackAction)
|
||||
})
|
||||
|
||||
it('ignores center clicks that originate from links or breadcrumbs', () => {
|
||||
class HTMLElementMock {
|
||||
constructor(private readonly result: unknown) {}
|
||||
|
||||
closest() {
|
||||
return this.result
|
||||
}
|
||||
}
|
||||
|
||||
const previousHTMLElement = globalThis.HTMLElement
|
||||
globalThis.HTMLElement = HTMLElementMock as any
|
||||
|
||||
try {
|
||||
expect(
|
||||
shouldIgnoreDiffUnchangedCenterClickTarget(
|
||||
new HTMLElementMock({}) as any,
|
||||
),
|
||||
).toBe(true)
|
||||
expect(
|
||||
shouldIgnoreDiffUnchangedCenterClickTarget(
|
||||
new HTMLElementMock(null) as any,
|
||||
),
|
||||
).toBe(false)
|
||||
expect(shouldIgnoreDiffUnchangedCenterClickTarget(null)).toBe(false)
|
||||
}
|
||||
finally {
|
||||
globalThis.HTMLElement = previousHTMLElement
|
||||
}
|
||||
})
|
||||
|
||||
it('handles only primary center clicks that are not already on actions', () => {
|
||||
class HTMLElementMock {
|
||||
constructor(private readonly result: unknown) {}
|
||||
|
||||
closest() {
|
||||
return this.result
|
||||
}
|
||||
}
|
||||
|
||||
const previousHTMLElement = globalThis.HTMLElement
|
||||
globalThis.HTMLElement = HTMLElementMock as any
|
||||
|
||||
try {
|
||||
expect(
|
||||
shouldHandleDiffUnchangedCenterClick({
|
||||
button: 0,
|
||||
target: new HTMLElementMock(null) as any,
|
||||
} as any),
|
||||
).toBe(true)
|
||||
expect(
|
||||
shouldHandleDiffUnchangedCenterClick({
|
||||
button: 1,
|
||||
target: new HTMLElementMock(null) as any,
|
||||
} as any),
|
||||
).toBe(false)
|
||||
expect(
|
||||
shouldHandleDiffUnchangedCenterClick({
|
||||
button: 0,
|
||||
target: new HTMLElementMock({}) as any,
|
||||
} as any),
|
||||
).toBe(false)
|
||||
}
|
||||
finally {
|
||||
globalThis.HTMLElement = previousHTMLElement
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves unchanged wheel handling and scroll targets', () => {
|
||||
expect(shouldHandleDiffUnchangedWheel({ deltaX: 0, deltaY: 0.4 } as any)).toBe(false)
|
||||
expect(shouldHandleDiffUnchangedWheel({ deltaX: 0, deltaY: 2 } as any)).toBe(true)
|
||||
|
||||
expect(
|
||||
resolveDiffUnchangedWheelScrollTarget(10, 20, {
|
||||
deltaX: 5,
|
||||
deltaY: 7,
|
||||
} as any),
|
||||
).toEqual({
|
||||
targetScrollTop: 17,
|
||||
targetScrollLeft: 25,
|
||||
syncHorizontal: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('binds reveal button activation only when a handle exists', () => {
|
||||
const events: string[] = []
|
||||
const button = {
|
||||
onclick: null as null | ((event: any) => void),
|
||||
}
|
||||
const handle = { id: 'top' }
|
||||
|
||||
bindDiffUnchangedRevealButtonAction(
|
||||
button as any,
|
||||
handle as any,
|
||||
(nextHandle) => {
|
||||
events.push((nextHandle as any).id)
|
||||
},
|
||||
)
|
||||
|
||||
button.onclick?.({
|
||||
preventDefault() {
|
||||
events.push('prevent')
|
||||
},
|
||||
stopPropagation() {
|
||||
events.push('stop')
|
||||
},
|
||||
})
|
||||
|
||||
expect(events).toEqual(['prevent', 'stop', 'top'])
|
||||
|
||||
bindDiffUnchangedRevealButtonAction(button as any, null, () => {
|
||||
events.push('unexpected')
|
||||
})
|
||||
expect(button.onclick).toBeNull()
|
||||
})
|
||||
|
||||
it('activates the expand action from a center root when present', () => {
|
||||
const events: string[] = []
|
||||
const root = {
|
||||
querySelector(selector: string) {
|
||||
if (selector !== 'a')
|
||||
return null
|
||||
return {
|
||||
click() {
|
||||
events.push('click')
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
expect(
|
||||
activateDiffUnchangedExpandAction(root as any, () => {
|
||||
events.push('before')
|
||||
}),
|
||||
).toBe(true)
|
||||
expect(events).toEqual(['before', 'click'])
|
||||
expect(
|
||||
activateDiffUnchangedExpandAction({ querySelector: () => null } as any),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../src/monaco-shim', () => {
|
||||
const editor = {
|
||||
EditorOption: {
|
||||
lineHeight: 'lineHeight',
|
||||
},
|
||||
}
|
||||
return {
|
||||
default: { editor },
|
||||
editor,
|
||||
}
|
||||
})
|
||||
|
||||
import {
|
||||
computeDiffHeight,
|
||||
computeDiffRawHeight,
|
||||
hasVerticalScrollbar,
|
||||
isUserNearBottom,
|
||||
revealEditorLine,
|
||||
} from '../src/core/diffViewport'
|
||||
|
||||
function createCodeEditor(lineCount: number, scrollHeight: number, lineHeight = 20) {
|
||||
return {
|
||||
getModel() {
|
||||
return {
|
||||
getLineCount() {
|
||||
return lineCount
|
||||
},
|
||||
}
|
||||
},
|
||||
getOption() {
|
||||
return lineHeight
|
||||
},
|
||||
getScrollHeight() {
|
||||
return scrollHeight
|
||||
},
|
||||
revealLine: vi.fn(),
|
||||
revealLineInCenter: vi.fn(),
|
||||
revealLineInCenterIfOutsideViewport: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
describe('diffViewport helpers', () => {
|
||||
it('computeDiffRawHeight prefers rendered scroll height and clamps to max', () => {
|
||||
const originalEditor = createCodeEditor(3, 140)
|
||||
const modifiedEditor = createCodeEditor(5, 220)
|
||||
const diffEditorView = {
|
||||
getOriginalEditor() {
|
||||
return originalEditor as any
|
||||
},
|
||||
getModifiedEditor() {
|
||||
return modifiedEditor as any
|
||||
},
|
||||
} as any
|
||||
|
||||
expect(computeDiffRawHeight({ diffEditorView, maxHeightValue: 180 })).toBe(180)
|
||||
expect(computeDiffRawHeight({ diffEditorView, maxHeightValue: 260 })).toBe(220)
|
||||
})
|
||||
|
||||
it('computeDiffHeight preserves inline streaming floor', () => {
|
||||
expect(
|
||||
computeDiffHeight({
|
||||
rawHeight: 120,
|
||||
isInlineMode: true,
|
||||
inlineDiffStreamingPresentationActive: true,
|
||||
inlineDiffStreamingHeightFloor: 180,
|
||||
}),
|
||||
).toEqual({
|
||||
height: 180,
|
||||
nextInlineDiffStreamingHeightFloor: 180,
|
||||
})
|
||||
})
|
||||
|
||||
it('viewport helpers detect scroll state consistently', () => {
|
||||
expect(
|
||||
hasVerticalScrollbar({
|
||||
computedHeight: 100,
|
||||
lineHeight: 20,
|
||||
scrollHeight: 130,
|
||||
}),
|
||||
).toBe(true)
|
||||
|
||||
expect(
|
||||
isUserNearBottom(
|
||||
{
|
||||
computedHeight: 100,
|
||||
lineHeight: 20,
|
||||
scrollHeight: 200,
|
||||
scrollTop: 120,
|
||||
viewportHeight: 40,
|
||||
},
|
||||
{
|
||||
autoScrollThresholdLines: 2,
|
||||
autoScrollThresholdPx: 8,
|
||||
},
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('revealEditorLine dispatches to the requested reveal strategy', () => {
|
||||
const editor = createCodeEditor(5, 120)
|
||||
|
||||
revealEditorLine(editor as any, 4, 'centerIfOutside', 1)
|
||||
revealEditorLine(editor as any, 5, 'center')
|
||||
revealEditorLine(editor as any, 6, 'bottom')
|
||||
|
||||
expect(editor.revealLineInCenterIfOutsideViewport).toHaveBeenCalledWith(4, 1)
|
||||
expect(editor.revealLineInCenter).toHaveBeenCalledWith(5)
|
||||
expect(editor.revealLine).toHaveBeenCalledWith(6)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,477 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
function installRafMocks() {
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: any) => {
|
||||
return setTimeout(() => cb(Date.now()), 0) as unknown as number
|
||||
})
|
||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => {
|
||||
clearTimeout(id as unknown as ReturnType<typeof setTimeout>)
|
||||
})
|
||||
}
|
||||
|
||||
async function loadUseMonaco() {
|
||||
vi.resetModules()
|
||||
|
||||
vi.doMock('../src/utils/registerMonacoThemes', () => {
|
||||
return {
|
||||
clearHighlighterCache: () => {},
|
||||
getOrCreateHighlighter: async () => null,
|
||||
registerMonacoThemes: async () => null,
|
||||
}
|
||||
})
|
||||
|
||||
vi.doMock('../src/monaco-shim', () => {
|
||||
let lastCreatedModel: any = null
|
||||
let lastCreatedEditor: any = null
|
||||
|
||||
class Range {
|
||||
constructor(
|
||||
public startLineNumber: number,
|
||||
public startColumn: number,
|
||||
public endLineNumber: number,
|
||||
public endColumn: number,
|
||||
) {}
|
||||
}
|
||||
|
||||
function createModel(initialValue: string, initialLanguage: string) {
|
||||
let value = initialValue
|
||||
let languageId = initialLanguage
|
||||
let getValueCallCount = 0
|
||||
const contentSizeListeners = new Set<() => void>()
|
||||
const contentChangeListeners = new Set<() => void>()
|
||||
|
||||
function lines() {
|
||||
return value.split('\n')
|
||||
}
|
||||
|
||||
function getOffsetAt(lineNumber: number, column: number) {
|
||||
const parts = lines()
|
||||
let offset = 0
|
||||
for (let i = 0; i < lineNumber - 1; i++)
|
||||
offset += (parts[i] ?? '').length + 1
|
||||
return offset + column - 1
|
||||
}
|
||||
|
||||
function emitChange() {
|
||||
contentChangeListeners.forEach(listener => listener())
|
||||
contentSizeListeners.forEach(listener => listener())
|
||||
}
|
||||
|
||||
return {
|
||||
getValue() {
|
||||
getValueCallCount += 1
|
||||
return value
|
||||
},
|
||||
setValue(next: string) {
|
||||
value = next
|
||||
emitChange()
|
||||
},
|
||||
getLineCount() {
|
||||
return lines().length
|
||||
},
|
||||
getLineMaxColumn(lineNumber: number) {
|
||||
return (lines()[lineNumber - 1] ?? '').length + 1
|
||||
},
|
||||
getPositionAt(offset: number) {
|
||||
const consumed = value.slice(0, offset).split('\n')
|
||||
return {
|
||||
lineNumber: consumed.length,
|
||||
column: consumed[consumed.length - 1].length + 1,
|
||||
}
|
||||
},
|
||||
getLanguageId() {
|
||||
return languageId
|
||||
},
|
||||
setLanguageId(next: string) {
|
||||
languageId = next
|
||||
},
|
||||
applyEdits(edits: Array<{ range: Range, text: string }>) {
|
||||
for (const edit of edits) {
|
||||
const start = getOffsetAt(
|
||||
edit.range.startLineNumber,
|
||||
edit.range.startColumn,
|
||||
)
|
||||
const end = getOffsetAt(
|
||||
edit.range.endLineNumber,
|
||||
edit.range.endColumn,
|
||||
)
|
||||
value = value.slice(0, start) + edit.text + value.slice(end)
|
||||
}
|
||||
emitChange()
|
||||
},
|
||||
onDidContentSizeChange(listener: () => void) {
|
||||
contentSizeListeners.add(listener)
|
||||
return {
|
||||
dispose() {
|
||||
contentSizeListeners.delete(listener)
|
||||
},
|
||||
}
|
||||
},
|
||||
onDidChangeContent(listener: () => void) {
|
||||
contentChangeListeners.add(listener)
|
||||
return {
|
||||
dispose() {
|
||||
contentChangeListeners.delete(listener)
|
||||
},
|
||||
}
|
||||
},
|
||||
__getGetValueCallCount() {
|
||||
return getValueCallCount
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const editor = {
|
||||
EditorOption: {
|
||||
lineHeight: 'lineHeight',
|
||||
readOnly: 'readOnly',
|
||||
},
|
||||
ScrollType: {
|
||||
Smooth: 'smooth',
|
||||
},
|
||||
create: vi.fn((_: any, options: any) => {
|
||||
const model = createModel(options.value ?? '', options.language ?? 'plaintext')
|
||||
lastCreatedModel = model
|
||||
const scrollListeners = new Set<(e: any) => void>()
|
||||
let scrollTop = 0
|
||||
const domNode = {
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
}
|
||||
|
||||
const editorApi = {
|
||||
getModel() {
|
||||
return model
|
||||
},
|
||||
getValue() {
|
||||
return model.getValue()
|
||||
},
|
||||
getOption(option: string) {
|
||||
if (option === editor.EditorOption.lineHeight)
|
||||
return 20
|
||||
if (option === editor.EditorOption.readOnly)
|
||||
return options.readOnly ?? true
|
||||
return undefined
|
||||
},
|
||||
getLayoutInfo() {
|
||||
return { height: 240 }
|
||||
},
|
||||
getScrollTop() {
|
||||
return scrollTop
|
||||
},
|
||||
setScrollTop: vi.fn((next: number) => {
|
||||
scrollTop = next
|
||||
}),
|
||||
getScrollHeight() {
|
||||
return model.getLineCount() * 20
|
||||
},
|
||||
getContentHeight() {
|
||||
return model.getLineCount() * 20
|
||||
},
|
||||
onDidContentSizeChange(listener: () => void) {
|
||||
return model.onDidContentSizeChange(listener)
|
||||
},
|
||||
onDidChangeModelContent(listener: () => void) {
|
||||
return model.onDidChangeContent(listener)
|
||||
},
|
||||
onDidScrollChange(listener: (e: any) => void) {
|
||||
scrollListeners.add(listener)
|
||||
return {
|
||||
dispose() {
|
||||
scrollListeners.delete(listener)
|
||||
},
|
||||
}
|
||||
},
|
||||
executeEdits(_: string, edits: Array<{ range: Range, text: string }>) {
|
||||
model.applyEdits(edits)
|
||||
},
|
||||
revealLine: vi.fn(),
|
||||
revealLineInCenter: vi.fn(),
|
||||
revealLineInCenterIfOutsideViewport: vi.fn(),
|
||||
dispose() {},
|
||||
getDomNode() {
|
||||
return domNode as any
|
||||
},
|
||||
__setScrollTop(next: number) {
|
||||
scrollTop = next
|
||||
},
|
||||
}
|
||||
lastCreatedEditor = editorApi
|
||||
return editorApi
|
||||
}),
|
||||
setTheme: vi.fn(),
|
||||
setModelLanguage: vi.fn((model: any, language: string) => {
|
||||
model.setLanguageId(language)
|
||||
}),
|
||||
}
|
||||
|
||||
const languages = {
|
||||
getLanguages: () => [],
|
||||
register: vi.fn(),
|
||||
}
|
||||
|
||||
return {
|
||||
default: { editor, languages, Range },
|
||||
editor,
|
||||
languages,
|
||||
Range,
|
||||
ScrollType: editor.ScrollType,
|
||||
__getLastModel() {
|
||||
return lastCreatedModel
|
||||
},
|
||||
__getLastEditor() {
|
||||
return lastCreatedEditor
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const base = await import('../src/index.base')
|
||||
const monacoModule: any = await import('../src/monaco-shim')
|
||||
return {
|
||||
...base,
|
||||
__getLastModel: monacoModule.__getLastModel,
|
||||
__getLastEditor: monacoModule.__getLastEditor,
|
||||
}
|
||||
}
|
||||
|
||||
describe('EditorManager update throttling', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-04-01T10:00:00.000Z'))
|
||||
installRafMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('respects updateThrottleMs after createEditor uses EditorManager', async () => {
|
||||
const { useMonaco } = await loadUseMonaco()
|
||||
const monaco = useMonaco({
|
||||
themes: ['vitesse-dark', 'vitesse-light'],
|
||||
languages: ['javascript'],
|
||||
readOnly: true,
|
||||
updateThrottleMs: 50,
|
||||
})
|
||||
|
||||
const container = { style: {}, innerHTML: '' } as any
|
||||
await monaco.createEditor(container, '', 'javascript')
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
monaco.updateCode('a', 'javascript')
|
||||
await vi.runAllTimersAsync()
|
||||
expect(monaco.getCode()).toBe('a')
|
||||
|
||||
monaco.updateCode('ab', 'javascript')
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
expect(monaco.getCode()).toBe('a')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(40)
|
||||
expect(monaco.getCode()).toBe('ab')
|
||||
})
|
||||
|
||||
it('propagates runtime throttle changes into the active EditorManager', async () => {
|
||||
const { useMonaco } = await loadUseMonaco()
|
||||
const monaco = useMonaco({
|
||||
themes: ['vitesse-dark', 'vitesse-light'],
|
||||
languages: ['javascript'],
|
||||
readOnly: true,
|
||||
updateThrottleMs: 100,
|
||||
})
|
||||
|
||||
const container = { style: {}, innerHTML: '' } as any
|
||||
await monaco.createEditor(container, '', 'javascript')
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
monaco.updateCode('a', 'javascript')
|
||||
await vi.runAllTimersAsync()
|
||||
expect(monaco.getCode()).toBe('a')
|
||||
|
||||
monaco.updateCode('ab', 'javascript')
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
expect(monaco.getCode()).toBe('a')
|
||||
|
||||
monaco.setUpdateThrottleMs(0)
|
||||
expect(monaco.getUpdateThrottleMs()).toBe(0)
|
||||
|
||||
await vi.runAllTimersAsync()
|
||||
expect(monaco.getCode()).toBe('ab')
|
||||
})
|
||||
|
||||
it('avoids rereading the whole model during programmatic streaming appends', async () => {
|
||||
const { useMonaco, __getLastModel } = await loadUseMonaco()
|
||||
const monaco = useMonaco({
|
||||
themes: ['vitesse-dark', 'vitesse-light'],
|
||||
languages: ['javascript'],
|
||||
readOnly: true,
|
||||
updateThrottleMs: 0,
|
||||
})
|
||||
|
||||
const container = { style: {}, innerHTML: '' } as any
|
||||
await monaco.createEditor(container, '', 'javascript')
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
const model = __getLastModel()
|
||||
const beforeFirstUpdate = model.__getGetValueCallCount()
|
||||
monaco.updateCode('a', 'javascript')
|
||||
await vi.runAllTimersAsync()
|
||||
expect(model.__getGetValueCallCount() - beforeFirstUpdate).toBe(0)
|
||||
|
||||
const beforeSecondUpdate = model.__getGetValueCallCount()
|
||||
monaco.updateCode('ab', 'javascript')
|
||||
await vi.runAllTimersAsync()
|
||||
expect(model.__getGetValueCallCount() - beforeSecondUpdate).toBe(0)
|
||||
expect(monaco.getCode()).toBe('ab')
|
||||
})
|
||||
|
||||
it('keeps frame-by-frame streaming appends byte-for-byte aligned', async () => {
|
||||
const { useMonaco } = await loadUseMonaco()
|
||||
const monaco = useMonaco({
|
||||
themes: ['vitesse-dark', 'vitesse-light'],
|
||||
languages: ['json'],
|
||||
readOnly: true,
|
||||
updateThrottleMs: 0,
|
||||
})
|
||||
const code = `{
|
||||
".c": "C",
|
||||
".cpp": "C++",
|
||||
".cc": "C++"
|
||||
}`
|
||||
|
||||
const container = { style: {}, innerHTML: '' } as any
|
||||
await monaco.createEditor(container, '', 'json')
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
for (let i = 1; i <= code.length; i++) {
|
||||
monaco.updateCode(code.slice(0, i), 'json')
|
||||
await vi.runAllTimersAsync()
|
||||
}
|
||||
|
||||
expect(monaco.getCode()).toBe(code)
|
||||
})
|
||||
|
||||
it('does not reveal while streamed content is still shorter than the editor height', async () => {
|
||||
const { useMonaco, __getLastEditor } = await loadUseMonaco()
|
||||
const monaco = useMonaco({
|
||||
themes: ['vitesse-dark', 'vitesse-light'],
|
||||
languages: ['json'],
|
||||
readOnly: true,
|
||||
updateThrottleMs: 0,
|
||||
})
|
||||
const code = `{
|
||||
".c": "C",
|
||||
".cpp": "C++",
|
||||
".cc": "C++"
|
||||
}`
|
||||
|
||||
const container = { style: {}, innerHTML: '' } as any
|
||||
await monaco.createEditor(container, '', 'json')
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
const editor = __getLastEditor()
|
||||
for (const line of code.split('\n')) {
|
||||
const next = monaco.getCode()
|
||||
? `${monaco.getCode()}\n${line}`
|
||||
: line
|
||||
monaco.updateCode(next, 'json')
|
||||
await vi.runAllTimersAsync()
|
||||
}
|
||||
|
||||
expect(editor.revealLine).not.toHaveBeenCalled()
|
||||
expect(editor.revealLineInCenter).not.toHaveBeenCalled()
|
||||
expect(editor.revealLineInCenterIfOutsideViewport).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses Monaco content height without forcing a minimum host height', async () => {
|
||||
const { useMonaco } = await loadUseMonaco()
|
||||
const monaco = useMonaco({
|
||||
themes: ['vitesse-dark', 'vitesse-light'],
|
||||
languages: ['json'],
|
||||
readOnly: true,
|
||||
updateThrottleMs: 0,
|
||||
})
|
||||
|
||||
const container = { style: {}, innerHTML: '' } as any
|
||||
await monaco.createEditor(container, '', 'json')
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(container.style.minHeight).toBeUndefined()
|
||||
expect(container.style.height).toBe('20px')
|
||||
|
||||
monaco.updateCode('{\n "a": 1\n}', 'json')
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(container.style.height).toBe('60px')
|
||||
})
|
||||
|
||||
it('keeps scroll pinned to top before streamed content reaches max height', async () => {
|
||||
const { useMonaco, __getLastEditor } = await loadUseMonaco()
|
||||
const monaco = useMonaco({
|
||||
themes: ['vitesse-dark', 'vitesse-light'],
|
||||
languages: ['json'],
|
||||
readOnly: true,
|
||||
updateThrottleMs: 0,
|
||||
})
|
||||
|
||||
const container = { style: {}, innerHTML: '' } as any
|
||||
await monaco.createEditor(container, '', 'json')
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
const editor = __getLastEditor()
|
||||
editor.__setScrollTop(8)
|
||||
monaco.updateCode('{\n "a": 1\n}', 'json')
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(container.style.height).toBe('60px')
|
||||
expect(container.style.overflow).toBe('hidden')
|
||||
expect(editor.setScrollTop).toHaveBeenCalledWith(0)
|
||||
})
|
||||
|
||||
it('does not fight an external host height within the height hysteresis', async () => {
|
||||
const { useMonaco } = await loadUseMonaco()
|
||||
const monaco = useMonaco({
|
||||
themes: ['vitesse-dark', 'vitesse-light'],
|
||||
languages: ['json'],
|
||||
readOnly: true,
|
||||
updateThrottleMs: 0,
|
||||
})
|
||||
|
||||
const container = {
|
||||
style: {},
|
||||
innerHTML: '',
|
||||
getBoundingClientRect: () => ({ height: 41 }),
|
||||
} as any
|
||||
await monaco.createEditor(container, '', 'json')
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
monaco.updateCode('{\n}', 'json')
|
||||
container.style.height = '41px'
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(container.style.height).toBe('41px')
|
||||
})
|
||||
|
||||
it('still syncs externally edited content before the next streamed update', async () => {
|
||||
const { useMonaco, __getLastModel } = await loadUseMonaco()
|
||||
const monaco = useMonaco({
|
||||
themes: ['vitesse-dark', 'vitesse-light'],
|
||||
languages: ['javascript'],
|
||||
readOnly: false,
|
||||
updateThrottleMs: 0,
|
||||
})
|
||||
|
||||
const container = { style: {}, innerHTML: '' } as any
|
||||
await monaco.createEditor(container, '', 'javascript')
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
const model = __getLastModel()
|
||||
model.setValue('hello')
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
monaco.updateCode('hello world', 'javascript')
|
||||
await vi.runAllTimersAsync()
|
||||
expect(monaco.getCode()).toBe('hello world')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,179 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createMockWrapper } from './mockWrapper'
|
||||
|
||||
/**
|
||||
* 测试 getCode API 功能
|
||||
*
|
||||
* 这个测试验证了在 updateCode 后调用 getCode 能够获取最新的代码内容
|
||||
*/
|
||||
describe('getCode API', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(Date.now())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should return current code from model', async () => {
|
||||
const w = createMockWrapper({ updateThrottleMs: 0 })
|
||||
|
||||
// 初始代码为空
|
||||
expect(w.model.getValue()).toBe('')
|
||||
|
||||
// 更新代码
|
||||
w.updateCode('console.log("hello")')
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
// 验证可以获取最新代码
|
||||
expect(w.model.getValue()).toBe('console.log("hello")')
|
||||
})
|
||||
|
||||
it('should return updated code after multiple updates', async () => {
|
||||
const w = createMockWrapper({ updateThrottleMs: 0 })
|
||||
|
||||
// 第一次更新
|
||||
w.updateCode('const a = 1')
|
||||
await vi.runAllTimersAsync()
|
||||
expect(w.model.getValue()).toBe('const a = 1')
|
||||
|
||||
// 第二次更新
|
||||
w.updateCode('const a = 2')
|
||||
await vi.runAllTimersAsync()
|
||||
expect(w.model.getValue()).toBe('const a = 2')
|
||||
|
||||
// 第三次更新
|
||||
w.updateCode('const a = 3')
|
||||
await vi.runAllTimersAsync()
|
||||
expect(w.model.getValue()).toBe('const a = 3')
|
||||
})
|
||||
|
||||
it('should return appended code correctly', async () => {
|
||||
const w = createMockWrapper({ updateThrottleMs: 0 })
|
||||
|
||||
// 初始代码
|
||||
w.updateCode('const a = 1')
|
||||
await vi.runAllTimersAsync()
|
||||
expect(w.model.getValue()).toBe('const a = 1')
|
||||
|
||||
// 追加代码(流式场景)
|
||||
w.updateCode('const a = 1\n')
|
||||
await vi.runAllTimersAsync()
|
||||
expect(w.model.getValue()).toBe('const a = 1\n')
|
||||
|
||||
w.updateCode('const a = 1\nconst b = 2')
|
||||
await vi.runAllTimersAsync()
|
||||
expect(w.model.getValue()).toBe('const a = 1\nconst b = 2')
|
||||
})
|
||||
|
||||
it('should handle streaming updates and return latest code', async () => {
|
||||
const w = createMockWrapper({ updateThrottleMs: 0 })
|
||||
|
||||
// 模拟流式更新场景
|
||||
w.updateCode('c')
|
||||
await vi.runAllTimersAsync()
|
||||
expect(w.model.getValue()).toBe('c')
|
||||
|
||||
w.updateCode('co')
|
||||
await vi.runAllTimersAsync()
|
||||
expect(w.model.getValue()).toBe('co')
|
||||
|
||||
w.updateCode('con')
|
||||
await vi.runAllTimersAsync()
|
||||
expect(w.model.getValue()).toBe('con')
|
||||
|
||||
w.updateCode('cons')
|
||||
await vi.runAllTimersAsync()
|
||||
expect(w.model.getValue()).toBe('cons')
|
||||
|
||||
w.updateCode('const')
|
||||
await vi.runAllTimersAsync()
|
||||
expect(w.model.getValue()).toBe('const')
|
||||
})
|
||||
|
||||
it('should return latest code with rapid updates', async () => {
|
||||
const w = createMockWrapper({ updateThrottleMs: 0 })
|
||||
|
||||
// 快速连续更新
|
||||
w.updateCode('a')
|
||||
w.updateCode('ab')
|
||||
w.updateCode('abc')
|
||||
w.updateCode('abcd')
|
||||
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
// 应该得到最后一次更新的值
|
||||
expect(w.model.getValue()).toBe('abcd')
|
||||
})
|
||||
|
||||
it('should return code with throttling enabled', async () => {
|
||||
const w = createMockWrapper({ updateThrottleMs: 50 })
|
||||
|
||||
w.updateCode('initial')
|
||||
await vi.runAllTimersAsync()
|
||||
expect(w.model.getValue()).toBe('initial')
|
||||
|
||||
// 快速更新
|
||||
w.updateCode('updated')
|
||||
|
||||
// 在节流期内,代码还未更新
|
||||
expect(w.model.getValue()).toBe('initial')
|
||||
|
||||
// 等待节流时间过去
|
||||
vi.advanceTimersByTime(60)
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
// 现在应该更新了
|
||||
expect(w.model.getValue()).toBe('updated')
|
||||
})
|
||||
|
||||
it('should return empty string for empty updates', async () => {
|
||||
const w = createMockWrapper({ updateThrottleMs: 0 })
|
||||
|
||||
w.updateCode('')
|
||||
await vi.runAllTimersAsync()
|
||||
expect(w.model.getValue()).toBe('')
|
||||
|
||||
w.updateCode('some code')
|
||||
await vi.runAllTimersAsync()
|
||||
expect(w.model.getValue()).toBe('some code')
|
||||
|
||||
// 清空代码
|
||||
w.updateCode('')
|
||||
await vi.runAllTimersAsync()
|
||||
expect(w.model.getValue()).toBe('')
|
||||
})
|
||||
|
||||
it('should handle complex code with special characters', async () => {
|
||||
const w = createMockWrapper({ updateThrottleMs: 0 })
|
||||
|
||||
const complexCode = `function test() {
|
||||
const str = "Hello, World!";
|
||||
const num = 42;
|
||||
return \`\${str} \${num}\`;
|
||||
}`
|
||||
|
||||
w.updateCode(complexCode)
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(w.model.getValue()).toBe(complexCode)
|
||||
})
|
||||
|
||||
it('should return code after incremental append updates', async () => {
|
||||
const w = createMockWrapper({ updateThrottleMs: 0 })
|
||||
|
||||
// 模拟用户输入或流式生成的场景
|
||||
let code = ''
|
||||
const increments = ['const ', 'x ', '= ', '10', ';']
|
||||
|
||||
for (const increment of increments) {
|
||||
code += increment
|
||||
w.updateCode(code)
|
||||
await vi.runAllTimersAsync()
|
||||
expect(w.model.getValue()).toBe(code)
|
||||
}
|
||||
|
||||
expect(w.model.getValue()).toBe('const x = 10;')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createHeightManager } from '../src/utils/height'
|
||||
|
||||
// polyfill requestAnimationFrame for test environment
|
||||
if (typeof (globalThis as any).requestAnimationFrame === 'undefined') {
|
||||
;(globalThis as any).requestAnimationFrame = (cb: FrameRequestCallback) => setTimeout(() => cb(Date.now()), 0)
|
||||
;(globalThis as any).cancelAnimationFrame = (id: number) => clearTimeout(id)
|
||||
}
|
||||
|
||||
describe('createHeightManager', () => {
|
||||
it('applies computed height and suppresses during application', async () => {
|
||||
// create a fake container
|
||||
const container = { style: { height: '' } } as unknown as HTMLElement
|
||||
let computeCalls = 0
|
||||
const cm = () => {
|
||||
computeCalls++
|
||||
return 100 + computeCalls
|
||||
}
|
||||
const m = createHeightManager(container, cm)
|
||||
// first update should set height
|
||||
m.update()
|
||||
// wait a tick for RAF via requestAnimationFrame
|
||||
await new Promise(res => setTimeout(res, 0))
|
||||
expect(container.style.height).toMatch(/px$/)
|
||||
// second update with same computed value should not change
|
||||
m.update()
|
||||
await new Promise(res => setTimeout(res, 0))
|
||||
expect(container.style.height).not.toBe('')
|
||||
m.dispose()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('should', () => {
|
||||
it('exported', () => {
|
||||
expect(1).toEqual(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,179 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { DiffEditorManager } from '../src/core/DiffEditorManager'
|
||||
import { EditorManager } from '../src/core/EditorManager'
|
||||
|
||||
vi.mock('../src/monaco-shim', () => {
|
||||
const editor = {
|
||||
EditorOption: { lineHeight: 16, readOnly: 0 },
|
||||
ScrollType: { Smooth: 1 },
|
||||
setModelLanguage: vi.fn(),
|
||||
}
|
||||
class Range {
|
||||
constructor(
|
||||
public startLineNumber: number,
|
||||
public startColumn: number,
|
||||
public endLineNumber: number,
|
||||
public endColumn: number,
|
||||
) {}
|
||||
}
|
||||
return {
|
||||
default: { editor, Range, languages: { getLanguages: () => [] } },
|
||||
editor,
|
||||
Range,
|
||||
languages: { getLanguages: () => [] },
|
||||
}
|
||||
})
|
||||
|
||||
function createStubModel(initial = '') {
|
||||
let value = initial
|
||||
const setValue = vi.fn((next: string) => {
|
||||
value = next
|
||||
})
|
||||
const applyEdits = vi.fn()
|
||||
|
||||
return {
|
||||
getValue() {
|
||||
return value
|
||||
},
|
||||
setValue,
|
||||
applyEdits,
|
||||
getLineCount() {
|
||||
return Math.max(1, value.split('\n').length)
|
||||
},
|
||||
getLanguageId() {
|
||||
return 'plaintext'
|
||||
},
|
||||
getLineMaxColumn(line: number) {
|
||||
const lines = value.split('\n')
|
||||
const index = Math.max(0, Math.min(line - 1, lines.length - 1))
|
||||
return (lines[index]?.length ?? 0) + 1
|
||||
},
|
||||
getPositionAt(offset: number) {
|
||||
return {
|
||||
lineNumber: 1,
|
||||
column: Math.max(1, offset + 1),
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createStubCodeEditor(model: ReturnType<typeof createStubModel>) {
|
||||
return {
|
||||
getModel() {
|
||||
return model
|
||||
},
|
||||
getOption() {
|
||||
return false
|
||||
},
|
||||
executeEdits: vi.fn(),
|
||||
getScrollHeight() {
|
||||
return 0
|
||||
},
|
||||
getScrollTop() {
|
||||
return 0
|
||||
},
|
||||
getLayoutInfo() {
|
||||
return { height: 0 }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('minimal edit option overrides', () => {
|
||||
it('EditorManager respects minimalEditMaxChars from options', () => {
|
||||
const model = createStubModel('abc')
|
||||
const manager = new EditorManager(
|
||||
{ readOnly: false, minimalEditMaxChars: 1 } as any,
|
||||
600,
|
||||
'600px',
|
||||
true,
|
||||
true,
|
||||
32,
|
||||
2,
|
||||
75,
|
||||
0,
|
||||
)
|
||||
|
||||
;(manager as any).editorView = createStubCodeEditor(model)
|
||||
|
||||
;(manager as any).applyMinimalEdit('abc', 'abXYc')
|
||||
|
||||
expect(model.setValue).toHaveBeenCalledWith('abXYc')
|
||||
expect((manager as any).editorView.executeEdits).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('EditorManager respects minimalEditMaxChangeRatio from options', () => {
|
||||
const model = createStubModel('abcd')
|
||||
const manager = new EditorManager(
|
||||
{
|
||||
readOnly: false,
|
||||
minimalEditMaxChars: 1000,
|
||||
minimalEditMaxChangeRatio: 0.1,
|
||||
} as any,
|
||||
600,
|
||||
'600px',
|
||||
true,
|
||||
true,
|
||||
32,
|
||||
2,
|
||||
75,
|
||||
0,
|
||||
)
|
||||
|
||||
;(manager as any).editorView = createStubCodeEditor(model)
|
||||
|
||||
;(manager as any).applyMinimalEdit('abcd', 'a1234567')
|
||||
|
||||
expect(model.setValue).toHaveBeenCalledWith('a1234567')
|
||||
expect((manager as any).editorView.executeEdits).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('DiffEditorManager respects minimalEditMaxChars from options', () => {
|
||||
const model = createStubModel('abc')
|
||||
const manager = new DiffEditorManager(
|
||||
{ readOnly: true, minimalEditMaxChars: 1 } as any,
|
||||
600,
|
||||
'600px',
|
||||
true,
|
||||
true,
|
||||
32,
|
||||
2,
|
||||
true,
|
||||
75,
|
||||
0,
|
||||
)
|
||||
|
||||
;(manager as any).modifiedModel = model
|
||||
|
||||
;(manager as any).applyMinimalEditToModel(model, 'abc', 'abXYc')
|
||||
|
||||
expect(model.setValue).toHaveBeenCalledWith('abXYc')
|
||||
expect(model.applyEdits).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('DiffEditorManager respects minimalEditMaxChangeRatio from options', () => {
|
||||
const model = createStubModel('abcd')
|
||||
const manager = new DiffEditorManager(
|
||||
{
|
||||
readOnly: true,
|
||||
minimalEditMaxChars: 1000,
|
||||
minimalEditMaxChangeRatio: 0.1,
|
||||
} as any,
|
||||
600,
|
||||
'600px',
|
||||
true,
|
||||
true,
|
||||
32,
|
||||
2,
|
||||
true,
|
||||
75,
|
||||
0,
|
||||
)
|
||||
|
||||
;(manager as any).modifiedModel = model
|
||||
|
||||
;(manager as any).applyMinimalEditToModel(model, 'abcd', 'a1234567')
|
||||
|
||||
expect(model.setValue).toHaveBeenCalledWith('a1234567')
|
||||
expect(model.applyEdits).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { computeMinimalEdit } from '../src/minimalEdit'
|
||||
|
||||
describe('computeMinimalEdit', () => {
|
||||
it('no-op when equal', () => {
|
||||
expect(computeMinimalEdit('abc', 'abc')).toBeNull()
|
||||
})
|
||||
|
||||
it('append only', () => {
|
||||
const res = computeMinimalEdit('abc', 'abcdef')!
|
||||
expect(res.start).toBe(3)
|
||||
expect(res.replaceText).toBe('def')
|
||||
})
|
||||
|
||||
it('prepend only', () => {
|
||||
const res = computeMinimalEdit('world', 'hello world')!
|
||||
expect(res.start).toBe(0)
|
||||
expect(res.replaceText).toBe('hello ')
|
||||
})
|
||||
|
||||
it('middle change', () => {
|
||||
const res = computeMinimalEdit('aXc', 'aYc')!
|
||||
expect(res.start).toBe(1)
|
||||
expect(res.replaceText).toBe('Y')
|
||||
})
|
||||
|
||||
it('replace all (prev empty)', () => {
|
||||
const res = computeMinimalEdit('', 'foo')!
|
||||
expect(res.start).toBe(0)
|
||||
expect(res.replaceText).toBe('foo')
|
||||
})
|
||||
|
||||
it('replace all (next empty)', () => {
|
||||
const res = computeMinimalEdit('bar', '')!
|
||||
expect(res.start).toBe(0)
|
||||
expect(res.replaceText).toBe('')
|
||||
})
|
||||
|
||||
it('emoji (UTF-16) consistency', () => {
|
||||
// 😀 is surrogate pair (U+1F600)
|
||||
const prev = 'hi😀'
|
||||
const next = 'hi😀 there'
|
||||
const res = computeMinimalEdit(prev, next)!
|
||||
// start should be at prev.length (4 UTF-16 code units: h i surrogate pair)
|
||||
expect(res.start).toBe(prev.length)
|
||||
expect(res.replaceText).toBe(' there')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
export function createMockWrapper(opts: { updateThrottleMs?: number } = {}) {
|
||||
const appendBuffer: string[] = []
|
||||
let appendBufferScheduled = false
|
||||
let appendImmediate: ReturnType<typeof setImmediate> | null = null
|
||||
let pendingUpdate: { code: string } | null = null
|
||||
let lastKnownCode = ''
|
||||
let throttleMs = opts.updateThrottleMs ?? 50
|
||||
let lastFlushTime = 0
|
||||
let throttleTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const model = {
|
||||
value: '',
|
||||
getValue() { return this.value },
|
||||
setValue(v: string) { this.value = v },
|
||||
getLineCount() { return this.value.split('\n').length },
|
||||
getLanguageId() { return 'javascript' },
|
||||
}
|
||||
|
||||
function clearBufferedAppends() {
|
||||
appendBuffer.length = 0
|
||||
appendBufferScheduled = false
|
||||
if (appendImmediate != null) {
|
||||
clearImmediate(appendImmediate)
|
||||
appendImmediate = null
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleAppendFlush() {
|
||||
appendBufferScheduled = true
|
||||
appendImmediate = setImmediate(() => {
|
||||
appendImmediate = null
|
||||
const text = appendBuffer.join('')
|
||||
appendBuffer.length = 0
|
||||
appendBufferScheduled = false
|
||||
model.value += text
|
||||
})
|
||||
}
|
||||
|
||||
function flushPendingUpdate() {
|
||||
lastFlushTime = Date.now()
|
||||
if (!pendingUpdate)
|
||||
return
|
||||
const { code } = pendingUpdate
|
||||
pendingUpdate = null
|
||||
let prev: string
|
||||
if (appendBuffer.length > 0) {
|
||||
clearBufferedAppends()
|
||||
prev = model.getValue()
|
||||
lastKnownCode = prev
|
||||
}
|
||||
else {
|
||||
prev = lastKnownCode || model.getValue()
|
||||
if (!lastKnownCode)
|
||||
lastKnownCode = prev
|
||||
}
|
||||
if (prev === code)
|
||||
return
|
||||
if (code.startsWith(prev)) {
|
||||
const suffix = code.slice(prev.length)
|
||||
if (suffix) {
|
||||
appendBuffer.push(suffix)
|
||||
// schedule append flush on next tick
|
||||
if (!appendBufferScheduled) {
|
||||
scheduleAppendFlush()
|
||||
}
|
||||
}
|
||||
lastKnownCode = code
|
||||
return
|
||||
}
|
||||
model.setValue(code)
|
||||
lastKnownCode = code
|
||||
}
|
||||
|
||||
function updateCode(code: string) {
|
||||
pendingUpdate = { code }
|
||||
// emulate raf via setImmediate
|
||||
setImmediate(() => {
|
||||
if (!throttleMs) { flushPendingUpdate(); return }
|
||||
const now = Date.now()
|
||||
const since = now - lastFlushTime
|
||||
if (since >= throttleMs) { flushPendingUpdate(); return }
|
||||
if (throttleTimer != null)
|
||||
return
|
||||
const wait = throttleMs - since
|
||||
throttleTimer = setTimeout(() => { throttleTimer = null; setImmediate(flushPendingUpdate) }, wait)
|
||||
})
|
||||
}
|
||||
|
||||
function setThrottleMs(ms: number) {
|
||||
throttleMs = ms
|
||||
}
|
||||
|
||||
function getThrottleMs() {
|
||||
return throttleMs
|
||||
}
|
||||
|
||||
return { updateCode, model, setThrottleMs, getThrottleMs }
|
||||
}
|
||||
|
||||
export type MockWrapper = ReturnType<typeof createMockWrapper>
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createRafScheduler } from '../src/utils/raf'
|
||||
|
||||
describe('createRafScheduler', () => {
|
||||
it('schedules and cancels using global time source', async () => {
|
||||
const calls: Array<{ cb: FrameRequestCallback, id: number }> = []
|
||||
let nextId = 1
|
||||
|
||||
const origRaf = (globalThis as any).requestAnimationFrame
|
||||
const origCancel = (globalThis as any).cancelAnimationFrame
|
||||
|
||||
;(globalThis as any).requestAnimationFrame = (cb: FrameRequestCallback) => {
|
||||
const id = nextId++
|
||||
calls.push({ cb, id })
|
||||
return id
|
||||
}
|
||||
;(globalThis as any).cancelAnimationFrame = (id: number) => {
|
||||
const idx = calls.findIndex(c => c.id === id)
|
||||
if (idx !== -1)
|
||||
calls.splice(idx, 1)
|
||||
}
|
||||
|
||||
try {
|
||||
const s = createRafScheduler()
|
||||
let fired = false
|
||||
s.schedule('update', () => {
|
||||
fired = true
|
||||
})
|
||||
// find scheduled call and invoke
|
||||
expect(calls.length).toBe(1)
|
||||
const call = calls[0]
|
||||
// invoking should call cb
|
||||
call.cb(123)
|
||||
// simulate that the time source would have removed the entry when executed
|
||||
calls.splice(0, 1)
|
||||
expect(fired).toBe(true)
|
||||
|
||||
// schedule and then cancel
|
||||
s.schedule('diff', () => {
|
||||
throw new Error('should not run')
|
||||
})
|
||||
expect(calls.length).toBe(1)
|
||||
s.cancel('diff')
|
||||
// cancelled
|
||||
expect(calls.length).toBe(0)
|
||||
}
|
||||
finally {
|
||||
;(globalThis as any).requestAnimationFrame = origRaf
|
||||
;(globalThis as any).cancelAnimationFrame = origCancel
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { registerMonacoThemes } from '../src/utils/registerMonacoThemes'
|
||||
|
||||
// Mock the module under test to avoid resolving heavy deps (monaco/shiki) during test bundling
|
||||
vi.mock('../src/utils/registerMonacoThemes', () => {
|
||||
return {
|
||||
registerMonacoThemes: async () => ({
|
||||
setTheme: async () => undefined,
|
||||
codeToHtml: (code: string) => `<pre>${code}</pre>`,
|
||||
}),
|
||||
// setHighlighterTheme removed; expose a mock highlighter instead
|
||||
}
|
||||
})
|
||||
|
||||
describe('registerMonacoThemes (API)', () => {
|
||||
it('exports functions and they are callable', async () => {
|
||||
const themes = ['vitesse-dark']
|
||||
const langs = ['javascript']
|
||||
|
||||
const highlighter = await registerMonacoThemes(themes as any, langs)
|
||||
expect(highlighter).toBeTruthy()
|
||||
expect(typeof highlighter.codeToHtml).toBe('function')
|
||||
|
||||
// previously tested setHighlighterTheme; removed in refactor — ensure highlighter is usable
|
||||
expect(typeof (highlighter as any).setTheme === 'function' || typeof (highlighter as any).codeToHtml === 'function').toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
describe('registerMonacoThemes', () => {
|
||||
it('re-registers when themes array is mutated in place', async () => {
|
||||
vi.resetModules()
|
||||
|
||||
const loadTheme = vi.fn(async () => undefined)
|
||||
const createHighlighter = vi.fn(async () => ({ loadTheme }))
|
||||
vi.doMock('shiki', () => ({ createHighlighter }))
|
||||
vi.doMock('@shikijs/monaco', () => ({ shikiToMonaco: vi.fn() }))
|
||||
vi.doMock('../src/monaco-shim', () => {
|
||||
const editor = { defineTheme: vi.fn(), setTheme: vi.fn(), create: vi.fn() }
|
||||
const languages = { getLanguages: () => [], register: vi.fn(), setTokensProvider: vi.fn() }
|
||||
return { default: { editor, languages }, editor, languages, Range: class {} }
|
||||
})
|
||||
|
||||
const { registerMonacoThemes } = await import('../src/utils/registerMonacoThemes')
|
||||
|
||||
const themes: any[] = ['vitesse-dark', 'vitesse-light']
|
||||
const langs = ['javascript']
|
||||
|
||||
await registerMonacoThemes(themes, langs)
|
||||
themes.push('andromeeda')
|
||||
await registerMonacoThemes(themes, langs)
|
||||
|
||||
// Shared Monaco highlighter is created once; additional themes are loaded incrementally.
|
||||
expect(createHighlighter).toHaveBeenCalledTimes(1)
|
||||
expect(loadTheme).toHaveBeenCalledWith('andromeeda')
|
||||
})
|
||||
|
||||
it('fully resets shared monaco highlighter state when clearing the cache', async () => {
|
||||
vi.resetModules()
|
||||
|
||||
const createHighlighter = vi.fn(async () => ({ loadTheme: vi.fn(async () => undefined) }))
|
||||
vi.doMock('shiki', () => ({ createHighlighter }))
|
||||
vi.doMock('@shikijs/monaco', () => ({ shikiToMonaco: vi.fn() }))
|
||||
vi.doMock('../src/monaco-shim', () => {
|
||||
const editor = { defineTheme: vi.fn(), setTheme: vi.fn(), create: vi.fn() }
|
||||
const languages = { getLanguages: () => [], register: vi.fn(), setTokensProvider: vi.fn() }
|
||||
return { default: { editor, languages }, editor, languages, Range: class {} }
|
||||
})
|
||||
|
||||
const {
|
||||
clearHighlighterCache,
|
||||
registerMonacoThemes,
|
||||
} = await import('../src/utils/registerMonacoThemes')
|
||||
|
||||
await registerMonacoThemes(['vitesse-dark', 'vitesse-light'], ['javascript'])
|
||||
clearHighlighterCache()
|
||||
await registerMonacoThemes(['vitesse-dark', 'vitesse-light'], ['javascript'])
|
||||
|
||||
expect(createHighlighter).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createScrollWatcherForEditor } from '../src/utils/scroll'
|
||||
|
||||
describe('createScrollWatcherForEditor', () => {
|
||||
it('calls onPause when delta < 0 and onMaybeResume otherwise', () => {
|
||||
let paused = false
|
||||
let resumed = false
|
||||
let last = 50
|
||||
const editor = (() => {
|
||||
let handler: ((e: any) => void) | null = null
|
||||
return {
|
||||
getScrollTop() { return last },
|
||||
onDidScrollChange(cb: (e: any) => void) {
|
||||
handler = cb
|
||||
return {
|
||||
dispose() {
|
||||
handler = null
|
||||
},
|
||||
}
|
||||
},
|
||||
// helper to simulate
|
||||
__emit(e: any) { handler?.(e) },
|
||||
}
|
||||
})()
|
||||
const disp = createScrollWatcherForEditor(editor as any, {
|
||||
onPause: () => { paused = true },
|
||||
onMaybeResume: () => { resumed = true },
|
||||
getLast: () => last,
|
||||
setLast: (v: number) => { last = v },
|
||||
})
|
||||
// simulate up scroll (delta < 0)
|
||||
editor.__emit({ scrollTop: 40 })
|
||||
expect(paused).toBe(true)
|
||||
// simulate down scroll
|
||||
editor.__emit({ scrollTop: 100 })
|
||||
expect(resumed).toBe(true)
|
||||
disp?.dispose?.()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Mock the local monaco shim so tests don't try to resolve the real
|
||||
// 'monaco-editor' package entry via vite.
|
||||
vi.mock('../src/monaco-shim', () => {
|
||||
const setTheme = vi.fn()
|
||||
const setModelLanguage = vi.fn()
|
||||
const editor = {
|
||||
setTheme,
|
||||
setModelLanguage,
|
||||
IStandaloneCodeEditor: class {},
|
||||
EditorOption: { lineHeight: 16 },
|
||||
}
|
||||
const languages = {
|
||||
getLanguages: () => [],
|
||||
register: () => {},
|
||||
}
|
||||
const Range = class {}
|
||||
return { default: { editor, languages, Range }, editor, languages, Range }
|
||||
})
|
||||
|
||||
describe('setTheme behavior', () => {
|
||||
beforeEach(() => {
|
||||
// src/index.ts keeps theme state at module scope; isolate each test.
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('should not call monaco.editor.setTheme when theme is already applied', async () => {
|
||||
const monaco = await import('../src/monaco-shim')
|
||||
const mod = await import('../src/index')
|
||||
const { setTheme } = mod.useMonaco({ themes: ['vitesse-dark', 'vitesse-light'] })
|
||||
// apply once
|
||||
await setTheme('vitesse-dark')
|
||||
// applying same theme again without force should be no-op
|
||||
await setTheme('vitesse-dark')
|
||||
expect(monaco.editor.setTheme).toHaveBeenCalled()
|
||||
// should only have been called once by the first application
|
||||
expect((monaco.editor.setTheme as any).mock.calls.length).toBe(1)
|
||||
})
|
||||
|
||||
it('should call monaco.editor.setTheme when force=true even if same theme', async () => {
|
||||
const monaco = await import('../src/monaco-shim')
|
||||
const mod = await import('../src/index')
|
||||
const { setTheme } = mod.useMonaco({ themes: ['vitesse-dark', 'vitesse-light'] })
|
||||
await setTheme('vitesse-dark')
|
||||
// force reapplication
|
||||
await setTheme('vitesse-dark', true)
|
||||
expect((monaco.editor.setTheme as any).mock.calls.length).toBe(2)
|
||||
})
|
||||
|
||||
it('should not no-op when another instance changed the global theme', async () => {
|
||||
const monaco = await import('../src/monaco-shim')
|
||||
const mod = await import('../src/index')
|
||||
|
||||
const a = mod.useMonaco({ themes: ['vitesse-dark', 'vitesse-light'] })
|
||||
const b = mod.useMonaco({ themes: ['vitesse-dark', 'vitesse-light'] })
|
||||
|
||||
await a.setTheme('vitesse-dark')
|
||||
await b.setTheme('vitesse-light')
|
||||
await a.setTheme('vitesse-dark')
|
||||
|
||||
expect((monaco.editor.setTheme as any).mock.calls.length).toBe(3)
|
||||
})
|
||||
|
||||
it('auto-loads missing shiki theme when highlighter is cached', async () => {
|
||||
vi.resetModules()
|
||||
|
||||
// Mock shiki/monaco registration layer: always returns the same highlighter
|
||||
// instance (simulating singleton caching), and requires explicit loadTheme
|
||||
// before setTheme will succeed.
|
||||
const loadedThemes = new Set<string>(['vitesse-dark', 'vitesse-light'])
|
||||
const highlighter = {
|
||||
loadTheme: vi.fn(async (t: string) => {
|
||||
loadedThemes.add(t)
|
||||
}),
|
||||
setTheme: vi.fn(async (t: string) => {
|
||||
if (!loadedThemes.has(t)) {
|
||||
const err: any = new Error(`Theme ${t} not found, you may need to load it first`)
|
||||
err.name = 'ShikiError'
|
||||
throw err
|
||||
}
|
||||
}),
|
||||
codeToHtml: vi.fn(() => ''),
|
||||
}
|
||||
|
||||
vi.doMock('../src/utils/registerMonacoThemes', () => {
|
||||
return {
|
||||
clearHighlighterCache: () => {},
|
||||
getOrCreateHighlighter: async () => highlighter,
|
||||
registerMonacoThemes: async () => highlighter,
|
||||
setThemeRegisterPromise: (p: any) => p,
|
||||
}
|
||||
})
|
||||
|
||||
// re-mock monaco shim for this resetModules scope
|
||||
vi.doMock('../src/monaco-shim', () => {
|
||||
const setTheme = vi.fn()
|
||||
const setModelLanguage = vi.fn()
|
||||
const editor = {
|
||||
setTheme,
|
||||
setModelLanguage,
|
||||
IStandaloneCodeEditor: class {},
|
||||
EditorOption: { lineHeight: 16 },
|
||||
}
|
||||
const languages = {
|
||||
getLanguages: () => [],
|
||||
register: () => {},
|
||||
}
|
||||
const Range = class {}
|
||||
return { default: { editor, languages, Range }, editor, languages, Range }
|
||||
})
|
||||
|
||||
const monaco = await import('../src/monaco-shim')
|
||||
const mod = await import('../src/index')
|
||||
const { setTheme } = mod.useMonaco({ themes: ['vitesse-dark', 'vitesse-light'] })
|
||||
|
||||
await setTheme('andromeeda')
|
||||
|
||||
expect(highlighter.loadTheme).toHaveBeenCalledWith('andromeeda')
|
||||
expect(monaco.editor.setTheme).toHaveBeenCalledWith('andromeeda')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,180 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { EditorManager } from '../src/core/EditorManager'
|
||||
import { createMockWrapper } from './mockWrapper'
|
||||
|
||||
vi.mock('../src/monaco-shim', () => {
|
||||
const editor = {
|
||||
EditorOption: { lineHeight: 16, readOnly: 0 },
|
||||
ScrollType: { Smooth: 1 },
|
||||
setModelLanguage: vi.fn(),
|
||||
}
|
||||
class Range {
|
||||
constructor(
|
||||
public lineNumber: number,
|
||||
public column: number,
|
||||
public endLineNumber: number,
|
||||
public endColumn: number,
|
||||
) {}
|
||||
}
|
||||
return {
|
||||
default: { editor, Range, languages: { getLanguages: () => [] } },
|
||||
editor,
|
||||
Range,
|
||||
languages: { getLanguages: () => [] },
|
||||
}
|
||||
})
|
||||
|
||||
function createManualScheduler() {
|
||||
const callbacks = new Map<string, FrameRequestCallback>()
|
||||
return {
|
||||
schedule: vi.fn((kind: string, cb: FrameRequestCallback) => {
|
||||
callbacks.set(kind, cb)
|
||||
}),
|
||||
cancel: vi.fn((kind: string) => {
|
||||
callbacks.delete(kind)
|
||||
}),
|
||||
run(kind: string) {
|
||||
const cb = callbacks.get(kind)
|
||||
if (!cb)
|
||||
return
|
||||
callbacks.delete(kind)
|
||||
cb(0 as unknown as DOMHighResTimeStamp)
|
||||
},
|
||||
has(kind: string) {
|
||||
return callbacks.has(kind)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createStubModel(initial = '') {
|
||||
let value = initial
|
||||
return {
|
||||
getValue() {
|
||||
return value
|
||||
},
|
||||
setValue(v: string) {
|
||||
value = v
|
||||
},
|
||||
getLineCount() {
|
||||
return Math.max(1, value.split('\n').length)
|
||||
},
|
||||
getLineMaxColumn(line: number) {
|
||||
const lines = value.split('\n')
|
||||
const idx = Math.max(0, Math.min(line - 1, lines.length - 1))
|
||||
return (lines[idx]?.length ?? 0) + 1
|
||||
},
|
||||
getLanguageId() {
|
||||
return 'plaintext'
|
||||
},
|
||||
applyEdits(edits: Array<{ text: string }>) {
|
||||
for (const edit of edits)
|
||||
value = value + edit.text
|
||||
},
|
||||
getPositionAt(offset: number) {
|
||||
return {
|
||||
lineNumber: 1,
|
||||
column: Math.max(1, offset + 1),
|
||||
}
|
||||
},
|
||||
dispose() {},
|
||||
}
|
||||
}
|
||||
|
||||
function createStubCodeEditor(model: ReturnType<typeof createStubModel>) {
|
||||
return {
|
||||
getModel() {
|
||||
return model
|
||||
},
|
||||
getOption() {
|
||||
return false
|
||||
},
|
||||
executeEdits: vi.fn((_source: string, edits: Array<{ text: string }>) => {
|
||||
for (const edit of edits)
|
||||
model.setValue(model.getValue() + edit.text)
|
||||
}),
|
||||
getScrollHeight: () => 0,
|
||||
getScrollTop: () => 0,
|
||||
getLayoutInfo: () => ({ height: 0 }),
|
||||
}
|
||||
}
|
||||
|
||||
function createEditorManagerHarness() {
|
||||
const manager = new EditorManager(
|
||||
{ readOnly: false } as any,
|
||||
600,
|
||||
'600px',
|
||||
true,
|
||||
true,
|
||||
32,
|
||||
2,
|
||||
75,
|
||||
0,
|
||||
)
|
||||
const scheduler = createManualScheduler()
|
||||
;(manager as any).rafScheduler = scheduler
|
||||
const model = createStubModel('')
|
||||
const editorView = createStubCodeEditor(model)
|
||||
;(manager as any).editorView = editorView
|
||||
;(manager as any).lastKnownCode = ''
|
||||
return { manager, scheduler, model }
|
||||
}
|
||||
|
||||
describe('buffered append races', () => {
|
||||
it('editor manager drops stale append buffers before an authoritative overwrite', () => {
|
||||
const { manager, scheduler, model } = createEditorManagerHarness()
|
||||
const overwrite = 'x'.repeat(16)
|
||||
|
||||
manager.updateCode('a', 'plaintext')
|
||||
scheduler.run('update')
|
||||
expect(scheduler.has('append')).toBe(true)
|
||||
|
||||
manager.updateCode(overwrite, 'plaintext')
|
||||
scheduler.run('update')
|
||||
scheduler.run('append')
|
||||
|
||||
expect(model.getValue()).toBe(overwrite)
|
||||
})
|
||||
|
||||
it('mock wrapper drops a buffered append before a later authoritative overwrite', () => {
|
||||
const immediates: Array<{ handle: number, fn: () => void }> = []
|
||||
let handleSeed = 1
|
||||
const overwrite = 'x'.repeat(16)
|
||||
const originalSetImmediate = globalThis.setImmediate
|
||||
const originalClearImmediate = globalThis.clearImmediate
|
||||
|
||||
globalThis.setImmediate = ((fn: (...args: any[]) => void) => {
|
||||
const handle = handleSeed++
|
||||
immediates.push({ handle, fn: () => fn() })
|
||||
return handle as unknown as ReturnType<typeof setImmediate>
|
||||
}) as typeof setImmediate
|
||||
|
||||
globalThis.clearImmediate = ((handle: ReturnType<typeof setImmediate>) => {
|
||||
const idx = immediates.findIndex(entry => entry.handle === (handle as unknown as number))
|
||||
if (idx !== -1)
|
||||
immediates.splice(idx, 1)
|
||||
}) as typeof clearImmediate
|
||||
|
||||
const runNextImmediate = () => {
|
||||
const entry = immediates.shift()
|
||||
if (entry)
|
||||
entry.fn()
|
||||
}
|
||||
|
||||
try {
|
||||
const wrapper = createMockWrapper({ updateThrottleMs: 0 })
|
||||
wrapper.updateCode('a')
|
||||
wrapper.updateCode(overwrite)
|
||||
|
||||
runNextImmediate()
|
||||
runNextImmediate()
|
||||
while (immediates.length > 0)
|
||||
runNextImmediate()
|
||||
|
||||
expect(wrapper.model.getValue()).toBe(overwrite)
|
||||
}
|
||||
finally {
|
||||
globalThis.setImmediate = originalSetImmediate
|
||||
globalThis.clearImmediate = originalClearImmediate
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createMockWrapper } from './mockWrapper'
|
||||
|
||||
describe('updateCode throttling', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
// initialize fake system time to a real timestamp so Date.now() calculations are meaningful
|
||||
vi.setSystemTime(Date.now())
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('respects updateThrottleMs and batches updates', async () => {
|
||||
const w = createMockWrapper({ updateThrottleMs: 50 })
|
||||
// send multiple updates quickly; first flush should take the latest pending value
|
||||
w.updateCode('a')
|
||||
w.updateCode('ab')
|
||||
w.updateCode('abc')
|
||||
|
||||
// run scheduled timers (setImmediate) -> this will flush once immediately
|
||||
await vi.runAllTimersAsync()
|
||||
expect(w.model.getValue()).toBe('abc')
|
||||
|
||||
// now send another update immediately; it should be throttled
|
||||
w.updateCode('abcd')
|
||||
// advance less than throttle -> should still not flush
|
||||
vi.advanceTimersByTime(20)
|
||||
expect(w.model.getValue()).toBe('abc')
|
||||
|
||||
// advance past throttle window and process timers -> now it flushes
|
||||
vi.advanceTimersByTime(60)
|
||||
await vi.runAllTimersAsync()
|
||||
expect(w.model.getValue()).toBe('abcd')
|
||||
})
|
||||
|
||||
it('allows changing throttle at runtime', async () => {
|
||||
const w = createMockWrapper({ updateThrottleMs: 100 })
|
||||
expect(w.getThrottleMs()).toBe(100)
|
||||
w.setThrottleMs(0)
|
||||
expect(w.getThrottleMs()).toBe(0)
|
||||
w.updateCode('x')
|
||||
// immediate flush when throttle==0
|
||||
await vi.runAllTimersAsync()
|
||||
expect(w.model.getValue()).toBe('x')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user