diff --git a/README.md b/README.md index 98b0306..5e76a21 100644 --- a/README.md +++ b/README.md @@ -85,10 +85,13 @@ cd MemeMeow 2. 创建 Compose 配置 ```bash -cp .env.example .env +uv run python -m scripts.sync_env # 编辑 .env,填写需要的模型凭据和可选视觉权重配置 ``` +重复运行同步命令时,`.env.example` 继续提供字段顺序、分组注释和默认值;已有 +`.env` 的同名字段值会被保留,模板中没有的本地字段会追加到文件末尾。 + 3. 启动 Compose 全栈 ```bash ./start.sh start diff --git a/frontend/e2e/app.spec.js b/frontend/e2e/app.spec.js index 549dc8d..3aa2df1 100644 --- a/frontend/e2e/app.spec.js +++ b/frontend/e2e/app.spec.js @@ -91,13 +91,17 @@ test('完整重试使用 scope 端点,取消不请求且 320px 弹层不越界 /** 使用真实浏览器核对默认选择、指定阶段多选和桌面/移动弹层布局。 */ test('重试选中默认可选择并按指定阶段提交', async ({ page }) => { const stageRequests = [] + const fullRetryRequests = [] + const consoleErrors = [] + page.on('console', (message) => { if (message.type() === 'error') consoleErrors.push(message.text()) }) + page.on('pageerror', (error) => { consoleErrors.push(error.message) }) await page.route('**/api/images?**', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [ - { meme_id: '55555555-5555-4555-8555-555555555555', filename: 'pending.png', size: 1024, extension: '.png', media_url: '/media/pending', metadata: { status: 'pending' }, embedding_status: 'pending', visual_embedding_status: 'pending' }, - { meme_id: '66666666-6666-4666-8666-666666666666', filename: 'ready.png', size: 2048, extension: '.png', media_url: '/media/ready', metadata: { status: 'ready' }, embedding_status: 'ready', visual_embedding_status: 'ready' }, + { meme_id: '55555555-5555-4555-8555-555555555555', filename: 'pending.png', size: 1024, extension: '.png', media_url: 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=', metadata: { status: 'pending' }, embedding_status: 'pending', visual_embedding_status: 'pending' }, + { meme_id: '66666666-6666-4666-8666-666666666666', filename: 'ready.png', size: 2048, extension: '.png', media_url: 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=', metadata: { status: 'ready' }, embedding_status: 'ready', visual_embedding_status: 'ready' }, ], total: 2, page: 1, @@ -112,6 +116,14 @@ test('重试选中默认可选择并按指定阶段提交', async ({ page }) => body: JSON.stringify({ target_count: 2, submitted_count: 2, failed_count: 0, results: [] }), }) }) + await page.route('**/api/images/context/batch', async (route) => { + fullRetryRequests.push(route.request()) + await route.fulfill({ + status: 202, + contentType: 'application/json', + body: JSON.stringify({ results: [{ meme_id: '55555555-5555-4555-8555-555555555555', task_id: 'job-1' }] }), + }) + }) await page.goto('/') await page.setViewportSize({ width: 1440, height: 1000 }) @@ -121,9 +133,24 @@ test('重试选中默认可选择并按指定阶段提交', async ({ page }) => await expect(page.getByRole('button', { name: /^重试选中/ })).toBeDisabled() await page.locator('.image-check input').first().check() - await page.getByRole('button', { name: /^重试选中/ }).click() + const retryButton = page.getByRole('button', { name: /^重试选中/ }) + expect(stageRequests).toHaveLength(0) + expect(fullRetryRequests).toHaveLength(0) + await retryButton.click() const dialog = page.getByRole('dialog', { name: '重试选中' }) await expect(dialog).toBeVisible() + await expect(dialog).toContainText('已选 1 张图片,选择本次要重新处理的范围。') + await expect(dialog.getByRole('button', { name: '完整重试' })).toBeEnabled() + expect(stageRequests).toHaveLength(0) + expect(fullRetryRequests).toHaveLength(0) + await page.keyboard.press('Escape') + await expect(dialog).toBeHidden() + await expect(retryButton).toBeFocused() + expect(stageRequests).toHaveLength(0) + expect(fullRetryRequests).toHaveLength(0) + + await retryButton.click() + await expect(dialog).toBeVisible() await page.screenshot({ path: '/home/infstellar/vscode/MemeMeow/frontend/test-results/retry-selected-desktop.png', fullPage: true }) await dialog.getByRole('radio', { name: '指定部分' }).check() @@ -138,6 +165,7 @@ test('重试选中默认可选择并按指定阶段提交', async ({ page }) => items: [{ meme_id: '55555555-5555-4555-8555-555555555555' }], stages: ['agent', 'text_embedding'], }) + expect(consoleErrors).toEqual([]) }) test('首页可加载并切换核心工作区', async ({ page }) => { diff --git a/frontend/e2e/collections.spec.js b/frontend/e2e/collections.spec.js new file mode 100644 index 0000000..50dada8 --- /dev/null +++ b/frontend/e2e/collections.spec.js @@ -0,0 +1,80 @@ +/** 使用真实 Chromium 验证合集列表、详情和响应式资产布局。 */ +import { expect, test } from '@playwright/test' +import { readFileSync } from 'node:fs' + +const image = readFileSync(new URL('../../legacy/streamlit-v1/screenshots/streamlit_vvquest.png', import.meta.url)) +const collection = { + collection_id: 'collection-1', + name: '本周会议里的反应合集', + member_count: 2, + cover_media_url: '/media/member-1', +} +const members = [ + { + meme_id: 'member-1', + filename: 'meeting-reaction-with-a-long-descriptive-file-name.png', + extension: '.png', + media_url: '/media/member-1', + }, + { + meme_id: 'member-2', + filename: 'waiting-for-the-decision.jpg', + extension: '.jpg', + media_url: '/media/member-2', + }, +] + +test('合集列表和详情在桌面、移动端保持可扫描且图片可见', async ({ page }) => { + const consoleErrors = [] + page.on('console', (message) => { + if (message.type() === 'error') consoleErrors.push(message.text()) + }) + await page.route('**/api/config', (route) => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ embedding_model: 'test-model', embedding_cache_ready: true }), + })) + await page.route('**/api/collections**', (route) => { + const path = new URL(route.request().url()).pathname + const body = path.endsWith('/collection-1') ? { ...collection, members } : { items: [collection], total: 1, page: 1, page_size: 50 } + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) }) + }) + await page.route('**/api/images/metadata**', (route) => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ filename: members[0].filename }), + })) + await page.route('**/media/**', (route) => route.fulfill({ status: 200, contentType: 'image/png', body: image })) + + await page.goto('/') + await page.setViewportSize({ width: 1440, height: 1000 }) + await page.getByRole('button', { name: '合集' }).click() + await expect(page.getByRole('heading', { name: '合集', exact: true })).toBeVisible() + await expect(page.locator('.collection-cover img')).toHaveCount(1) + await expect.poll(() => page.locator('.collection-cover img').evaluate((image) => image.naturalWidth)).toBeGreaterThan(0) + await page.screenshot({ path: '/home/infstellar/vscode/MemeMeow/frontend/test-results/collections-list-desktop.png', fullPage: true }) + + await page.setViewportSize({ width: 390, height: 844 }) + await expect(page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).resolves.toBe(true) + await page.screenshot({ path: '/home/infstellar/vscode/MemeMeow/frontend/test-results/collections-list-mobile.png', fullPage: true }) + + await page.getByRole('button', { name: `打开合集 ${collection.name}` }).click() + await expect(page.getByRole('heading', { name: collection.name })).toBeVisible() + await expect(page.getByRole('button', { name: `从合集移除 ${members[0].filename}` })).toBeVisible() + await expect(page.locator('.collection-asset-media img')).toHaveCount(2) + await expect.poll(() => page.locator('.collection-asset-media img').first().evaluate((image) => image.naturalWidth)).toBeGreaterThan(0) + await page.getByRole('button', { name: `查看 ${members[0].filename} 图片与元数据` }).click() + await expect(page.getByRole('button', { name: '关闭图片预览' })).toBeVisible() + await page.getByRole('button', { name: '关闭图片预览' }).click() + await page.setViewportSize({ width: 320, height: 844 }) + await expect(page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).resolves.toBe(true) + await page.setViewportSize({ width: 390, height: 844 }) + await page.screenshot({ path: '/home/infstellar/vscode/MemeMeow/frontend/test-results/collections-detail-mobile.png', fullPage: true }) + + await page.setViewportSize({ width: 1440, height: 1000 }) + await expect(page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).resolves.toBe(true) + await page.screenshot({ path: '/home/infstellar/vscode/MemeMeow/frontend/test-results/collections-detail-desktop.png', fullPage: true }) + await page.getByRole('button', { name: '返回合集列表' }).focus() + await expect(page.getByRole('button', { name: '返回合集列表' })).toBeFocused() + expect(consoleErrors).toEqual([]) +}) diff --git a/frontend/src/components/CollectionsWorkspace.test.js b/frontend/src/components/CollectionsWorkspace.test.js new file mode 100644 index 0000000..cff731e --- /dev/null +++ b/frontend/src/components/CollectionsWorkspace.test.js @@ -0,0 +1,110 @@ +/** 合集列表、详情和成员维护行为测试,确保视觉整理不改变业务契约。 */ +import { flushPromises, mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { collections, createCollection, collection, renameCollection, deleteCollection, removeCollectionMember, imageMetadata } = vi.hoisted(() => ({ + collections: vi.fn(), + createCollection: vi.fn(), + collection: vi.fn(), + renameCollection: vi.fn(), + deleteCollection: vi.fn(), + removeCollectionMember: vi.fn(), + imageMetadata: vi.fn(), +})) + +vi.mock('../api', () => ({ + api: { collections, createCollection, collection, renameCollection, deleteCollection, removeCollectionMember, imageMetadata }, +})) + +import CollectionsWorkspace from './CollectionsWorkspace.vue' + +const member = { + meme_id: 'meme-1', + filename: 'a-very-long-image-name-that-stays-readable.png', + extension: '.png', + media_url: '/media/meme-1', +} + +const summary = { + collection_id: 'collection-1', + name: '会议反应', + member_count: 1, + cover_media_url: '/media/meme-1', +} + +describe('CollectionsWorkspace', () => { + beforeEach(() => { + collections.mockReset().mockResolvedValue({ items: [summary] }) + createCollection.mockReset().mockResolvedValue({ collection_id: 'collection-2', name: '新合集' }) + collection.mockReset().mockResolvedValue({ ...summary, members: [member] }) + renameCollection.mockReset().mockResolvedValue({ ...summary, name: '改名后的合集' }) + deleteCollection.mockReset().mockResolvedValue({}) + removeCollectionMember.mockReset().mockResolvedValue({}) + imageMetadata.mockReset().mockResolvedValue({}) + vi.stubGlobal('prompt', vi.fn()) + vi.stubGlobal('confirm', vi.fn(() => true)) + }) + + it('通过内联表单创建合集并刷新列表', async () => { + const created = { collection_id: 'collection-2', name: '新合集', member_count: 0 } + createCollection.mockResolvedValueOnce(created) + collections.mockResolvedValueOnce({ items: [summary] }).mockResolvedValueOnce({ items: [summary, created] }) + const wrapper = mount(CollectionsWorkspace) + await flushPromises() + + await wrapper.get('#collection-name').setValue('新合集') + await wrapper.get('.collection-create-form').trigger('submit') + await flushPromises() + + expect(createCollection).toHaveBeenCalledWith({ name: '新合集' }) + expect(wrapper.findAll('.collection-row')).toHaveLength(2) + expect(wrapper.text()).toContain('合集已创建') + }) + + it('进入详情后保留重命名、返回和删除操作', async () => { + const wrapper = mount(CollectionsWorkspace) + await flushPromises() + + await wrapper.get('[aria-label="打开合集 会议反应"]').trigger('click') + await flushPromises() + expect(collection).toHaveBeenCalledWith('collection-1') + expect(wrapper.get('h1').text()).toBe('会议反应') + expect(wrapper.get('.collection-gallery')).toBeTruthy() + + window.prompt = vi.fn(() => '改名后的合集') + collections.mockResolvedValueOnce({ items: [summary] }) + await wrapper.get('.collection-detail-actions .quiet').trigger('click') + await flushPromises() + expect(renameCollection).toHaveBeenCalledWith('collection-1', { name: '改名后的合集' }) + expect(wrapper.get('h1').text()).toBe('改名后的合集') + + collections.mockResolvedValueOnce({ items: [summary] }) + await wrapper.get('.collection-back').trigger('click') + await flushPromises() + expect(wrapper.get('h1').text()).toBe('合集') + + await wrapper.get('[aria-label="打开合集 会议反应"]').trigger('click') + await flushPromises() + window.confirm = vi.fn(() => true) + collections.mockResolvedValueOnce({ items: [] }) + await wrapper.get('.collection-detail-actions .collection-danger').trigger('click') + await flushPromises() + expect(deleteCollection).toHaveBeenCalledWith('collection-1') + expect(wrapper.get('h1').text()).toBe('合集') + }) + + it('在资产卡片内移除成员并刷新详情', async () => { + const wrapper = mount(CollectionsWorkspace) + await flushPromises() + await wrapper.get('[aria-label="打开合集 会议反应"]').trigger('click') + await flushPromises() + + collection.mockResolvedValueOnce({ ...summary, member_count: 0, members: [] }) + await wrapper.get('[aria-label="从合集移除 a-very-long-image-name-that-stays-readable.png"]').trigger('click') + await flushPromises() + + expect(removeCollectionMember).toHaveBeenCalledWith('collection-1', 'meme-1') + expect(wrapper.get('.collection-empty').text()).toContain('这个合集还没有图片') + expect(wrapper.text()).toContain('图片已从合集移除') + }) +}) diff --git a/frontend/src/components/CollectionsWorkspace.vue b/frontend/src/components/CollectionsWorkspace.vue index d85e488..f6a6d66 100644 --- a/frontend/src/components/CollectionsWorkspace.vue +++ b/frontend/src/components/CollectionsWorkspace.vue @@ -55,7 +55,9 @@ async function renameCollection(item: CollectionSummary): Promise { try { await api.renameCollection(item.collection_id, { name }) notice.value = '合集已重命名' - if (selectedCollection.value?.collection_id === item.collection_id) selectedCollection.value.name = name + if (selectedCollection.value?.collection_id === item.collection_id) { + selectedCollection.value = { ...selectedCollection.value, name } + } await loadCollections() } catch (reason) { emit('error', errorMessage(reason)) @@ -128,45 +130,70 @@ onMounted(loadCollections)