feat(i18n): T15 full-stack language audit with coverage assertions
- viewer/admin 错误码→文案映射按契约 ErrorCodes 枚举全集登记(机器守护 spec 遍历枚举断言无缺漏 + 映射键在 zh/en 词条均存在 + locale 键集 递归相等);mobile 镜像补齐 DOWNLOAD_DISABLED(P4a 增补漏登) - 漏译修复:viewer.photoRemoved en 侧缺失(审计测试捕获); DOWNLOAD_DISABLED 观众侧专属文案 + useDownload 专属提示接线; admin apiMsg 增 RATE_LIMITED/ALBUM_NOT_PUBLIC/DOWNLOAD_DISABLED/ SYNC_AUTH_FAILED 四码词条 - 运行时抽查:admin 主旅程 en 版 e2e(登录→相册管理全英文 + 偏好 持久);embed iframe lang=en 渲染与深链跟随 e2e(zh 侧既有用例覆盖) - contracts 新增 ./errors 浏览器安全子路径(errors.ts 纯 TS); admin 增 @photo/contracts devDep(仅测试消费) Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import { flushPromises, mount, type VueWrapper } from '@vue/test-utils';
|
||||
import { defineComponent, ref } from 'vue';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
ApiError,
|
||||
getPhotoDownload,
|
||||
getPublicPhotos,
|
||||
type PublicAlbum,
|
||||
@@ -250,6 +251,20 @@ describe('photo download (P4a Task 4)', () => {
|
||||
expect(openSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('DOWNLOAD_DISABLED surfaces the dedicated localized notice (T15 漏译修复)', async () => {
|
||||
// 相册中途关闭下载开关(载荷仍开但端点 403)→ 专属文案而非通用失败。
|
||||
mockedGetPhotoDownload.mockReset().mockRejectedValue(
|
||||
new ApiError('DOWNLOAD_DISABLED', 'downloads disabled', 403, null),
|
||||
);
|
||||
const wrapper = mountLightbox(makeAlbum());
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('.control.download').trigger('click');
|
||||
await flushPromises();
|
||||
expect(wrapper.find('.download-failed[role="alert"]').text()).toContain('该相册未开放下载');
|
||||
expect(wrapper.find('.download-failed').text()).not.toContain('下载失败');
|
||||
});
|
||||
|
||||
it('WeChat webview: click opens the guidance overlay instead of hitting the endpoint', async () => {
|
||||
stubUserAgent(WECHAT_IOS_UA);
|
||||
const wrapper = mountLightbox(makeAlbum());
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ErrorCodes } from '@photo/contracts/errors';
|
||||
import { errorCodeI18nKeys } from '../api/errors';
|
||||
import en from '../i18n/locales/en';
|
||||
import zhCN from '../i18n/locales/zh-CN';
|
||||
|
||||
// T15 全端语言验收(P4b Task 6):错误码→文案映射的机器守护。
|
||||
// 1. 契约 ErrorCodes 枚举全集在本表无缺漏(A12「主要提示无漏译」);
|
||||
// 2. 表内每个文案键在 zh-CN / en 两份词条都真实存在(防键注册了但一侧
|
||||
// 没写文案的真漏译;点路径解析);
|
||||
// 3. zh-CN / en 两份词条键集合递归相等(模板级 zh/en 一致性,镜像 admin
|
||||
// 侧 locale 守护与 P3 mobile arb 完整性测试)。
|
||||
// 服务端不做文案本地化(错误体 message 仅英文诊断兜底,客户端按稳定 code
|
||||
// 映射——见 openapi.yaml info.description),故本表即观众侧错误文案全集。
|
||||
|
||||
type Messages = Record<string, unknown>;
|
||||
|
||||
/** 递归收集词条文件的叶子键点路径集合(errors.albumNotFound 等)。 */
|
||||
function leafPaths(messages: Messages, prefix = ''): Set<string> {
|
||||
const paths = new Set<string>();
|
||||
for (const [key, value] of Object.entries(messages)) {
|
||||
const path = prefix === '' ? key : `${prefix}.${key}`;
|
||||
if (value !== null && typeof value === 'object') {
|
||||
for (const child of leafPaths(value as Messages, path)) paths.add(child);
|
||||
} else {
|
||||
paths.add(path);
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
/** 按点路径取词条值(不存在返回 undefined)。 */
|
||||
function resolvePath(messages: Messages, path: string): unknown {
|
||||
let current: unknown = messages;
|
||||
for (const segment of path.split('.')) {
|
||||
if (current === null || typeof current !== 'object') return undefined;
|
||||
current = (current as Messages)[segment];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
describe('错误码文案覆盖(T15/A12)', () => {
|
||||
const zhPaths = leafPaths(zhCN as Messages);
|
||||
const enPaths = leafPaths(en as Messages);
|
||||
|
||||
it('契约 ErrorCodes 枚举全集在 errorCodeI18nKeys 无缺漏', () => {
|
||||
const codes = Object.values(ErrorCodes);
|
||||
expect(codes.length).toBeGreaterThanOrEqual(18); // 契约现存 18 码,只增不减
|
||||
for (const code of codes) {
|
||||
expect(errorCodeI18nKeys[code], `缺少错误码映射: ${code}`).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('映射表内每个文案键在 zh-CN 与 en 词条均存在且非空', () => {
|
||||
for (const [code, key] of Object.entries(errorCodeI18nKeys)) {
|
||||
for (const [locale, paths] of [
|
||||
['zh-CN', zhPaths],
|
||||
['en', enPaths],
|
||||
] as const) {
|
||||
expect(paths.has(key), `${code} 的文案键 ${key} 未登记于 ${locale} 词条`).toBe(true);
|
||||
const value = resolvePath((locale === 'zh-CN' ? zhCN : en) as Messages, key);
|
||||
expect(typeof value === 'string' && value.length > 0, `${key} 在 ${locale} 非非空字符串`).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('zh-CN 与 en 词条键集合递归相等(无单侧漏译)', () => {
|
||||
expect([...zhPaths].filter((path) => !enPaths.has(path))).toEqual([]);
|
||||
expect([...enPaths].filter((path) => !zhPaths.has(path))).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -2,19 +2,38 @@ import { NETWORK_ERROR_CODE } from './client';
|
||||
|
||||
// 错误码 → i18n 文案键映射表:401/404 等观众可认知的码给出确定性文案,
|
||||
// 未登记码统一走 errors.unknown(原始 message 仅用于日志/排查)。
|
||||
//
|
||||
// T15 全端语言验收(P4b Task 6):本表按契约枚举全集登记(机器守护见
|
||||
// __tests__/error-coverage.spec.ts,遍历 @photo/contracts ErrorCodes 断言
|
||||
// 无缺漏)。viewer 可达码给专属文案;管理/上传/内部域码(登录凭据、成员
|
||||
// 冲突、上传校验、服务间鉴权——viewer 无对应界面)按移动端先例防御性
|
||||
// 注册到通用文案,杜绝未来路由扩展时静默漏译。
|
||||
export const errorCodeI18nKeys: Readonly<Record<string, string>> = {
|
||||
// 客户端合成码
|
||||
[NETWORK_ERROR_CODE]: 'errors.network',
|
||||
// 401 域
|
||||
AUTH_TOKEN_EXPIRED: 'errors.authExpired',
|
||||
AUTH_FORBIDDEN: 'errors.forbidden',
|
||||
// 登录凭据错误:viewer 无登录面(App/admin 域),防御性注册。
|
||||
AUTH_INVALID_CREDENTIALS: 'errors.unknown',
|
||||
AUTH_RATE_LIMITED: 'errors.rateLimited',
|
||||
// P2 公开互动(likes/visits)匿名限速 429——viewer 直面。
|
||||
RATE_LIMITED: 'errors.rateLimited',
|
||||
AUTH_FORBIDDEN: 'errors.forbidden',
|
||||
// 404 域
|
||||
NOT_FOUND: 'errors.notFound',
|
||||
ALBUM_NOT_FOUND: 'errors.albumNotFound',
|
||||
ALBUM_NOT_PUBLIC: 'errors.albumNotAvailable',
|
||||
PHOTO_NOT_FOUND: 'errors.photoNotFound',
|
||||
PHOTO_NOT_PUBLIC: 'errors.photoNotFound',
|
||||
// P4a 下载两级开关(A18):相册可见但禁止下载——下载入口与文案直达。
|
||||
DOWNLOAD_DISABLED: 'errors.downloadDisabled',
|
||||
// 上传/成员域(admin 与 App 上传链路):viewer 不可达,防御性注册。
|
||||
ALBUM_UPLOAD_DISABLED: 'errors.unknown',
|
||||
MEMBER_CONFLICT: 'errors.unknown',
|
||||
UPLOAD_SESSION_CONFLICT: 'errors.unknown',
|
||||
UPLOAD_VERIFY_FAILED: 'errors.unknown',
|
||||
// 服务间内部同步鉴权(/internal/**):外部客户端不可达,防御性注册。
|
||||
SYNC_AUTH_FAILED: 'errors.unknown',
|
||||
// 其余
|
||||
VALIDATION_FAILED: 'errors.validationFailed',
|
||||
INTERNAL_ERROR: 'errors.internal',
|
||||
|
||||
@@ -439,9 +439,9 @@ onBeforeUnmount(() => {
|
||||
|
||||
<p v-if="extending" class="loading">{{ t('album.loading') }}</p>
|
||||
|
||||
<!-- 下载失败轻提示(端点失败/网络;可重点击重试)。 -->
|
||||
<!-- 下载失败轻提示(端点失败/网络;DOWNLOAD_DISABLED 专属文案;可重点击重试)。 -->
|
||||
<p v-if="download !== undefined && download.failed" class="download-failed" role="alert">
|
||||
{{ t('download.failed') }}
|
||||
{{ t(download.failed) }}
|
||||
</p>
|
||||
|
||||
<!-- 微信内置浏览器引导(P4a Task 4):替代直接下载——长按保存 + 复制深链。 -->
|
||||
|
||||
@@ -17,10 +17,12 @@
|
||||
// 打开后可正常下载)。clipboard 不可用时降级展示可选中文本供手动长按复制。
|
||||
import { computed, onBeforeUnmount, reactive, ref, type Ref } from 'vue';
|
||||
import {
|
||||
ApiError,
|
||||
getPhotoDownload,
|
||||
type DownloadVariant,
|
||||
type PublicAlbum,
|
||||
} from '../api/client';
|
||||
import { ErrorCodes } from '@photo/contracts/errors';
|
||||
|
||||
export interface DownloadInputs {
|
||||
token: Ref<string>;
|
||||
@@ -63,7 +65,9 @@ export const COPY_NOTICE_MS = 2500;
|
||||
|
||||
export function useDownload(inputs: DownloadInputs) {
|
||||
const busy = ref(false);
|
||||
const failed = ref(false);
|
||||
/** 失败文案键(''=无失败):默认通用失败;DOWNLOAD_DISABLED 给专属文案
|
||||
* (T15 漏译修复——相册中途关闭下载开关时观众可理解的确定性提示)。 */
|
||||
const failed = ref<'' | 'download.failed' | 'errors.downloadDisabled'>('');
|
||||
/** 微信引导浮层可见(点击下载时处于微信内置浏览器)。 */
|
||||
const wechatGuide = ref(false);
|
||||
const copyNotice = ref<'' | 'copied' | 'failed'>('');
|
||||
@@ -90,7 +94,7 @@ export function useDownload(inputs: DownloadInputs) {
|
||||
const photoId = inputs.photoId();
|
||||
if (photoId === null || busy.value) return;
|
||||
busy.value = true;
|
||||
failed.value = false;
|
||||
failed.value = '';
|
||||
try {
|
||||
let credential = await getPhotoDownload(inputs.token.value, photoId, variant);
|
||||
if (isExpired(credential.expiresAt)) {
|
||||
@@ -99,8 +103,11 @@ export function useDownload(inputs: DownloadInputs) {
|
||||
credential = await getPhotoDownload(inputs.token.value, photoId, variant);
|
||||
}
|
||||
openDownloadUrl(credential.url);
|
||||
} catch {
|
||||
failed.value = true;
|
||||
} catch (error) {
|
||||
failed.value =
|
||||
error instanceof ApiError && error.code === ErrorCodes.DOWNLOAD_DISABLED
|
||||
? 'errors.downloadDisabled'
|
||||
: 'download.failed';
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
|
||||
@@ -51,6 +51,8 @@ export default {
|
||||
loadFailed: 'Failed to load image',
|
||||
// PHOTO_NOT_PUBLIC 专用:not found / unpublished / hidden share one code (anti-enumeration).
|
||||
photoUnavailable: 'Photo not found or unavailable',
|
||||
// 轮播中被移除(ws photo.hidden)的轻提示。
|
||||
photoRemoved: 'This photo has been removed',
|
||||
backToAlbum: 'Back to album',
|
||||
},
|
||||
route: { tokenLabel: 'Album token', photoLabel: 'Photo' },
|
||||
@@ -67,5 +69,7 @@ export default {
|
||||
internal: 'Service temporarily unavailable, please try again later',
|
||||
unknown: 'Something went wrong, please try again later',
|
||||
network: 'Network error, please check your connection',
|
||||
// DOWNLOAD_DISABLED (P4a two-tier download switches): visible but not downloadable.
|
||||
downloadDisabled: 'Downloads are not enabled for this album',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -69,5 +69,7 @@ export default {
|
||||
internal: '服务暂时不可用,请稍后再试',
|
||||
unknown: '出错了,请稍后再试',
|
||||
network: '网络连接失败,请检查网络',
|
||||
// DOWNLOAD_DISABLED(P4a 下载两级开关):相册可见但未开放下载。
|
||||
downloadDisabled: '该相册未开放下载',
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user