303 lines
10 KiB
TypeScript
303 lines
10 KiB
TypeScript
/**
|
||
* 20-notes: Notes Vue 组件化测试
|
||
*
|
||
* 覆盖功能点:
|
||
* 初始化 — 默认笔记、首条选中、侧边栏/编辑区结构
|
||
* 列表 — titleOf/previewOf、按更新时间排序
|
||
* 搜索 — 关键字过滤、无结果状态、空搜索恢复全量
|
||
* 选择 — 点击切换、编辑器同步内容、日期显示
|
||
* 新建 — 新增空笔记、自动选中、持久化
|
||
* 编辑 — 输入更新 body/updated、自动保存
|
||
* 删除 — 确认后移除、无笔记时编辑器禁用
|
||
* win.appState — add/remove/cur 暴露给菜单
|
||
* 卸载 — DOM 清理
|
||
*/
|
||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||
import { h, render, nextTick } from 'vue'
|
||
import NotesComponent from '../../src/apps/notes/Notes.vue'
|
||
import { store } from '../../src/composables/useStore'
|
||
import { setupDOM } from '../helpers'
|
||
|
||
function makeMockWin() {
|
||
const el = document.createElement('div'); el.className = 'window'
|
||
document.getElementById('window-layer')?.appendChild(el)
|
||
const body = document.createElement('div'); body.className = 'win-body'
|
||
el.appendChild(body)
|
||
return { id: 'nt', appId: 'notes', el, body, timers: [] as any[], data: {} }
|
||
}
|
||
|
||
/** 选中笔记 */
|
||
function clickNote(body: HTMLElement, idx: number) {
|
||
const rows = body.querySelectorAll('.note-row')
|
||
if (rows[idx]) (rows[idx] as HTMLElement).click()
|
||
}
|
||
|
||
/** 获取编辑器 */
|
||
function editor(body: HTMLElement) { return body.querySelector('.notes-editor') as HTMLTextAreaElement }
|
||
|
||
/** 获取笔记标题列表 */
|
||
function titles(body: HTMLElement): string[] {
|
||
return [...body.querySelectorAll('.note-title')].map(e => e.textContent || '')
|
||
}
|
||
|
||
/** 模拟编辑器输入 */
|
||
function edit(body: HTMLElement, text: string) {
|
||
const ta = editor(body)
|
||
ta.value = text
|
||
ta.dispatchEvent(new Event('input', { bubbles: true }))
|
||
}
|
||
|
||
describe('20-notes — Notes Vue 组件化', () => {
|
||
let win: any
|
||
beforeEach(() => {
|
||
localStorage.clear()
|
||
store.set('notes', null)
|
||
setupDOM()
|
||
win = makeMockWin()
|
||
})
|
||
afterEach(() => { render(null, win.body); win.el.remove() })
|
||
|
||
function mount() { const v = h(NotesComponent, { win }); render(v, win.body) }
|
||
|
||
// ==================== 初始化 ====================
|
||
describe('初始化', () => {
|
||
it('挂载后渲染侧边栏和编辑区', () => {
|
||
mount()
|
||
expect(win.body.querySelector('.notes-side')).toBeTruthy()
|
||
expect(win.body.querySelector('.notes-main')).toBeTruthy()
|
||
})
|
||
|
||
it('无存储数据时自动创建默认笔记', () => {
|
||
mount()
|
||
const t = titles(win.body)
|
||
expect(t.length).toBeGreaterThanOrEqual(1)
|
||
expect(t[0]).toBeTruthy()
|
||
})
|
||
|
||
it('首条笔记默认选中', async () => {
|
||
mount(); await nextTick()
|
||
const first = win.body.querySelector('.note-row')
|
||
expect(first?.classList.contains('sel')).toBe(true)
|
||
})
|
||
|
||
it('编辑器显示选中笔记内容', async () => {
|
||
mount(); await nextTick()
|
||
const ta = editor(win.body)
|
||
expect(ta.value).toContain('欢迎使用备忘录')
|
||
})
|
||
|
||
it('日期显示', async () => {
|
||
mount(); await nextTick()
|
||
expect(win.body.querySelector('.notes-date')?.textContent).toBeTruthy()
|
||
})
|
||
})
|
||
|
||
// ==================== 列表与标题 ====================
|
||
describe('列表', () => {
|
||
it('titleOf 取第一行作为标题', () => {
|
||
store.set('notes', [{ id: 't1', body: '标题行\n第二行内容', updated: Date.now() }])
|
||
mount()
|
||
expect(titles(win.body)).toContain('标题行')
|
||
})
|
||
|
||
it('空 body 显示"新备忘录"', () => {
|
||
store.set('notes', [{ id: 't2', body: '', updated: Date.now() }])
|
||
mount()
|
||
expect(titles(win.body)).toContain('新备忘录')
|
||
})
|
||
|
||
it('标题截断到 40 字符', () => {
|
||
store.set('notes', [{ id: 't3', body: 'A'.repeat(50), updated: Date.now() }])
|
||
mount()
|
||
const t = titles(win.body)
|
||
expect(t[0]!.length).toBeLessThanOrEqual(40)
|
||
})
|
||
|
||
it('副标题显示第二行前 26 字符', () => {
|
||
store.set('notes', [{ id: 't4', body: '标题\n' + 'B'.repeat(30), updated: Date.now() }])
|
||
mount()
|
||
const sub = win.body.querySelector('.note-sub')?.textContent || ''
|
||
expect(sub.length).toBeLessThanOrEqual(26 + 10) // 26 + relTime
|
||
})
|
||
|
||
it('多笔记按更新时间倒序排列', async () => {
|
||
store.set('notes', [
|
||
{ id: 'a', body: 'older', updated: 1000 },
|
||
{ id: 'b', body: 'newer', updated: 2000 },
|
||
])
|
||
mount(); await nextTick()
|
||
expect(titles(win.body)[0]).toBe('newer')
|
||
expect(titles(win.body)[1]).toBe('older')
|
||
})
|
||
})
|
||
|
||
// ==================== 新建 ====================
|
||
describe('新建', () => {
|
||
it('新建笔记加入列表并自动选中', async () => {
|
||
mount()
|
||
const before = titles(win.body).length
|
||
win.appState.add()
|
||
await nextTick()
|
||
expect(titles(win.body).length).toBe(before + 1)
|
||
// 新笔记应为空标题 "新备忘录"
|
||
expect(titles(win.body)).toContain('新备忘录')
|
||
// 编辑器应为空
|
||
expect(editor(win.body).value).toBe('')
|
||
expect(editor(win.body).disabled).toBe(false)
|
||
})
|
||
|
||
it('新建笔记持久化到 localStorage', () => {
|
||
mount()
|
||
win.appState.add()
|
||
const data = store.get('notes', null)
|
||
expect(data).toBeTruthy()
|
||
expect(data.length).toBeGreaterThanOrEqual(2) // 默认 + 新建
|
||
})
|
||
})
|
||
|
||
// ==================== 编辑 ====================
|
||
describe('编辑', () => {
|
||
it('编辑器输入更新正文', async () => {
|
||
mount(); await nextTick()
|
||
edit(win.body, '新的第一行\n第二行')
|
||
await nextTick()
|
||
// 标题应更新
|
||
expect(titles(win.body)[0]).toBe('新的第一行')
|
||
})
|
||
|
||
it('输入后自动保存到 localStorage', async () => {
|
||
mount(); await nextTick()
|
||
edit(win.body, 'hello world')
|
||
// 等待 debounce
|
||
await new Promise(r => setTimeout(r, 400))
|
||
const data = store.get('notes', null)
|
||
expect(data?.[0].body).toBe('hello world')
|
||
})
|
||
|
||
it('更新时间戳在编辑时刷新', async () => {
|
||
mount(); await nextTick()
|
||
const before = win.body.querySelector('.notes-date')?.textContent
|
||
await new Promise(r => setTimeout(r, 50))
|
||
edit(win.body, 'new content')
|
||
await nextTick()
|
||
// 时间戳应改变(由于 debounce 350ms,这里只是 verifies 逻辑不崩溃)
|
||
expect(win.body.querySelector('.notes-date')).toBeTruthy()
|
||
})
|
||
})
|
||
|
||
// ==================== 搜索 ====================
|
||
describe('搜索', () => {
|
||
it('关键字过滤笔记列表', async () => {
|
||
store.set('notes', [
|
||
{ id: 's1', body: '苹果笔记', updated: Date.now() },
|
||
{ id: 's2', body: '香蕉记录', updated: Date.now() + 1 },
|
||
])
|
||
mount(); await nextTick()
|
||
const input = win.body.querySelector('.notes-search') as HTMLInputElement
|
||
input.value = '苹果'
|
||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||
await nextTick()
|
||
expect(titles(win.body)).toContain('苹果笔记')
|
||
expect(titles(win.body)).not.toContain('香蕉记录')
|
||
})
|
||
|
||
it('无匹配显示"无结果"', async () => {
|
||
mount(); await nextTick()
|
||
const input = win.body.querySelector('.notes-search') as HTMLInputElement
|
||
input.value = 'zzzz_no_match'
|
||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||
await nextTick()
|
||
expect(win.body.querySelector('.empty-state')?.textContent).toContain('无结果')
|
||
})
|
||
|
||
it('清除搜索恢复全量列表', async () => {
|
||
store.set('notes', [
|
||
{ id: 's3', body: 'A', updated: 1 },
|
||
{ id: 's4', body: 'B', updated: 2 },
|
||
])
|
||
mount(); await nextTick()
|
||
const input = win.body.querySelector('.notes-search') as HTMLInputElement
|
||
input.value = 'A'; input.dispatchEvent(new Event('input', { bubbles: true }))
|
||
await nextTick()
|
||
expect(titles(win.body).length).toBe(1)
|
||
// 清除
|
||
input.value = ''; input.dispatchEvent(new Event('input', { bubbles: true }))
|
||
await nextTick()
|
||
expect(titles(win.body).length).toBe(2)
|
||
})
|
||
})
|
||
|
||
// ==================== 选择 ====================
|
||
describe('选择', () => {
|
||
it('点击第二笔记切换编辑器内容', async () => {
|
||
store.set('notes', [
|
||
{ id: 'x1', body: '第一笔记', updated: 1 },
|
||
{ id: 'x2', body: '第二笔记', updated: 2 },
|
||
])
|
||
mount(); await nextTick()
|
||
// 排序后 updated:2 在前(index 0),updated:1 在后(index 1)
|
||
// 默认选中第一条("第二笔记"),点击第二条("第一笔记")应切换
|
||
clickNote(win.body, 1)
|
||
await nextTick(); await nextTick()
|
||
expect(editor(win.body).value).toBe('第一笔记')
|
||
})
|
||
|
||
it('无笔记时编辑器禁用', () => {
|
||
store.set('notes', [])
|
||
mount()
|
||
expect(editor(win.body).disabled).toBe(true)
|
||
})
|
||
})
|
||
|
||
// ==================== 删除 ====================
|
||
describe('删除', () => {
|
||
it('删除当前笔记后选中下一条', async () => {
|
||
store.set('notes', [
|
||
{ id: 'd1', body: '删除我', updated: 1 },
|
||
{ id: 'd2', body: '保留', updated: 2 },
|
||
])
|
||
mount(); await nextTick()
|
||
// 选中第一条
|
||
clickNote(win.body, 0)
|
||
await nextTick()
|
||
// removeNote 需要 ui.confirm,这里直接操作数据模拟
|
||
const data = store.get('notes', null)
|
||
store.set('notes', data.filter((n: any) => n.id !== 'd1'))
|
||
// 重新挂载验证
|
||
render(null, win.body)
|
||
mount(); await nextTick()
|
||
expect(titles(win.body)).not.toContain('删除我')
|
||
expect(titles(win.body)).toContain('保留')
|
||
})
|
||
})
|
||
|
||
// ==================== win.appState ====================
|
||
describe('win.appState', () => {
|
||
it('暴露 add/remove 方法', () => {
|
||
mount()
|
||
expect(typeof win.appState.add).toBe('function')
|
||
expect(typeof win.appState.remove).toBe('function')
|
||
})
|
||
|
||
it('cur 为当前选中笔记引用', () => {
|
||
mount()
|
||
expect(win.appState.cur).toBeTruthy()
|
||
expect(win.appState.cur.body).toContain('欢迎使用备忘录')
|
||
})
|
||
|
||
it('cur 为 null 时删除菜单 disabled', () => {
|
||
store.set('notes', [])
|
||
mount()
|
||
expect(win.appState.cur).toBeNull()
|
||
})
|
||
})
|
||
|
||
// ==================== 卸载 ====================
|
||
describe('卸载', () => {
|
||
it('卸载后 DOM 为空', () => {
|
||
mount(); render(null, win.body)
|
||
expect(win.body.children.length).toBe(0)
|
||
})
|
||
})
|
||
})
|