mirror of
https://github.com/MemeMeow-Studio/MemeMeow.git
synced 2026-10-17 04:41:01 +08:00
feat: enhance CollectionsWorkspace with improved UI and functionality
- Refactor collection renaming logic to maintain state integrity. - Update UI elements for better accessibility and user experience in CollectionsWorkspace. - Implement responsive design for collection details and asset layout. - Add comprehensive tests for CollectionsWorkspace and LibraryWorkspace components. - Introduce sync_env script for managing .env files with safety and structure. - Create tests for sync_env functionality to ensure correct behavior and error handling.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
@@ -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('图片已从合集移除')
|
||||
})
|
||||
})
|
||||
@@ -55,7 +55,9 @@ async function renameCollection(item: CollectionSummary): Promise<void> {
|
||||
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)
|
||||
|
||||
<template>
|
||||
<section class="workspace" :aria-busy="busy">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<div class="section-head" :class="{ 'collection-section-head': selectedCollection }">
|
||||
<div class="collection-heading">
|
||||
<button v-if="selectedCollection" class="collection-back" type="button" @click="backToCollections">
|
||||
返回合集列表
|
||||
</button>
|
||||
<h1>{{ selectedCollection ? selectedCollection.name : '合集' }}</h1>
|
||||
<p>{{ selectedCollection ? '管理合集成员。删除合集不会删除图片。' : '用合集组织图片库中的资产。' }}</p>
|
||||
<p v-if="selectedCollection">
|
||||
{{ members.length }} 张图片<span aria-hidden="true"> · </span>删除合集不会删除图片。
|
||||
</p>
|
||||
<p v-else>用合集组织图片库中的资产。</p>
|
||||
</div>
|
||||
<div v-if="selectedCollection" class="collection-detail-actions">
|
||||
<div v-if="selectedCollection" class="collection-detail-actions" role="group" aria-label="合集管理操作">
|
||||
<button class="quiet" type="button" :disabled="busy" @click="renameCollection(selectedCollection)">重命名</button>
|
||||
<button class="quiet" type="button" :disabled="busy" @click="deleteCollection(selectedCollection)">删除</button>
|
||||
<button class="quiet" type="button" @click="backToCollections">返回合集</button>
|
||||
<button class="quiet collection-danger" type="button" :disabled="busy" @click="deleteCollection(selectedCollection)">删除合集</button>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="selectedCollection">
|
||||
<div class="library-list" role="list">
|
||||
<article v-for="item in members" :key="item.meme_id" class="library-row" role="listitem">
|
||||
<button class="library-preview-trigger" type="button" :aria-label="`查看 ${item.filename} 图片与元数据`" @click="openImagePreview(item, $event)">
|
||||
<div class="collection-gallery" role="list" aria-label="合集图片">
|
||||
<article v-for="item in members" :key="item.meme_id" class="collection-asset" role="listitem">
|
||||
<button class="collection-asset-media" type="button" :aria-label="`查看 ${item.filename} 图片与元数据`" @click="openImagePreview(item, $event)">
|
||||
<img :src="item.media_url" :alt="`预览 ${item.filename}`" loading="lazy" />
|
||||
</button>
|
||||
<div class="file-meta"><strong :title="item.filename">{{ item.filename }}</strong><small>{{ item.extension }}</small></div>
|
||||
<button class="quiet" type="button" :disabled="busy" @click="removeMember(item)">移除</button>
|
||||
<div class="collection-asset-meta">
|
||||
<strong :title="item.filename">{{ item.filename }}</strong>
|
||||
<small>{{ item.extension || '图片' }}</small>
|
||||
<button class="quiet collection-remove" type="button" :disabled="busy" :aria-label="`从合集移除 ${item.filename}`" @click="removeMember(item)">
|
||||
移除
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
<div v-if="!members.length" class="empty-state compact"><h2>这个合集还没有图片</h2><p>在图片库选择图片后加入合集。</p></div>
|
||||
<div v-if="!members.length" class="empty-state compact collection-empty">
|
||||
<h2>这个合集还没有图片</h2>
|
||||
<p>在图片库选择图片后加入合集。</p>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="notice" class="inline-notice" role="status">{{ notice }}</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="toolbar collection-toolbar">
|
||||
<input v-model="collectionName" aria-label="合集名称" placeholder="新合集名称" @keyup.enter="createCollection" />
|
||||
<button class="primary" type="button" :disabled="busy || !collectionName.trim()" @click="createCollection">创建合集</button>
|
||||
</div>
|
||||
<form class="collection-create-form" aria-label="创建合集" @submit.prevent="createCollection">
|
||||
<label class="sr-only" for="collection-name">新合集名称</label>
|
||||
<input id="collection-name" v-model="collectionName" placeholder="新合集名称" autocomplete="off" />
|
||||
<button class="primary" type="submit" :disabled="busy || !collectionName.trim()">
|
||||
{{ busy ? '创建中...' : '创建合集' }}
|
||||
</button>
|
||||
</form>
|
||||
<p v-if="notice" class="inline-notice" role="status">{{ notice }}</p>
|
||||
<div class="library-list" role="list">
|
||||
<article v-for="item in collections" :key="item.collection_id" class="library-row collection-row" role="listitem">
|
||||
<button class="collection-open" type="button" :disabled="busy" @click="openCollection(item)">
|
||||
<img v-if="item.cover_media_url" :src="item.cover_media_url" :alt="item.name" />
|
||||
<span class="file-meta"><strong>{{ item.name }}</strong><small>{{ item.member_count }} 张图片</small></span>
|
||||
<div v-if="busy && !collections.length" class="empty-state compact collection-loading" role="status">
|
||||
<h2>正在加载合集</h2>
|
||||
</div>
|
||||
<div v-else class="collection-list" role="list" aria-label="合集列表">
|
||||
<article v-for="item in collections" :key="item.collection_id" class="collection-row" role="listitem">
|
||||
<button class="collection-open" type="button" :disabled="busy" :aria-label="`打开合集 ${item.name}`" @click="openCollection(item)">
|
||||
<span class="collection-cover" aria-hidden="true">
|
||||
<img v-if="item.cover_media_url" :src="item.cover_media_url" alt="" loading="lazy" />
|
||||
<span v-else>无图</span>
|
||||
</span>
|
||||
<span class="collection-summary">
|
||||
<strong :title="item.name">{{ item.name }}</strong>
|
||||
<span class="collection-count">{{ item.member_count ?? 0 }} 张图片</span>
|
||||
</span>
|
||||
</button>
|
||||
<div class="collection-actions">
|
||||
<div class="collection-actions" role="group" aria-label="合集操作">
|
||||
<button class="quiet" type="button" :disabled="busy" @click="renameCollection(item)">重命名</button>
|
||||
<button class="quiet" type="button" :disabled="busy" @click="deleteCollection(item)">删除</button>
|
||||
<button class="quiet collection-danger" type="button" :disabled="busy" @click="deleteCollection(item)">删除合集</button>
|
||||
</div>
|
||||
</article>
|
||||
<div v-if="!collections.length && !busy" class="empty-state compact"><h2>还没有合集</h2><p>创建合集后可从图片库添加成员。</p></div>
|
||||
|
||||
@@ -15,6 +15,7 @@ vi.mock('../api', () => ({
|
||||
api: { images, imageMetadata, contextBatch, retryImageStagesBatch, unreadyProcessing, submitImageStage },
|
||||
}))
|
||||
|
||||
import { api } from '../api'
|
||||
import LibraryWorkspace from './LibraryWorkspace.vue'
|
||||
|
||||
const image = {
|
||||
@@ -123,6 +124,50 @@ describe('LibraryWorkspace', () => {
|
||||
expect(unreadyProcessing.mock.calls[0][0]).not.toHaveProperty('cursor')
|
||||
})
|
||||
|
||||
it('即使完整重试能力尚未注入,点击主按钮也先显示图片处理选项且不请求', async () => {
|
||||
const submitUnreadyProcessing = api.unreadyProcessing
|
||||
delete api.unreadyProcessing
|
||||
try {
|
||||
const wrapper = mount(LibraryWorkspace, {
|
||||
props: { config: null, cacheTask: null, cacheBusy: false, refreshToken: 0 },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
await fullRetryButton(wrapper).trigger('click')
|
||||
|
||||
expect(wrapper.get('[role="dialog"][aria-labelledby="processing-options-title"]').text()).toContain('图片处理选项')
|
||||
expect(unreadyProcessing).not.toHaveBeenCalled()
|
||||
await wrapper.get('[aria-label="取消图片处理"]').trigger('click')
|
||||
expect(unreadyProcessing).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
} finally {
|
||||
api.unreadyProcessing = submitUnreadyProcessing
|
||||
}
|
||||
})
|
||||
|
||||
it('提交失败时保留已选处理选项,允许用户再次确认', async () => {
|
||||
unreadyProcessing.mockRejectedValueOnce(new Error('network_failed'))
|
||||
const wrapper = mount(LibraryWorkspace, {
|
||||
props: { config: { reverse_image_available: true }, cacheTask: null, cacheBusy: false, refreshToken: 0 },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
await fullRetryButton(wrapper).trigger('click')
|
||||
await wrapper.get('.processing-options-dialog input[value="auto"]').setValue(true)
|
||||
await wrapper.get('.processing-options-dialog input[type="checkbox"]').setValue(true)
|
||||
await wrapper.get('.processing-options-dialog form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.get('.processing-options-dialog input[value="auto"]').element.checked).toBe(true)
|
||||
expect(wrapper.get('.processing-options-dialog input[type="checkbox"]').element.checked).toBe(true)
|
||||
expect(unreadyProcessing).toHaveBeenCalledTimes(1)
|
||||
|
||||
await wrapper.get('.processing-options-dialog form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(unreadyProcessing).toHaveBeenCalledTimes(2)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('按提交、复用、冲突和失败分类展示 scope 级摘要及逐图结果', async () => {
|
||||
unreadyProcessing.mockResolvedValue({
|
||||
target_count: 4,
|
||||
|
||||
@@ -258,10 +258,6 @@ async function confirmRetrySelected(payload: { mode: SelectedImageRetryMode; sta
|
||||
/** 打开 scope 级完整重试的共享选项对话框。 */
|
||||
function openUnreadyOptions(event: MouseEvent): void {
|
||||
if (retryBusy.value) return
|
||||
if (typeof api.unreadyProcessing !== 'function') {
|
||||
emit('error', '完整重试服务不可用')
|
||||
return
|
||||
}
|
||||
if (!preserveRetryOptions.value) retryOptions.value = { reverse_image_policy: 'forbid', auto_name: false }
|
||||
retryDetails.value = []
|
||||
processingOptionsTrigger.value = event.currentTarget instanceof HTMLElement ? event.currentTarget : null
|
||||
@@ -284,9 +280,15 @@ async function confirmUnreadyOptions(options: ImageProcessingOptions): Promise<v
|
||||
retryNotice.value = ''
|
||||
retryOptions.value = options
|
||||
preserveRetryOptions.value = true
|
||||
const submitUnreadyProcessing = api.unreadyProcessing
|
||||
if (typeof submitUnreadyProcessing !== 'function') {
|
||||
// API 能力尚未注入时也先保留选项弹层,避免点击主按钮变成无反馈操作。
|
||||
emit('error', '完整重试服务不可用')
|
||||
return
|
||||
}
|
||||
retryBusy.value = true
|
||||
try {
|
||||
const response = await api.unreadyProcessing(options) as UnreadyProcessingResponse
|
||||
const response = await submitUnreadyProcessing(options) as UnreadyProcessingResponse
|
||||
retryDetails.value = response.results || []
|
||||
retryNotice.value = `完整重试:目标 ${response.target_count ?? 0},提交 ${response.submitted_count ?? 0},复用 ${response.reused_count ?? 0},冲突 ${response.conflict_count ?? 0},失败 ${response.failed_count ?? 0}`
|
||||
processingOptionsOpen.value = false
|
||||
|
||||
+42
-5
@@ -298,12 +298,40 @@ button, input, select, textarea { border-radius: var(--radius-control); }
|
||||
.retry-stage-hint.error { color: var(--error); }
|
||||
.collection-form { display: grid; gap: 16px; padding: 20px 24px 24px; }
|
||||
.collection-inline-create { display: grid; gap: 12px; border-top: 1px solid var(--line); padding-top: 16px; }
|
||||
.collection-toolbar { align-items: stretch; }
|
||||
/* 合集沿用工作台阅读轴,列表和详情只增加必要的资产布局。 */
|
||||
.collection-heading { min-width: 0; }
|
||||
.collection-back { display: block; min-height: 44px; margin: -4px 0 8px; border: 0; padding: 0; color: var(--accent-deep); background: transparent; font-size: 15px; font-weight: 700; line-height: 1.3; text-align: left; }
|
||||
.collection-back:hover { color: var(--ink); text-decoration: underline; text-underline-offset: 3px; }
|
||||
.collection-detail-actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 8px; min-width: 0; }
|
||||
.collection-row { width: 100%; border: 0; border-bottom: 1px solid var(--line); background: transparent; text-align: left; }
|
||||
.collection-row img { display: block; width: 64px; height: 64px; object-fit: contain; border: 1px solid var(--line); border-radius: 5px; background: var(--surface); }
|
||||
.collection-open { display: flex; flex: 1; align-items: center; gap: 14px; border: 0; background: transparent; text-align: left; }
|
||||
.collection-actions { display: flex; gap: 8px; margin-left: auto; }
|
||||
.collection-danger { border-color: var(--line-error); color: var(--error); }
|
||||
.collection-danger:hover:not(:disabled) { border-color: var(--error); background: var(--surface-error); color: #8d3037; }
|
||||
.collection-create-form { display: grid; grid-template-columns: minmax(0, 1fr) max-content; align-items: stretch; gap: 10px; max-width: 720px; margin-bottom: 26px; }
|
||||
.collection-create-form input { width: 100%; min-width: 0; height: 50px; border: 1px solid var(--line-strong); padding: 0 14px; color: var(--ink-soft); background: var(--surface); font-size: 16px; }
|
||||
.collection-create-form input::placeholder { color: var(--muted-soft); opacity: 1; }
|
||||
.collection-create-form .primary { min-height: 50px; }
|
||||
.collection-list { border-top: 1px solid var(--line); }
|
||||
.collection-row { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) max-content; align-items: center; gap: 18px; min-height: 88px; border-bottom: 1px solid var(--line); padding: 11px 0; }
|
||||
.collection-open { display: grid; grid-template-columns: 64px minmax(0, 1fr); align-items: center; gap: 14px; min-width: 0; border: 0; padding: 0; color: var(--ink-soft); background: transparent; text-align: left; }
|
||||
.collection-open:hover:not(:disabled) .collection-summary strong { color: var(--accent-deep); }
|
||||
.collection-open:disabled { opacity: .72; }
|
||||
.collection-cover { display: grid; width: 64px; height: 64px; place-items: center; overflow: hidden; border: 1px solid var(--line); border-radius: 5px; color: var(--muted-soft); background: var(--surface-muted); font-size: 13px; }
|
||||
.collection-cover img { display: block; width: 100%; height: 100%; object-fit: contain; background: var(--surface); }
|
||||
.collection-summary { display: grid; gap: 5px; min-width: 0; }
|
||||
.collection-summary strong { display: block; min-width: 0; overflow: hidden; color: var(--ink-soft); font-size: 16px; font-weight: 700; line-height: 1.3; overflow-wrap: anywhere; text-overflow: ellipsis; }
|
||||
.collection-count { display: block; color: var(--muted-soft); font-size: 14px; line-height: 1.35; white-space: nowrap; }
|
||||
.collection-actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 8px; }
|
||||
.collection-gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 16px; }
|
||||
.collection-asset { min-width: 0; overflow: hidden; border: 1px solid var(--line); border-radius: var(--radius-surface); background: var(--surface); }
|
||||
.collection-asset-media { display: grid; width: 100%; aspect-ratio: 1; place-items: center; overflow: hidden; border: 0; border-bottom: 1px solid var(--line); border-radius: 0; padding: 0; background: var(--workspace); }
|
||||
.collection-asset-media:hover { background: var(--surface-muted); }
|
||||
.collection-asset-media img { display: block; width: 100%; height: 100%; object-fit: contain; }
|
||||
.collection-asset-meta { display: grid; grid-template-columns: minmax(0, 1fr) max-content; align-items: center; gap: 5px 10px; min-width: 0; padding: 10px 12px 11px; }
|
||||
.collection-asset-meta strong { grid-column: 1 / -1; display: -webkit-box; min-width: 0; overflow: hidden; color: var(--ink-soft); font-size: 15px; font-weight: 700; line-height: 1.35; overflow-wrap: anywhere; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
|
||||
.collection-asset-meta small { min-width: 0; color: var(--muted-soft); font-size: 14px; line-height: 1.3; }
|
||||
.collection-remove { min-height: 40px; padding-inline: 11px; color: var(--error); }
|
||||
.collection-remove:hover:not(:disabled) { border-color: var(--error); background: var(--surface-error); color: #8d3037; }
|
||||
.collection-empty { grid-column: 1 / -1; }
|
||||
.collection-loading { border-style: solid; background: var(--surface-muted); }
|
||||
.metadata-panel { min-height: 0; display: flex; flex-direction: column; overflow: hidden; border: 1px solid var(--line); border-radius: 6px; }
|
||||
.metadata-panel-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 13px 15px; border-bottom: 1px solid var(--line); }
|
||||
.metadata-panel-head h3 { margin: 0; color: var(--ink-soft); font-size: 17px; }
|
||||
@@ -398,7 +426,15 @@ button, input, select, textarea { border-radius: var(--radius-control); }
|
||||
.task-toolbar select { width: 100%; min-width: 0; font-size: 16px; }
|
||||
.task-drawer-backdrop { inset: 0; }
|
||||
.task-drawer { width: 100%; padding: 24px 18px; }
|
||||
.collection-section-head { display: grid; gap: 14px; }
|
||||
.collection-detail-actions { justify-content: flex-start; width: 100%; }
|
||||
.collection-create-form { grid-template-columns: minmax(0, 1fr); max-width: none; }
|
||||
.collection-create-form .primary { width: 100%; }
|
||||
.collection-row { grid-template-columns: minmax(0, 1fr); gap: 10px; padding: 12px 0; }
|
||||
.collection-actions { justify-content: flex-start; }
|
||||
.collection-gallery { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
||||
.collection-asset-meta { padding-inline: 10px; }
|
||||
.collection-asset-meta strong { font-size: 14px; }
|
||||
.image-dialog-backdrop { padding: 10px; place-items: stretch; }
|
||||
.image-dialog { max-height: calc(100vh - 20px); }
|
||||
.image-dialog-head { padding: 16px; }
|
||||
@@ -425,6 +461,7 @@ button, input, select, textarea { border-radius: var(--radius-control); }
|
||||
.cache-pill { display: inline-flex; margin-top: 14px; }
|
||||
.toolbar > input, .toolbar > select, .toolbar > button { flex-basis: 100%; width: 100%; }
|
||||
.toolbar-group > button { flex-basis: 100%; width: 100%; }
|
||||
.collection-gallery { grid-template-columns: minmax(0, 1fr); }
|
||||
.processing-options-actions { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
"""项目维护脚本:按 ``.env.example`` 的结构同步本地 ``.env``。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import tempfile
|
||||
from collections.abc import Sequence
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv.parser import Binding, parse_stream
|
||||
|
||||
|
||||
def _parse_bindings(content: str, source: Path) -> list[Binding]:
|
||||
"""解析 dotenv 文本并拒绝语法错误,供合并前的数据校验调用。"""
|
||||
bindings = list(parse_stream(StringIO(content)))
|
||||
errors = [str(binding.original.line) for binding in bindings if binding.error]
|
||||
if errors:
|
||||
lines = ", ".join(errors)
|
||||
raise ValueError(f"{source} 存在无法解析的 dotenv 内容(起始行:{lines})")
|
||||
return bindings
|
||||
|
||||
|
||||
def _assignment_parts(binding: Binding) -> tuple[str, str, str] | None:
|
||||
"""拆出赋值语句的前缀、原始值和后缀,避免改变引号及变量插值语义。"""
|
||||
text = binding.original.string
|
||||
quote: str | None = None
|
||||
equals_at: int | None = None
|
||||
for index, character in enumerate(text):
|
||||
if character in {"'", '"'}:
|
||||
escaped = index > 0 and text[index - 1] == "\\"
|
||||
if not escaped and (quote is None or quote == character):
|
||||
quote = None if quote == character else character
|
||||
elif character == "=" and quote is None:
|
||||
equals_at = index
|
||||
break
|
||||
elif character in {"\r", "\n"} and quote is None and text[:index].strip():
|
||||
break
|
||||
if equals_at is None:
|
||||
return None
|
||||
|
||||
value_start = equals_at + 1
|
||||
while value_start < len(text) and text[value_start].isspace() and text[value_start] not in {"\r", "\n"}:
|
||||
value_start += 1
|
||||
|
||||
if value_start < len(text) and text[value_start] in {"'", '"'}:
|
||||
delimiter = text[value_start]
|
||||
value_end = value_start + 1
|
||||
while value_end < len(text):
|
||||
if text[value_end] == delimiter:
|
||||
backslashes = 0
|
||||
cursor = value_end - 1
|
||||
while cursor >= value_start and text[cursor] == "\\":
|
||||
backslashes += 1
|
||||
cursor -= 1
|
||||
if backslashes % 2 == 0:
|
||||
value_end += 1
|
||||
break
|
||||
value_end += 1
|
||||
else:
|
||||
line_end = len(text)
|
||||
for newline in ("\r", "\n"):
|
||||
position = text.find(newline, value_start)
|
||||
if position >= 0:
|
||||
line_end = min(line_end, position)
|
||||
unquoted = text[value_start:line_end]
|
||||
comment = re.search(r"[^\S\r\n]+#", unquoted)
|
||||
value_end = value_start + (comment.start() if comment else len(unquoted.rstrip()))
|
||||
return text[:value_start], text[value_start:value_end], text[value_end:]
|
||||
|
||||
|
||||
def _binding_head(binding: Binding) -> tuple[str, str]:
|
||||
"""拆出模板字段名及其后缀,供无等号声明和普通赋值相互转换。"""
|
||||
match = re.match(r"\s*(?:export[^\S\r\n]+)?(?:'[^']+'|[^=#\s]+)", binding.original.string)
|
||||
if match is None:
|
||||
raise ValueError(f"无法定位 dotenv 字段:{binding.key}")
|
||||
return binding.original.string[: match.end()], binding.original.string[match.end() :]
|
||||
|
||||
|
||||
def _without_leading_blank_lines(text: str) -> str:
|
||||
"""移除解析器归入字段的前置空行,供模板外字段统一追加到文件尾部。"""
|
||||
return re.sub(r"\A(?:[^\S\r\n]*(?:\r\n|\n|\r))+", "", text)
|
||||
|
||||
|
||||
def _newline_for(content: str) -> str:
|
||||
"""选择模板使用的换行符,供追加字段时保持文件风格一致。"""
|
||||
return "\r\n" if "\r\n" in content else "\n"
|
||||
|
||||
|
||||
def _read_text(path: Path) -> str:
|
||||
"""读取 dotenv 原文且不转换换行符,供同步过程保留模板的文件风格。"""
|
||||
with path.open("r", encoding="utf-8", newline="") as handle:
|
||||
return handle.read()
|
||||
|
||||
|
||||
def merge_env_text(example: str, current: str, *, example_path: Path, env_path: Path) -> str:
|
||||
"""以模板结构合并 dotenv 文本,返回可直接写入目标文件的完整内容。"""
|
||||
example_bindings = _parse_bindings(example, example_path)
|
||||
current_bindings = _parse_bindings(current, env_path)
|
||||
|
||||
current_by_key: dict[str, Binding] = {}
|
||||
for binding in current_bindings:
|
||||
if binding.key is not None:
|
||||
# 字典保留字段首次出现的位置,同时让最后一次定义提供最终生效值。
|
||||
current_by_key[binding.key] = binding
|
||||
|
||||
template_keys = {binding.key for binding in example_bindings if binding.key is not None}
|
||||
output: list[str] = []
|
||||
for binding in example_bindings:
|
||||
source = current_by_key.get(binding.key) if binding.key is not None else None
|
||||
if source is None:
|
||||
output.append(binding.original.string)
|
||||
continue
|
||||
|
||||
template_parts = _assignment_parts(binding)
|
||||
source_parts = _assignment_parts(source)
|
||||
if template_parts is None and source_parts is None:
|
||||
output.append(binding.original.string)
|
||||
continue
|
||||
if template_parts is None:
|
||||
head, suffix = _binding_head(binding)
|
||||
output.append(f"{head}={source_parts[1]}{suffix}")
|
||||
continue
|
||||
prefix, _, suffix = template_parts
|
||||
if source_parts is None:
|
||||
head, _ = _binding_head(binding)
|
||||
output.append(f"{head}{suffix}")
|
||||
else:
|
||||
output.append(f"{prefix}{source_parts[1]}{suffix}")
|
||||
|
||||
extras = [
|
||||
_without_leading_blank_lines(binding.original.string)
|
||||
for key, binding in current_by_key.items()
|
||||
if key not in template_keys
|
||||
]
|
||||
if not extras:
|
||||
return "".join(output)
|
||||
|
||||
newline = _newline_for(example)
|
||||
merged = "".join(output)
|
||||
if merged and not merged.endswith(("\n", "\r")):
|
||||
merged += newline
|
||||
if merged and not merged.endswith(newline * 2):
|
||||
merged += newline
|
||||
for extra in extras:
|
||||
merged += extra
|
||||
if not merged.endswith(("\n", "\r")):
|
||||
merged += newline
|
||||
return merged
|
||||
|
||||
|
||||
def _validate_target(target: Path) -> None:
|
||||
"""确认写入目标不会跟随符号链接或覆盖非普通文件。"""
|
||||
if target.is_symlink():
|
||||
raise ValueError(f"拒绝写入符号链接:{target}")
|
||||
if target.exists() and not target.is_file():
|
||||
raise ValueError(f"写入目标不是普通文件:{target}")
|
||||
for parent in (target.parent, *target.parent.parents):
|
||||
if parent == parent.parent:
|
||||
break
|
||||
if parent.is_symlink():
|
||||
raise ValueError(f"拒绝通过符号链接目录写入:{parent}")
|
||||
|
||||
|
||||
def _atomic_write(target: Path, content: str) -> None:
|
||||
"""以 ``0600`` 权限原子替换目标,避免密钥短暂暴露或留下半写文件。"""
|
||||
_validate_target(target)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
descriptor, temporary_name = tempfile.mkstemp(prefix=f".{target.name}.", dir=target.parent)
|
||||
temporary = Path(temporary_name)
|
||||
try:
|
||||
os.fchmod(descriptor, stat.S_IRUSR | stat.S_IWUSR)
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8", newline="") as handle:
|
||||
handle.write(content)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(temporary, target)
|
||||
try:
|
||||
directory_descriptor = os.open(target.parent, os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(directory_descriptor)
|
||||
finally:
|
||||
os.close(directory_descriptor)
|
||||
except OSError:
|
||||
pass
|
||||
except Exception:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
def sync_env(example_path: str | Path = ".env.example", env_path: str | Path = ".env") -> Path:
|
||||
"""读取模板和已有配置、完成合并并原子写回,供 CLI 或其他维护脚本调用。"""
|
||||
example = Path(example_path).expanduser()
|
||||
target = Path(env_path).expanduser()
|
||||
if example.resolve() == target.resolve():
|
||||
raise ValueError("模板与输出不能是同一个文件")
|
||||
if not example.is_file():
|
||||
raise FileNotFoundError(f"dotenv 模板不存在:{example}")
|
||||
_validate_target(target)
|
||||
example_text = _read_text(example)
|
||||
current_text = _read_text(target) if target.exists() else ""
|
||||
merged = merge_env_text(example_text, current_text, example_path=example, env_path=target)
|
||||
_atomic_write(target, merged)
|
||||
return target
|
||||
|
||||
|
||||
def _parser() -> argparse.ArgumentParser:
|
||||
"""构造命令行解析器,供入口函数和 CLI 测试复用。"""
|
||||
parser = argparse.ArgumentParser(description="以 .env.example 为蓝本同步 .env")
|
||||
parser.add_argument("--example", default=".env.example", help="dotenv 模板路径(默认:.env.example)")
|
||||
parser.add_argument("--env", default=".env", help="dotenv 输出路径(默认:.env)")
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
"""执行 dotenv 同步命令,并仅输出不含配置值的结果摘要。"""
|
||||
parser = _parser()
|
||||
arguments = parser.parse_args(argv)
|
||||
try:
|
||||
target = sync_env(arguments.example, arguments.env)
|
||||
except (OSError, ValueError) as error:
|
||||
parser.error(str(error))
|
||||
print(f"已根据 {arguments.example} 生成 {target}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,122 @@
|
||||
"""dotenv 模板同步脚本的合并规则与安全写入测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import stat
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts.sync_env import merge_env_text, sync_env
|
||||
|
||||
|
||||
def test_merge_uses_template_structure_and_preserves_raw_values(tmp_path: Path) -> None:
|
||||
"""同名字段只移植原始值,模板的顺序、注释、默认值和换行保持不变。"""
|
||||
example_path = tmp_path / ".env.example"
|
||||
env_path = tmp_path / ".env"
|
||||
example = "# group\nA=template # keep\nB=default\nMULTI=default\n"
|
||||
current = "B='old # value' # discard\nA=${OTHER}\nMULTI=\"first\nsecond\"\n"
|
||||
|
||||
merged = merge_env_text(example, current, example_path=example_path, env_path=env_path)
|
||||
|
||||
assert merged == "# group\nA=${OTHER} # keep\nB='old # value'\nMULTI=\"first\nsecond\"\n"
|
||||
|
||||
|
||||
def test_sync_creates_env_from_template_with_safe_permissions(tmp_path: Path) -> None:
|
||||
"""目标不存在时直接复制模板语义,并把新文件权限限制为当前用户读写。"""
|
||||
example = tmp_path / ".env.example"
|
||||
target = tmp_path / ".env"
|
||||
example.write_text("# template\nA=default\n", encoding="utf-8")
|
||||
|
||||
assert sync_env(example, target) == target
|
||||
|
||||
assert target.read_text(encoding="utf-8") == "# template\nA=default\n"
|
||||
assert stat.S_IMODE(target.stat().st_mode) == 0o600
|
||||
|
||||
|
||||
def test_sync_appends_only_final_extra_definitions_in_original_order(tmp_path: Path) -> None:
|
||||
"""模板外字段按首次出现顺序追加,重复字段只保留最后一次生效定义。"""
|
||||
example = tmp_path / ".env.example"
|
||||
target = tmp_path / ".env"
|
||||
example.write_text("A=default\n", encoding="utf-8")
|
||||
target.write_text("EXTRA_B=first\nEXTRA_A=one\nEXTRA_B=final # keep\nA=local\n", encoding="utf-8")
|
||||
|
||||
sync_env(example, target)
|
||||
|
||||
assert target.read_text(encoding="utf-8") == (
|
||||
"A=local\n\nEXTRA_B=final # keep\nEXTRA_A=one\n"
|
||||
)
|
||||
|
||||
|
||||
def test_sync_is_idempotent(tmp_path: Path) -> None:
|
||||
"""对已同步文件再次运行不会产生空行、排序或内容漂移。"""
|
||||
example = tmp_path / ".env.example"
|
||||
target = tmp_path / ".env"
|
||||
example.write_text("# group\nA=default\nB=default\n", encoding="utf-8")
|
||||
target.write_text("B=local\nEXTRA=value\n", encoding="utf-8")
|
||||
|
||||
sync_env(example, target)
|
||||
first = target.read_bytes()
|
||||
sync_env(example, target)
|
||||
|
||||
assert target.read_bytes() == first
|
||||
|
||||
|
||||
def test_sync_preserves_crlf_from_template(tmp_path: Path) -> None:
|
||||
"""磁盘读写不应把模板的 CRLF 换行静默转换为 LF。"""
|
||||
example = tmp_path / ".env.example"
|
||||
target = tmp_path / ".env"
|
||||
example.write_bytes(b"A=default\r\nB=default\r\n")
|
||||
target.write_bytes(b"A=local\r\nEXTRA=value\r\n")
|
||||
|
||||
sync_env(example, target)
|
||||
|
||||
assert target.read_bytes() == b"A=local\r\nB=default\r\n\r\nEXTRA=value\r\n"
|
||||
|
||||
|
||||
def test_merge_preserves_no_assignment_semantics(tmp_path: Path) -> None:
|
||||
"""合法的无等号声明保持 None 语义,模板声明也能接收已有的普通值。"""
|
||||
example_path = tmp_path / ".env.example"
|
||||
env_path = tmp_path / ".env"
|
||||
|
||||
assert merge_env_text(
|
||||
"# group\nA=default # keep\n",
|
||||
"export A\n",
|
||||
example_path=example_path,
|
||||
env_path=env_path,
|
||||
) == "# group\nA # keep\n"
|
||||
assert merge_env_text(
|
||||
"# group\nA # keep\n",
|
||||
"export A='local value'\n",
|
||||
example_path=example_path,
|
||||
env_path=env_path,
|
||||
) == "# group\nA='local value' # keep\n"
|
||||
|
||||
|
||||
def test_sync_rejects_invalid_env_without_modifying_it(tmp_path: Path) -> None:
|
||||
"""旧配置含语法错误时中止同步,避免在覆盖时静默丢失无法识别的字段。"""
|
||||
example = tmp_path / ".env.example"
|
||||
target = tmp_path / ".env"
|
||||
example.write_text("A=default\n", encoding="utf-8")
|
||||
original = b"A=local\nBROKEN LINE\n"
|
||||
target.write_bytes(original)
|
||||
|
||||
with pytest.raises(ValueError, match="无法解析"):
|
||||
sync_env(example, target)
|
||||
|
||||
assert target.read_bytes() == original
|
||||
|
||||
|
||||
def test_sync_rejects_symlink_target(tmp_path: Path) -> None:
|
||||
"""输出路径为符号链接时拒绝写入,防止脚本越过调用者指定的目标。"""
|
||||
example = tmp_path / ".env.example"
|
||||
actual = tmp_path / "actual.env"
|
||||
target = tmp_path / ".env"
|
||||
example.write_text("A=default\n", encoding="utf-8")
|
||||
actual.write_text("A=local\n", encoding="utf-8")
|
||||
target.symlink_to(actual)
|
||||
|
||||
with pytest.raises(ValueError, match="符号链接"):
|
||||
sync_env(example, target)
|
||||
|
||||
assert actual.read_text(encoding="utf-8") == "A=local\n"
|
||||
Reference in New Issue
Block a user