test: cover browser history writes and modal markers

This commit is contained in:
DiheChen committed 2026-11-04 21:26:34 +08:00
1 parent d5e302f83a
commit be016479ac
1 file changed
+331 -1
+331 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterMarker, afterPop, afterPush, canGoBack, canGoForward } from './session-history'
describe('历史记录的序号', () => {
@@ -27,3 +27,333 @@ describe('历史记录的序号', () => {
expect(canGoForward(marked)).toBe(false)
})
})
interface HistoryEntry {
state: unknown
url: string
}
// 和浏览器一样,后退要到之后的任务才完成:settle() 模拟那一刻的 popstate。
class FakeHistory {
entries: HistoryEntry[]
at = 0
traversals: number[] = []
failure: Error | null = null
readonly events = new EventTarget()
constructor(state: unknown) {
this.entries = [{ state, url: '/start' }]
}
get current(): HistoryEntry {
return this.entries[this.at] as HistoryEntry
}
get state(): unknown {
return this.current.state
}
get urls(): string[] {
return this.entries.map(entry => entry.url)
}
pushState(state: unknown, _title: string, url: string): void {
if (this.failure)
throw this.failure
this.entries = [...this.entries.slice(0, this.at + 1), { state, url }]
this.at += 1
}
replaceState(state: unknown, _title: string, url?: string): void {
if (this.failure)
throw this.failure
this.entries[this.at] = { state, url: url ?? this.current.url }
}
back(): void {
this.traversals.push(-1)
}
settle(): void {
const delta = this.traversals.shift()
if (delta === undefined)
throw new Error('没有等待中的后退')
this.pop(delta)
}
pop(delta: number): void {
this.at += delta
this.events.dispatchEvent(Object.assign(new Event('popstate'), { state: this.state }))
}
press(delta = -1): void {
this.pop(delta)
}
}
async function open(state: unknown = null) {
vi.resetModules()
const history = new FakeHistory(state)
vi.stubGlobal('window', {
history,
location: {
get href() {
return history.current.url
},
},
addEventListener: (type: string, listener: EventListener) => history.events.addEventListener(type, listener),
})
const module = await import('./session-history')
const log: string[] = []
const firstOpen = module.startHistory(() => log.push('notify'))
return { ...module, history, log, firstOpen }
}
afterEach(() => {
vi.unstubAllGlobals()
})
describe('打开页面', () => {
it('第一次打开时给当前记录写上序号', async () => {
const { firstOpen, history, currentEntry } = await open()
expect(firstOpen).toBe(true)
expect(history.state).toEqual({ index: 0 })
expect(currentEntry().position).toEqual({ index: 0, last: 0 })
})
it('刷新时沿用记录里的序号和标题', async () => {
const { firstOpen, history, currentEntry } = await open({ index: 3, title: '房间' })
expect(firstOpen).toBe(false)
expect(history.state).toEqual({ index: 3, title: '房间' })
expect(currentEntry()).toEqual({ position: { index: 3, last: 3 }, title: '房间' })
})
it('刷新时弹窗已经关闭,标记记录改作页面的记录', async () => {
const { firstOpen, history } = await open({ index: 2, title: '房间', modal: true })
expect(firstOpen).toBe(false)
expect(history.state).toEqual({ index: 2, title: '房间' })
})
it.each([
['字符串', 'other'],
['没有序号', {}],
['负数序号', { index: -1 }],
['小数序号', { index: 1.5 }],
])('别的脚本写入的状态(%s)当作第一次打开', async (_, state) => {
const { firstOpen, history } = await open(state)
expect(firstOpen).toBe(true)
expect(history.state).toEqual({ index: 0 })
})
})
describe('写入记录', () => {
it('新记录的序号加一,后退和前进由 popstate 同步', async () => {
const { writeEntry, currentEntry, history, log } = await open()
writeEntry('/a', false, '甲')
writeEntry('/b', false)
expect(history.urls).toEqual(['/start', '/a', '/b'])
expect(currentEntry()).toEqual({ position: { index: 2, last: 2 }, title: undefined })
history.press()
expect(currentEntry()).toEqual({ position: { index: 1, last: 2 }, title: '甲' })
expect(log).toEqual(['notify'])
})
it('替换时序号不变,不给标题就沿用原来的', async () => {
const { writeEntry, currentEntry, history } = await open({ index: 1, title: '房间' })
writeEntry('/a', true)
expect(history.state).toEqual({ index: 1, title: '房间' })
writeEntry('/b', true, '搜索')
expect(history.urls).toEqual(['/b'])
expect(currentEntry()).toEqual({ position: { index: 1, last: 1 }, title: '搜索' })
})
it('浏览器限制调用频率时地址暂时落后,序号不变,页面照常', async () => {
const { writeEntry, currentEntry, history } = await open()
history.failure = new DOMException('Too many calls', 'SecurityError')
expect(() => writeEntry('/a', false)).not.toThrow()
expect(() => writeEntry('/b', true, '甲')).not.toThrow()
expect(currentEntry()).toEqual({ position: { index: 0, last: 0 }, title: undefined })
history.failure = null
writeEntry('/c', false)
expect(currentEntry().position).toEqual({ index: 1, last: 1 })
})
it('后退到别的脚本写入的记录时,序号保持不变', async () => {
const { writeEntry, currentEntry, history, log } = await open()
writeEntry('/a', false)
writeEntry('/b', false)
history.entries[1] = { state: 'other', url: '/a' }
history.press()
expect(currentEntry().position).toEqual({ index: 2, last: 2 })
expect(log).toEqual(['notify'])
})
it('其他错误照常抛出', async () => {
const { writeEntry, history } = await open()
history.failure = new DOMException('bad', 'DataCloneError')
expect(() => writeEntry('/a', false)).toThrow('bad')
})
})
describe('弹窗', () => {
it('整个弹窗栈只占一条记录,标记不占序号', async () => {
const { registerModalHistory, currentEntry, history } = await open()
registerModalHistory(() => {})
registerModalHistory(() => {})
expect(history.entries).toHaveLength(2)
expect(history.state).toEqual({ index: 0, modal: true })
expect(currentEntry().position).toEqual({ index: 0, last: 0 })
})
it('返回键只关最上层的弹窗,下面还有弹窗时重新压入标记', async () => {
const { registerModalHistory, history, log } = await open()
registerModalHistory(() => log.push('close 1'))
registerModalHistory(() => log.push('close 2'))
history.press()
expect(log).toEqual(['notify', 'close 2'])
expect(history.state).toEqual({ index: 0, modal: true })
history.press()
expect(log).toEqual(['notify', 'close 2', 'notify', 'close 1'])
expect(history.state).toEqual({ index: 0 })
})
it('代码关闭弹窗时退掉标记,关闭之前再调用也不重复后退', async () => {
const { registerModalHistory, history, log } = await open()
const close = registerModalHistory(() => log.push('close'))
close()
close()
expect(history.traversals).toEqual([-1])
history.settle()
expect(history.state).toEqual({ index: 0 })
expect(log).toEqual(['notify'])
})
it('重复关闭同一个弹窗时不影响其他弹窗', async () => {
const { registerModalHistory, history, log } = await open()
const close = registerModalHistory(() => log.push('close 1'))
registerModalHistory(() => log.push('close 2'))
close()
close()
history.press()
expect(log).toEqual(['notify', 'close 2'])
})
it('正在退掉标记时关闭弹窗,不再后退一次', async () => {
const { registerModalHistory, writeEntry, history } = await open()
const close = registerModalHistory(() => {})
writeEntry('/filtered', true)
close()
expect(history.traversals).toEqual([-1])
})
it('后退之后打开弹窗,前面的记录就没有了', async () => {
const { registerModalHistory, writeEntry, currentEntry, history } = await open()
writeEntry('/a', false)
history.press()
expect(currentEntry().position).toEqual({ index: 0, last: 1 })
registerModalHistory(() => {})
expect(currentEntry().position).toEqual({ index: 0, last: 0 })
})
it('下面还有弹窗时,关闭上层的弹窗不后退', async () => {
const { registerModalHistory, history } = await open()
registerModalHistory(() => {})
const close = registerModalHistory(() => {})
close()
expect(history.traversals).toEqual([])
})
it('退掉标记期间的写入排队,落在页面的记录上', async () => {
const { registerModalHistory, writeEntry, currentEntry, history } = await open()
const close = registerModalHistory(() => {})
close()
writeEntry('/next', false, '下一页')
expect(history.urls).toEqual(['/start', '/start'])
history.settle()
expect(history.urls).toEqual(['/start', '/next'])
expect(currentEntry()).toEqual({ position: { index: 1, last: 1 }, title: '下一页' })
})
it('弹窗开着时替换地址:先退到页面的记录再替换,然后压回标记', async () => {
const { registerModalHistory, writeEntry, history, log } = await open()
registerModalHistory(() => log.push('close'))
writeEntry('/filtered', true)
writeEntry('/filtered-more', true)
expect(history.traversals).toEqual([-1])
history.settle()
expect(history.urls).toEqual(['/filtered-more', '/filtered-more'])
expect(history.entries.map(entry => entry.state)).toEqual([{ index: 0 }, { index: 0, modal: true }])
expect(log).toEqual(['notify'])
})
it('在弹窗里换页面时,新页面的记录顶替标记', async () => {
const { registerModalHistory, writeEntry, currentEntry, history } = await open()
registerModalHistory(() => {})
writeEntry('/room', false)
expect(history.urls).toEqual(['/start', '/room'])
expect(history.state).toEqual({ index: 1 })
expect(currentEntry().position).toEqual({ index: 1, last: 1 })
})
it('前进到已经关闭的弹窗留下的标记时退回页面', async () => {
const { registerModalHistory, history, log } = await open()
registerModalHistory(() => {})()
history.settle()
log.length = 0
history.press(1)
expect(history.traversals).toEqual([-1])
expect(log).toEqual([])
history.settle()
expect(history.state).toEqual({ index: 0 })
expect(log).toEqual(['notify'])
})
it('写不进标记时弹窗照常打开,返回键关掉它的同时后退一页', async () => {
const { registerModalHistory, writeEntry, history, log } = await open()
writeEntry('/a', false)
history.failure = new DOMException('Too many calls', 'SecurityError')
registerModalHistory(() => log.push('close'))
history.failure = null
expect(history.entries).toHaveLength(2)
history.press()
expect(history.urls[history.at]).toBe('/start')
expect(log).toEqual(['notify', 'close'])
})
it('页面先跟上记录再关弹窗:关弹窗时可能接着导航', async () => {
const { registerModalHistory, writeEntry, history, log } = await open()
registerModalHistory(() => {
log.push('close')
writeEntry('/after-close', false)
})
history.press()
expect(log).toEqual(['notify', 'close'])
expect(history.urls).toEqual(['/start', '/after-close'])
})
})
describe('后退一页', () => {
it('没有弹窗时直接后退', async () => {
const { goBack, writeEntry, history, log } = await open()
writeEntry('/a', false)
goBack()
expect(history.traversals).toEqual([-1])
history.settle()
expect(log).toEqual(['notify'])
})
it('标记压在页面上时只退到页面的记录,已经在退时不再后退', async () => {
const { goBack, registerModalHistory, history } = await open()
registerModalHistory(() => {})
goBack()
goBack()
expect(history.traversals).toEqual([-1])
})
})