From f16d04fe028963bbb10d9c12e2324a0060d4d29d Mon Sep 17 00:00:00 2001 From: Stev_Wang <304865932@qq.com> Date: Wed, 4 Nov 2026 11:50:11 +0800 Subject: [PATCH] =?UTF-8?q?test(e2e):=20A4=20=E5=AD=97=E5=85=B8=E4=B8=8E?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E7=AB=AF=E5=88=B0=E7=AB=AF=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景:A4-QA-01 需要真实 API + Web + PG 的浏览器回归。\n改动:新增 e2e/a4-dicts-settings.spec.ts,覆盖三层合并 / 编码只读 / 树词表切换 / 类型与条目启停 / 命名预览 / 阈值二次确认 / 变更记录 / 权限三态;e2e/README 登记 A4 用例与 Semi 选择器口径。\n影响面:仅测试文件;A4_E2E=1 才执行,默认 skip。\n回滚:删除本文件即可。\n验证:A4_E2E=1 运行 7 passed(约 1.1m);eslint/prettier 通过。 --- e2e/README.md | 44 ++- e2e/a4-dicts-settings.spec.ts | 678 ++++++++++++++++++++++++++++++++++ 2 files changed, 716 insertions(+), 6 deletions(-) create mode 100644 e2e/a4-dicts-settings.spec.ts diff --git a/e2e/README.md b/e2e/README.md index eeef29b..789bce6 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -2,12 +2,13 @@ ## 现有用例 -| 文件 | 说明 | 默认是否执行 | -| --------------------- | --------------------------------------------- | ----------------- | -| `smoke.spec.ts` | A0 冒烟:登录、模块切换、403 / 404 | 是 | -| `a1-identity.spec.ts` | A1-QA-01 账号生命周期(①–⑥) | 否,需 `A1_E2E=1` | -| `a2-roles.spec.ts` | A2-QA-01 角色管理 / 权限三态 / SoD / 即时生效 | 否,需 `A2_E2E=1` | -| `a3-client.spec.ts` | A3-QA-01 客户与行业六页可访问 / 数据加载 | 否,需 `A3_E2E=1` | +| 文件 | 说明 | 默认是否执行 | +| --------------------------- | ------------------------------------------------------------------------ | ----------------- | +| `smoke.spec.ts` | A0 冒烟:登录、模块切换、403 / 404 | 是 | +| `a1-identity.spec.ts` | A1-QA-01 账号生命周期(①–⑥) | 否,需 `A1_E2E=1` | +| `a2-roles.spec.ts` | A2-QA-01 角色管理 / 权限三态 / SoD / 即时生效 | 否,需 `A2_E2E=1` | +| `a3-client.spec.ts` | A3-QA-01 客户与行业六页可访问 / 数据加载 | 否,需 `A3_E2E=1` | +| `a4-dicts-settings.spec.ts` | A4-QA-01 数据字典 / 三层合并 / 编码只读 / 系统配置 / 阈值确认 / 变更记录 | 否,需 `A4_E2E=1` | ## A1 账号生命周期用例 @@ -77,3 +78,34 @@ A3_E2E=1 pnpm exec playwright test e2e/a3-client.spec.ts --reporter=list - 可选覆盖:`A3_E2E_BASE_URL`(缺省 `http://127.0.0.1:5173`)、`A3_E2E_API_BASE_URL`(缺省 `http://127.0.0.1:3000/api/v1`)。 - 截图:`design/a3-ui-v1/impl/`(实施报告引用)。 + +## A4 数据字典与系统配置用例(A4-QA-01) + +覆盖 7 条: + +1. `/system/dicts` 主从页:h1、左侧「字典类型」、右栏 `script_direction` 类型头、三层 chips(通用 5 + 父行业 2 + 本行业 1 = 生效 7)、默认行业「电商 / 服饰鞋包」、种草短视频(已覆盖)/ 直播切片 / 穿搭教程;树 ↔ 词表切换(`?view=tree|list`);行业作用域切全平台(生效 5)再切回。 +2. 条目:新增 SideSheet → 列表出现 → 编辑态编码 disabled 且值锁定 → Modal.confirm 停用 / 启用 → 行状态变化。 +3. 类型:新建(`a4e2e_` 唯一 code + 名称 + 分组 + 形态树)→ 左侧类型列表可见 → 编辑类型编码 / 形态只读。 +4. `/system/settings`:6 组导航、6 个占位符 chips、实时预览「电商-花间集-口播-20261104-01」、模板行内编辑与放弃恢复。 +5. 阈值:客户基础字段上限改 12 → 保存全部更改 → 二次确认 → toast / Prisma 断言版本 +1 / 变更记录抽屉含前后值与原因。 +6. 只读态:无 `dict:manage` / `system:configure` 账号访问两页 → `/403`。 +7. 未登录访问 `/system/dicts` → 重定向登录页。 + +### 前置与运行 + +1. API 3000 + Web 5173(`pnpm dev`,真实 PG;已执行 `pnpm db:init`)。 +2. 用例自建临时超管 `qa4.dicts.e2e` 与只读账号 `qa4.dicts.ro`(Prisma 写入,结束后删除);字典类型 / 条目按 `a4e2e_` 前缀清理;系统配置保存后恢复原值并删除本次 history。 + +```bash +A4_E2E=1 pnpm exec playwright test e2e/a4-dicts-settings.spec.ts --reporter=list +``` + +- 可选覆盖:`A4_E2E_BASE_URL`(缺省 `http://127.0.0.1:5173`)、`A4_E2E_API_BASE_URL`(缺省 `http://127.0.0.1:3000/api/v1`)。 +- 结果:7 passed(约 1.1m)。 +- 截图:不生成常驻截图;失败时 Playwright 默认 `only-on-failure` 落 `test-results/`。 + +### 选择器校准(Semi) + +- Semi Select 选项可访问名含图标 aria-label(tick),精确匹配不到,使用 `.semi-select-option` 文本点击。 +- `Modal.confirm` 的确认 / 取消按钮 `aria-label` 固定为 `confirm` / `cancel`,不要按 `okText` 文本定位。 +- `RadioGroup` 隐藏 input 被 addon 拦截,点可见文本或 `.semi-radio-addon-buttonRadio`。 diff --git a/e2e/a4-dicts-settings.spec.ts b/e2e/a4-dicts-settings.spec.ts new file mode 100644 index 0000000..998e347 --- /dev/null +++ b/e2e/a4-dicts-settings.spec.ts @@ -0,0 +1,678 @@ +/// +/** + * A4-QA-01 · 数据字典与系统配置端到端用例(真实 API + 真实 PG + 真实浏览器) + * + * 覆盖: + * ① /system/dicts:h1「数据字典」、左侧「字典类型」、右栏类型头、三层 chips、树 / 词表切换、行业作用域; + * ② 默认类型 script_direction + 行业视角「电商 / 服饰鞋包」→ 生效 7 项,并可见「种草短视频 / 直播切片 / + * 穿搭教程」与「已覆盖」标签; + * ③ 新增条目 → 列表出现 → 打开编辑时编码只读 → 停用 / 启用(Modal.confirm)→ 行状态变化; + * ④ 新建字典类型(编码前缀 a4e2e_)→ 类型列表可见 → 打开编辑验证编码只读; + * ⑤ /system/settings:h1「系统配置」、左侧 6 组、命名规则占位符 chips 与实时预览、模板行内编辑; + * ⑥ 修改「客户基础字段上限」为 12 → 保存全部更改 → 二次确认 → toast / 版本 +1 / 变更记录抽屉。 + * ⑦ 无 dict:manage / system:configure 的账号访问 /system/dicts 与 /system/settings → 403; + * ⑧ 未登录访问 /system/dicts → 重定向登录页。 + * + * 门控:仅 A4_E2E=1 时执行;默认 skip,不影响 smoke / CI。 + * 前置:API 3000、Web 5173(`pnpm dev`,真实 PG + Redis,已 `pnpm db:init`)。 + * 数据自建自清:临时账号 qa4.dicts.e2e(超管)+ qa4.dicts.ro(素材运营,无字典 / 配置权限), + * 用例结束删除;字典条目 / 类型按 `a4e2e_` 前缀清理;系统配置保存后通过 Prisma 恢复原值并删除本次历史行。 + * + * 运行:A4_E2E=1 pnpm exec playwright test e2e/a4-dicts-settings.spec.ts --reporter=list + */ +import { randomBytes } from 'node:crypto'; + +import { type APIRequestContext, expect, type Page, request, test } from '@playwright/test'; +import { PrismaClient } from '@prisma/client'; + +const API_BASE = process.env.A4_E2E_API_BASE_URL ?? 'http://127.0.0.1:3000/api/v1'; +const WEB_BASE = process.env.A4_E2E_BASE_URL ?? 'http://127.0.0.1:5173'; +const ENABLED = process.env.A4_E2E === '1'; + +const PASSWORD = 'lingxi2026'; +/** 与 a3 / a1 共用的 argon2id 哈希(明文 lingxi2026) */ +const PASSWORD_HASH = + '$argon2id$v=19$m=65536,p=4,t=3$Osdgg3Fz53AwcoC2kyGiGg$QVbxW7Ok/SlCNbML95go2J/6GMSfE1EG3jBTJgR4zO4'; +const ADMIN_ACCOUNT = 'qa4.dicts.e2e'; +const READONLY_ACCOUNT = 'qa4.dicts.ro'; + +/** 类型 / 条目编码前缀:用于自清理与识别异常中断遗留数据 */ +const TYPE_CODE_PREFIX = 'a4e2e_'; +const ITEM_CODE_PREFIX = 'a4e2e_item_'; + +const SEED_BASE_FIELD_LIMIT = 10; +const BASE_FIELD_KEY = 'limit.customer.base_field'; +const SAVE_REASON = '在系统配置页调整'; + +const ENCODING = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; +const prisma = new PrismaClient(); + +let apiContext: APIRequestContext | null = null; +let adminSession: QaSession | null = null; +let readonlySession: QaSession | null = null; +let bootstrapError: string | null = null; + +let baseFieldOriginalValue = SEED_BASE_FIELD_LIMIT; +let baseFieldBeforeVersion = 1; +let baseFieldAfterVersion: number | null = null; + +/** 本次运行创建的字典数据(失败时 afterAll 兜底清理) */ +let createdItemId: string | null = null; +let createdTypeCode: string | null = null; + +interface QaSession { + accessToken: string; + refreshToken: string; + user: { id: string; name: string; account: string; roleName?: string }; + permissions: string[]; + mustChangePassword: boolean; +} + +test.skip(!ENABLED, 'A4_E2E=1 才运行数据字典 / 系统配置 e2e(依赖 API :3000 + web :5173 + PG)'); + +test.use({ baseURL: WEB_BASE, viewport: { width: 1440, height: 900 } }); + +// --------------------------------------------------------------------------- +// 基础工具 +// --------------------------------------------------------------------------- + +function randomSuffix(): string { + return randomBytes(3).toString('hex'); +} + +function ulid(): string { + let time = Date.now(); + const timeChars = new Array(10); + for (let index = 9; index >= 0; index -= 1) { + timeChars[index] = ENCODING[time % 32] ?? '0'; + time = Math.floor(time / 32); + } + const random = randomBytes(16); + return `${timeChars.join('')}${Array.from(random, (byte) => ENCODING[byte & 31] ?? '0').join('')}`; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function requireRecord(value: unknown, label: string): Record { + if (!isRecord(value)) { + throw new Error(`${label} 不是 JSON 对象`); + } + return value; +} + +function requireString(source: Record, key: string, label: string): string { + const value = source[key]; + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`${label}.${key} 缺失或不是字符串`); + } + return value; +} + +function stringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string') + : []; +} + +function requireApi(): APIRequestContext { + if (apiContext === null) { + throw new Error('APIRequestContext 未初始化'); + } + return apiContext; +} + +function requireAdminSession(): QaSession { + if (adminSession === null) { + throw new Error('超管会话未就绪'); + } + return adminSession; +} + +function requireReadonlySession(): QaSession { + if (readonlySession === null) { + throw new Error('只读账号会话未就绪'); + } + return readonlySession; +} + +/** 把登录态写入 localStorage(auth-store 的持久化 key) */ +async function injectSession(page: Page, session: QaSession): Promise { + const persisted = { + accessToken: session.accessToken, + refreshToken: session.refreshToken, + user: session.user, + permissions: session.permissions, + mustChangePassword: session.mustChangePassword, + }; + await page.addInitScript((value: unknown) => { + window.localStorage.setItem('lingxi.auth.session', JSON.stringify(value)); + }, persisted); +} + +async function loginViaApi(account: string): Promise { + const context = requireApi(); + const login = await context.post(`${API_BASE}/auth/login`, { + data: { account, password: PASSWORD }, + }); + expect(login.status(), `POST /auth/login ${account}`).toBe(200); + const body = requireRecord((await login.json()) as unknown, '登录响应'); + const accessToken = requireString(body, 'accessToken', '登录响应'); + const refreshToken = requireString(body, 'refreshToken', '登录响应'); + const meResponse = await context.get(`${API_BASE}/me`, { + headers: { authorization: `Bearer ${accessToken}` }, + }); + expect(meResponse.status(), `GET /me ${account}`).toBe(200); + const me = requireRecord((await meResponse.json()) as unknown, '/me 响应'); + const user = requireRecord(me.user, '/me.user'); + const roles = Array.isArray(me.roles) ? me.roles : []; + const firstRole = isRecord(roles[0]) ? roles[0] : null; + return { + accessToken, + refreshToken, + user: { + id: requireString(user, 'id', '/me.user'), + name: requireString(user, 'name', '/me.user'), + account: requireString(user, 'account', '/me.user'), + ...(firstRole === null ? {} : { roleName: requireString(firstRole, 'name', '/me.roles[0]') }), + }, + permissions: stringArray(me.permissions), + mustChangePassword: me.mustChangePassword === true, + }; +} + +async function createUser(account: string, name: string, roleCode: string): Promise { + const [role, department] = await Promise.all([ + prisma.role.findUniqueOrThrow({ where: { code: roleCode } }), + prisma.department.findUniqueOrThrow({ where: { code: 'content-g1' } }), + ]); + await prisma.user.deleteMany({ where: { account } }); + await prisma.user.create({ + data: { + id: ulid(), + name, + account, + email: `${account}@lingxi.cn`, + phone: `139${randomBytes(4).readUInt32BE(0).toString().padStart(8, '0').slice(-8)}`, + passwordHash: PASSWORD_HASH, + departmentId: department.id, + mustChangePassword: false, + roles: { create: [{ roleId: role.id }] }, + }, + }); +} + +/** 清理异常中断运行遗留的字典数据(前缀匹配,避免影响 seed 与其他用例) */ +async function cleanupDictLeftovers(): Promise { + const staleTypes = await prisma.dictType.findMany({ + where: { code: { startsWith: TYPE_CODE_PREFIX } }, + select: { id: true }, + }); + if (staleTypes.length > 0) { + await prisma.dictItem.deleteMany({ + where: { typeId: { in: staleTypes.map((row) => row.id) } }, + }); + await prisma.dictType.deleteMany({ where: { id: { in: staleTypes.map((row) => row.id) } } }); + } + await prisma.dictItem.deleteMany({ where: { code: { startsWith: ITEM_CODE_PREFIX } } }); +} + +/** 读取并记录「客户基础字段上限」的原始值 / 版本;异常遗留值先恢复 seed 默认值 */ +async function prepareBaseFieldConfig(): Promise { + const row = await prisma.systemConfig.findUnique({ where: { configKey: BASE_FIELD_KEY } }); + const original = typeof row?.configValue === 'number' ? row.configValue : SEED_BASE_FIELD_LIMIT; + baseFieldOriginalValue = original; + baseFieldBeforeVersion = row?.version ?? 1; + if (original !== SEED_BASE_FIELD_LIMIT) { + // 上次运行可能异常中断;恢复 seed 默认值,保证本次「改成 12」一定产生变更。 + await prisma.systemConfig.update({ + where: { configKey: BASE_FIELD_KEY }, + data: { configValue: SEED_BASE_FIELD_LIMIT }, + }); + baseFieldOriginalValue = SEED_BASE_FIELD_LIMIT; + } +} + +interface DictTypeRow { + id: string; + code: string; +} + +async function readDictTypeByCode(code: string): Promise { + return prisma.dictType.findUnique({ where: { code }, select: { id: true, code: true } }); +} + +// --------------------------------------------------------------------------- +// 浏览器 / 页面辅助 +// --------------------------------------------------------------------------- + +async function openDicts(page: Page): Promise { + await injectSession(page, requireAdminSession()); + await page.goto('/system/dicts'); + await expect(page.getByRole('heading', { name: '数据字典' })).toBeVisible(); + await expect(page.getByTestId('app-shell')).toBeVisible(); +} + +async function gotoSystem(page: Page, path: string): Promise { + await injectSession(page, requireAdminSession()); + await page.goto(path); +} + +function dictItemRow(page: Page, text: string) { + return page.locator('.lx-dict-table tbody tr', { hasText: text }); +} + +/** + * 选择 Semi Select 下拉项。 + * 注意:Semi Select option 的可访问名会带上勾选图标的 aria-label(tick), + * `getByRole('option', { name, exact: true })` 匹配不到,这里统一按选项文本定位。 + */ +async function pickSelectOption(page: Page, optionName: string): Promise { + const option = page.locator('.semi-select-option').filter({ hasText: optionName }).first(); + await expect(option).toBeVisible({ timeout: 10_000 }); + await option.click(); +} + +async function waitToast(page: Page, text: string | RegExp): Promise { + await expect(page.locator('[role="alert"]', { hasText: text }).first()).toBeVisible({ + timeout: 15_000, + }); +} + +// --------------------------------------------------------------------------- +// 全局编排 +// --------------------------------------------------------------------------- + +test.beforeAll(async () => { + if (!ENABLED) return; + apiContext = await request.newContext(); + try { + const web = await apiContext.get(`${WEB_BASE}/`); + expect(web.status(), `web 未就绪:请在 ${WEB_BASE} 启动前端`).toBe(200); + await cleanupDictLeftovers(); + await prepareBaseFieldConfig(); + await createUser(ADMIN_ACCOUNT, 'A4 端到端超管', 'super_admin'); + await createUser(READONLY_ACCOUNT, 'A4 端到端只读', 'material_operator'); + adminSession = await loginViaApi(ADMIN_ACCOUNT); + readonlySession = await loginViaApi(READONLY_ACCOUNT); + expect(adminSession.permissions, '超管应含 dict:manage').toContain('dict:manage'); + expect(adminSession.permissions, '超管应含 system:configure').toContain('system:configure'); + expect(readonlySession.permissions, '素材运营不应含 dict:manage').not.toContain('dict:manage'); + expect(readonlySession.permissions, '素材运营不应含 system:configure').not.toContain( + 'system:configure', + ); + } catch (error) { + bootstrapError = error instanceof Error ? error.message : String(error); + } +}); + +test.afterAll(async () => { + if (!ENABLED) return; + try { + // 字典:先删条目再删类型(按本次记录 + 前缀双保险) + if (createdItemId !== null) { + await prisma.dictItem.deleteMany({ where: { id: createdItemId } }); + } + if (createdTypeCode !== null) { + const type = await readDictTypeByCode(createdTypeCode); + if (type !== null) { + await prisma.dictItem.deleteMany({ where: { typeId: type.id } }); + await prisma.dictType.deleteMany({ where: { id: type.id } }); + } + } + await cleanupDictLeftovers(); + + // 系统配置:恢复原值 / 原版本,并删除本次产生的历史行 + if (baseFieldAfterVersion !== null) { + await prisma.systemConfigVersion.deleteMany({ + where: { configKey: BASE_FIELD_KEY, version: baseFieldAfterVersion }, + }); + } + const configRow = await prisma.systemConfig.findUnique({ + where: { configKey: BASE_FIELD_KEY }, + }); + if (configRow !== null) { + await prisma.systemConfig.update({ + where: { configKey: BASE_FIELD_KEY }, + data: { configValue: baseFieldOriginalValue, version: baseFieldBeforeVersion }, + }); + } + + await prisma.user.deleteMany({ where: { account: { in: [ADMIN_ACCOUNT, READONLY_ACCOUNT] } } }); + } catch { + // 清理失败不覆盖真实断言结果;残留由下一次 beforeAll 按前缀清理 + } + await apiContext?.dispose(); + apiContext = null; + await prisma.$disconnect(); +}); + +test.beforeEach(() => { + test.skip(bootstrapError !== null, bootstrapError ?? '测试环境未就绪'); +}); + +// --------------------------------------------------------------------------- +// ① / ② 字典主从页:默认类型、三层 chips、树表切换、行业作用域 +// --------------------------------------------------------------------------- + +test('数据字典:默认类型 / 三层 chips / 树词表切换 / 行业作用域', async ({ page }) => { + test.setTimeout(120_000); + await openDicts(page); + + // 左侧类型栏与右栏类型头 + await expect(page.getByRole('complementary', { name: '字典类型' })).toBeVisible(); + await expect(page.locator('.lx-dict-list__title')).toHaveText('字典类型'); + const detailHead = page.locator('.lx-dict-head'); + await expect(detailHead.locator('.lx-dict-head__name b').first()).toHaveText('脚本方向'); + await expect(detailHead.locator('.lx-dict-head__meta code').first()).toHaveText( + 'script_direction', + ); + + // 默认行业视角:电商 / 服饰鞋包 + await expect(page.locator('.lx-dict-scope__select')).toContainText('电商 / 服饰鞋包'); + + // 三层 chips:通用 5 + 父行业 2 + 本行业 1 = 生效 7 + const chips = page.locator('.lx-dict-scope__layers .lx-dict-layer-chip'); + await expect(chips.filter({ hasText: '通用' })).toContainText('5'); + await expect(chips.filter({ hasText: '父行业' })).toContainText('2'); + await expect(chips.filter({ hasText: '本行业' })).toContainText('1'); + await expect(page.locator('.lx-dict-layer-chip.is-total')).toContainText('生效'); + await expect(page.locator('.lx-dict-layer-chip.is-total')).toContainText('7'); + await expect(page.locator('.lx-dict-viewrow__hint')).toContainText('生效 7 项'); + + // 词表视图:种短视频 / 直播切片 / 穿搭教程 + 「已覆盖」标签 + await expect(page.locator('.lx-dict-table')).toBeVisible(); + for (const name of ['种草短视频', '直播切片', '穿搭教程']) { + await expect(dictItemRow(page, name)).toBeVisible(); + } + await expect(dictItemRow(page, '种草短视频').getByText('已覆盖')).toBeVisible(); + await expect(dictItemRow(page, '直播切片').getByText('已覆盖')).toHaveCount(0); + + // 树 / 词表切换:点击「树视图」→ URL ?view=tree 且渲染树 + // Semi RadioGroup 的隐藏 input 被 addon 覆盖,直接点击可见的 addon 文本。 + const viewSwitcher = page.locator('.lx-dict-viewrow'); + await viewSwitcher + .getByText(/树视图/) + .first() + .click(); + await expect(page).toHaveURL(/view=tree/); + await expect(page.locator('.lx-dict-tree')).toBeVisible(); + await expect(page.locator('.lx-dict-table')).toHaveCount(0); + await viewSwitcher + .getByText(/词表视图/) + .first() + .click(); + await expect(page).toHaveURL(/view=list/); + await expect(page.locator('.lx-dict-table')).toBeVisible(); + + // 行业作用域:切到全平台 → 只保留通用层(生效 5) + await page.locator('.lx-dict-scope__select').click(); + await pickSelectOption(page, '全平台'); + await expect(page).toHaveURL(/industry=all/); + await expect(page.locator('.lx-dict-layer-chip.is-total')).toContainText('5'); + await expect(page.locator('.lx-dict-layer-chip', { hasText: '父行业' })).toHaveCount(0); + await expect(dictItemRow(page, '直播切片')).toHaveCount(0); + await expect(dictItemRow(page, '口播')).toBeVisible(); + + // 切回电商 / 服饰鞋包 → 三层恢复为 7 + await page.locator('.lx-dict-scope__select').click(); + await pickSelectOption(page, '电商 / 服饰鞋包'); + await expect(page.locator('.lx-dict-layer-chip.is-total')).toContainText('7'); + await expect(dictItemRow(page, '直播切片')).toBeVisible(); +}); + +// --------------------------------------------------------------------------- +// ③ 条目:新增 → 编辑编码只读 → 停用 / 启用 +// --------------------------------------------------------------------------- + +test('数据字典:新增条目 → 编辑编码只读 → 停用 / 启用', async ({ page }) => { + test.setTimeout(150_000); + await openDicts(page); + await expect(page.locator('.lx-dict-table tbody tr').first()).toBeVisible(); + + const suffix = randomSuffix(); + const itemCode = `${ITEM_CODE_PREFIX}${suffix}`; + const itemName = `A4E2E测试条目${suffix}`; + + await page.getByRole('button', { name: '新增条目' }).first().click(); + const sheet = page.locator('.semi-sidesheet'); + await expect(sheet).toBeVisible(); + await expect(sheet.locator('.semi-sidesheet-title')).toHaveText('新增条目'); + await sheet.getByPlaceholder('如:口播').fill(itemName); + await sheet.getByPlaceholder('字母、数字、下划线或短横线').fill(itemCode); + await sheet.getByRole('button', { name: '保存', exact: true }).click(); + await waitToast(page, '条目已创建'); + + const row = dictItemRow(page, itemCode); + await expect(row).toBeVisible({ timeout: 15_000 }); + await expect(row).toContainText(itemName); + await expect(row.locator('.lx-dict-status--active')).toBeVisible(); + + // 记录 id,afterAll 兜底清理 + const createdItem = await prisma.dictItem.findFirst({ + where: { code: itemCode }, + select: { id: true }, + }); + createdItemId = createdItem?.id ?? null; + expect(createdItemId, '新增条目应落库').not.toBeNull(); + + // 打开编辑:编码输入禁用且值不可改 + await row.getByRole('button', { name: '编辑', exact: true }).click(); + const editSheet = page.locator('.semi-sidesheet'); + await expect(editSheet.locator('.semi-sidesheet-title')).toHaveText('编辑条目'); + const codeInput = editSheet.getByPlaceholder('字母、数字、下划线或短横线'); + await expect(codeInput).toBeDisabled(); + await expect(codeInput).toHaveValue(itemCode); + await expect(editSheet.getByText('编码创建后不可修改')).toBeVisible(); + await editSheet.getByRole('button', { name: '取消', exact: true }).click(); + await expect(editSheet).toHaveCount(0); + + // 停用条目:Modal.confirm 点「停用」(Semi 确认按钮的 aria-label 固定为 confirm) + await row.getByRole('button', { name: '停用', exact: true }).click(); + const disableDialog = page.getByRole('dialog', { name: new RegExp(`停用条目「${itemName}」`) }); + await expect(disableDialog).toBeVisible(); + await disableDialog.getByRole('button', { name: 'confirm' }).click(); + await waitToast(page, `已停用「${itemName}」`); + await expect(row.locator('.lx-dict-status--disabled')).toBeVisible(); + await expect(row).toHaveClass(/is-disabled/); + + // 启用条目:Modal.confirm 点「启用」 + await row.getByRole('button', { name: '启用', exact: true }).click(); + const enableDialog = page.getByRole('dialog', { name: new RegExp(`启用条目「${itemName}」`) }); + await expect(enableDialog).toBeVisible(); + await enableDialog.getByRole('button', { name: 'confirm' }).click(); + await waitToast(page, `已启用「${itemName}」`); + await expect(row.locator('.lx-dict-status--active')).toBeVisible(); + await expect(row).not.toHaveClass(/is-disabled/); +}); + +// --------------------------------------------------------------------------- +// ④ 字典类型:新建 → 列表可见 → 编辑编码只读 +// --------------------------------------------------------------------------- + +test('数据字典:新建类型 → 列表可见 → 编辑类型编码只读', async ({ page }) => { + test.setTimeout(120_000); + await openDicts(page); + + const suffix = randomSuffix(); + const typeCode = `${TYPE_CODE_PREFIX}type_${suffix}`; + const typeName = `A4E2E测试类型${suffix}`; + createdTypeCode = typeCode; + + await page.getByRole('button', { name: '新建字典类型' }).click(); + const modal = page.getByRole('dialog', { name: '新建字典类型' }); + await expect(modal).toBeVisible(); + await modal.getByPlaceholder('如:投放位置').fill(typeName); + await modal.getByPlaceholder('小写字母、数字与下划线').fill(typeCode); + + // 所属分组:质量与合规 + const groupField = modal.locator('.lx-dict-field', { hasText: '所属分组' }); + await groupField.locator('.semi-select').click(); + await pickSelectOption(page, '质量与合规'); + // 形态:树 + const kindField = modal.locator('.lx-dict-field', { hasText: '形态' }); + await kindField.locator('.semi-select').click(); + await pickSelectOption(page, '树'); + await expect(kindField.locator('.semi-select')).toContainText('树'); + + await modal.getByRole('button', { name: '保存', exact: true }).click(); + await waitToast(page, '字典类型已创建'); + + // 保存后自动进入新类型详情;左侧类型列表可见 + await expect(page).toHaveURL(new RegExp(`/system/dicts/${typeCode}`)); + await expect(page.locator('.lx-dict-head__name b').first()).toHaveText(typeName); + const typeRow = page.locator('.lx-dict-type', { hasText: typeCode }); + await expect(typeRow).toBeVisible({ timeout: 15_000 }); + await expect(typeRow).toContainText(typeName); + + // 编辑类型:编码只读、形态只读 + await page.getByRole('button', { name: '编辑类型' }).click(); + const editModal = page.getByRole('dialog', { name: '编辑字典类型' }); + await expect(editModal).toBeVisible(); + const codeField = editModal.locator('.lx-dict-field', { hasText: '类型编码' }); + await expect(codeField.locator('input')).toBeDisabled(); + await expect(codeField.locator('input')).toHaveValue(typeCode); + await expect(editModal.getByText('创建后不可修改').first()).toBeVisible(); + const editKindField = editModal.locator('.lx-dict-field', { hasText: '形态' }); + await expect(editKindField.locator('.semi-select-disabled')).toBeVisible(); + await editModal.getByRole('button', { name: '取消', exact: true }).click(); + await expect(editModal).toHaveCount(0); +}); + +// --------------------------------------------------------------------------- +// ⑤ 系统配置:6 组、命名占位符与实时预览、模板行内编辑 +// --------------------------------------------------------------------------- + +test('系统配置:6 组导航 / 命名占位符 / 实时预览 / 模板编辑', async ({ page }) => { + test.setTimeout(120_000); + await gotoSystem(page, '/system/settings'); + await expect(page.getByRole('heading', { name: '系统配置' })).toBeVisible(); + await expect(page.getByTestId('app-shell')).toBeVisible(); + + // 左侧 6 组 + await expect(page.getByRole('complementary', { name: '配置分组' })).toBeVisible(); + const navItems = page.locator('.lx-settings-nav__item'); + await expect(navItems).toHaveCount(6); + for (const groupName of [ + '基础信息', + '命名规则', + '导出与归档', + '通知开关', + '阈值与上限', + '业务阈值', + ]) { + await expect(navItems.filter({ hasText: groupName })).toBeVisible(); + } + + // 命名规则:占位符 chips + 默认实时预览 + await expect(page.locator('.lx-settings-naming-aside')).toBeVisible(); + await expect(page.locator('.lx-settings-aside-head')).toContainText('命名占位符'); + await expect(page.locator('.lx-settings-token')).toHaveCount(6); + await expect(page.locator('.lx-settings-preview__value')).toHaveText( + '电商-花间集-口播-20261104-01', + ); + const originalPreview = await page.locator('.lx-settings-preview__value').innerText(); + + // 点击「编辑」改模板 → 预览实时变化 → 放弃更改恢复 + const patternRow = page + .locator('.lx-settings-config.is-pattern') + .filter({ hasText: '脚本命名规则' }); + await patternRow.getByRole('button', { name: '编辑', exact: true }).click(); + const patternInput = page.getByLabel('命名规则'); + await expect(patternInput).toBeVisible(); + await patternInput.fill('{行业}-{客户}-{脚本方向}-{日期}-{序号}-{规格}'); + await expect(page.locator('.lx-settings-preview__value')).toHaveText( + '电商-花间集-口播-20261104-01-1080x1920', + ); + await page + .locator('.lx-settings-form__foot') + .getByRole('button', { name: '放弃更改', exact: true }) + .click(); + await expect(page.locator('.lx-settings-preview__value')).toHaveText(originalPreview); + await expect(page.locator('.lx-settings-headindicator')).toContainText('所有修改已保存'); +}); + +// --------------------------------------------------------------------------- +// ⑥ 阈值修改:保存 → 二次确认 → toast / 版本 +1 / 变更记录 +// --------------------------------------------------------------------------- + +test('系统配置:阈值修改 → 二次确认 → 版本 +1 → 变更记录', async ({ page }) => { + test.setTimeout(150_000); + await gotoSystem(page, '/system/settings'); + await expect(page.getByRole('heading', { name: '系统配置' })).toBeVisible(); + + // 进入「阈值与上限」 + await page.locator('.lx-settings-nav__item', { hasText: '阈值与上限' }).click(); + await expect(page).toHaveURL(/group=limits/); + await expect(page.locator('.lx-settings-form__head h2')).toHaveText('阈值与上限'); + + const baseFieldRow = page.locator('.lx-settings-config').filter({ hasText: '客户基础字段上限' }); + await expect(baseFieldRow).toBeVisible(); + await baseFieldRow.getByLabel('客户基础字段上限').fill('12'); + await expect(page.locator('.lx-settings-headindicator')).toContainText('未保存 1 项'); + + await page + .locator('.lx-settings-form__foot') + .getByRole('button', { name: '保存全部更改', exact: true }) + .click(); + + // 阈值类配置二次确认 + const confirmDialog = page.getByRole('dialog', { name: /确认修改阈值与上限/ }); + await expect(confirmDialog).toBeVisible(); + await expect(confirmDialog.getByText('客户基础字段上限')).toBeVisible(); + await expect(confirmDialog).toContainText('12'); + await confirmDialog.getByRole('button', { name: 'confirm' }).click(); + + await waitToast(page, '配置已保存,版本已更新'); + await expect(page.locator('.lx-settings-headindicator')).toContainText('所有修改已保存'); + + // 版本 +1(值来自 Prisma 当前行) + const saved = await prisma.systemConfig.findUnique({ where: { configKey: BASE_FIELD_KEY } }); + expect(saved?.configValue, '配置值应为 12').toBe(12); + baseFieldAfterVersion = saved?.version ?? null; + expect(baseFieldAfterVersion, '配置版本应 +1').toBe(baseFieldBeforeVersion + 1); + await expect( + page.locator('.lx-settings-config').filter({ hasText: '客户基础字段上限' }), + ).toContainText(`v${String(baseFieldAfterVersion)}`); + + // 变更记录抽屉:出现本次记录(版本 / 前后值 / 原因) + await page.getByRole('button', { name: '变更记录' }).click(); + const historySheet = page.locator('.semi-sidesheet'); + await expect(historySheet).toBeVisible(); + await expect(historySheet.locator('.semi-sidesheet-title')).toHaveText('配置变更记录'); + const historyItem = page + .locator('.lx-settings-history-item') + .filter({ hasText: '客户基础字段上限' }) + .filter({ hasText: `v${String(baseFieldAfterVersion)}` }); + await expect(historyItem).toBeVisible({ timeout: 15_000 }); + await expect(historyItem).toContainText('12'); + await expect(historyItem).toContainText(SAVE_REASON); +}); + +// --------------------------------------------------------------------------- +// ⑦ 权限:无 dict:manage / system:configure 的账号 → 403 +// --------------------------------------------------------------------------- + +test('权限:无字典 / 配置权限的账号访问 A4 页面显示 403', async ({ page }) => { + test.setTimeout(90_000); + await injectSession(page, requireReadonlySession()); + + await page.goto('/system/dicts'); + await expect(page).toHaveURL(/\/403/); + await expect(page.getByRole('heading', { name: '没有访问权限' })).toBeVisible(); + + await page.goto('/system/settings'); + await expect(page).toHaveURL(/\/403/); + await expect(page.getByRole('heading', { name: '没有访问权限' })).toBeVisible(); +}); + +// --------------------------------------------------------------------------- +// ⑧ 未登录:访问 /system/dicts 重定向登录页 +// --------------------------------------------------------------------------- + +test('未登录访问 /system/dicts 重定向到登录页', async ({ page }) => { + await page.goto('/system/dicts'); + await expect(page).toHaveURL(/\/login\?redirect=/); + await expect(page.getByRole('heading', { name: '登录灵犀' })).toBeVisible(); +});