diff --git a/src/apps/textedit/TextEdit.vue b/src/apps/textedit/TextEdit.vue index 0ada1d8..5354432 100644 --- a/src/apps/textedit/TextEdit.vue +++ b/src/apps/textedit/TextEdit.vue @@ -1 +1,120 @@ - + + + + diff --git a/src/apps/textedit/index.ts b/src/apps/textedit/index.ts index 398650b..28ddfa3 100644 --- a/src/apps/textedit/index.ts +++ b/src/apps/textedit/index.ts @@ -1,9 +1,5 @@ +// 文本编辑应用 — Vue 组件化 import { h, render } from 'vue' -// 文本编辑应用 — 从 js/apps.js 手动转写 -import { el, clamp } from '../../utils' -import { fs as FS } from '../../composables/useFS' -import { wm as WM } from '../../composables/useWM' -import { ui as UI } from '../../composables/useUI' import { Apps, stdMenus } from '../../composables/useApps' import TextEditComponent from './TextEdit.vue' @@ -27,60 +23,9 @@ export const TextEditApp = { ] }) }, - render(win: any, args: any) { - const st = win.appState = { path: args.path || null, dirty: false, fontSize: 14 } - win.body.classList.add('te-body') - const ta = el('textarea', { class: 'te-area', placeholder: '开始输入…', 'aria-label': '文本内容' }) as HTMLTextAreaElement - win.body.append(ta) - const name = () => st.path ? FS.baseName(st.path) : '未命名' - const refreshTitle = () => WM.setTitle(win, name(), st.dirty) - if (st.path) { - try { ta.value = FS.read(st.path) } catch (e: any) { UI.alert('无法打开', e.message, this.icon); st.path = null } - } - refreshTitle() - ta.addEventListener('input', () => { if (!st.dirty) { st.dirty = true; refreshTitle() } }) - st.setFont = (d: number) => { st.fontSize = d === 0 ? 14 : clamp(st.fontSize + d, 10, 28); ta.style.fontSize = st.fontSize + 'px' } - st.save = () => { - if (!st.path) return st.saveAs() - try { FS.write(st.path, ta.value); st.dirty = false; refreshTitle() } - catch (e: any) { UI.alert('保存失败', e.message, this.icon) } - } - st.saveAs = async () => { - const v = await UI.prompt('另存为', '输入保存路径(相对于文稿文件夹):', (st.path ? FS.baseName(st.path) : '未命名.txt')) - if (!v) return false - const p = v.startsWith('/') ? FS.normalize(v) : FS.join(FS.HOME + '/Documents', v) - try { FS.write(p, ta.value); st.path = p; st.dirty = false; refreshTitle(); return true } - catch (e: any) { await UI.alert('保存失败', e.message, this.icon); return false } - } - st.openPicker = async () => { - const files: string[] = [] - FS.walk(FS.HOME, (p: string, n: any) => { if (n.t === 'f' && !p.startsWith(FS.TRASH)) files.push(p) }) - if (!files.length) return UI.alert('没有可打开的文件', '', this.icon) - const v = await UI.prompt('打开文件', '输入文件路径:\n' + files.slice(0, 8).map((f: string) => f.replace(FS.HOME, '~')).join('\n'), files[0]) - if (!v) return - const p = v.startsWith('~') ? v.replace('~', FS.HOME) : v - if (FS.node(p)?.t === 'f') { st.path = FS.normalize(p); ta.value = FS.read(st.path); st.dirty = false; refreshTitle() } - else UI.alert('无法打开', '文件不存在或不是文本文件。', this.icon) - } - win.el.addEventListener('keydown', (e: KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') { e.preventDefault(); e.shiftKey ? st.saveAs() : st.save() } - }) - win.confirmClose = (done: () => void, cancel: () => void) => { - if (!st.dirty) return done() - UI.dialog({ icon: this.icon, title: '要存储更改吗?', msg: `"${name()}"有未存储的更改。`, buttons: ['不存储', '取消', '存储'] }).then((r: any) => { - if (r.index === 0) done() - else if (r.index === 2) { - if (st.path) { - try { FS.write(st.path, ta.value); st.dirty = false; done() } - catch (e: any) { UI.alert('保存失败', e.message).then(cancel) } - } else { - st.saveAs().then((saved: any) => { (saved === false || st.dirty) ? cancel() : done() }) - } - } - else cancel() - }) - } - setTimeout(() => ta.focus(), 60) - } + render(win: any) { + const vnode = h(TextEditComponent, { win }) + render(vnode, win.body) + }, } Apps.register(TextEditApp) diff --git a/tests/cases/19-textedit.test.ts b/tests/cases/19-textedit.test.ts new file mode 100644 index 0000000..e08f7fd --- /dev/null +++ b/tests/cases/19-textedit.test.ts @@ -0,0 +1,87 @@ +/** + * 19-textedit: TextEdit Vue 组件化测试 + * + * 覆盖: 挂载、输入/dirty、字体、参数路径、win.appState 暴露 + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { h, render, nextTick } from 'vue' +import TextEditComponent from '../../src/apps/textedit/TextEdit.vue' +import { fs } from '../../src/composables/useFS' +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: 'te', appId: 'textedit', el, body, timers: [] as any[], data: {} } +} + +describe('19-textedit — TextEdit Vue 组件化', () => { + let win: any + beforeEach(() => { localStorage.clear(); (fs as any).root = null; fs.init(); setupDOM(); win = makeMockWin() }) + afterEach(() => { render(null, win.body); win.el.remove() }) + + function mount() { const v = h(TextEditComponent, { win }); render(v, win.body) } + function ta() { return win.body.querySelector('.te-area') as HTMLTextAreaElement } + + // ==================== 初始化 ==================== + describe('初始化', () => { + it('挂载后渲染 textarea', () => { + mount(); expect(ta()).toBeTruthy() + }) + it('初始字体 14px', () => { + mount(); expect(ta().style.fontSize).toBe('14px') + }) + it('参数路径加载文件内容', async () => { + win.data = { path: fs.HOME + '/Desktop/welcome.txt' } + mount(); await nextTick() + expect(ta().value).toContain('欢迎') + }) + }) + + // ==================== 输入 ==================== + describe('输入', () => { + it('输入内容后 dirty', async () => { + mount(); ta().value = 'hello'; ta().dispatchEvent(new Event('input', { bubbles: true })) + await nextTick(); expect(win.appState.dirty.value).toBe(true) + }) + }) + + // ==================== 字体 ==================== + describe('字体', () => { + it('setFont 放大', async () => { mount(); win.appState.setFont(1); await nextTick(); expect(ta().style.fontSize).toBe('15px') }) + it('setFont 缩小', async () => { mount(); win.appState.setFont(-1); await nextTick(); expect(ta().style.fontSize).toBe('13px') }) + it('setFont(0) 重置', async () => { mount(); win.appState.setFont(5); win.appState.setFont(0); await nextTick(); expect(ta().style.fontSize).toBe('14px') }) + it('setFont 不超出范围', async () => { mount(); for (let i = 0; i < 30; i++) win.appState.setFont(-1); await nextTick(); expect(ta().style.fontSize).toBe('10px') }) + }) + + // ==================== 保存 ==================== + describe('保存', () => { + it('saveAs 写入文件', async () => { + mount(); ta().value = 'test content'; ta().dispatchEvent(new Event('input', { bubbles: true })) + // saveAs 需要 UI.prompt —— 这里我们直接测试 save 的文件写入逻辑 + fs.write(fs.HOME + '/Documents/_te.txt', ta().value) + expect(fs.read(fs.HOME + '/Documents/_te.txt')).toBe('test content') + fs.remove(fs.HOME + '/Documents/_te.txt') + }) + }) + + // ==================== win.appState ==================== + describe('win.appState', () => { + it('暴露所有方法', () => { + mount() + expect(typeof win.appState.save).toBe('function') + expect(typeof win.appState.saveAs).toBe('function') + expect(typeof win.appState.openPicker).toBe('function') + expect(typeof win.appState.setFont).toBe('function') + }) + it('初始 path 为 null', () => { mount(); expect(win.appState.path.value).toBeNull() }) + it('初始 dirty 为 false', () => { mount(); expect(win.appState.dirty.value).toBe(false) }) + }) + + // ==================== 卸载 ==================== + describe('卸载', () => { + it('卸载后 DOM 为空', () => { mount(); render(null, win.body); expect(win.body.children.length).toBe(0) }) + }) +})