diff --git a/src/apps/appstore/AppStore.vue b/src/apps/appstore/AppStore.vue index 0ada1d8..026d5f2 100644 --- a/src/apps/appstore/AppStore.vue +++ b/src/apps/appstore/AppStore.vue @@ -1 +1,189 @@ - + + + + + {{ t[1] }} + + + + + + + 编辑推荐 + + + 今日 App + 熊掌记 + 记录灵感,优雅如斯。 + + {{ installing === 'bear' ? '安装中…' : installed('bear') ? '打开' : '获取' }} + + + + + 热门 App + + + + + {{ item.name }} + {{ item.desc }} + {{ item.cat }} + + {{ installing === item.id ? '安装中…' : installed(item.id) ? '打开' : '获取' }} + + + + + + + + {{ cat }} + + + + + {{ item.name }} + {{ item.desc }} + {{ item.cat }} + + {{ installing === item.id ? '安装中…' : installed(item.id) ? '打开' : '获取' }} + + + + + + + + 搜索 App + 没有找到相关 App + + + + + {{ item.name }} + {{ item.desc }} + {{ item.cat }} + + {{ installing === item.id ? '安装中…' : installed(item.id) ? '打开' : '获取' }} + + + + + + + + + ✅ + 全部 App 均为最新版本 + + + + 已安装 + + + + + {{ item.name }} + {{ item.desc }} + {{ item.cat }} + + 打开 + + + 所有已安装 App 均为最新版本。 + + + + + + + diff --git a/src/apps/appstore/index.ts b/src/apps/appstore/index.ts index 96f643d..a8b4f41 100644 --- a/src/apps/appstore/index.ts +++ b/src/apps/appstore/index.ts @@ -1,34 +1,16 @@ +// App Store 应用 — Vue 组件化 import { h, render } from 'vue' -// App Store 应用 — 从 js/apps3.js 手动转写 -import { $, $$, el, iconImg, debounce } from '../../utils' -import { bus as Bus } from '../../composables/useBus' -import { store as Store } from '../../composables/useStore' -import { fs as FS, setAppStoreRefForFS } from '../../composables/useFS' import { Apps, stdMenus } from '../../composables/useApps' +import { store } from '../../composables/useStore' +import { FS, setAppStoreRefForFS } from '../../composables/useFS' import AppStoreComponent from './AppStore.vue' -import { Notify } from '../../composables/useNotify' import { setAppStoreForLaunchpad } from '../launchpad/index' -const STORE_CATALOG = [ - { id: 'bear', name: '熊掌记', cat: '效率', desc: '优雅的 Markdown 笔记', icon: '/assets/icons/bear.png' }, - { id: 'typora', name: 'Typora', cat: '效率', desc: '所见即所得 Markdown 编辑器', icon: '/assets/icons/typora.png' }, - { id: 'vscode', name: 'VS Code Web', cat: '开发', desc: '轻量代码编辑器', icon: '/assets/icons/vscode.png' }, - { id: 'keynote', name: 'Keynote 讲演', cat: '效率', desc: '制作精美演示文稿', icon: '/assets/icons/keynote.png' }, - { id: 'podcasts', name: '播客', cat: '娱乐', desc: '收听你喜爱的节目', icon: '/assets/icons/podcasts.png' }, - { id: 'news', name: 'Apple News', cat: '阅读', desc: '精选科技资讯', icon: '/assets/icons/news.png' }, - { id: 'github', name: 'GitHub Desktop', cat: '开发', desc: '浏览与管理代码仓库', icon: '/assets/icons/github.png' }, - { id: 'tv', name: 'Apple TV', cat: '娱乐', desc: '观看影片与节目', icon: '/assets/icons/tv.png' }, -] - export const AppStoreApp = { id: 'appstore', name: 'App Store', icon: '/assets/icons/appstore.png', w: 860, h: 600, minW: 600, minH: 420, - installed() { return Store.get('appstore-installed', []) }, - isInstalled(id: string) { return this.installed().includes(id) }, - setInstalled(id: string) { - const l = this.installed() - if (!l.includes(id)) { l.push(id); Store.set('appstore-installed', l); FS.syncApps(); Bus.emit('appstore:changed') } - }, + installed(): string[] { return store.get('appstore-installed', []) }, + isInstalled(id: string): boolean { return this.installed().includes(id) }, menus(win: any) { return stdMenus(this, { store: [ @@ -40,62 +22,11 @@ export const AppStoreApp = { }, onArgs(args: any, win: any) { if (args.tab) win.appState?.go(args.tab) }, render(win: any) { - const st = win.appState = { tab: 'discover', q: '' } - win.body.classList.add('store-body') - const tabs: [string, string][] = [['discover', '发现'], ['cats', '分类'], ['arcade', '搜索'], ['updates', '更新']] - const tabbar = el('div', { class: 'store-tabs' }) - const search = el('input', { class: 'text-input store-search', type: 'search', placeholder: '搜索 App' }) as HTMLInputElement - const content = el('div', { class: 'store-content' }) - win.body.append(el('div', { class: 'store-top' }, tabbar, search), content) - const btnFor = (item: any) => { - const installed = this.isInstalled(item.id) - const b = el('button', { class: 'btn store-get' + (installed ? '' : ' primary'), text: installed ? '打开' : '获取' }) as HTMLButtonElement - b.addEventListener('click', () => { - if (this.isInstalled(item.id)) { Apps.open(item.id); return } - b.disabled = true; b.textContent = '安装中…'; b.classList.add('installing') - setTimeout(() => { - this.setInstalled(item.id); b.textContent = '打开'; b.disabled = false; b.classList.remove('installing', 'primary') - Notify.send({ appId: 'appstore', title: 'App Store', body: `"${item.name}"已安装,可在启动台与应用程序文件夹中找到。` }) - }, 1200 + Math.random() * 1200) - }) - return b - } - const cardFor = (item: any, big?: boolean) => { - return el('div', { class: 'store-card' + (big ? ' big' : '') }, iconImg(item.icon, '', 'store-icon'), - el('div', { class: 'store-info' }, el('div', { class: 'store-name', text: item.name }), el('div', { class: 'store-desc', text: item.desc }), el('div', { class: 'store-cat', text: item.cat })), btnFor(item)) - } - st.go = (tab: string) => { - st.tab = tab; $$('.store-tab', tabbar).forEach((n: HTMLElement) => n.classList.toggle('on', n.dataset.tab === tab)) - content.innerHTML = '' - if (tab === 'discover') { - content.append(el('h2', { class: 'store-h', text: '编辑推荐' })) - const hero = el('div', { class: 'store-hero' }, el('div', null, el('div', { class: 'store-hero-tag', text: '今日 App' }), el('div', { class: 'store-hero-name', text: '熊掌记' }), el('div', { class: 'store-hero-desc', text: '记录灵感,优雅如斯。' }), el('div', { class: 'store-card hero-get', style: { background: 'transparent', border: 'none', padding: '10px 0 0' } }, btnFor(STORE_CATALOG[0]))), iconImg('/assets/icons/bear.png', '')) - content.append(hero, el('h2', { class: 'store-h', text: '热门 App' })) - const grid = el('div', { class: 'store-grid' }); STORE_CATALOG.slice(1).forEach((i: any) => grid.append(cardFor(i))); content.append(grid) - } else if (tab === 'cats') { - const cats = [...new Set(STORE_CATALOG.map((i: any) => i.cat))] - cats.forEach((cat: string) => { content.append(el('h2', { class: 'store-h', text: cat })); const grid = el('div', { class: 'store-grid' }); STORE_CATALOG.filter((i: any) => i.cat === cat).forEach((i: any) => grid.append(cardFor(i))); content.append(grid) }) - } else if (tab === 'arcade') { - content.append(el('h2', { class: 'store-h', text: '搜索 App' })) - const grid = el('div', { class: 'store-grid' }); const hits = STORE_CATALOG.filter((i: any) => !st.q || (i.name + i.desc + i.cat).toLowerCase().includes(st.q.toLowerCase())) - if (!hits.length) content.append(el('div', { class: 'empty-state', style: { height: '120px' }, text: '没有找到相关 App' })) - hits.forEach((i: any) => grid.append(cardFor(i))); content.append(grid) - } else if (tab === 'updates') { - const inst = STORE_CATALOG.filter((i: any) => this.isInstalled(i.id)) - if (!inst.length) content.append(el('div', { class: 'empty-state', style: { height: '200px' } }, el('div', { class: 'es-icon', text: '✅' }), el('div', { text: '全部 App 均为最新版本' }))) - else { content.append(el('h2', { class: 'store-h', text: '已安装' })); const grid = el('div', { class: 'store-grid' }); inst.forEach((i: any) => grid.append(cardFor(i))); content.append(grid, el('p', { class: 'set-note', text: '所有已安装 App 均为最新版本。' })) } - } - } - tabs.forEach(([id, name]) => { const b = el('button', { class: 'store-tab', text: name, dataset: { tab: id } }); b.addEventListener('click', () => st.go(id)); tabbar.append(b) }) - search.addEventListener('input', debounce(() => { st.q = search.value.trim(); st.go('arcade') }, 200)) - search.addEventListener('keydown', (e: KeyboardEvent) => e.stopPropagation()); - (win as any)._unsub = Bus.on('appstore:changed', () => { if (document.body.contains(win.el)) st.go(st.tab) }) - win.onClose = () => (win as any)._unsub && (win as any)._unsub() - st.go('discover') - } + const vnode = h(AppStoreComponent, { win }) + render(vnode, win.body) + }, } -Apps.register(AppStoreApp); -(window as any).AppStoreApp = AppStoreApp -// 注入引用到 FS(syncApps 需要)和 Launchpad(过滤需要) +Apps.register(AppStoreApp) +;(window as any).AppStoreApp = AppStoreApp setAppStoreRefForFS(AppStoreApp) setAppStoreForLaunchpad(AppStoreApp) diff --git a/tests/cases/33-appstore.test.ts b/tests/cases/33-appstore.test.ts new file mode 100644 index 0000000..f01d665 --- /dev/null +++ b/tests/cases/33-appstore.test.ts @@ -0,0 +1,225 @@ +/** + * 33-appstore: AppStore Vue 组件化测试 + * + * 覆盖: DOM 结构、4 个 tab 切换、发现页(Hero/热门)、分类页(分组显示)、 + * 搜索(输入/结果)、更新页(已安装列表/空状态)、 + * 安装/打开按钮、Store 持久化、卸载清理 + * + * ⚠️ 安装模拟的 setTimeout 延迟在 jsdom 中可 with fake timers 测试; + * 此处验证按钮状态切换逻辑,定时器行为标注即可。 + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { h, render, nextTick } from 'vue' +import AppStoreComponent from '../../src/apps/appstore/AppStore.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: 'appstore', appId: 'appstore', el, body, timers: [] as any[], data: {} } +} + +describe('33-appstore — AppStore Vue 组件化', () => { + let win: any + beforeEach(() => { localStorage.clear(); store.set('appstore-installed', null); setupDOM(); win = makeMockWin() }) + afterEach(() => { render(null, win.body); win.el.remove() }) + + function mount() { const v = h(AppStoreComponent, { win }); render(v, win.body) } + + // ==================== DOM 结构 ==================== + describe('DOM 结构', () => { + it('4 个 tab 按钮存在', () => { + mount() + expect(win.body.querySelectorAll('.store-tab').length).toBe(4) + }) + + it('搜索框存在', () => { + mount() + expect(win.body.querySelector('.store-search')).toBeTruthy() + }) + + it('默认 tab 为发现(discover)', () => { + mount() + expect(win.body.querySelector('.store-tab.on')?.textContent).toContain('发现') + }) + + it('store-content 存在', () => { + mount() + expect(win.body.querySelector('.store-content')).toBeTruthy() + }) + }) + + // ==================== Tab 切换 ==================== + describe('Tab 切换', () => { + it('点击分类 tab 切换', async () => { + mount() + const tabs = win.body.querySelectorAll('.store-tab') + const catsTab = [...tabs].find(t => t.textContent.includes('分类')) as HTMLElement + catsTab.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await nextTick() + expect(win.body.querySelector('.store-tab.on')?.textContent).toContain('分类') + }) + + it('点击更新 tab 切换', async () => { + mount() + const tabs = win.body.querySelectorAll('.store-tab') + const updatesTab = [...tabs].find(t => t.textContent.includes('更新')) as HTMLElement + updatesTab.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await nextTick() + expect(win.body.querySelector('.store-tab.on')?.textContent).toContain('更新') + }) + + it('点击搜索 tab 切换', async () => { + mount() + const tabs = win.body.querySelectorAll('.store-tab') + const searchTab = [...tabs].find(t => t.textContent.includes('搜索')) as HTMLElement + searchTab.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await nextTick() + expect(win.body.querySelector('.store-tab.on')?.textContent).toContain('搜索') + }) + }) + + // ==================== 发现页 ==================== + describe('发现页', () => { + it('Hero 区域显示熊掌记', () => { + mount() + expect(win.body.querySelector('.store-hero')).toBeTruthy() + expect(win.body.querySelector('.store-hero-name')?.textContent).toBe('熊掌记') + }) + + it('热门 App 网格有 7 个卡片', () => { + mount() + // 除 Hero(熊掌记) 外,热门有 7 个 + expect(win.body.querySelectorAll('.store-card').length).toBeGreaterThanOrEqual(7) + }) + + it('每个卡片有名称/描述/分类', () => { + mount() + // 仅检查 store-grid 内的卡片(排除 Hero 区域) + const cards = win.body.querySelectorAll('.store-grid .store-card') + expect(cards.length).toBeGreaterThan(0) + const first = cards[0] as HTMLElement + expect(first.querySelector('.store-name')).toBeTruthy() + expect(first.querySelector('.store-desc')).toBeTruthy() + expect(first.querySelector('.store-cat')).toBeTruthy() + }) + }) + + // ==================== 分类页 ==================== + describe('分类页', () => { + it('分类页按类别分组', async () => { + mount() + const tabs = win.body.querySelectorAll('.store-tab') + ;([...tabs].find(t => t.textContent.includes('分类')) as HTMLElement) + .dispatchEvent(new MouseEvent('click', { bubbles: true })) + await nextTick() + const titles = [...win.body.querySelectorAll('.store-h')].map(e => e.textContent) + expect(titles.length).toBeGreaterThanOrEqual(2) + }) + }) + + // ==================== 搜索 ==================== + describe('搜索', () => { + it('输入关键字自动切换到搜索 tab', async () => { + mount() + const input = win.body.querySelector('.store-search') as HTMLInputElement + input.value = '熊掌记' + input.dispatchEvent(new Event('input', { bubbles: true })) + // 等待 debounce + await new Promise(r => setTimeout(r, 250)) + await nextTick() + expect(win.body.querySelector('.store-tab.on')?.textContent).toContain('搜索') + }) + + it('搜索匹配结果显示', async () => { + mount() + const input = win.body.querySelector('.store-search') as HTMLInputElement + input.value = '熊掌记' + input.dispatchEvent(new Event('input', { bubbles: true })) + await new Promise(r => setTimeout(r, 250)) + await nextTick() + expect(win.body.querySelectorAll('.store-card').length).toBeGreaterThanOrEqual(1) + }) + + it('无匹配显示"没有找到相关 App"', async () => { + mount() + const input = win.body.querySelector('.store-search') as HTMLInputElement + input.value = 'zzz_nonexistent_app' + input.dispatchEvent(new Event('input', { bubbles: true })) + await new Promise(r => setTimeout(r, 250)) + await nextTick() + expect(win.body.querySelector('.empty-state')?.textContent).toContain('没有找到') + }) + }) + + // ==================== 安装/打开 ==================== + describe('安装/打开', () => { + it('未安装时显示"获取"按钮', () => { + mount() + const btns = win.body.querySelectorAll('.store-get') + const getBtns = [...btns].filter(b => b.textContent === '获取') + expect(getBtns.length).toBeGreaterThanOrEqual(1) + }) + + it('已安装时显示"打开"按钮', () => { + store.set('appstore-installed', ['bear']) + mount() + // Hero 区域的熊掌记按钮应为"打开" + const heroBtn = win.body.querySelector('.hero-get .store-get') + expect(heroBtn?.textContent).toBe('打开') + }) + + it('点击"获取"后变为"安装中…"', async () => { + mount() + const getBtn = [...win.body.querySelectorAll('.store-get')].find(b => b.textContent === '获取') as HTMLElement + getBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await nextTick() + expect(getBtn.textContent).toBe('安装中…') + }) + }) + + // ==================== 更新页 ==================== + describe('更新页', () => { + it('无已安装时显示"全部 App 均为最新版本"', async () => { + mount() + const tabs = win.body.querySelectorAll('.store-tab') + ;([...tabs].find(t => t.textContent.includes('更新')) as HTMLElement) + .dispatchEvent(new MouseEvent('click', { bubbles: true })) + await nextTick() + expect(win.body.querySelector('.empty-state')?.textContent).toContain('最新版本') + }) + + it('有已安装时显示已安装列表', async () => { + store.set('appstore-installed', ['bear', 'vscode']) + mount() + const tabs = win.body.querySelectorAll('.store-tab') + ;([...tabs].find(t => t.textContent.includes('更新')) as HTMLElement) + .dispatchEvent(new MouseEvent('click', { bubbles: true })) + await nextTick() + expect(win.body.querySelectorAll('.store-card').length).toBeGreaterThanOrEqual(2) + }) + }) + + // ==================== 持久化 ==================== + describe('持久化', () => { + it('从 store 加载已安装列表', () => { + store.set('appstore-installed', ['bear', 'vscode']) + mount() + // 验证 bear 按钮文字为"打开" + const heroBtn = win.body.querySelector('.hero-get .store-get') + expect(heroBtn?.textContent).toBe('打开') + }) + }) + + // ==================== 卸载 ==================== + describe('卸载', () => { + it('卸载后 DOM 为空', () => { + mount() + render(null, win.body) + expect(win.body.children.length).toBe(0) + }) + }) +})
所有已安装 App 均为最新版本。