macos-web/tests/cases/20-notes.test.ts

303 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 0updated: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)
})
})
})